Compare commits
10 Commits
refactor/n
...
docs/weekl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdde64a0a2 | ||
|
|
bd82c855e0 | ||
|
|
5b7ef3fe21 | ||
|
|
85de833776 | ||
|
|
cab46567c0 | ||
|
|
63435bdb34 | ||
|
|
20255da61f | ||
|
|
c2dba8f4ee | ||
|
|
6f579c5992 | ||
|
|
e729e5edb8 |
@@ -150,7 +150,7 @@
|
||||
"playwright/no-element-handle": "error",
|
||||
"playwright/no-eval": "error",
|
||||
"playwright/no-focused-test": "error",
|
||||
"playwright/no-force-option": "off",
|
||||
"playwright/no-force-option": "error",
|
||||
"playwright/no-networkidle": "error",
|
||||
"playwright/no-page-pause": "error",
|
||||
"playwright/no-skipped-test": "error",
|
||||
|
||||
@@ -44,6 +44,7 @@ This project uses **pnpm**. Always prefer scripts defined in `package.json` (e.g
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `pnpm dev`: Start Vite dev server.
|
||||
- `pnpm dev:cloud`: Dev server connected to cloud backend (testcloud.comfy.org)
|
||||
- `pnpm dev:electron`: Dev server with Electron API mocks
|
||||
- `pnpm build`: Type-check then production build to `dist/`
|
||||
- `pnpm preview`: Preview the production build locally
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"id": "selection-bbox-test",
|
||||
"revision": 0,
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"pos": [300, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [800, 200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "latent",
|
||||
"type": "LATENT",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [512, 512, 1]
|
||||
}
|
||||
],
|
||||
"links": [[1, 2, 0, 3, 0, "LATENT"]],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 0,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Test Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [],
|
||||
"pos": { "0": 200, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [],
|
||||
"pos": { "0": 520, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [],
|
||||
"groups": [],
|
||||
"links": [],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
50
browser_tests/assets/widgets/image_crop_widget.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "ImageCropV2",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 500],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCropV2"
|
||||
},
|
||||
"widgets_values": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 512,
|
||||
"height": 512
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
100
browser_tests/assets/widgets/image_crop_with_source.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1]
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ImageCropV2",
|
||||
"pos": [450, 50],
|
||||
"size": [400, 500],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCropV2"
|
||||
},
|
||||
"widgets_values": [
|
||||
{
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
"width": 100,
|
||||
"height": 100
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PreviewImage",
|
||||
"pos": [900, 50],
|
||||
"size": [315, 270],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "IMAGE"],
|
||||
[2, 2, 0, 3, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -351,7 +351,7 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
async dismissToasts() {
|
||||
const closeButtons = this.page.locator('.p-toast-close-button')
|
||||
for (const btn of await closeButtons.all()) {
|
||||
await btn.click({ force: true }).catch(() => {})
|
||||
await btn.click().catch(() => {})
|
||||
}
|
||||
// Wait for all toast elements to fully animate out and detach from DOM
|
||||
await expect(this.page.locator('.p-toast-message'))
|
||||
|
||||
@@ -71,7 +71,7 @@ export class Topbar {
|
||||
async closeWorkflowTab(tabName: string) {
|
||||
const tab = this.getWorkflowTab(tabName)
|
||||
await tab.hover()
|
||||
await tab.locator('.close-button').click({ force: true })
|
||||
await tab.locator('.close-button').click()
|
||||
}
|
||||
|
||||
getSaveDialog(): Locator {
|
||||
|
||||
@@ -151,6 +151,7 @@ export class BuilderSelectHelper {
|
||||
const widgetLocator = this.comfyPage.vueNodes
|
||||
.getNodeLocator(String(nodeRef.id))
|
||||
.getByLabel(widgetName, { exact: true })
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||
await widgetLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
@@ -199,6 +200,7 @@ export class BuilderSelectHelper {
|
||||
const nodeLocator = this.comfyPage.vueNodes.getNodeLocator(
|
||||
String(nodeRef.id)
|
||||
)
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Node container has conditional pointer-events:none that blocks actionability
|
||||
await nodeLocator.click({ force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
@@ -74,6 +74,51 @@ export class CanvasHelper {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a canvas-element-relative position to absolute page coordinates.
|
||||
* Use with `page.mouse` APIs when Vue DOM overlays above the canvas would
|
||||
* cause Playwright's actionability check to fail on the canvas locator.
|
||||
*/
|
||||
private async toAbsolute(position: Position): Promise<Position> {
|
||||
const box = await this.canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not available')
|
||||
return { x: box.x + position.x, y: box.y + position.y }
|
||||
}
|
||||
|
||||
/**
|
||||
* Click at canvas-element-relative coordinates using `page.mouse.click()`.
|
||||
* Bypasses Playwright's actionability checks on the canvas locator, which
|
||||
* can fail when Vue-rendered DOM nodes overlay the `<canvas>` element.
|
||||
*/
|
||||
async mouseClickAt(
|
||||
position: Position,
|
||||
options?: {
|
||||
button?: 'left' | 'right' | 'middle'
|
||||
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
|
||||
}
|
||||
): Promise<void> {
|
||||
const abs = await this.toAbsolute(position)
|
||||
const modifiers = options?.modifiers ?? []
|
||||
for (const mod of modifiers) await this.page.keyboard.down(mod)
|
||||
try {
|
||||
await this.page.mouse.click(abs.x, abs.y, {
|
||||
button: options?.button
|
||||
})
|
||||
} finally {
|
||||
for (const mod of modifiers) await this.page.keyboard.up(mod)
|
||||
}
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Double-click at canvas-element-relative coordinates using `page.mouse`.
|
||||
*/
|
||||
async mouseDblclickAt(position: Position): Promise<void> {
|
||||
const abs = await this.toAbsolute(position)
|
||||
await this.page.mouse.dblclick(abs.x, abs.y)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async clickEmptySpace(): Promise<void> {
|
||||
await this.canvas.click({ position: DefaultGraphPositions.emptySpaceClick })
|
||||
await this.nextFrame()
|
||||
|
||||
@@ -19,15 +19,6 @@ export class KeyboardHelper {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async altSend(
|
||||
keyToPress: string,
|
||||
locator: Locator | null = this.canvas
|
||||
): Promise<void> {
|
||||
const target = locator ?? this.page.keyboard
|
||||
await target.press(`Alt+${keyToPress}`)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async selectAll(locator?: Locator | null): Promise<void> {
|
||||
await this.ctrlSend('KeyA', locator)
|
||||
}
|
||||
@@ -36,10 +27,6 @@ export class KeyboardHelper {
|
||||
await this.ctrlSend('KeyB', locator)
|
||||
}
|
||||
|
||||
async collapse(locator?: Locator | null): Promise<void> {
|
||||
await this.altSend('KeyC', locator)
|
||||
}
|
||||
|
||||
async undo(locator?: Locator | null): Promise<void> {
|
||||
await this.ctrlSend('KeyZ', locator)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ComfyWorkflowJSON,
|
||||
NodeId
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
@@ -123,27 +120,6 @@ export class NodeOperationsHelper {
|
||||
}
|
||||
}
|
||||
|
||||
async getSerializedGraph(): Promise<ComfyWorkflowJSON> {
|
||||
return this.page.evaluate(
|
||||
() => window.app!.graph.serialize() as ComfyWorkflowJSON
|
||||
)
|
||||
}
|
||||
|
||||
async loadGraph(data: ComfyWorkflowJSON): Promise<void> {
|
||||
await this.page.evaluate(
|
||||
(d) => window.app!.loadGraphData(d, true, true, null),
|
||||
data
|
||||
)
|
||||
}
|
||||
|
||||
async repositionNodes(
|
||||
positions: Record<string, [number, number]>
|
||||
): Promise<void> {
|
||||
const data = await this.getSerializedGraph()
|
||||
applyNodePositions(data, positions)
|
||||
await this.loadGraph(data)
|
||||
}
|
||||
|
||||
async resizeNode(
|
||||
nodePos: Position,
|
||||
nodeSize: Size,
|
||||
@@ -214,13 +190,3 @@ export class NodeOperationsHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
function applyNodePositions(
|
||||
data: ComfyWorkflowJSON,
|
||||
positions: Record<string, [number, number]>
|
||||
): void {
|
||||
for (const node of data.nodes) {
|
||||
const pos = positions[String(node.id)]
|
||||
if (pos) node.pos = pos
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
export interface CanvasRect {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface MeasureResult {
|
||||
selectionBounds: CanvasRect | null
|
||||
nodeVisualBounds: Record<string, CanvasRect>
|
||||
}
|
||||
|
||||
// Must match createBounds(selectedItems, 10) in src/extensions/core/selectionBorder.ts:19
|
||||
const SELECTION_PADDING = 10
|
||||
|
||||
export async function measureSelectionBounds(
|
||||
page: Page,
|
||||
nodeIds: string[]
|
||||
): Promise<MeasureResult> {
|
||||
return page.evaluate(
|
||||
({ ids, padding }) => {
|
||||
const canvas = window.app!.canvas
|
||||
const ds = canvas.ds
|
||||
|
||||
const selectedItems = canvas.selectedItems
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const item of selectedItems) {
|
||||
const rect = item.boundingRect
|
||||
// For collapsed nodes, use DOM element size (matches selectionBorder.ts
|
||||
// which reads layoutStore.collapsedSize in Vue mode)
|
||||
const id = 'id' in item ? String(item.id) : null
|
||||
const isCollapsed =
|
||||
'flags' in item &&
|
||||
!!(item as { flags?: { collapsed?: boolean } }).flags?.collapsed
|
||||
const el =
|
||||
id && isCollapsed
|
||||
? document.querySelector(`[data-node-id="${id}"]`)
|
||||
: null
|
||||
const w = el instanceof HTMLElement ? el.offsetWidth : rect[2]
|
||||
const h = el instanceof HTMLElement ? el.offsetHeight : rect[3]
|
||||
|
||||
minX = Math.min(minX, rect[0])
|
||||
minY = Math.min(minY, rect[1])
|
||||
maxX = Math.max(maxX, rect[0] + w)
|
||||
maxY = Math.max(maxY, rect[1] + h)
|
||||
}
|
||||
const selectionBounds =
|
||||
selectedItems.size > 0
|
||||
? {
|
||||
x: minX - padding,
|
||||
y: minY - padding,
|
||||
w: maxX - minX + 2 * padding,
|
||||
h: maxY - minY + 2 * padding
|
||||
}
|
||||
: null
|
||||
|
||||
const canvasEl = canvas.canvas as HTMLCanvasElement
|
||||
const canvasRect = canvasEl.getBoundingClientRect()
|
||||
const nodeVisualBounds: Record<
|
||||
string,
|
||||
{ x: number; y: number; w: number; h: number }
|
||||
> = {}
|
||||
|
||||
for (const id of ids) {
|
||||
const nodeEl = document.querySelector(
|
||||
`[data-node-id="${id}"]`
|
||||
) as HTMLElement | null
|
||||
|
||||
// Legacy mode: no Vue DOM element, use boundingRect directly
|
||||
if (!nodeEl) {
|
||||
const node = window.app!.graph._nodes.find(
|
||||
(n: { id: number | string }) => String(n.id) === id
|
||||
)
|
||||
if (node) {
|
||||
const rect = node.boundingRect
|
||||
nodeVisualBounds[id] = {
|
||||
x: rect[0],
|
||||
y: rect[1],
|
||||
w: rect[2],
|
||||
h: rect[3]
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const domRect = nodeEl.getBoundingClientRect()
|
||||
const footerEls = nodeEl.querySelectorAll(
|
||||
'[data-testid="subgraph-enter-button"], [data-testid="node-footer"]'
|
||||
)
|
||||
let bottom = domRect.bottom
|
||||
for (const footerEl of footerEls) {
|
||||
bottom = Math.max(bottom, footerEl.getBoundingClientRect().bottom)
|
||||
}
|
||||
|
||||
nodeVisualBounds[id] = {
|
||||
x: (domRect.left - canvasRect.left) / ds.scale - ds.offset[0],
|
||||
y: (domRect.top - canvasRect.top) / ds.scale - ds.offset[1],
|
||||
w: domRect.width / ds.scale,
|
||||
h: (bottom - domRect.top) / ds.scale
|
||||
}
|
||||
}
|
||||
|
||||
return { selectionBounds, nodeVisualBounds }
|
||||
},
|
||||
{ ids: nodeIds, padding: SELECTION_PADDING }
|
||||
) as Promise<MeasureResult>
|
||||
}
|
||||
@@ -21,6 +21,10 @@ export const TestIds = {
|
||||
contextMenu: 'canvas-context-menu',
|
||||
toggleMinimapButton: 'toggle-minimap-button',
|
||||
closeMinimapButton: 'close-minimap-button',
|
||||
minimapContainer: 'minimap-container',
|
||||
minimapCanvas: 'minimap-canvas',
|
||||
minimapViewport: 'minimap-viewport',
|
||||
minimapInteractionOverlay: 'minimap-interaction-overlay',
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
|
||||
zoomControlsButton: 'zoom-controls-button',
|
||||
zoomInAction: 'zoom-in-action',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { ManageGroupNode } from '@e2e/helpers/manageGroupNode'
|
||||
@@ -356,7 +355,11 @@ export class NodeReference {
|
||||
}
|
||||
async click(
|
||||
position: 'title' | 'collapse',
|
||||
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
|
||||
options?: {
|
||||
button?: 'left' | 'right' | 'middle'
|
||||
modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[]
|
||||
moveMouseToEmptyArea?: boolean
|
||||
}
|
||||
) {
|
||||
let clickPos: Position
|
||||
switch (position) {
|
||||
@@ -377,12 +380,7 @@ export class NodeReference {
|
||||
delete options.moveMouseToEmptyArea
|
||||
}
|
||||
|
||||
await this.comfyPage.canvas.click({
|
||||
...options,
|
||||
position: clickPos,
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt(clickPos, options)
|
||||
if (moveMouseToEmptyArea) {
|
||||
await this.comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
}
|
||||
@@ -499,31 +497,18 @@ export class NodeReference {
|
||||
|
||||
await expect(async () => {
|
||||
// Try just clicking the enter button first
|
||||
await this.comfyPage.canvas.click({
|
||||
position: { x: 250, y: 250 },
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
|
||||
|
||||
await this.comfyPage.canvas.click({
|
||||
position: subgraphButtonPos,
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt(subgraphButtonPos)
|
||||
|
||||
if (await checkIsInSubgraph()) return
|
||||
|
||||
for (const position of clickPositions) {
|
||||
// Clear any selection first
|
||||
await this.comfyPage.canvas.click({
|
||||
position: { x: 250, y: 250 },
|
||||
force: true
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseClickAt({ x: 250, y: 250 })
|
||||
|
||||
// Double-click to enter subgraph
|
||||
await this.comfyPage.canvas.dblclick({ position, force: true })
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.comfyPage.canvasOps.mouseDblclickAt(position)
|
||||
|
||||
if (await checkIsInSubgraph()) return
|
||||
}
|
||||
|
||||
66
browser_tests/helpers/painter.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import type { TestGraphAccess } from '@e2e/types/globals'
|
||||
|
||||
export async function drawStroke(
|
||||
page: Page,
|
||||
canvas: Locator,
|
||||
opts: { startXPct?: number; endXPct?: number; yPct?: number } = {}
|
||||
): Promise<void> {
|
||||
const { startXPct = 0.3, endXPct = 0.7, yPct = 0.5 } = opts
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not found')
|
||||
await page.mouse.move(
|
||||
box.x + box.width * startXPct,
|
||||
box.y + box.height * yPct
|
||||
)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(
|
||||
box.x + box.width * endXPct,
|
||||
box.y + box.height * yPct,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
export async function hasCanvasContent(canvas: Locator): Promise<boolean> {
|
||||
return canvas.evaluate((el: HTMLCanvasElement) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const { data } = ctx.getImageData(0, 0, el.width, el.height)
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
export async function triggerSerialization(page: Page): Promise<void> {
|
||||
await page.evaluate(async () => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
if (!graph) {
|
||||
throw new Error(
|
||||
'Global window.graph is absent. Ensure workflow fixture is loaded.'
|
||||
)
|
||||
}
|
||||
|
||||
const node = graph._nodes_by_id?.['1']
|
||||
if (!node) {
|
||||
throw new Error(
|
||||
'Target node with ID "1" not found in graph._nodes_by_id.'
|
||||
)
|
||||
}
|
||||
|
||||
const widget = node.widgets?.find((w) => w.name === 'mask')
|
||||
if (!widget) {
|
||||
throw new Error('Widget "mask" not found on target node 1.')
|
||||
}
|
||||
|
||||
if (typeof widget.serializeValue !== 'function') {
|
||||
throw new Error(
|
||||
'mask widget on node 1 does not have a serializeValue function.'
|
||||
)
|
||||
}
|
||||
|
||||
await widget.serializeValue(node, 0)
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Image Compare', () => {
|
||||
test.describe('Image Compare', { tag: '@widget' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_compare_widget')
|
||||
@@ -21,7 +22,12 @@ test.describe('Image Compare', () => {
|
||||
|
||||
async function setImageCompareValue(
|
||||
comfyPage: ComfyPage,
|
||||
value: { beforeImages: string[]; afterImages: string[] }
|
||||
value: {
|
||||
beforeImages: string[]
|
||||
afterImages: string[]
|
||||
beforeAlt?: string
|
||||
afterAlt?: string
|
||||
}
|
||||
) {
|
||||
await comfyPage.page.evaluate(
|
||||
({ value }) => {
|
||||
@@ -37,6 +43,48 @@ test.describe('Image Compare', () => {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async function moveToPercentage(
|
||||
page: Page,
|
||||
containerLocator: Locator,
|
||||
percentage: number
|
||||
) {
|
||||
const box = await containerLocator.boundingBox()
|
||||
if (!box) throw new Error('Container not found')
|
||||
await page.mouse.move(
|
||||
box.x + box.width * (percentage / 100),
|
||||
box.y + box.height / 2
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForImagesLoaded(node: Locator) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
node.evaluate((el) => {
|
||||
const imgs = el.querySelectorAll('img')
|
||||
return (
|
||||
imgs.length > 0 &&
|
||||
Array.from(imgs).every(
|
||||
(img) => img.complete && img.naturalWidth > 0
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function getClipPathInsetRightPercent(imgLocator: Locator) {
|
||||
return imgLocator.evaluate((el) => {
|
||||
// Accessing raw style avoids cross-browser getComputedStyle normalization issues
|
||||
// Format is uniformly "inset(0 60% 0 0)" per Vue runtime inline style bindings
|
||||
const parts = (el as HTMLElement).style.clipPath.split(' ')
|
||||
return parts.length > 1 ? parseFloat(parts[1]) : -1
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Shows empty state when no images are set',
|
||||
{ tag: '@smoke' },
|
||||
@@ -50,6 +98,10 @@ test.describe('Image Compare', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slider defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Slider defaults to 50% with both images set',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
@@ -71,11 +123,440 @@ test.describe('Image Compare', () => {
|
||||
await expect(handle).toBeVisible()
|
||||
|
||||
expect(
|
||||
await handle.evaluate((el) => (el as HTMLElement).style.left)
|
||||
await handle.evaluate((el) => (el as HTMLElement).style.left),
|
||||
'Slider should default to 50% before screenshot'
|
||||
).toBe('50%')
|
||||
await expect(beforeImg).toHaveCSS('clip-path', /50%/)
|
||||
await expect
|
||||
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||
.toBeCloseTo(50, 0)
|
||||
|
||||
await waitForImagesLoaded(node)
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await expect(node).toHaveScreenshot('image-compare-default-50.png')
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slider interaction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Mouse hover moves slider position',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.getByRole('presentation')
|
||||
const beforeImg = node.locator('img[alt="Before image"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
// Left edge: sliderPosition ≈ 5 → clip-path inset right ≈ 95%
|
||||
await moveToPercentage(comfyPage.page, afterImg, 5)
|
||||
await expect
|
||||
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||
.toBeGreaterThan(90)
|
||||
await expect
|
||||
.poll(() =>
|
||||
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||
)
|
||||
.toBeLessThan(10)
|
||||
|
||||
// Right edge: sliderPosition ≈ 95 → clip-path inset right ≈ 5%
|
||||
await moveToPercentage(comfyPage.page, afterImg, 95)
|
||||
await expect
|
||||
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||
.toBeLessThan(10)
|
||||
await expect
|
||||
.poll(() =>
|
||||
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||
)
|
||||
.toBeGreaterThan(90)
|
||||
}
|
||||
)
|
||||
|
||||
test('Slider preserves last position when mouse leaves widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.getByRole('presentation')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
await moveToPercentage(comfyPage.page, afterImg, 30)
|
||||
// Wait for Vue to commit the slider update
|
||||
await expect
|
||||
.poll(() =>
|
||||
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||
)
|
||||
.toBeCloseTo(30, 0)
|
||||
const positionWhileInside = parseFloat(
|
||||
await handle.evaluate((el) => (el as HTMLElement).style.left)
|
||||
)
|
||||
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
|
||||
// Position must not reset to default 50%
|
||||
await expect
|
||||
.poll(() =>
|
||||
handle.evaluate((el) => parseFloat((el as HTMLElement).style.left))
|
||||
)
|
||||
.toBeCloseTo(positionWhileInside, 0)
|
||||
})
|
||||
|
||||
test('Slider clamps to 0% at left edge of container', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const handle = node.getByRole('presentation')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await expect(afterImg).toBeVisible()
|
||||
|
||||
const box = await afterImg.boundingBox()
|
||||
if (!box) throw new Error('Container not found')
|
||||
|
||||
// Move to the leftmost pixel (elementX = 0 → sliderPosition = 0)
|
||||
await comfyPage.page.mouse.move(box.x, box.y + box.height / 2)
|
||||
await expect
|
||||
.poll(() => handle.evaluate((el) => (el as HTMLElement).style.left))
|
||||
.toBe('0%')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Single image modes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Only before image shows without slider when afterImages is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('Before', '#c00')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url],
|
||||
afterImages: []
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toHaveCount(1)
|
||||
await expect(node.getByRole('presentation')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Only after image shows without slider when beforeImages is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [],
|
||||
afterImages: [url]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toHaveCount(1)
|
||||
await expect(node.getByRole('presentation')).toBeHidden()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test(
|
||||
'Batch navigation appears when before side has multiple images',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
const afterUrl = createTestImageDataUrl('B1', '#888')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
|
||||
await expect(node.getByTestId('batch-nav')).toBeVisible()
|
||||
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('1 / 3')
|
||||
// after-batch renders only when afterBatchCount > 1
|
||||
await expect(node.getByTestId('after-batch')).toBeHidden()
|
||||
await expect(beforeBatch.getByTestId('batch-prev')).toBeDisabled()
|
||||
}
|
||||
)
|
||||
|
||||
test('Batch navigation is hidden when both sides have single images', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('Image', '#c00')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url],
|
||||
afterImages: [url]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.getByTestId('batch-nav')).toBeHidden()
|
||||
})
|
||||
|
||||
test(
|
||||
'Navigate forward through before images',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [createTestImageDataUrl('B1', '#888')]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
const counter = beforeBatch.getByTestId('batch-counter')
|
||||
const nextBtn = beforeBatch.getByTestId('batch-next')
|
||||
const prevBtn = beforeBatch.getByTestId('batch-prev')
|
||||
|
||||
await nextBtn.click()
|
||||
await expect(counter).toHaveText('2 / 3')
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||
'src',
|
||||
url2
|
||||
)
|
||||
await expect(prevBtn).toBeEnabled()
|
||||
|
||||
await nextBtn.click()
|
||||
await expect(counter).toHaveText('3 / 3')
|
||||
await expect(nextBtn).toBeDisabled()
|
||||
}
|
||||
)
|
||||
|
||||
test('Navigate backward through before images', async ({ comfyPage }) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [createTestImageDataUrl('B1', '#888')]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
const counter = beforeBatch.getByTestId('batch-counter')
|
||||
const nextBtn = beforeBatch.getByTestId('batch-next')
|
||||
const prevBtn = beforeBatch.getByTestId('batch-prev')
|
||||
|
||||
await nextBtn.click()
|
||||
await nextBtn.click()
|
||||
await expect(counter).toHaveText('3 / 3')
|
||||
|
||||
await prevBtn.click()
|
||||
await expect(counter).toHaveText('2 / 3')
|
||||
await expect(prevBtn).toBeEnabled()
|
||||
await expect(nextBtn).toBeEnabled()
|
||||
})
|
||||
|
||||
test('Before and after batch navigation are independent', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url1 = createTestImageDataUrl('A1', '#c00')
|
||||
const url2 = createTestImageDataUrl('A2', '#0c0')
|
||||
const url3 = createTestImageDataUrl('A3', '#00c')
|
||||
const urlA = createTestImageDataUrl('B1', '#880')
|
||||
const urlB = createTestImageDataUrl('B2', '#008')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [url1, url2, url3],
|
||||
afterImages: [urlA, urlB]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeBatch = node.getByTestId('before-batch')
|
||||
const afterBatch = node.getByTestId('after-batch')
|
||||
|
||||
await beforeBatch.getByTestId('batch-next').click()
|
||||
await afterBatch.getByTestId('batch-next').click()
|
||||
|
||||
await expect(beforeBatch.getByTestId('batch-counter')).toHaveText('2 / 3')
|
||||
await expect(afterBatch.getByTestId('batch-counter')).toHaveText('2 / 2')
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||
'src',
|
||||
url2
|
||||
)
|
||||
await expect(node.locator('img[alt="After image"]')).toHaveAttribute(
|
||||
'src',
|
||||
urlB
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Visual regression screenshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
for (const { pct, expectedClipMin, expectedClipMax } of [
|
||||
{ pct: 25, expectedClipMin: 70, expectedClipMax: 80 },
|
||||
{ pct: 75, expectedClipMin: 20, expectedClipMax: 30 }
|
||||
]) {
|
||||
test(
|
||||
`Screenshot at ${pct}% slider position`,
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const beforeImg = node.locator('img[alt="Before image"]')
|
||||
const afterImg = node.locator('img[alt="After image"]')
|
||||
await waitForImagesLoaded(node)
|
||||
await moveToPercentage(comfyPage.page, afterImg, pct)
|
||||
await expect
|
||||
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||
.toBeGreaterThan(expectedClipMin)
|
||||
await expect
|
||||
.poll(() => getClipPathInsetRightPercent(beforeImg))
|
||||
.toBeLessThan(expectedClipMax)
|
||||
|
||||
await expect(node).toHaveScreenshot(`image-compare-slider-${pct}.png`)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('Widget remains stable with broken image URLs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: ['https://example.invalid/broken.png'],
|
||||
afterImages: ['https://example.invalid/broken2.png']
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toHaveCount(2)
|
||||
await expect(node.getByRole('presentation')).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
node.evaluate((el) => {
|
||||
const imgs = el.querySelectorAll('img')
|
||||
let errors = 0
|
||||
imgs.forEach((img) => {
|
||||
if (img.complete && img.naturalWidth === 0 && img.src) errors++
|
||||
})
|
||||
return errors
|
||||
})
|
||||
)
|
||||
.toBe(2)
|
||||
})
|
||||
|
||||
test('Rapid value updates show latest images and reset batch index', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const redUrl = createTestImageDataUrl('Red', '#c00')
|
||||
const green1Url = createTestImageDataUrl('G1', '#0c0')
|
||||
const green2Url = createTestImageDataUrl('G2', '#090')
|
||||
const blueUrl = createTestImageDataUrl('Blue', '#00c')
|
||||
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [redUrl, green1Url],
|
||||
afterImages: [blueUrl]
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await node.getByTestId('before-batch').getByTestId('batch-next').click()
|
||||
await expect(
|
||||
node.getByTestId('before-batch').getByTestId('batch-counter')
|
||||
).toHaveText('2 / 2')
|
||||
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [green1Url, green2Url],
|
||||
afterImages: [blueUrl]
|
||||
})
|
||||
|
||||
await expect(node.locator('img[alt="Before image"]')).toHaveAttribute(
|
||||
'src',
|
||||
green1Url
|
||||
)
|
||||
await expect(
|
||||
node.getByTestId('before-batch').getByTestId('batch-counter')
|
||||
).toHaveText('1 / 2')
|
||||
})
|
||||
|
||||
test('Legacy string value shows single image without slider', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const url = createTestImageDataUrl('Legacy', '#c00')
|
||||
await comfyPage.page.evaluate(
|
||||
({ url }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecompare')
|
||||
if (widget) {
|
||||
widget.value = url
|
||||
widget.callback?.(url)
|
||||
}
|
||||
},
|
||||
{ url }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img')).toHaveCount(1)
|
||||
await expect(node.getByRole('presentation')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Custom beforeAlt and afterAlt are used as img alt text', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const beforeUrl = createTestImageDataUrl('Before', '#c00')
|
||||
const afterUrl = createTestImageDataUrl('After', '#00c')
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: [beforeUrl],
|
||||
afterImages: [afterUrl],
|
||||
beforeAlt: 'Custom before',
|
||||
afterAlt: 'Custom after'
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node.locator('img[alt="Custom before"]')).toBeVisible()
|
||||
await expect(node.locator('img[alt="Custom after"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Large batch sizes show correct counter', async ({ comfyPage }) => {
|
||||
const images = Array.from({ length: 20 }, (_, i) =>
|
||||
createTestImageDataUrl(String(i + 1), '#c00')
|
||||
)
|
||||
await setImageCompareValue(comfyPage, {
|
||||
beforeImages: images,
|
||||
afterImages: images
|
||||
})
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(
|
||||
node.getByTestId('before-batch').getByTestId('batch-counter')
|
||||
).toHaveText('1 / 20')
|
||||
await expect(
|
||||
node.getByTestId('after-batch').getByTestId('batch-counter')
|
||||
).toHaveText('1 / 20')
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -1,8 +1,38 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
function hasCanvasContent(canvas: Locator): Promise<boolean> {
|
||||
return canvas.evaluate((el: HTMLCanvasElement) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const { data } = ctx.getImageData(0, 0, el.width, el.height)
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
async function clickMinimapAt(
|
||||
overlay: Locator,
|
||||
page: Page,
|
||||
relX: number,
|
||||
relY: number
|
||||
) {
|
||||
const box = await overlay.boundingBox()
|
||||
expect(box, 'Minimap interaction overlay not found').toBeTruthy()
|
||||
|
||||
// Click area — avoiding the settings button (top-left, 32×32px)
|
||||
// and close button (top-right, 32×32px)
|
||||
await page.mouse.click(
|
||||
box!.x + box!.width * relX,
|
||||
box!.y + box!.height * relY
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
@@ -13,14 +43,20 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
|
||||
test('Validate minimap is visible by default', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
const minimapCanvas = minimapContainer.locator('.minimap-canvas')
|
||||
const minimapCanvas = minimapContainer.getByTestId(
|
||||
TestIds.canvas.minimapCanvas
|
||||
)
|
||||
await expect(minimapCanvas).toBeVisible()
|
||||
|
||||
const minimapViewport = minimapContainer.locator('.minimap-viewport')
|
||||
const minimapViewport = minimapContainer.getByTestId(
|
||||
TestIds.canvas.minimapViewport
|
||||
)
|
||||
await expect(minimapViewport).toBeVisible()
|
||||
|
||||
await expect(minimapContainer).toHaveCSS('position', 'relative')
|
||||
@@ -40,12 +76,16 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
|
||||
await expect(toggleButton).toBeVisible()
|
||||
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
const toggleButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
@@ -60,7 +100,9 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
|
||||
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
|
||||
const minimapContainer = comfyPage.page.locator('.litegraph-minimap')
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
@@ -72,7 +114,7 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
|
||||
test('Close button hides minimap', async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
|
||||
@@ -88,7 +130,9 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
'Panning canvas moves minimap viewport',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
const minimap = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
|
||||
@@ -105,14 +149,135 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
}
|
||||
)
|
||||
|
||||
test('Minimap canvas is non-empty for a workflow with nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const minimapCanvas = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapCanvas
|
||||
)
|
||||
await expect(minimapCanvas).toBeVisible()
|
||||
|
||||
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(true)
|
||||
})
|
||||
|
||||
test('Minimap canvas is empty after all nodes are deleted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const minimapCanvas = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapCanvas
|
||||
)
|
||||
await expect(minimapCanvas).toBeVisible()
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.vueNodes.deleteSelected()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(false)
|
||||
})
|
||||
|
||||
test('Clicking minimap corner pans the main canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
|
||||
const overlay = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapInteractionOverlay
|
||||
)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const before = await comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
|
||||
const transformBefore = await viewport.evaluate(
|
||||
(el: HTMLElement) => el.style.transform
|
||||
)
|
||||
|
||||
await clickMinimapAt(overlay, comfyPage.page, 0.15, 0.85)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
)
|
||||
.not.toStrictEqual(before)
|
||||
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform))
|
||||
.not.toBe(transformBefore)
|
||||
})
|
||||
|
||||
test('Clicking minimap center after FitView causes minimal canvas movement', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
const overlay = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapInteractionOverlay
|
||||
)
|
||||
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.offset[0] -= 1000
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const transformBefore = await viewport.evaluate(
|
||||
(el: HTMLElement) => el.style.transform
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.fitViewToSelectionAnimated({ duration: 1 })
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform), {
|
||||
timeout: 2000
|
||||
})
|
||||
.not.toBe(transformBefore)
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const before = await comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
|
||||
await clickMinimapAt(overlay, comfyPage.page, 0.5, 0.5)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
|
||||
// ~3px overlay error × ~15 canvas/minimap scale ≈ 45, rounded up
|
||||
const TOLERANCE = 50
|
||||
expect(
|
||||
Math.abs(after.x - before.x),
|
||||
`offset.x changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
|
||||
).toBeLessThan(TOLERANCE)
|
||||
expect(
|
||||
Math.abs(after.y - before.y),
|
||||
`offset.y changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
|
||||
).toBeLessThan(TOLERANCE)
|
||||
})
|
||||
|
||||
test(
|
||||
'Viewport rectangle is visible and positioned within minimap',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.locator('.litegraph-minimap')
|
||||
const minimap = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const viewport = minimap.locator('.minimap-viewport')
|
||||
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
await expect(async () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ async function openSelectionToolboxHelp(comfyPage: ComfyPage) {
|
||||
|
||||
const helpButton = comfyPage.selectionToolbox.getByTestId('info-button')
|
||||
await expect(helpButton).toBeVisible()
|
||||
await helpButton.click({ force: true })
|
||||
await helpButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return comfyPage.page.getByTestId('properties-panel')
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { UploadImageResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
drawStroke,
|
||||
hasCanvasContent,
|
||||
triggerSerialization
|
||||
} from '@e2e/helpers/painter'
|
||||
|
||||
test.describe('Painter', () => {
|
||||
test.describe('Painter', { tag: '@widget' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => window.app?.graph?.clear())
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
@@ -20,9 +28,15 @@ test.describe('Painter', () => {
|
||||
await expect(painterWidget).toBeVisible()
|
||||
|
||||
await expect(painterWidget.locator('canvas')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Brush')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Eraser')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Clear')).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.getByRole('button', { name: 'Brush' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.getByRole('button', { name: 'Eraser' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-clear-button')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.locator('input[type="color"]').first()
|
||||
).toBeVisible()
|
||||
@@ -39,22 +53,66 @@ test.describe('Painter', () => {
|
||||
const canvas = node.locator('.widget-expands canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
expect(await hasCanvasContent(canvas), 'canvas should start empty').toBe(
|
||||
false
|
||||
)
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
|
||||
await expect
|
||||
.poll(async () =>
|
||||
canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return true
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
return data.data.every((v, i) => (i % 4 === 3 ? v === 0 : true))
|
||||
})
|
||||
)
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas should have content after stroke'
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
await expect(node).toHaveScreenshot('painter-after-stroke.png')
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Drawing', () => {
|
||||
test(
|
||||
'Eraser removes drawn content',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas must have content before erasing'
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
canvas.evaluate((el: HTMLCanvasElement) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const cx = Math.floor(el.width / 2)
|
||||
const cy = Math.floor(el.height / 2)
|
||||
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
|
||||
return data.every((v, i) => i % 4 !== 3 || v === 0)
|
||||
}),
|
||||
{ message: 'erased area should be transparent' }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
test('Stroke ends cleanly when pointer up fires outside canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not found')
|
||||
|
||||
@@ -68,29 +126,250 @@ test.describe('Painter', () => {
|
||||
box.y + box.height * 0.5,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await comfyPage.page.mouse.move(box.x - 20, box.y + box.height * 0.5)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(async () =>
|
||||
canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return false
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
)
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message:
|
||||
'canvas should have content after stroke with pointer up outside'
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
await expect(node).toHaveScreenshot('painter-after-stroke.png')
|
||||
}
|
||||
)
|
||||
test.describe('Tool selection', () => {
|
||||
test('Tool switching toggles brush-only controls', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
|
||||
await expect(painterWidget.getByTestId('painter-color-row')).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-hardness-row')
|
||||
).toBeVisible()
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-color-row'),
|
||||
'color row should be hidden in eraser mode'
|
||||
).toBeHidden()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-hardness-row')
|
||||
).toBeHidden()
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Brush' }).click()
|
||||
|
||||
await expect(painterWidget.getByTestId('painter-color-row')).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-hardness-row')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Brush settings', () => {
|
||||
test('Size slider updates the displayed value', async ({ comfyPage }) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const sizeRow = painterWidget.getByTestId('painter-size-row')
|
||||
const sizeSlider = sizeRow.getByRole('slider')
|
||||
const sizeDisplay = sizeRow.getByTestId('painter-size-value')
|
||||
|
||||
await expect(sizeDisplay).toHaveText('20')
|
||||
|
||||
await sizeSlider.focus()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await sizeSlider.press('ArrowRight')
|
||||
}
|
||||
|
||||
await expect(sizeDisplay).toHaveText('30')
|
||||
})
|
||||
|
||||
test('Opacity input clamps out-of-range values', async ({ comfyPage }) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const opacityInput = painterWidget
|
||||
.getByTestId('painter-color-row')
|
||||
.locator('input[type="number"]')
|
||||
|
||||
await opacityInput.fill('150')
|
||||
await opacityInput.press('Tab')
|
||||
await expect(opacityInput).toHaveValue('100')
|
||||
|
||||
await opacityInput.fill('-10')
|
||||
await opacityInput.press('Tab')
|
||||
await expect(opacityInput).toHaveValue('0')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Canvas size controls', () => {
|
||||
test('Width and height sliders visible without connected input', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
|
||||
await expect(painterWidget.getByTestId('painter-width-row')).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-height-row')
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
painterWidget.getByTestId('painter-dimension-text')
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Width slider resizes the canvas element', async ({ comfyPage }) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
const widthSlider = painterWidget
|
||||
.getByTestId('painter-width-row')
|
||||
.getByRole('slider')
|
||||
|
||||
const initialWidth = await canvas.evaluate(
|
||||
(el: HTMLCanvasElement) => el.width
|
||||
)
|
||||
expect(initialWidth, 'canvas should start at default width').toBe(512)
|
||||
|
||||
await widthSlider.focus()
|
||||
await widthSlider.press('ArrowRight')
|
||||
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
|
||||
.toBe(576)
|
||||
})
|
||||
|
||||
test(
|
||||
'Resize preserves existing drawing',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
const widthSlider = painterWidget
|
||||
.getByTestId('painter-width-row')
|
||||
.getByRole('slider')
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas must have content before resize'
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
await widthSlider.focus()
|
||||
await widthSlider.press('ArrowRight')
|
||||
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((el: HTMLCanvasElement) => el.width))
|
||||
.toBe(576)
|
||||
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
await expect(node).toHaveScreenshot('painter-after-resize.png')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Clear', () => {
|
||||
test(
|
||||
'Clear removes all drawn content',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const painterWidget = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas must have content before clear'
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
const clearButton = painterWidget.getByTestId('painter-clear-button')
|
||||
await clearButton.dispatchEvent('click')
|
||||
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), {
|
||||
message: 'canvas should be clear after click'
|
||||
})
|
||||
.toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Serialization', () => {
|
||||
test('Drawing triggers upload on serialization', async ({ comfyPage }) => {
|
||||
const mockUploadResponse: UploadImageResponse = {
|
||||
name: 'painter-test.png'
|
||||
}
|
||||
let uploadCount = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
uploadCount++
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockUploadResponse)
|
||||
})
|
||||
})
|
||||
|
||||
const canvas = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands canvas')
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
|
||||
await triggerSerialization(comfyPage.page)
|
||||
|
||||
expect(uploadCount, 'should upload exactly once').toBe(1)
|
||||
})
|
||||
|
||||
test('Empty canvas does not upload on serialization', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
let uploadCount = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
uploadCount++
|
||||
const mockResponse: UploadImageResponse = { name: 'painter-test.png' }
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockResponse)
|
||||
})
|
||||
})
|
||||
|
||||
await triggerSerialization(comfyPage.page)
|
||||
|
||||
expect(uploadCount, 'empty canvas should not upload').toBe(0)
|
||||
})
|
||||
|
||||
test('Upload failure shows error toast', async ({ comfyPage }) => {
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
await route.fulfill({ status: 500 })
|
||||
})
|
||||
|
||||
const canvas = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands canvas')
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
|
||||
await expect(triggerSerialization(comfyPage.page)).rejects.toThrow()
|
||||
|
||||
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 26 KiB |
@@ -1,234 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { measureSelectionBounds } from '@e2e/fixtures/helpers/boundsUtils'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
const SUBGRAPH_ID = '2'
|
||||
const REGULAR_ID = '3'
|
||||
const WORKFLOW = 'selection/subgraph-with-regular-node'
|
||||
|
||||
const REF_POS: [number, number] = [100, 100]
|
||||
const TARGET_POSITIONS: Record<string, [number, number]> = {
|
||||
'bottom-left': [50, 500],
|
||||
'bottom-right': [600, 500]
|
||||
}
|
||||
|
||||
type NodeType = 'subgraph' | 'regular'
|
||||
type NodeState = 'expanded' | 'collapsed'
|
||||
type Position = 'bottom-left' | 'bottom-right'
|
||||
|
||||
function getTargetId(type: NodeType): string {
|
||||
return type === 'subgraph' ? SUBGRAPH_ID : REGULAR_ID
|
||||
}
|
||||
|
||||
function getRefId(type: NodeType): string {
|
||||
return type === 'subgraph' ? REGULAR_ID : SUBGRAPH_ID
|
||||
}
|
||||
|
||||
async function userToggleCollapse(
|
||||
comfyPage: ComfyPage,
|
||||
nodeRef: NodeReference
|
||||
) {
|
||||
await nodeRef.click('title')
|
||||
await comfyPage.keyboard.collapse()
|
||||
}
|
||||
|
||||
async function userToggleBypass(comfyPage: ComfyPage, nodeRef: NodeReference) {
|
||||
await nodeRef.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
}
|
||||
|
||||
async function assertSelectionEncompassesNodes(
|
||||
page: Page,
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) {
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(2)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await measureSelectionBounds(page, nodeIds)
|
||||
expect(result.selectionBounds).not.toBeNull()
|
||||
|
||||
const sel = result.selectionBounds!
|
||||
const selRight = sel.x + sel.w
|
||||
const selBottom = sel.y + sel.h
|
||||
|
||||
for (const nodeId of nodeIds) {
|
||||
const vis = result.nodeVisualBounds[nodeId]
|
||||
expect(vis).toBeDefined()
|
||||
|
||||
expect(sel.x).toBeLessThanOrEqual(vis.x)
|
||||
expect(selRight).toBeGreaterThanOrEqual(vis.x + vis.w)
|
||||
expect(sel.y).toBeLessThanOrEqual(vis.y)
|
||||
expect(selBottom).toBeGreaterThanOrEqual(vis.y + vis.h)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Selection bounding box (Vue mode)',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
const nodeTypes: NodeType[] = ['subgraph', 'regular']
|
||||
const nodeStates: NodeState[] = ['expanded', 'collapsed']
|
||||
const positions: Position[] = ['bottom-left', 'bottom-right']
|
||||
|
||||
for (const type of nodeTypes) {
|
||||
for (const state of nodeStates) {
|
||||
for (const pos of positions) {
|
||||
test(`${type} node (${state}) at ${pos}: selection bounds encompass node`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const targetId = getTargetId(type)
|
||||
const refId = getRefId(type)
|
||||
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[refId]: REF_POS,
|
||||
[targetId]: TARGET_POSITIONS[pos]
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.vueNodes.getNodeLocator(targetId).waitFor()
|
||||
await comfyPage.vueNodes.getNodeLocator(refId).waitFor()
|
||||
|
||||
if (state === 'collapsed') {
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(targetId)
|
||||
await userToggleCollapse(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
}
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
refId,
|
||||
targetId
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'Selection bounding box (Vue mode) — collapsed node bypass toggle',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
test('collapsed node narrows bounding box when bypass is removed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[SUBGRAPH_ID]: REF_POS,
|
||||
[REGULAR_ID]: TARGET_POSITIONS['bottom-right']
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
||||
await userToggleBypass(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
await userToggleCollapse(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
|
||||
await userToggleBypass(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
SUBGRAPH_ID,
|
||||
REGULAR_ID
|
||||
])
|
||||
})
|
||||
|
||||
test('collapsed node widens bounding box when bypass is added', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[SUBGRAPH_ID]: REF_POS,
|
||||
[REGULAR_ID]: TARGET_POSITIONS['bottom-right']
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
||||
await userToggleCollapse(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
|
||||
await userToggleBypass(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
SUBGRAPH_ID,
|
||||
REGULAR_ID
|
||||
])
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'Selection bounding box (legacy mode)',
|
||||
{ tag: ['@canvas', '@node'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.canvasOps.resetView()
|
||||
})
|
||||
|
||||
const nodeStates: NodeState[] = ['expanded', 'collapsed']
|
||||
const positions: Position[] = ['bottom-left', 'bottom-right']
|
||||
|
||||
for (const state of nodeStates) {
|
||||
for (const pos of positions) {
|
||||
test(`legacy node (${state}) at ${pos}: selection bounds encompass node`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nodeOps.repositionNodes({
|
||||
[SUBGRAPH_ID]: REF_POS,
|
||||
[REGULAR_ID]: TARGET_POSITIONS[pos]
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
if (state === 'collapsed') {
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
|
||||
await userToggleCollapse(comfyPage, nodeRef)
|
||||
await expect.poll(() => nodeRef.isCollapsed()).toBe(true)
|
||||
}
|
||||
|
||||
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
|
||||
SUBGRAPH_ID,
|
||||
REGULAR_ID
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -49,7 +49,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
|
||||
const deleteButton = comfyPage.page.getByTestId('delete-button')
|
||||
await expect(deleteButton).toBeVisible()
|
||||
await deleteButton.click({ force: true })
|
||||
await deleteButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
@@ -65,7 +65,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
|
||||
const infoButton = comfyPage.page.getByTestId('info-button')
|
||||
await expect(infoButton).toBeVisible()
|
||||
await infoButton.click({ force: true })
|
||||
await infoButton.click()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -98,7 +98,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
|
||||
const deleteButton = comfyPage.page.getByTestId('delete-button')
|
||||
await expect(deleteButton).toBeVisible()
|
||||
await deleteButton.click({ force: true })
|
||||
await deleteButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
@@ -120,7 +120,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
|
||||
const bypassButton = comfyPage.page.getByTestId('bypass-button')
|
||||
await expect(bypassButton).toBeVisible()
|
||||
await bypassButton.click({ force: true })
|
||||
await bypassButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
@@ -128,7 +128,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
BYPASS_CLASS
|
||||
)
|
||||
|
||||
await bypassButton.click({ force: true })
|
||||
await bypassButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
||||
@@ -147,7 +147,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
'convert-to-subgraph-button'
|
||||
)
|
||||
await expect(convertButton).toBeVisible()
|
||||
await convertButton.click({ force: true })
|
||||
await convertButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// KSampler should be gone, replaced by a subgraph node
|
||||
@@ -175,7 +175,7 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
'convert-to-subgraph-button'
|
||||
)
|
||||
await expect(convertButton).toBeVisible()
|
||||
await convertButton.click({ force: true })
|
||||
await convertButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
@@ -200,13 +200,14 @@ test.describe('Selection Toolbox - Button Actions', { tag: '@ui' }, () => {
|
||||
await comfyPage.nodeOps.selectNodes(['KSampler', 'Empty Latent Image'])
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const frameButton = comfyPage.page.getByRole('button', {
|
||||
name: /Frame Nodes/i
|
||||
})
|
||||
await expect(frameButton).toBeVisible()
|
||||
await comfyPage.page
|
||||
await expect(
|
||||
comfyPage.selectionToolbox.getByRole('button', {
|
||||
name: /Frame Nodes/i
|
||||
})
|
||||
).toBeVisible()
|
||||
await comfyPage.selectionToolbox
|
||||
.getByRole('button', { name: /Frame Nodes/i })
|
||||
.click({ force: true })
|
||||
.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
|
||||
@@ -62,7 +62,7 @@ test.describe(
|
||||
return
|
||||
}
|
||||
|
||||
await moreOptionsBtn.click({ force: true })
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menuOptionsVisibleAfterClick = await comfyPage.page
|
||||
@@ -126,9 +126,7 @@ test.describe(
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
)[0]
|
||||
await openMoreOptions(comfyPage)
|
||||
await comfyPage.page
|
||||
.getByText('Rename', { exact: true })
|
||||
.click({ force: true })
|
||||
await comfyPage.page.getByText('Rename', { exact: true }).click()
|
||||
const input = comfyPage.page.locator(
|
||||
'.group-title-editor.node-title-editor .editable-text input'
|
||||
)
|
||||
@@ -153,11 +151,7 @@ test.describe(
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
await comfyPage.page
|
||||
.locator('#graph-canvas')
|
||||
.click({ position: { x: 0, y: 50 }, force: true })
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.canvasOps.mouseClickAt({ x: 0, y: 50 })
|
||||
await expect(
|
||||
comfyPage.page.getByText('Rename', { exact: true })
|
||||
).toBeHidden()
|
||||
|
||||
@@ -199,12 +199,7 @@ test.describe(
|
||||
|
||||
const stepsWidget = await ksampler.getWidget(2)
|
||||
const widgetPos = await stepsWidget.getPosition()
|
||||
await comfyPage.canvas.click({
|
||||
position: widgetPos,
|
||||
button: 'right',
|
||||
force: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
|
||||
|
||||
// Look for the Promote Widget menu entry
|
||||
const promoteEntry = comfyPage.page
|
||||
@@ -235,12 +230,7 @@ test.describe(
|
||||
const stepsWidget = await ksampler.getWidget(2)
|
||||
const widgetPos = await stepsWidget.getPosition()
|
||||
|
||||
await comfyPage.canvas.click({
|
||||
position: widgetPos,
|
||||
button: 'right',
|
||||
force: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
|
||||
|
||||
const promoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
@@ -266,12 +256,7 @@ test.describe(
|
||||
const stepsWidget2 = await ksampler2.getWidget(2)
|
||||
const widgetPos2 = await stepsWidget2.getPosition()
|
||||
|
||||
await comfyPage.canvas.click({
|
||||
position: widgetPos2,
|
||||
button: 'right',
|
||||
force: true
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.canvasOps.mouseClickAt(widgetPos2, { button: 'right' })
|
||||
|
||||
const unpromoteEntry = comfyPage.page
|
||||
.locator('.litemenu-entry')
|
||||
|
||||
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
@@ -94,6 +94,7 @@ async function connectSlots(
|
||||
const fromLoc = slotLocator(page, from.nodeId, from.index, false)
|
||||
const toLoc = slotLocator(page, to.nodeId, to.index, true)
|
||||
await expectVisibleAll(fromLoc, toLoc)
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
|
||||
await fromLoc.dragTo(toLoc, { force: true })
|
||||
await nextFrame()
|
||||
}
|
||||
@@ -192,6 +193,7 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
|
||||
const inputSlot = slotLocator(comfyPage.page, clipNode.id, 0, true)
|
||||
await expectVisibleAll(outputSlot, inputSlot)
|
||||
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
|
||||
await outputSlot.dragTo(inputSlot, { force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
@@ -218,6 +220,7 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
|
||||
const inputSlot = slotLocator(comfyPage.page, samplerNode.id, 3, true)
|
||||
await expectVisibleAll(outputSlot, inputSlot)
|
||||
|
||||
// oxlint-disable-next-line playwright/no-force-option -- Slot dot's parent wrapper div intercepts actionability check on inner dot
|
||||
await outputSlot.dragTo(inputSlot, { force: true })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 88 KiB |
162
browser_tests/tests/vueNodes/widgets/imageCrop.spec.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
type CropValue = { x: number; y: number; width: number; height: number } | null
|
||||
|
||||
test.describe('Image Crop', { tag: '@widget' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test.describe('without source image', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test(
|
||||
'Shows empty state when no input image is connected',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect.soft(node.getByTestId('crop-empty-icon')).toBeVisible()
|
||||
await expect.soft(node).toContainText('No input image connected')
|
||||
await expect.soft(node.getByTestId('crop-overlay')).toHaveCount(0)
|
||||
await expect.soft(node.locator('img')).toHaveCount(0)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Renders controls in default state',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node.getByText('Ratio')).toBeVisible()
|
||||
await expect(
|
||||
node.locator('button:has(.icon-\\[lucide--lock-open\\])')
|
||||
).toBeVisible()
|
||||
await expect(node.locator('input')).toHaveCount(4)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'with source image after execution',
|
||||
{ tag: ['@widget', '@slow'] },
|
||||
() => {
|
||||
async function getCropValue(comfyPage: ComfyPage): Promise<CropValue> {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const n = window.app!.graph.getNodeById(2)
|
||||
const w = n?.widgets?.find((w) => w.type === 'imagecrop')
|
||||
const v = w?.value
|
||||
if (v && typeof v === 'object' && 'x' in v) {
|
||||
const crop = v as {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
return {
|
||||
x: crop.x,
|
||||
y: crop.y,
|
||||
width: crop.width,
|
||||
height: crop.height
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_crop_with_source')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await comfyPage.runButton.click()
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeLocator('2').locator('img')
|
||||
).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
|
||||
test(
|
||||
'Displays source image with crop overlay after execution',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const img = node.locator('img')
|
||||
|
||||
await expect
|
||||
.poll(() => img.evaluate((el: HTMLImageElement) => el.naturalWidth))
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await expect(node.getByTestId('crop-overlay')).toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
await expect(node).toHaveScreenshot('image-crop-with-source.png', {
|
||||
maxDiffPixelRatio: 0.05
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Drag crop box updates crop position',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('2')
|
||||
const cropBox = node.getByTestId('crop-overlay')
|
||||
const box = await cropBox.boundingBox()
|
||||
if (!box) throw new Error('Crop box not found')
|
||||
|
||||
const valueBefore = await getCropValue(comfyPage)
|
||||
if (!valueBefore)
|
||||
throw new Error('Widget value missing — check fixture setup')
|
||||
|
||||
const startX = box.x + box.width / 2
|
||||
const startY = box.y + box.height / 2
|
||||
|
||||
const pointerOpts = { bubbles: true, cancelable: true, pointerId: 1 }
|
||||
await cropBox.dispatchEvent('pointerdown', {
|
||||
...pointerOpts,
|
||||
clientX: startX,
|
||||
clientY: startY
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await cropBox.dispatchEvent('pointermove', {
|
||||
...pointerOpts,
|
||||
clientX: startX + 15,
|
||||
clientY: startY + 10
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
await cropBox.dispatchEvent('pointermove', {
|
||||
...pointerOpts,
|
||||
clientX: startX + 30,
|
||||
clientY: startY + 20
|
||||
})
|
||||
await cropBox.dispatchEvent('pointerup', {
|
||||
...pointerOpts,
|
||||
clientX: startX + 30,
|
||||
clientY: startY + 20
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const valueAfter = await getCropValue(comfyPage)
|
||||
expect(valueAfter?.x).toBeGreaterThan(valueBefore.x)
|
||||
expect(valueAfter?.y).toBeGreaterThan(valueBefore.y)
|
||||
expect(valueAfter?.width).toBe(valueBefore.width)
|
||||
expect(valueAfter?.height).toBe(valueBefore.height)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
await expect(node).toHaveScreenshot('image-crop-after-drag.png', {
|
||||
maxDiffPixelRatio: 0.05
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -22,10 +22,8 @@ test.describe('Vue Integer Widget', () => {
|
||||
const initialValue = Number(await controls.input.inputValue())
|
||||
|
||||
// Verify widget is disabled when linked
|
||||
await controls.incrementButton.click({ force: true })
|
||||
await expect(controls.input).toHaveValue(initialValue.toString())
|
||||
|
||||
await controls.decrementButton.click({ force: true })
|
||||
await expect(controls.incrementButton).toBeDisabled()
|
||||
await expect(controls.decrementButton).toBeDisabled()
|
||||
await expect(controls.input).toHaveValue(initialValue.toString())
|
||||
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.3",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -17,8 +17,12 @@
|
||||
<div
|
||||
v-else-if="!imageUrl"
|
||||
class="flex size-full flex-col items-center justify-center text-center"
|
||||
data-testid="crop-empty-state"
|
||||
>
|
||||
<i class="mb-2 icon-[lucide--image] size-12" />
|
||||
<i
|
||||
class="mb-2 icon-[lucide--image] size-12"
|
||||
data-testid="crop-empty-icon"
|
||||
/>
|
||||
<p class="text-sm">{{ $t('imageCrop.noInputImage') }}</p>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +47,7 @@
|
||||
)
|
||||
"
|
||||
:style="cropBoxStyle"
|
||||
data-testid="crop-overlay"
|
||||
@pointerdown="handleDragStart"
|
||||
@pointermove="handleDragMove"
|
||||
@pointerup="handleDragEnd"
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
:has-skeleton="hasSkeleton"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@export-model="handleExportModel"
|
||||
@update-hdri-file="handleHDRIFileUpdate"
|
||||
/>
|
||||
<AnimationControls
|
||||
v-if="animations && animations.length > 0"
|
||||
@@ -139,6 +140,7 @@ const {
|
||||
handleClearRecording,
|
||||
handleSeek,
|
||||
handleBackgroundImageUpdate,
|
||||
handleHDRIFileUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
cleanup
|
||||
|
||||
@@ -6,19 +6,21 @@
|
||||
@pointerup.stop
|
||||
@wheel.stop
|
||||
>
|
||||
<div class="show-menu relative">
|
||||
<div class="relative">
|
||||
<Button
|
||||
ref="menuTriggerRef"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
:aria-label="$t('menu.showMenu')"
|
||||
class="rounded-full"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<i class="pi pi-bars text-lg text-base-foreground" />
|
||||
<i class="icon-[lucide--menu] text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-show="isMenuOpen"
|
||||
ref="menuPanelRef"
|
||||
class="absolute top-0 left-12 rounded-lg bg-interface-menu-surface shadow-lg"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
@@ -42,7 +44,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeCategory" class="rounded-lg bg-smoke-700/30">
|
||||
<SceneControls
|
||||
v-if="showSceneControls"
|
||||
@@ -51,6 +52,9 @@
|
||||
v-model:background-image="sceneConfig!.backgroundImage"
|
||||
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
:hdri-active="
|
||||
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
|
||||
"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
/>
|
||||
|
||||
@@ -70,11 +74,19 @@
|
||||
v-model:fov="cameraConfig!.fov"
|
||||
/>
|
||||
|
||||
<LightControls
|
||||
v-if="showLightControls"
|
||||
v-model:light-intensity="lightConfig!.intensity"
|
||||
v-model:material-mode="modelConfig!.materialMode"
|
||||
/>
|
||||
<div v-if="showLightControls" class="flex flex-col">
|
||||
<LightControls
|
||||
v-model:light-intensity="lightConfig!.intensity"
|
||||
v-model:material-mode="modelConfig!.materialMode"
|
||||
v-model:hdri-config="lightConfig!.hdri"
|
||||
/>
|
||||
|
||||
<HDRIControls
|
||||
v-model:hdri-config="lightConfig!.hdri"
|
||||
:has-background-image="!!sceneConfig?.backgroundImage"
|
||||
@update-hdri-file="handleHDRIFileUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ExportControls
|
||||
v-if="showExportControls"
|
||||
@@ -85,10 +97,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
|
||||
import HDRIControls from '@/components/load3d/controls/HDRIControls.vue'
|
||||
import LightControls from '@/components/load3d/controls/LightControls.vue'
|
||||
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
|
||||
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
|
||||
@@ -117,6 +131,17 @@ const cameraConfig = defineModel<CameraConfig>('cameraConfig')
|
||||
const lightConfig = defineModel<LightConfig>('lightConfig')
|
||||
|
||||
const isMenuOpen = ref(false)
|
||||
const menuPanelRef = ref<HTMLElement | null>(null)
|
||||
const menuTriggerRef = ref<InstanceType<typeof Button> | null>(null)
|
||||
|
||||
useDismissableOverlay({
|
||||
isOpen: isMenuOpen,
|
||||
getOverlayEl: () => menuPanelRef.value,
|
||||
getTriggerEl: () => menuTriggerRef.value?.$el ?? null,
|
||||
onDismiss: () => {
|
||||
isMenuOpen.value = false
|
||||
}
|
||||
})
|
||||
const activeCategory = ref<string>('scene')
|
||||
const categoryLabels: Record<string, string> = {
|
||||
scene: 'load3d.scene',
|
||||
@@ -160,21 +185,26 @@ const selectCategory = (category: string) => {
|
||||
isMenuOpen.value = false
|
||||
}
|
||||
|
||||
const categoryIcons = {
|
||||
scene: 'icon-[lucide--image]',
|
||||
model: 'icon-[lucide--box]',
|
||||
camera: 'icon-[lucide--camera]',
|
||||
light: 'icon-[lucide--sun]',
|
||||
export: 'icon-[lucide--download]'
|
||||
} as const
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
const icons = {
|
||||
scene: 'pi pi-image',
|
||||
model: 'pi pi-box',
|
||||
camera: 'pi pi-camera',
|
||||
light: 'pi pi-sun',
|
||||
export: 'pi pi-download'
|
||||
}
|
||||
// @ts-expect-error fixme ts strict error
|
||||
return `${icons[category]} text-base-foreground text-lg`
|
||||
const icon =
|
||||
category in categoryIcons
|
||||
? categoryIcons[category as keyof typeof categoryIcons]
|
||||
: 'icon-[lucide--circle]'
|
||||
return cn(icon, 'text-lg text-base-foreground')
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
(e: 'exportModel', format: string): void
|
||||
(e: 'updateHdriFile', file: File | null): void
|
||||
}>()
|
||||
|
||||
const handleBackgroundImageUpdate = (file: File | null) => {
|
||||
@@ -185,19 +215,7 @@ const handleExportModel = (format: string) => {
|
||||
emit('exportModel', format)
|
||||
}
|
||||
|
||||
const closeSlider = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
if (!target.closest('.show-menu')) {
|
||||
isMenuOpen.value = false
|
||||
}
|
||||
const handleHDRIFileUpdate = (file: File | null) => {
|
||||
emit('updateHdriFile', file)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeSlider)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeSlider)
|
||||
})
|
||||
</script>
|
||||
|
||||
148
src/components/load3d/controls/HDRIControls.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div v-if="!hasBackgroundImage || hdriConfig?.hdriPath" class="flex flex-col">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: hdriConfig?.hdriPath
|
||||
? $t('load3d.hdri.changeFile')
|
||||
: $t('load3d.hdri.uploadFile'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="
|
||||
hdriConfig?.hdriPath
|
||||
? $t('load3d.hdri.changeFile')
|
||||
: $t('load3d.hdri.uploadFile')
|
||||
"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<i class="icon-[lucide--upload] text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<template v-if="hdriConfig?.hdriPath">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.hdri.label'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn('rounded-full', hdriConfig?.enabled && 'ring-2 ring-white/50')
|
||||
"
|
||||
:aria-label="$t('load3d.hdri.label')"
|
||||
@click="toggleEnabled"
|
||||
>
|
||||
<i class="icon-[lucide--globe] text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.hdri.showAsBackground'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-full',
|
||||
hdriConfig?.showAsBackground && 'ring-2 ring-white/50'
|
||||
)
|
||||
"
|
||||
:aria-label="$t('load3d.hdri.showAsBackground')"
|
||||
@click="toggleShowAsBackground"
|
||||
>
|
||||
<i class="icon-[lucide--image] text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.hdri.removeFile'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.hdri.removeFile')"
|
||||
@click="onRemoveHDRI"
|
||||
>
|
||||
<i class="icon-[lucide--x] text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
class="hidden"
|
||||
:accept="SUPPORTED_HDRI_EXTENSIONS_ACCEPT"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import {
|
||||
SUPPORTED_HDRI_EXTENSIONS,
|
||||
SUPPORTED_HDRI_EXTENSIONS_ACCEPT
|
||||
} from '@/extensions/core/load3d/constants'
|
||||
import type { HDRIConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { hasBackgroundImage = false } = defineProps<{
|
||||
hasBackgroundImage?: boolean
|
||||
}>()
|
||||
|
||||
const hdriConfig = defineModel<HDRIConfig>('hdriConfig')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateHdriFile', file: File | null): void
|
||||
}>()
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function onFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0] ?? null
|
||||
input.value = ''
|
||||
if (file) {
|
||||
const ext = `.${file.name.split('.').pop()?.toLowerCase() ?? ''}`
|
||||
if (!SUPPORTED_HDRI_EXTENSIONS.has(ext)) {
|
||||
useToastStore().addAlert(t('toastMessages.unsupportedHDRIFormat'))
|
||||
return
|
||||
}
|
||||
}
|
||||
emit('updateHdriFile', file)
|
||||
}
|
||||
|
||||
function toggleEnabled() {
|
||||
if (!hdriConfig.value) return
|
||||
hdriConfig.value = {
|
||||
...hdriConfig.value,
|
||||
enabled: !hdriConfig.value.enabled
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowAsBackground() {
|
||||
if (!hdriConfig.value) return
|
||||
hdriConfig.value = {
|
||||
...hdriConfig.value,
|
||||
showAsBackground: !hdriConfig.value.showAsBackground
|
||||
}
|
||||
}
|
||||
|
||||
function onRemoveHDRI() {
|
||||
emit('updateHdriFile', null)
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,24 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div v-if="showLightIntensityButton" class="show-light-intensity relative">
|
||||
<div
|
||||
v-if="embedded && showIntensityControl"
|
||||
class="flex w-[200px] flex-col gap-2 rounded-lg bg-black/50 p-3 shadow-lg"
|
||||
>
|
||||
<span class="text-sm font-medium text-base-foreground">{{
|
||||
$t('load3d.lightIntensity')
|
||||
}}</span>
|
||||
<Slider
|
||||
:model-value="sliderValue"
|
||||
class="w-full"
|
||||
:min="sliderMin"
|
||||
:max="sliderMax"
|
||||
:step="sliderStep"
|
||||
@update:model-value="onSliderUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="showIntensityControl" class="relative">
|
||||
<Button
|
||||
ref="triggerRef"
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.lightIntensity'),
|
||||
showDelay: 300
|
||||
@@ -12,19 +29,20 @@
|
||||
:aria-label="$t('load3d.lightIntensity')"
|
||||
@click="toggleLightIntensity"
|
||||
>
|
||||
<i class="pi pi-sun text-lg text-base-foreground" />
|
||||
<i class="icon-[lucide--sun] text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<div
|
||||
v-show="showLightIntensity"
|
||||
class="absolute top-0 left-12 rounded-lg bg-black/50 p-4 shadow-lg"
|
||||
style="width: 150px"
|
||||
ref="panelRef"
|
||||
class="absolute top-0 left-12 w-[200px] rounded-lg bg-black/50 p-3 shadow-lg"
|
||||
>
|
||||
<Slider
|
||||
v-model="lightIntensity"
|
||||
:model-value="sliderValue"
|
||||
class="w-full"
|
||||
:min="lightIntensityMinimum"
|
||||
:max="lightIntensityMaximum"
|
||||
:step="lightAdjustmentIncrement"
|
||||
:min="sliderMin"
|
||||
:max="sliderMax"
|
||||
:step="sliderStep"
|
||||
@update:model-value="onSliderUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,20 +50,30 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Slider from 'primevue/slider'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { MaterialMode } from '@/extensions/core/load3d/interfaces'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
import type {
|
||||
HDRIConfig,
|
||||
MaterialMode
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
const lightIntensity = defineModel<number>('lightIntensity')
|
||||
const materialMode = defineModel<MaterialMode>('materialMode')
|
||||
const hdriConfig = defineModel<HDRIConfig | undefined>('hdriConfig')
|
||||
|
||||
const showLightIntensityButton = computed(
|
||||
() => materialMode.value === 'original'
|
||||
const { embedded = false } = defineProps<{
|
||||
embedded?: boolean
|
||||
}>()
|
||||
|
||||
const usesHdriIntensity = computed(
|
||||
() => !!hdriConfig.value?.hdriPath?.length && !!hdriConfig.value?.enabled
|
||||
)
|
||||
const showLightIntensity = ref(false)
|
||||
|
||||
const showIntensityControl = computed(() => materialMode.value === 'original')
|
||||
|
||||
const lightIntensityMaximum = useSettingStore().get(
|
||||
'Comfy.Load3D.LightIntensityMaximum'
|
||||
@@ -57,23 +85,49 @@ const lightAdjustmentIncrement = useSettingStore().get(
|
||||
'Comfy.Load3D.LightAdjustmentIncrement'
|
||||
)
|
||||
|
||||
const sliderMin = computed(() =>
|
||||
usesHdriIntensity.value ? 0 : lightIntensityMinimum
|
||||
)
|
||||
const sliderMax = computed(() =>
|
||||
usesHdriIntensity.value ? 5 : lightIntensityMaximum
|
||||
)
|
||||
const sliderStep = computed(() =>
|
||||
usesHdriIntensity.value ? 0.1 : lightAdjustmentIncrement
|
||||
)
|
||||
|
||||
const sliderValue = computed(() => {
|
||||
if (usesHdriIntensity.value) {
|
||||
return [hdriConfig.value?.intensity ?? 1]
|
||||
}
|
||||
return [lightIntensity.value ?? lightIntensityMinimum]
|
||||
})
|
||||
|
||||
const showLightIntensity = ref(false)
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<InstanceType<typeof Button> | null>(null)
|
||||
|
||||
useDismissableOverlay({
|
||||
isOpen: showLightIntensity,
|
||||
getOverlayEl: () => panelRef.value,
|
||||
getTriggerEl: () => triggerRef.value?.$el ?? null,
|
||||
onDismiss: () => {
|
||||
showLightIntensity.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function toggleLightIntensity() {
|
||||
showLightIntensity.value = !showLightIntensity.value
|
||||
}
|
||||
|
||||
function closeLightSlider(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
if (!target.closest('.show-light-intensity')) {
|
||||
showLightIntensity.value = false
|
||||
function onSliderUpdate(value: number[] | undefined) {
|
||||
if (!value?.length) return
|
||||
const next = value[0]
|
||||
if (usesHdriIntensity.value) {
|
||||
const h = hdriConfig.value
|
||||
if (!h) return
|
||||
hdriConfig.value = { ...h, intensity: next }
|
||||
} else {
|
||||
lightIntensity.value = next
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', closeLightSlider)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeLightSlider)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,53 +11,55 @@
|
||||
<i class="pi pi-table text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.backgroundColor'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.backgroundColor')"
|
||||
@click="openColorPicker"
|
||||
>
|
||||
<i class="pi pi-palette text-lg text-base-foreground" />
|
||||
<input
|
||||
ref="colorPickerRef"
|
||||
type="color"
|
||||
:value="backgroundColor"
|
||||
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
|
||||
@input="
|
||||
updateBackgroundColor(($event.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<template v-if="!hdriActive">
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.backgroundColor'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.backgroundColor')"
|
||||
@click="openColorPicker"
|
||||
>
|
||||
<i class="pi pi-palette text-lg text-base-foreground" />
|
||||
<input
|
||||
ref="colorPickerRef"
|
||||
type="color"
|
||||
:value="backgroundColor"
|
||||
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
|
||||
@input="
|
||||
updateBackgroundColor(($event.target as HTMLInputElement).value)
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.uploadBackgroundImage'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.uploadBackgroundImage')"
|
||||
@click="openImagePicker"
|
||||
>
|
||||
<i class="pi pi-image text-lg text-base-foreground" />
|
||||
<input
|
||||
ref="imagePickerRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
|
||||
@change="uploadBackgroundImage"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="!hasBackgroundImage">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.uploadBackgroundImage'),
|
||||
showDelay: 300
|
||||
}"
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.uploadBackgroundImage')"
|
||||
@click="openImagePicker"
|
||||
>
|
||||
<i class="pi pi-image text-lg text-base-foreground" />
|
||||
<input
|
||||
ref="imagePickerRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="pointer-events-none absolute m-0 size-0 p-0 opacity-0"
|
||||
@change="uploadBackgroundImage"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="hasBackgroundImage">
|
||||
<Button
|
||||
@@ -112,6 +114,10 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { hdriActive = false } = defineProps<{
|
||||
hdriActive?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
}>()
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
<div
|
||||
v-if="isImageInputConnected"
|
||||
class="text-center text-xs text-muted-foreground"
|
||||
data-testid="painter-dimension-text"
|
||||
>
|
||||
{{ canvasWidth }} x {{ canvasHeight }}
|
||||
</div>
|
||||
@@ -100,6 +101,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
data-testid="painter-size-row"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[brushSize]"
|
||||
@@ -109,9 +111,12 @@
|
||||
class="flex-1"
|
||||
@update:model-value="(v) => v?.length && (brushSize = v[0])"
|
||||
/>
|
||||
<span class="text-node-text-muted w-8 text-center text-xs">{{
|
||||
brushSize
|
||||
}}</span>
|
||||
<span
|
||||
class="text-node-text-muted w-8 text-center text-xs"
|
||||
data-testid="painter-size-value"
|
||||
>
|
||||
{{ brushSize }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="tool === PAINTER_TOOLS.BRUSH">
|
||||
@@ -123,6 +128,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 w-full items-center gap-2 rounded-lg bg-component-node-widget-background px-4"
|
||||
data-testid="painter-color-row"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
@@ -166,6 +172,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
data-testid="painter-hardness-row"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[brushHardnessPercent]"
|
||||
@@ -192,6 +199,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
data-testid="painter-width-row"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[canvasWidth]"
|
||||
@@ -214,6 +222,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 items-center gap-2 rounded-lg bg-component-node-widget-background pr-2 pl-3"
|
||||
data-testid="painter-height-row"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[canvasHeight]"
|
||||
@@ -255,6 +264,7 @@
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
data-testid="painter-clear-button"
|
||||
:class="
|
||||
cn(
|
||||
'gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground',
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
@@ -72,24 +71,6 @@ function useVueNodeLifecycleIndividual() {
|
||||
nodeManager.value = null
|
||||
}
|
||||
|
||||
// Wire up LiteGraph.getCollapsedSize callback in the renderer layer
|
||||
// (kept out of useVueFeatureFlags to avoid a platform → renderer import)
|
||||
watch(
|
||||
shouldRenderVueNodes,
|
||||
() => {
|
||||
LiteGraph.getCollapsedSize = shouldRenderVueNodes.value
|
||||
? (nodeId) => {
|
||||
try {
|
||||
return layoutStore.getNodeCollapsedSize(String(nodeId))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Watch for Vue nodes enabled state changes
|
||||
watch(
|
||||
() => shouldRenderVueNodes.value && Boolean(comfyApp.canvas?.graph),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ref, watch } from 'vue'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { ColorComparisonMethod } from '@/extensions/core/maskeditor/types'
|
||||
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||
import { rgbToHsl } from '@/utils/colorUtil'
|
||||
|
||||
const getPixelAlpha = (
|
||||
data: Uint8ClampedArray,
|
||||
@@ -47,39 +48,8 @@ const rgbToHSL = (
|
||||
g: number,
|
||||
b: number
|
||||
): { h: number; s: number; l: number } => {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
let h = 0
|
||||
let s = 0
|
||||
const l = (max + min) / 2
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0)
|
||||
break
|
||||
case g:
|
||||
h = (b - r) / d + 2
|
||||
break
|
||||
case b:
|
||||
h = (r - g) / d + 4
|
||||
break
|
||||
}
|
||||
h /= 6
|
||||
}
|
||||
|
||||
return {
|
||||
h: h * 360,
|
||||
s: s * 100,
|
||||
l: l * 100
|
||||
}
|
||||
const hsl = rgbToHsl({ r, g, b })
|
||||
return { h: hsl.h * 360, s: hsl.s * 100, l: hsl.l * 100 }
|
||||
}
|
||||
|
||||
const rgbToLab = (rgb: {
|
||||
|
||||
@@ -23,7 +23,17 @@ vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
default: {
|
||||
splitFilePath: vi.fn(),
|
||||
getResourceURL: vi.fn(),
|
||||
uploadFile: vi.fn()
|
||||
uploadFile: vi.fn(),
|
||||
mapSceneLightIntensityToHdri: vi.fn(
|
||||
(scene: number, min: number, max: number) => {
|
||||
const span = max - min
|
||||
const t = span > 0 ? (scene - min) / span : 0
|
||||
const clampedT = Math.min(1, Math.max(0, t))
|
||||
const mapped = clampedT * 5
|
||||
const minHdri = 0.25
|
||||
return Math.min(5, Math.max(minHdri, mapped))
|
||||
}
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -72,7 +82,13 @@ describe('useLoad3d', () => {
|
||||
state: null
|
||||
},
|
||||
'Light Config': {
|
||||
intensity: 5
|
||||
intensity: 5,
|
||||
hdri: {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
},
|
||||
'Resource Folder': ''
|
||||
},
|
||||
@@ -122,6 +138,11 @@ describe('useLoad3d', () => {
|
||||
isPlyModel: vi.fn().mockReturnValue(false),
|
||||
hasSkeleton: vi.fn().mockReturnValue(false),
|
||||
setShowSkeleton: vi.fn(),
|
||||
loadHDRI: vi.fn().mockResolvedValue(undefined),
|
||||
setHDRIEnabled: vi.fn(),
|
||||
setHDRIAsBackground: vi.fn(),
|
||||
setHDRIIntensity: vi.fn(),
|
||||
clearHDRI: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
@@ -167,7 +188,13 @@ describe('useLoad3d', () => {
|
||||
fov: 75
|
||||
})
|
||||
expect(composable.lightConfig.value).toEqual({
|
||||
intensity: 5
|
||||
intensity: 5,
|
||||
hdri: {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
})
|
||||
expect(composable.isRecording.value).toBe(false)
|
||||
expect(composable.hasRecording.value).toBe(false)
|
||||
@@ -476,7 +503,7 @@ describe('useLoad3d', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setLightIntensity).toHaveBeenCalledWith(10)
|
||||
expect(mockNode.properties['Light Config']).toEqual({
|
||||
expect(mockNode.properties['Light Config']).toMatchObject({
|
||||
intensity: 10
|
||||
})
|
||||
})
|
||||
@@ -912,6 +939,97 @@ describe('useLoad3d', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('hdri controls', () => {
|
||||
it('should call setHDRIEnabled when hdriConfig.enabled changes', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.lightConfig.value = {
|
||||
...composable.lightConfig.value,
|
||||
hdri: { ...composable.lightConfig.value.hdri!, enabled: true }
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setHDRIEnabled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should call setHDRIAsBackground when hdriConfig.showAsBackground changes', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.lightConfig.value = {
|
||||
...composable.lightConfig.value,
|
||||
hdri: { ...composable.lightConfig.value.hdri!, showAsBackground: true }
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setHDRIAsBackground).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should call setHDRIIntensity when hdriConfig.intensity changes', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.lightConfig.value = {
|
||||
...composable.lightConfig.value,
|
||||
hdri: { ...composable.lightConfig.value.hdri!, intensity: 2.5 }
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
expect(mockLoad3d.setHDRIIntensity).toHaveBeenCalledWith(2.5)
|
||||
})
|
||||
|
||||
it('should upload file, load HDRI and update hdriConfig', async () => {
|
||||
vi.mocked(Load3dUtils.uploadFile).mockResolvedValue('3d/env.hdr')
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['3d', 'env.hdr'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue(
|
||||
'/view?filename=env.hdr'
|
||||
)
|
||||
vi.mocked(api.apiURL).mockReturnValue(
|
||||
'http://localhost/view?filename=env.hdr'
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
const file = new File([''], 'env.hdr', { type: 'image/x-hdr' })
|
||||
await composable.handleHDRIFileUpdate(file)
|
||||
|
||||
expect(Load3dUtils.uploadFile).toHaveBeenCalledWith(file, '3d')
|
||||
expect(mockLoad3d.loadHDRI).toHaveBeenCalledWith(
|
||||
'http://localhost/view?filename=env.hdr'
|
||||
)
|
||||
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('3d/env.hdr')
|
||||
expect(composable.lightConfig.value.hdri!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should clear HDRI when file is null', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
composable.lightConfig.value = {
|
||||
...composable.lightConfig.value,
|
||||
hdri: {
|
||||
enabled: true,
|
||||
hdriPath: '3d/env.hdr',
|
||||
showAsBackground: true,
|
||||
intensity: 1
|
||||
}
|
||||
}
|
||||
|
||||
await composable.handleHDRIFileUpdate(null)
|
||||
|
||||
expect(mockLoad3d.clearHDRI).toHaveBeenCalled()
|
||||
expect(composable.lightConfig.value.hdri!.hdriPath).toBe('')
|
||||
expect(composable.lightConfig.value.hdri!.enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null node ref', () => {
|
||||
const nodeRef = ref(null)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MaybeRef } from 'vue'
|
||||
|
||||
import { toRef } from '@vueuse/core'
|
||||
import { getActivePinia } from 'pinia'
|
||||
import { nextTick, ref, toRaw, watch } from 'vue'
|
||||
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
@@ -24,6 +25,7 @@ import type {
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -58,8 +60,15 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
})
|
||||
|
||||
const lightConfig = ref<LightConfig>({
|
||||
intensity: 5
|
||||
intensity: 5,
|
||||
hdri: {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
})
|
||||
const lastNonHdriLightIntensity = ref(lightConfig.value.intensity)
|
||||
|
||||
const isRecording = ref(false)
|
||||
const hasRecording = ref(false)
|
||||
@@ -185,8 +194,45 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
|
||||
const savedLightConfig = node.properties['Light Config'] as LightConfig
|
||||
const savedHdriEnabled = savedLightConfig?.hdri?.enabled ?? false
|
||||
if (savedLightConfig) {
|
||||
lightConfig.value = savedLightConfig
|
||||
lightConfig.value = {
|
||||
intensity: savedLightConfig.intensity ?? lightConfig.value.intensity,
|
||||
hdri: {
|
||||
...lightConfig.value.hdri!,
|
||||
...savedLightConfig.hdri,
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
lastNonHdriLightIntensity.value = lightConfig.value.intensity
|
||||
}
|
||||
|
||||
const hdri = lightConfig.value.hdri
|
||||
let hdriLoaded = false
|
||||
if (hdri?.hdriPath) {
|
||||
const hdriUrl = api.apiURL(
|
||||
Load3dUtils.getResourceURL(
|
||||
...Load3dUtils.splitFilePath(hdri.hdriPath),
|
||||
'input'
|
||||
)
|
||||
)
|
||||
try {
|
||||
await load3d.loadHDRI(hdriUrl)
|
||||
hdriLoaded = true
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore HDRI:', error)
|
||||
lightConfig.value = {
|
||||
...lightConfig.value,
|
||||
hdri: { ...lightConfig.value.hdri!, hdriPath: '', enabled: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hdriLoaded && savedHdriEnabled) {
|
||||
lightConfig.value = {
|
||||
...lightConfig.value,
|
||||
hdri: { ...lightConfig.value.hdri!, enabled: true }
|
||||
}
|
||||
}
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
@@ -213,6 +259,39 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
} else if (cameraStateToRestore) {
|
||||
load3d.setCameraState(cameraStateToRestore)
|
||||
}
|
||||
|
||||
applySceneConfigToLoad3d()
|
||||
applyLightConfigToLoad3d()
|
||||
}
|
||||
|
||||
const applySceneConfigToLoad3d = () => {
|
||||
if (!load3d) return
|
||||
const cfg = sceneConfig.value
|
||||
load3d.toggleGrid(cfg.showGrid)
|
||||
if (!lightConfig.value.hdri?.enabled) {
|
||||
load3d.setBackgroundColor(cfg.backgroundColor)
|
||||
}
|
||||
if (cfg.backgroundRenderMode) {
|
||||
load3d.setBackgroundRenderMode(cfg.backgroundRenderMode)
|
||||
}
|
||||
}
|
||||
|
||||
const applyLightConfigToLoad3d = () => {
|
||||
if (!load3d) return
|
||||
const cfg = lightConfig.value
|
||||
load3d.setLightIntensity(cfg.intensity)
|
||||
const hdri = cfg.hdri
|
||||
if (!hdri) return
|
||||
load3d.setHDRIIntensity(hdri.intensity)
|
||||
load3d.setHDRIAsBackground(hdri.showAsBackground)
|
||||
load3d.setHDRIEnabled(hdri.enabled)
|
||||
}
|
||||
|
||||
const persistLightConfigToNode = () => {
|
||||
const n = nodeRef.value
|
||||
if (n) {
|
||||
n.properties['Light Config'] = lightConfig.value
|
||||
}
|
||||
}
|
||||
|
||||
const getModelUrl = (modelPath: string): string | null => {
|
||||
@@ -260,22 +339,44 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
watch(
|
||||
sceneConfig,
|
||||
async (newValue) => {
|
||||
if (load3d && nodeRef.value) {
|
||||
(newValue) => {
|
||||
if (nodeRef.value) {
|
||||
nodeRef.value.properties['Scene Config'] = newValue
|
||||
load3d.toggleGrid(newValue.showGrid)
|
||||
load3d.setBackgroundColor(newValue.backgroundColor)
|
||||
|
||||
await load3d.setBackgroundImage(newValue.backgroundImage || '')
|
||||
|
||||
if (newValue.backgroundRenderMode) {
|
||||
load3d.setBackgroundRenderMode(newValue.backgroundRenderMode)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sceneConfig.value.showGrid,
|
||||
(showGrid) => {
|
||||
load3d?.toggleGrid(showGrid)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sceneConfig.value.backgroundColor,
|
||||
(color) => {
|
||||
if (!load3d || lightConfig.value.hdri?.enabled) return
|
||||
load3d.setBackgroundColor(color)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sceneConfig.value.backgroundImage,
|
||||
async (image) => {
|
||||
if (!load3d) return
|
||||
await load3d.setBackgroundImage(image || '')
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sceneConfig.value.backgroundRenderMode,
|
||||
(mode) => {
|
||||
if (mode) load3d?.setBackgroundRenderMode(mode)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
modelConfig,
|
||||
(newValue) => {
|
||||
@@ -302,14 +403,54 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
)
|
||||
|
||||
watch(
|
||||
lightConfig,
|
||||
(newValue) => {
|
||||
if (load3d && nodeRef.value) {
|
||||
nodeRef.value.properties['Light Config'] = newValue
|
||||
load3d.setLightIntensity(newValue.intensity)
|
||||
() => lightConfig.value.intensity,
|
||||
(intensity) => {
|
||||
if (!load3d || !nodeRef.value) return
|
||||
if (!lightConfig.value.hdri?.enabled) {
|
||||
lastNonHdriLightIntensity.value = intensity
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
persistLightConfigToNode()
|
||||
load3d.setLightIntensity(intensity)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => lightConfig.value.hdri?.intensity,
|
||||
(intensity) => {
|
||||
if (!load3d || !nodeRef.value) return
|
||||
if (intensity === undefined) return
|
||||
persistLightConfigToNode()
|
||||
load3d.setHDRIIntensity(intensity)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => lightConfig.value.hdri?.showAsBackground,
|
||||
(show) => {
|
||||
if (!load3d || !nodeRef.value) return
|
||||
if (show === undefined) return
|
||||
persistLightConfigToNode()
|
||||
load3d.setHDRIAsBackground(show)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => lightConfig.value.hdri?.enabled,
|
||||
(enabled, prevEnabled) => {
|
||||
if (!load3d || !nodeRef.value) return
|
||||
if (enabled === undefined) return
|
||||
if (enabled && prevEnabled === false) {
|
||||
lastNonHdriLightIntensity.value = lightConfig.value.intensity
|
||||
}
|
||||
if (!enabled && prevEnabled === true) {
|
||||
lightConfig.value = {
|
||||
...lightConfig.value,
|
||||
intensity: lastNonHdriLightIntensity.value
|
||||
}
|
||||
}
|
||||
persistLightConfigToNode()
|
||||
load3d.setHDRIEnabled(enabled)
|
||||
}
|
||||
)
|
||||
|
||||
watch(playing, (newValue) => {
|
||||
@@ -377,6 +518,98 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleHDRIFileUpdate = async (file: File | null) => {
|
||||
const capturedLoad3d = load3d
|
||||
if (!capturedLoad3d) return
|
||||
|
||||
if (!file) {
|
||||
lightConfig.value = {
|
||||
...lightConfig.value,
|
||||
hdri: {
|
||||
...lightConfig.value.hdri!,
|
||||
hdriPath: '',
|
||||
enabled: false,
|
||||
showAsBackground: false
|
||||
}
|
||||
}
|
||||
capturedLoad3d.clearHDRI()
|
||||
return
|
||||
}
|
||||
|
||||
const resourceFolder =
|
||||
(nodeRef.value?.properties['Resource Folder'] as string) || ''
|
||||
|
||||
const subfolder = resourceFolder.trim()
|
||||
? `3d/${resourceFolder.trim()}`
|
||||
: '3d'
|
||||
|
||||
const uploadedPath = await Load3dUtils.uploadFile(file, subfolder)
|
||||
if (!uploadedPath) {
|
||||
return
|
||||
}
|
||||
|
||||
// Re-validate: node may have been removed during upload
|
||||
if (load3d !== capturedLoad3d) return
|
||||
|
||||
const hdriUrl = api.apiURL(
|
||||
Load3dUtils.getResourceURL(
|
||||
...Load3dUtils.splitFilePath(uploadedPath),
|
||||
'input'
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
loadingMessage.value = t('load3d.loadingHDRI')
|
||||
await capturedLoad3d.loadHDRI(hdriUrl)
|
||||
|
||||
if (load3d !== capturedLoad3d) return
|
||||
|
||||
let sceneMin = 1
|
||||
let sceneMax = 10
|
||||
if (getActivePinia() != null) {
|
||||
const settingStore = useSettingStore()
|
||||
sceneMin = settingStore.get(
|
||||
'Comfy.Load3D.LightIntensityMinimum'
|
||||
) as number
|
||||
sceneMax = settingStore.get(
|
||||
'Comfy.Load3D.LightIntensityMaximum'
|
||||
) as number
|
||||
}
|
||||
const mappedHdriIntensity = Load3dUtils.mapSceneLightIntensityToHdri(
|
||||
lightConfig.value.intensity,
|
||||
sceneMin,
|
||||
sceneMax
|
||||
)
|
||||
lightConfig.value = {
|
||||
...lightConfig.value,
|
||||
hdri: {
|
||||
...lightConfig.value.hdri!,
|
||||
hdriPath: uploadedPath,
|
||||
enabled: true,
|
||||
showAsBackground: true,
|
||||
intensity: mappedHdriIntensity
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load HDRI:', error)
|
||||
capturedLoad3d.clearHDRI()
|
||||
lightConfig.value = {
|
||||
...lightConfig.value,
|
||||
hdri: {
|
||||
...lightConfig.value.hdri!,
|
||||
hdriPath: '',
|
||||
enabled: false,
|
||||
showAsBackground: false
|
||||
}
|
||||
}
|
||||
useToastStore().addAlert(t('toastMessages.failedToLoadHDRI'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMessage.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackgroundImageUpdate = async (file: File | null) => {
|
||||
if (!file) {
|
||||
sceneConfig.value.backgroundImage = ''
|
||||
@@ -642,6 +875,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
handleClearRecording,
|
||||
handleSeek,
|
||||
handleBackgroundImageUpdate,
|
||||
handleHDRIFileUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
cleanup
|
||||
|
||||
223
src/extensions/core/load3d/HDRIManager.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { HDRIManager } from './HDRIManager'
|
||||
import Load3dUtils from './Load3dUtils'
|
||||
|
||||
const { mockFromEquirectangular, mockDisposePMREM } = vi.hoisted(() => ({
|
||||
mockFromEquirectangular: vi.fn(),
|
||||
mockDisposePMREM: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./Load3dUtils', () => ({
|
||||
default: {
|
||||
getFilenameExtension: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('three', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof THREE>()
|
||||
class MockPMREMGenerator {
|
||||
compileEquirectangularShader = vi.fn()
|
||||
fromEquirectangular = mockFromEquirectangular
|
||||
dispose = mockDisposePMREM
|
||||
}
|
||||
return { ...actual, PMREMGenerator: MockPMREMGenerator }
|
||||
})
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/EXRLoader', () => {
|
||||
class EXRLoader {
|
||||
load(
|
||||
_url: string,
|
||||
resolve: (t: THREE.Texture) => void,
|
||||
_onProgress: undefined,
|
||||
_reject: (e: unknown) => void
|
||||
) {
|
||||
resolve(new THREE.DataTexture(new Uint8Array(4), 1, 1))
|
||||
}
|
||||
}
|
||||
return { EXRLoader }
|
||||
})
|
||||
|
||||
vi.mock('three/examples/jsm/loaders/RGBELoader', () => {
|
||||
class RGBELoader {
|
||||
load(
|
||||
_url: string,
|
||||
resolve: (t: THREE.Texture) => void,
|
||||
_onProgress: undefined,
|
||||
_reject: (e: unknown) => void
|
||||
) {
|
||||
resolve(new THREE.DataTexture(new Uint8Array(4), 1, 1))
|
||||
}
|
||||
}
|
||||
return { RGBELoader }
|
||||
})
|
||||
|
||||
function makeMockEventManager() {
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
emitEvent: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('HDRIManager', () => {
|
||||
let scene: THREE.Scene
|
||||
let eventManager: ReturnType<typeof makeMockEventManager>
|
||||
let manager: HDRIManager
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
scene = new THREE.Scene()
|
||||
eventManager = makeMockEventManager()
|
||||
|
||||
mockFromEquirectangular.mockReturnValue({
|
||||
texture: new THREE.Texture(),
|
||||
dispose: vi.fn()
|
||||
})
|
||||
|
||||
manager = new HDRIManager(scene, {} as THREE.WebGLRenderer, eventManager)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('starts disabled with default intensity', () => {
|
||||
expect(manager.isEnabled).toBe(false)
|
||||
expect(manager.showAsBackground).toBe(false)
|
||||
expect(manager.intensity).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadHDRI', () => {
|
||||
it('loads .exr files without error', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('exr')
|
||||
|
||||
await expect(
|
||||
manager.loadHDRI('http://example.com/env.exr')
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('loads .hdr files without error', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
|
||||
await expect(
|
||||
manager.loadHDRI('http://example.com/env.hdr')
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies to scene immediately when already enabled', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
manager.setEnabled(true)
|
||||
// No texture loaded yet so scene.environment stays null
|
||||
expect(scene.environment).toBeNull()
|
||||
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
|
||||
expect(scene.environment).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not apply to scene when disabled', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
|
||||
expect(scene.environment).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setEnabled', () => {
|
||||
it('applies environment map to scene when enabled after loading', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
|
||||
manager.setEnabled(true)
|
||||
|
||||
expect(scene.environment).not.toBeNull()
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith('hdriChange', {
|
||||
enabled: true,
|
||||
showAsBackground: false
|
||||
})
|
||||
})
|
||||
|
||||
it('removes environment map from scene when disabled', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
manager.setEnabled(true)
|
||||
|
||||
manager.setEnabled(false)
|
||||
|
||||
expect(scene.environment).toBeNull()
|
||||
expect(eventManager.emitEvent).toHaveBeenLastCalledWith('hdriChange', {
|
||||
enabled: false,
|
||||
showAsBackground: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('setIntensity', () => {
|
||||
it('updates scene intensity when enabled', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
manager.setEnabled(true)
|
||||
|
||||
manager.setIntensity(2.5)
|
||||
|
||||
expect(scene.environmentIntensity).toBe(2.5)
|
||||
expect(manager.intensity).toBe(2.5)
|
||||
})
|
||||
|
||||
it('stores intensity without applying when disabled', () => {
|
||||
manager.setIntensity(3)
|
||||
|
||||
expect(manager.intensity).toBe(3)
|
||||
expect(scene.environmentIntensity).not.toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setShowAsBackground', () => {
|
||||
it('sets scene background texture when enabled and showing as background', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
manager.setEnabled(true)
|
||||
|
||||
manager.setShowAsBackground(true)
|
||||
|
||||
expect(scene.background).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clears scene background when showAsBackground is false', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
manager.setEnabled(true)
|
||||
manager.setShowAsBackground(true)
|
||||
|
||||
manager.setShowAsBackground(false)
|
||||
|
||||
expect(scene.background).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
it('removes HDRI from scene and resets state', async () => {
|
||||
vi.mocked(Load3dUtils.getFilenameExtension).mockReturnValue('hdr')
|
||||
await manager.loadHDRI('http://example.com/env.hdr')
|
||||
manager.setEnabled(true)
|
||||
|
||||
manager.clear()
|
||||
|
||||
expect(manager.isEnabled).toBe(false)
|
||||
expect(scene.environment).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dispose', () => {
|
||||
it('disposes PMREMGenerator', () => {
|
||||
manager.dispose()
|
||||
|
||||
expect(mockDisposePMREM).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
142
src/extensions/core/load3d/HDRIManager.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as THREE from 'three'
|
||||
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
|
||||
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
|
||||
|
||||
import Load3dUtils from './Load3dUtils'
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
|
||||
export class HDRIManager {
|
||||
private scene: THREE.Scene
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private pmremGenerator: THREE.PMREMGenerator
|
||||
private eventManager: EventManagerInterface
|
||||
|
||||
private hdriTexture: THREE.Texture | null = null
|
||||
private envMapTarget: THREE.WebGLRenderTarget | null = null
|
||||
|
||||
private _isEnabled: boolean = false
|
||||
private _showAsBackground: boolean = false
|
||||
private _intensity: number = 1
|
||||
|
||||
get isEnabled() {
|
||||
return this._isEnabled
|
||||
}
|
||||
|
||||
get showAsBackground() {
|
||||
return this._showAsBackground
|
||||
}
|
||||
|
||||
get intensity() {
|
||||
return this._intensity
|
||||
}
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.scene = scene
|
||||
this.renderer = renderer
|
||||
this.pmremGenerator = new THREE.PMREMGenerator(renderer)
|
||||
this.pmremGenerator.compileEquirectangularShader()
|
||||
this.eventManager = eventManager
|
||||
}
|
||||
|
||||
async loadHDRI(url: string): Promise<void> {
|
||||
const ext = Load3dUtils.getFilenameExtension(url)
|
||||
|
||||
let newTexture: THREE.Texture
|
||||
if (ext === 'exr') {
|
||||
newTexture = await new Promise<THREE.Texture>((resolve, reject) => {
|
||||
new EXRLoader().load(url, resolve, undefined, reject)
|
||||
})
|
||||
} else {
|
||||
newTexture = await new Promise<THREE.Texture>((resolve, reject) => {
|
||||
new RGBELoader().load(url, resolve, undefined, reject)
|
||||
})
|
||||
}
|
||||
|
||||
newTexture.mapping = THREE.EquirectangularReflectionMapping
|
||||
const newEnvMapTarget = this.pmremGenerator.fromEquirectangular(newTexture)
|
||||
|
||||
// Dispose old resources only after the new one is ready
|
||||
this.hdriTexture?.dispose()
|
||||
this.envMapTarget?.dispose()
|
||||
this.hdriTexture = newTexture
|
||||
this.envMapTarget = newEnvMapTarget
|
||||
|
||||
if (this._isEnabled) {
|
||||
this.applyToScene()
|
||||
}
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
this._isEnabled = enabled
|
||||
if (enabled) {
|
||||
if (this.envMapTarget) {
|
||||
this.applyToScene()
|
||||
}
|
||||
} else {
|
||||
this.removeFromScene()
|
||||
}
|
||||
}
|
||||
|
||||
setShowAsBackground(show: boolean): void {
|
||||
this._showAsBackground = show
|
||||
if (this._isEnabled && this.envMapTarget) {
|
||||
this.applyToScene()
|
||||
}
|
||||
}
|
||||
|
||||
setIntensity(intensity: number): void {
|
||||
this._intensity = intensity
|
||||
if (this._isEnabled) {
|
||||
this.scene.environmentIntensity = intensity
|
||||
}
|
||||
}
|
||||
|
||||
private applyToScene(): void {
|
||||
const envMap = this.envMapTarget?.texture
|
||||
if (!envMap) return
|
||||
this.scene.environment = envMap
|
||||
this.scene.environmentIntensity = this._intensity
|
||||
this.scene.background = this._showAsBackground ? this.hdriTexture : null
|
||||
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||
this.renderer.toneMappingExposure = 1.0
|
||||
this.eventManager.emitEvent('hdriChange', {
|
||||
enabled: this._isEnabled,
|
||||
showAsBackground: this._showAsBackground
|
||||
})
|
||||
}
|
||||
|
||||
private removeFromScene(): void {
|
||||
this.scene.environment = null
|
||||
if (this.scene.background === this.hdriTexture) {
|
||||
this.scene.background = null
|
||||
}
|
||||
this.renderer.toneMapping = THREE.NoToneMapping
|
||||
this.renderer.toneMappingExposure = 1.0
|
||||
this.eventManager.emitEvent('hdriChange', {
|
||||
enabled: false,
|
||||
showAsBackground: this._showAsBackground
|
||||
})
|
||||
}
|
||||
|
||||
private clearResources(): void {
|
||||
this.removeFromScene()
|
||||
this.hdriTexture?.dispose()
|
||||
this.envMapTarget?.dispose()
|
||||
this.hdriTexture = null
|
||||
this.envMapTarget = null
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.clearResources()
|
||||
this._isEnabled = false
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clearResources()
|
||||
this.pmremGenerator.dispose()
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export class LightingManager implements LightingManagerInterface {
|
||||
currentIntensity: number = 3
|
||||
private scene: THREE.Scene
|
||||
private eventManager: EventManagerInterface
|
||||
private lightMultipliers = new Map<THREE.Light, number>()
|
||||
|
||||
constructor(scene: THREE.Scene, eventManager: EventManagerInterface) {
|
||||
this.scene = scene
|
||||
@@ -25,59 +26,53 @@ export class LightingManager implements LightingManagerInterface {
|
||||
this.scene.remove(light)
|
||||
})
|
||||
this.lights = []
|
||||
this.lightMultipliers.clear()
|
||||
}
|
||||
|
||||
setupLights(): void {
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5)
|
||||
this.scene.add(ambientLight)
|
||||
this.lights.push(ambientLight)
|
||||
const addLight = (light: THREE.Light, multiplier: number) => {
|
||||
this.scene.add(light)
|
||||
this.lights.push(light)
|
||||
this.lightMultipliers.set(light, multiplier)
|
||||
}
|
||||
|
||||
addLight(new THREE.AmbientLight(0xffffff, 0.5), 0.5)
|
||||
|
||||
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8)
|
||||
mainLight.position.set(0, 10, 10)
|
||||
this.scene.add(mainLight)
|
||||
this.lights.push(mainLight)
|
||||
addLight(mainLight, 0.8)
|
||||
|
||||
const backLight = new THREE.DirectionalLight(0xffffff, 0.5)
|
||||
backLight.position.set(0, 10, -10)
|
||||
this.scene.add(backLight)
|
||||
this.lights.push(backLight)
|
||||
addLight(backLight, 0.5)
|
||||
|
||||
const leftFillLight = new THREE.DirectionalLight(0xffffff, 0.3)
|
||||
leftFillLight.position.set(-10, 0, 0)
|
||||
this.scene.add(leftFillLight)
|
||||
this.lights.push(leftFillLight)
|
||||
addLight(leftFillLight, 0.3)
|
||||
|
||||
const rightFillLight = new THREE.DirectionalLight(0xffffff, 0.3)
|
||||
rightFillLight.position.set(10, 0, 0)
|
||||
this.scene.add(rightFillLight)
|
||||
this.lights.push(rightFillLight)
|
||||
addLight(rightFillLight, 0.3)
|
||||
|
||||
const bottomLight = new THREE.DirectionalLight(0xffffff, 0.2)
|
||||
bottomLight.position.set(0, -10, 0)
|
||||
this.scene.add(bottomLight)
|
||||
this.lights.push(bottomLight)
|
||||
addLight(bottomLight, 0.2)
|
||||
}
|
||||
|
||||
setLightIntensity(intensity: number): void {
|
||||
this.currentIntensity = intensity
|
||||
this.lights.forEach((light) => {
|
||||
if (light instanceof THREE.DirectionalLight) {
|
||||
if (light === this.lights[1]) {
|
||||
light.intensity = intensity * 0.8
|
||||
} else if (light === this.lights[2]) {
|
||||
light.intensity = intensity * 0.5
|
||||
} else if (light === this.lights[5]) {
|
||||
light.intensity = intensity * 0.2
|
||||
} else {
|
||||
light.intensity = intensity * 0.3
|
||||
}
|
||||
} else if (light instanceof THREE.AmbientLight) {
|
||||
light.intensity = intensity * 0.5
|
||||
}
|
||||
light.intensity = intensity * (this.lightMultipliers.get(light) ?? 1)
|
||||
})
|
||||
|
||||
this.eventManager.emitEvent('lightIntensityChange', intensity)
|
||||
}
|
||||
|
||||
setHDRIMode(hdriActive: boolean): void {
|
||||
this.lights.forEach((light) => {
|
||||
light.visible = !hdriActive
|
||||
})
|
||||
}
|
||||
|
||||
reset(): void {}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
HDRIConfig,
|
||||
LightConfig,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
@@ -113,6 +114,7 @@ class Load3DConfiguration {
|
||||
|
||||
const lightConfig = this.loadLightConfig()
|
||||
this.applyLightConfig(lightConfig)
|
||||
if (lightConfig.hdri) this.applyHDRISettings(lightConfig.hdri)
|
||||
}
|
||||
|
||||
private loadSceneConfig(): SceneConfig {
|
||||
@@ -140,13 +142,27 @@ class Load3DConfiguration {
|
||||
}
|
||||
|
||||
private loadLightConfig(): LightConfig {
|
||||
const hdriDefaults: HDRIConfig = {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
|
||||
if (this.properties && 'Light Config' in this.properties) {
|
||||
return this.properties['Light Config'] as LightConfig
|
||||
const saved = this.properties['Light Config'] as Partial<LightConfig>
|
||||
return {
|
||||
intensity:
|
||||
saved.intensity ??
|
||||
(useSettingStore().get('Comfy.Load3D.LightIntensity') as number),
|
||||
hdri: { ...hdriDefaults, ...(saved.hdri ?? {}) }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity')
|
||||
} as LightConfig
|
||||
intensity: useSettingStore().get('Comfy.Load3D.LightIntensity') as number,
|
||||
hdri: hdriDefaults
|
||||
}
|
||||
}
|
||||
|
||||
private loadModelConfig(): ModelConfig {
|
||||
@@ -190,6 +206,15 @@ class Load3DConfiguration {
|
||||
this.load3d.setLightIntensity(config.intensity)
|
||||
}
|
||||
|
||||
private applyHDRISettings(config: HDRIConfig) {
|
||||
if (!config.hdriPath) return
|
||||
this.load3d.setHDRIIntensity(config.intensity)
|
||||
this.load3d.setHDRIAsBackground(config.showAsBackground)
|
||||
if (config.enabled) {
|
||||
this.load3d.setHDRIEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
private applyModelConfig(config: ModelConfig) {
|
||||
this.load3d.setUpDirection(config.upDirection)
|
||||
this.load3d.setMaterialMode(config.materialMode)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AnimationManager } from './AnimationManager'
|
||||
import { CameraManager } from './CameraManager'
|
||||
import { ControlsManager } from './ControlsManager'
|
||||
import { EventManager } from './EventManager'
|
||||
import { HDRIManager } from './HDRIManager'
|
||||
import { LightingManager } from './LightingManager'
|
||||
import { LoaderManager } from './LoaderManager'
|
||||
import { ModelExporter } from './ModelExporter'
|
||||
@@ -54,6 +55,7 @@ class Load3d {
|
||||
cameraManager: CameraManager
|
||||
controlsManager: ControlsManager
|
||||
lightingManager: LightingManager
|
||||
hdriManager: HDRIManager
|
||||
viewHelperManager: ViewHelperManager
|
||||
loaderManager: LoaderManager
|
||||
modelManager: SceneModelManager
|
||||
@@ -126,6 +128,12 @@ class Load3d {
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.hdriManager = new HDRIManager(
|
||||
this.sceneManager.scene,
|
||||
this.renderer,
|
||||
this.eventManager
|
||||
)
|
||||
|
||||
this.viewHelperManager = new ViewHelperManager(
|
||||
this.renderer,
|
||||
this.getActiveCamera.bind(this),
|
||||
@@ -635,6 +643,33 @@ class Load3d {
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
async loadHDRI(url: string): Promise<void> {
|
||||
await this.hdriManager.loadHDRI(url)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setHDRIEnabled(enabled: boolean): void {
|
||||
this.hdriManager.setEnabled(enabled)
|
||||
this.lightingManager.setHDRIMode(enabled)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setHDRIAsBackground(show: boolean): void {
|
||||
this.hdriManager.setShowAsBackground(show)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setHDRIIntensity(intensity: number): void {
|
||||
this.hdriManager.setIntensity(intensity)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
clearHDRI(): void {
|
||||
this.hdriManager.clear()
|
||||
this.lightingManager.setHDRIMode(false)
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
setTargetSize(width: number, height: number): void {
|
||||
this.targetWidth = width
|
||||
this.targetHeight = height
|
||||
@@ -858,6 +893,7 @@ class Load3d {
|
||||
this.cameraManager.dispose()
|
||||
this.controlsManager.dispose()
|
||||
this.lightingManager.dispose()
|
||||
this.hdriManager.dispose()
|
||||
this.viewHelperManager.dispose()
|
||||
this.loaderManager.dispose()
|
||||
this.modelManager.dispose()
|
||||
|
||||
25
src/extensions/core/load3d/Load3dUtils.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
|
||||
describe('Load3dUtils.mapSceneLightIntensityToHdri', () => {
|
||||
it('maps scene slider low end to a small positive HDRI intensity', () => {
|
||||
expect(Load3dUtils.mapSceneLightIntensityToHdri(1, 1, 10)).toBe(0.25)
|
||||
expect(Load3dUtils.mapSceneLightIntensityToHdri(10, 1, 10)).toBe(5)
|
||||
})
|
||||
|
||||
it('maps midpoint proportionally', () => {
|
||||
expect(Load3dUtils.mapSceneLightIntensityToHdri(5.5, 1, 10)).toBeCloseTo(
|
||||
2.5
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps scene ratio and HDRI ceiling', () => {
|
||||
expect(Load3dUtils.mapSceneLightIntensityToHdri(-10, 1, 10)).toBe(0.25)
|
||||
expect(Load3dUtils.mapSceneLightIntensityToHdri(100, 1, 10)).toBe(5)
|
||||
})
|
||||
|
||||
it('uses minimum HDRI when span is zero', () => {
|
||||
expect(Load3dUtils.mapSceneLightIntensityToHdri(3, 5, 5)).toBe(0.25)
|
||||
})
|
||||
})
|
||||
@@ -89,6 +89,15 @@ class Load3dUtils {
|
||||
return uploadPath
|
||||
}
|
||||
|
||||
static getFilenameExtension(url: string): string | undefined {
|
||||
const queryString = url.split('?')[1]
|
||||
if (queryString) {
|
||||
const filename = new URLSearchParams(queryString).get('filename')
|
||||
if (filename) return filename.split('.').pop()?.toLowerCase()
|
||||
}
|
||||
return url.split('?')[0].split('.').pop()?.toLowerCase()
|
||||
}
|
||||
|
||||
static splitFilePath(path: string): [string, string] {
|
||||
const folder_separator = path.lastIndexOf('/')
|
||||
if (folder_separator === -1) {
|
||||
@@ -122,6 +131,19 @@ class Load3dUtils {
|
||||
|
||||
await Promise.all(uploadPromises)
|
||||
}
|
||||
|
||||
static mapSceneLightIntensityToHdri(
|
||||
sceneIntensity: number,
|
||||
sceneMin: number,
|
||||
sceneMax: number
|
||||
): number {
|
||||
const span = sceneMax - sceneMin
|
||||
const t = span > 0 ? (sceneIntensity - sceneMin) / span : 0
|
||||
const clampedT = Math.min(1, Math.max(0, t))
|
||||
const mapped = clampedT * 5
|
||||
const minHdri = 0.25
|
||||
return Math.min(5, Math.max(minHdri, mapped))
|
||||
}
|
||||
}
|
||||
|
||||
export default Load3dUtils
|
||||
|
||||
@@ -16,3 +16,9 @@ export const SUPPORTED_EXTENSIONS = new Set([
|
||||
])
|
||||
|
||||
export const SUPPORTED_EXTENSIONS_ACCEPT = [...SUPPORTED_EXTENSIONS].join(',')
|
||||
|
||||
export const SUPPORTED_HDRI_EXTENSIONS = new Set(['.hdr', '.exr'])
|
||||
|
||||
export const SUPPORTED_HDRI_EXTENSIONS_ACCEPT = [
|
||||
...SUPPORTED_HDRI_EXTENSIONS
|
||||
].join(',')
|
||||
|
||||
@@ -47,6 +47,14 @@ export interface CameraConfig {
|
||||
|
||||
export interface LightConfig {
|
||||
intensity: number
|
||||
hdri?: HDRIConfig
|
||||
}
|
||||
|
||||
export interface HDRIConfig {
|
||||
enabled: boolean
|
||||
hdriPath: string
|
||||
showAsBackground: boolean
|
||||
intensity: number
|
||||
}
|
||||
|
||||
export interface EventCallback<T = unknown> {
|
||||
|
||||
198
src/lib/litegraph/src/LGraphCanvas.cloneZIndex.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeLayout } from '@/renderer/core/layout/types'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const TEST_NODE_TYPE = 'test/CloneZIndex' as const
|
||||
|
||||
class TestNode extends LGraphNode {
|
||||
static override type = TEST_NODE_TYPE
|
||||
|
||||
constructor(title?: string) {
|
||||
super(title ?? TEST_NODE_TYPE)
|
||||
this.type = TEST_NODE_TYPE
|
||||
}
|
||||
}
|
||||
|
||||
function createCanvas(graph: LGraph): LGraphCanvas {
|
||||
const el = document.createElement('canvas')
|
||||
el.width = 800
|
||||
el.height = 600
|
||||
|
||||
const ctx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
getTransform: vi
|
||||
.fn()
|
||||
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
textAlign: 'left' as CanvasTextAlign,
|
||||
textBaseline: 'alphabetic' as CanvasTextBaseline
|
||||
} satisfies Partial<CanvasRenderingContext2D>
|
||||
|
||||
el.getContext = vi
|
||||
.fn()
|
||||
.mockReturnValue(ctx as unknown as CanvasRenderingContext2D)
|
||||
el.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
return new LGraphCanvas(el, graph, { skip_render: true })
|
||||
}
|
||||
|
||||
function createLayoutEntry(node: LGraphNode, zIndex: number) {
|
||||
const nodeId = String(node.id)
|
||||
const layout: NodeLayout = {
|
||||
id: nodeId,
|
||||
position: { x: node.pos[0], y: node.pos[1] },
|
||||
size: { width: node.size[0], height: node.size[1] },
|
||||
zIndex,
|
||||
visible: true,
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
}
|
||||
}
|
||||
layoutStore.applyOperation({
|
||||
type: 'createNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
layout,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.Canvas,
|
||||
actor: 'test'
|
||||
})
|
||||
}
|
||||
|
||||
function setZIndex(nodeId: string, zIndex: number, previousZIndex: number) {
|
||||
layoutStore.applyOperation({
|
||||
type: 'setNodeZIndex',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
zIndex,
|
||||
previousZIndex,
|
||||
timestamp: Date.now(),
|
||||
source: LayoutSource.Canvas,
|
||||
actor: 'test'
|
||||
})
|
||||
}
|
||||
|
||||
describe('cloned node z-index in Vue renderer', () => {
|
||||
let graph: LGraph
|
||||
let canvas: LGraphCanvas
|
||||
let previousVueNodesMode: boolean
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
previousVueNodesMode = LiteGraph.vueNodesMode
|
||||
LiteGraph.vueNodesMode = true
|
||||
LiteGraph.registerNodeType(TEST_NODE_TYPE, TestNode)
|
||||
|
||||
graph = new LGraph()
|
||||
canvas = createCanvas(graph)
|
||||
LGraphCanvas.active_canvas = canvas
|
||||
|
||||
layoutStore.initializeFromLiteGraph([])
|
||||
|
||||
// Simulate Vue runtime: create layout entries when nodes are added
|
||||
graph.onNodeAdded = (node: LGraphNode) => {
|
||||
createLayoutEntry(node, 0)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.vueNodesMode = previousVueNodesMode
|
||||
})
|
||||
|
||||
it('places cloned nodes above the original node z-index', () => {
|
||||
const originalNode = new TestNode()
|
||||
originalNode.pos = [100, 100]
|
||||
originalNode.size = [200, 100]
|
||||
graph.add(originalNode)
|
||||
|
||||
const originalNodeId = String(originalNode.id)
|
||||
|
||||
setZIndex(originalNodeId, 5, 0)
|
||||
|
||||
const originalLayout = layoutStore.getNodeLayoutRef(originalNodeId).value
|
||||
expect(originalLayout?.zIndex).toBe(5)
|
||||
|
||||
// Clone the node via cloneNodes (same path as right-click > clone)
|
||||
const result = LGraphCanvas.cloneNodes([originalNode])
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.created.length).toBe(1)
|
||||
|
||||
const clonedNode = result!.created[0] as LGraphNode
|
||||
const clonedNodeId = String(clonedNode.id)
|
||||
|
||||
// The cloned node should have a z-index higher than the original
|
||||
const clonedLayout = layoutStore.getNodeLayoutRef(clonedNodeId).value
|
||||
expect(clonedLayout).toBeDefined()
|
||||
expect(clonedLayout!.zIndex).toBeGreaterThan(originalLayout!.zIndex)
|
||||
})
|
||||
|
||||
it('assigns distinct sequential z-indices when cloning multiple nodes', () => {
|
||||
const nodeA = new TestNode()
|
||||
nodeA.pos = [100, 100]
|
||||
nodeA.size = [200, 100]
|
||||
graph.add(nodeA)
|
||||
setZIndex(String(nodeA.id), 3, 0)
|
||||
|
||||
const nodeB = new TestNode()
|
||||
nodeB.pos = [400, 100]
|
||||
nodeB.size = [200, 100]
|
||||
graph.add(nodeB)
|
||||
setZIndex(String(nodeB.id), 7, 0)
|
||||
|
||||
const result = LGraphCanvas.cloneNodes([nodeA, nodeB])
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.created.length).toBe(2)
|
||||
|
||||
const clonedA = result!.created[0] as LGraphNode
|
||||
const clonedB = result!.created[1] as LGraphNode
|
||||
const layoutA = layoutStore.getNodeLayoutRef(String(clonedA.id)).value!
|
||||
const layoutB = layoutStore.getNodeLayoutRef(String(clonedB.id)).value!
|
||||
|
||||
// Both cloned nodes should be above the highest original (z-index 7)
|
||||
expect(layoutA.zIndex).toBeGreaterThan(7)
|
||||
expect(layoutB.zIndex).toBeGreaterThan(7)
|
||||
|
||||
// Each cloned node should have a distinct z-index
|
||||
expect(layoutA.zIndex).not.toBe(layoutB.zIndex)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
|
||||
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
@@ -4270,6 +4271,17 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
|
||||
layoutStore.batchUpdateNodeBounds(newPositions)
|
||||
|
||||
// Bring cloned/pasted nodes to front so they render above the originals
|
||||
const allNodes = layoutStore.getAllNodes().value
|
||||
let maxZIndex = 0
|
||||
for (const [, layout] of allNodes) {
|
||||
if (layout.zIndex > maxZIndex) maxZIndex = layout.zIndex
|
||||
}
|
||||
const { setNodeZIndex } = useLayoutMutations()
|
||||
for (let i = 0; i < newPositions.length; i++) {
|
||||
setNodeZIndex(newPositions[i].nodeId, maxZIndex + i + 1)
|
||||
}
|
||||
|
||||
this.selectItems(created)
|
||||
forEachNode(graph, (n) => n.onGraphConfigured?.())
|
||||
forEachNode(graph, (n) => n.onAfterGraphConfigured?.())
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
Point,
|
||||
ISerialisedNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { Rect } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
@@ -654,71 +653,4 @@ describe('LGraphNode', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('measure() collapsed with getCollapsedSize', () => {
|
||||
let out: Rect
|
||||
|
||||
beforeEach(() => {
|
||||
out = [0, 0, 0, 0] as unknown as Rect
|
||||
node.flags.collapsed = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.getCollapsedSize = undefined
|
||||
})
|
||||
|
||||
test('uses getCollapsedSize when callback returns a value', () => {
|
||||
LiteGraph.getCollapsedSize = () => ({ width: 200, height: 40 })
|
||||
node.measure(out)
|
||||
|
||||
expect(out[2]).toBe(200)
|
||||
expect(out[3]).toBe(40)
|
||||
})
|
||||
|
||||
test('falls back to legacy when getCollapsedSize returns undefined', () => {
|
||||
LiteGraph.getCollapsedSize = () => undefined
|
||||
node.measure(out)
|
||||
|
||||
expect(out[3]).toBe(LiteGraph.NODE_TITLE_HEIGHT)
|
||||
})
|
||||
|
||||
test('falls back to legacy when getCollapsedSize is not set', () => {
|
||||
node.measure(out)
|
||||
|
||||
expect(out[3]).toBe(LiteGraph.NODE_TITLE_HEIGHT)
|
||||
})
|
||||
|
||||
test('reads fresh collapsed size on every call (no stale cache)', () => {
|
||||
const spy = vi.fn(() => ({ width: 180, height: 36 }))
|
||||
LiteGraph.getCollapsedSize = spy
|
||||
|
||||
node.measure(out)
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
expect(out[2]).toBe(180)
|
||||
|
||||
// Simulate size change (e.g. bypass badge added/removed on collapsed node)
|
||||
spy.mockReturnValue({ width: 240, height: 36 })
|
||||
node.measure(out)
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
expect(out[2]).toBe(240)
|
||||
})
|
||||
|
||||
test('reflects updated size after expand→collapse cycle', () => {
|
||||
const spy = vi.fn(() => ({ width: 180, height: 36 }))
|
||||
LiteGraph.getCollapsedSize = spy
|
||||
|
||||
node.measure(out)
|
||||
expect(out[2]).toBe(180)
|
||||
|
||||
node.flags.collapsed = false
|
||||
node.measure(out)
|
||||
expect(out[2]).toBe(node.size[0])
|
||||
|
||||
node.flags.collapsed = true
|
||||
spy.mockReturnValue({ width: 200, height: 40 })
|
||||
node.measure(out)
|
||||
expect(out[2]).toBe(200)
|
||||
expect(out[3]).toBe(40)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2084,34 +2084,24 @@ export class LGraphNode
|
||||
const renderTitle =
|
||||
titleMode != TitleMode.TRANSPARENT_TITLE &&
|
||||
titleMode != TitleMode.NO_TITLE
|
||||
// NODE_TITLE_HEIGHT (30) is used in both legacy and Vue modes.
|
||||
// Vue headers may be taller in CSS (e.g. 36px), but the canvas
|
||||
// coordinate system positions nodes at pos.y - 30 via CSS transform
|
||||
// (see LGraphNode.vue), so the bounding rect must match.
|
||||
const titleHeight = renderTitle ? LiteGraph.NODE_TITLE_HEIGHT : 0
|
||||
|
||||
out[0] = this.pos[0]
|
||||
out[1] = this.pos[1] - titleHeight
|
||||
out[1] = this.pos[1] + -titleHeight
|
||||
if (!this.flags?.collapsed) {
|
||||
out[2] = this.size[0]
|
||||
out[3] = this.size[1] + titleHeight
|
||||
} else {
|
||||
const collapsedSize = LiteGraph.getCollapsedSize?.(this.id)
|
||||
if (collapsedSize) {
|
||||
out[2] = collapsedSize.width
|
||||
out[3] = collapsedSize.height
|
||||
} else {
|
||||
if (ctx) ctx.font = this.innerFontStyle
|
||||
this._collapsed_width = Math.min(
|
||||
this.size[0],
|
||||
ctx
|
||||
? cachedMeasureText(ctx, this.getTitle() ?? '') +
|
||||
LiteGraph.NODE_TITLE_HEIGHT * 2
|
||||
: 0
|
||||
)
|
||||
out[2] = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
|
||||
out[3] = LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
if (ctx) ctx.font = this.innerFontStyle
|
||||
this._collapsed_width = Math.min(
|
||||
this.size[0],
|
||||
ctx
|
||||
? cachedMeasureText(ctx, this.getTitle() ?? '') +
|
||||
LiteGraph.NODE_TITLE_HEIGHT * 2
|
||||
: 0
|
||||
)
|
||||
out[2] = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
|
||||
out[3] = LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -355,15 +355,6 @@ export class LiteGraphGlobal {
|
||||
*/
|
||||
vueNodesMode: boolean = false
|
||||
|
||||
/**
|
||||
* Optional accessor for collapsed node dimensions in Vue mode.
|
||||
* Set by the Vue layer to provide DOM-measured collapsed sizes
|
||||
* that measure() can use instead of canvas text measurement.
|
||||
*/
|
||||
getCollapsedSize?: (
|
||||
nodeId: string | number
|
||||
) => { width: number; height: number } | undefined
|
||||
|
||||
// Special Rendering Values pulled out of app.ts patches
|
||||
nodeOpacity = 1
|
||||
nodeLightness: number | undefined = undefined
|
||||
|
||||
@@ -155,7 +155,6 @@ LiteGraphGlobal {
|
||||
"dialog_close_on_mouse_leave_delay": 500,
|
||||
"distance": [Function],
|
||||
"do_add_triggers_slots": false,
|
||||
"getCollapsedSize": undefined,
|
||||
"highlight_selected_group": true,
|
||||
"isInsideRectangle": [Function],
|
||||
"leftMouseClickBehavior": "panning",
|
||||
|
||||
@@ -1988,7 +1988,16 @@
|
||||
"openIn3DViewer": "Open in 3D Viewer",
|
||||
"dropToLoad": "Drop 3D model to load",
|
||||
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl, .ply, .spz, .splat, .ksplat)",
|
||||
"uploadingModel": "Uploading 3D model..."
|
||||
"uploadingModel": "Uploading 3D model...",
|
||||
"loadingHDRI": "Loading HDRI...",
|
||||
"hdri": {
|
||||
"label": "HDRI Environment",
|
||||
"uploadFile": "Upload HDRI (.hdr, .exr)",
|
||||
"changeFile": "Change HDRI",
|
||||
"removeFile": "Remove HDRI",
|
||||
"showAsBackground": "Show as Background",
|
||||
"intensity": "Intensity"
|
||||
}
|
||||
},
|
||||
"imageCrop": {
|
||||
"loading": "Loading...",
|
||||
@@ -2083,7 +2092,9 @@
|
||||
"failedToUpdateMaterialMode": "Failed to update material mode",
|
||||
"failedToUpdateEdgeThreshold": "Failed to update edge threshold",
|
||||
"failedToUploadBackgroundImage": "Failed to upload background image",
|
||||
"failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}"
|
||||
"failedToUpdateBackgroundRenderMode": "Failed to update background render mode to {mode}",
|
||||
"failedToLoadHDRI": "Failed to load HDRI file",
|
||||
"unsupportedHDRIFormat": "Unsupported file format. Please upload a .hdr or .exr file."
|
||||
},
|
||||
"nodeErrors": {
|
||||
"render": "Node Render Error",
|
||||
|
||||
@@ -645,42 +645,4 @@ describe('layoutStore CRDT operations', () => {
|
||||
expect(layoutStore.getSlotLayout(slotKey)).toEqual(slotLayout)
|
||||
}
|
||||
)
|
||||
|
||||
describe('collapsed size lifecycle', () => {
|
||||
const nodeId = 'collapsed-node'
|
||||
|
||||
beforeEach(() => {
|
||||
layoutStore.initializeFromLiteGraph([
|
||||
{ id: nodeId, pos: [0, 0], size: [200, 100] }
|
||||
])
|
||||
})
|
||||
|
||||
it('stores and retrieves collapsed size', () => {
|
||||
layoutStore.updateNodeCollapsedSize(nodeId, { width: 180, height: 36 })
|
||||
expect(layoutStore.getNodeCollapsedSize(nodeId)).toEqual({
|
||||
width: 180,
|
||||
height: 36
|
||||
})
|
||||
})
|
||||
|
||||
it('clears collapsed size', () => {
|
||||
layoutStore.updateNodeCollapsedSize(nodeId, { width: 180, height: 36 })
|
||||
layoutStore.clearNodeCollapsedSize(nodeId)
|
||||
expect(layoutStore.getNodeCollapsedSize(nodeId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for non-existent node', () => {
|
||||
expect(layoutStore.getNodeCollapsedSize('no-such-node')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when ynode has invalid collapsedSize data', () => {
|
||||
// Write a non-Size value directly to simulate corrupted CRDT data
|
||||
layoutStore.updateNodeCollapsedSize(nodeId, { width: 10, height: 20 })
|
||||
// Valid data should work
|
||||
expect(layoutStore.getNodeCollapsedSize(nodeId)).toEqual({
|
||||
width: 10,
|
||||
height: 20
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,7 +34,6 @@ import type {
|
||||
NodeId,
|
||||
NodeLayout,
|
||||
Point,
|
||||
Size,
|
||||
RerouteId,
|
||||
RerouteLayout,
|
||||
ResizeNodeOperation,
|
||||
@@ -1547,33 +1546,6 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
this.currentSource = originalSource
|
||||
}
|
||||
|
||||
updateNodeCollapsedSize(nodeId: NodeId, size: Size): void {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (!ynode) return
|
||||
this.ydoc.transact(() => {
|
||||
ynode.set('collapsedSize', size)
|
||||
}, this.currentActor)
|
||||
this.nodeTriggers.get(nodeId)?.()
|
||||
}
|
||||
|
||||
getNodeCollapsedSize(nodeId: NodeId): Size | undefined {
|
||||
const v = this.ynodes.get(nodeId)?.get('collapsedSize')
|
||||
if (typeof v === 'object' && v !== null && 'width' in v && 'height' in v) {
|
||||
return v as Size
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
clearNodeCollapsedSize(nodeId: NodeId): void {
|
||||
const ynode = this.ynodes.get(nodeId)
|
||||
if (ynode?.has('collapsedSize')) {
|
||||
this.ydoc.transact(() => {
|
||||
ynode.delete('collapsedSize')
|
||||
}, this.currentActor)
|
||||
this.nodeTriggers.get(nodeId)?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
|
||||
@@ -313,11 +313,6 @@ export interface LayoutStore {
|
||||
// Returns all slot layout keys currently tracked by the store
|
||||
getAllSlotKeys(): string[]
|
||||
|
||||
// Collapsed node size (Vue mode only)
|
||||
updateNodeCollapsedSize(nodeId: NodeId, size: Size): void
|
||||
getNodeCollapsedSize(nodeId: NodeId): Size | undefined
|
||||
clearNodeCollapsedSize(nodeId: NodeId): void
|
||||
|
||||
// Direct mutation API (CRDT-ready)
|
||||
applyOperation(operation: LayoutOperation): void
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="litegraph-minimap relative border border-interface-stroke bg-comfy-menu-bg shadow-interface"
|
||||
data-testid="minimap-container"
|
||||
:style="containerStyles"
|
||||
>
|
||||
<Button
|
||||
@@ -58,12 +59,18 @@
|
||||
:width="width"
|
||||
:height="height"
|
||||
class="minimap-canvas"
|
||||
data-testid="minimap-canvas"
|
||||
/>
|
||||
|
||||
<div class="minimap-viewport" :style="viewportStyles" />
|
||||
<div
|
||||
class="minimap-viewport"
|
||||
:style="viewportStyles"
|
||||
data-testid="minimap-viewport"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute inset-0 touch-none"
|
||||
data-testid="minimap-interaction-overlay"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed } from 'vue'
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -176,7 +176,13 @@ describe('LGraphNode', () => {
|
||||
it('should call resize tracking composable with node ID', () => {
|
||||
renderLGraphNode({ nodeData: mockNodeData })
|
||||
|
||||
expect(useVueElementTracking).toHaveBeenCalledWith('test-node-123', 'node')
|
||||
expect(useVueElementTracking).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
'node'
|
||||
)
|
||||
const idArg = vi.mocked(useVueElementTracking).mock.calls[0]?.[0]
|
||||
const id = toValue(idArg)
|
||||
expect(id).toEqual('test-node-123')
|
||||
})
|
||||
|
||||
it('should render with data-node-id attribute', () => {
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
cn(
|
||||
'group/node lg-node absolute isolate text-sm',
|
||||
'flex flex-col contain-layout contain-style',
|
||||
isRerouteNode
|
||||
? 'h-(--node-height)'
|
||||
: 'min-h-(--node-height) min-w-(--min-node-width)',
|
||||
isRerouteNode ? 'h-(--node-height)' : 'min-w-(--min-node-width)',
|
||||
cursorClass,
|
||||
isSelected && 'outline-node-component-outline',
|
||||
executing && 'outline-node-stroke-executing',
|
||||
@@ -57,7 +55,8 @@
|
||||
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
|
||||
isSelected
|
||||
? 'border-node-component-outline'
|
||||
: 'border-node-stroke-executing'
|
||||
: 'border-node-stroke-executing',
|
||||
footerStateOutlineBottomClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
@@ -67,23 +66,27 @@
|
||||
cn(
|
||||
'pointer-events-none absolute border border-solid border-component-node-border',
|
||||
rootBorderShapeClass,
|
||||
hasAnyError ? '-inset-1' : 'inset-0'
|
||||
hasAnyError ? '-inset-1' : 'inset-0',
|
||||
footerRootBorderBottomClass
|
||||
)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
data-node-body
|
||||
data-testid="node-inner-wrapper"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
|
||||
'w-(--node-width)',
|
||||
!isRerouteNode && 'min-w-(--min-node-width)',
|
||||
!isRerouteNode && 'min-h-(--node-height) min-w-(--min-node-width)',
|
||||
shapeClass,
|
||||
hasAnyError && 'ring-4 ring-destructive-background',
|
||||
bypassed && bypassOverlayClass,
|
||||
muted && mutedOverlayClass,
|
||||
isDraggingOver && 'bg-primary-500/10 ring-4 ring-primary-500'
|
||||
{
|
||||
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0 before:bg-bypass/60`]:
|
||||
bypassed,
|
||||
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
|
||||
muted,
|
||||
'bg-primary-500/10 ring-4 ring-primary-500': isDraggingOver
|
||||
}
|
||||
)
|
||||
"
|
||||
:style="{
|
||||
@@ -193,6 +196,7 @@
|
||||
:is-subgraph="!!lgraphNode?.isSubgraphNode()"
|
||||
:has-any-error="hasAnyError"
|
||||
:show-errors-tab-enabled="showErrorsTabEnabled"
|
||||
:is-collapsed="isCollapsed"
|
||||
:show-advanced-inputs-button="showAdvancedInputsButton"
|
||||
:show-advanced-state="showAdvancedState"
|
||||
:header-color="applyLightThemeColor(nodeData?.color)"
|
||||
@@ -218,6 +222,8 @@
|
||||
cn(
|
||||
baseResizeHandleClasses,
|
||||
handle.positionClasses,
|
||||
(handle.corner === 'SE' || handle.corner === 'SW') &&
|
||||
footerResizeHandleBottomClass,
|
||||
handle.cursorClass,
|
||||
'group-hover/node:opacity-100'
|
||||
)
|
||||
@@ -265,7 +271,6 @@ import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { st } from '@/i18n'
|
||||
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
|
||||
import {
|
||||
LGraphCanvas,
|
||||
LGraphEventMode,
|
||||
@@ -311,6 +316,7 @@ import {
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { isTransparent } from '@/utils/colorUtil'
|
||||
|
||||
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTransform'
|
||||
|
||||
@@ -340,7 +346,7 @@ const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =
|
||||
useNodeEventHandlers()
|
||||
const { bringNodeToFront } = useNodeZIndex()
|
||||
|
||||
useVueElementTracking(String(nodeData.id), 'node')
|
||||
useVueElementTracking(() => nodeData.id, 'node')
|
||||
|
||||
const { selectedNodeIds } = storeToRefs(useCanvasStore())
|
||||
const isSelected = computed(() => {
|
||||
@@ -560,6 +566,30 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
||||
}
|
||||
)
|
||||
|
||||
const hasFooter = computed(() => {
|
||||
return !!(
|
||||
(hasAnyError.value && showErrorsTabEnabled.value) ||
|
||||
lgraphNode.value?.isSubgraphNode() ||
|
||||
(!lgraphNode.value?.isSubgraphNode() &&
|
||||
(showAdvancedState.value || showAdvancedInputsButton.value))
|
||||
)
|
||||
})
|
||||
|
||||
// Footer offset computed classes
|
||||
|
||||
const footerStateOutlineBottomClass = computed(() =>
|
||||
hasFooter.value ? '-bottom-[35px]' : ''
|
||||
)
|
||||
|
||||
const footerRootBorderBottomClass = computed(() =>
|
||||
hasFooter.value ? '-bottom-8' : ''
|
||||
)
|
||||
|
||||
const footerResizeHandleBottomClass = computed(() => {
|
||||
if (!hasFooter.value) return ''
|
||||
return hasAnyError.value ? 'bottom-[-31px]' : 'bottom-[-35px]'
|
||||
})
|
||||
|
||||
const cursorClass = computed(() => {
|
||||
if (nodeData.flags?.pinned) return 'cursor-default'
|
||||
return layoutStore.isDraggingVueNodes.value
|
||||
@@ -628,28 +658,14 @@ const selectionShapeClass = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const BEFORE_OVERLAY_BASE =
|
||||
'before:pointer-events-none before:absolute before:inset-0'
|
||||
|
||||
const bypassOverlayClass = computed(() => {
|
||||
const beforeShapeClass = computed(() => {
|
||||
switch (nodeData.shape) {
|
||||
case RenderShape.BOX:
|
||||
return `${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return `before:rounded-tl-2xl before:rounded-br-2xl ${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
|
||||
return 'before:rounded-tl-2xl before:rounded-br-2xl'
|
||||
default:
|
||||
return `before:rounded-2xl ${BEFORE_OVERLAY_BASE} before:bg-bypass/60`
|
||||
}
|
||||
})
|
||||
|
||||
const mutedOverlayClass = computed(() => {
|
||||
switch (nodeData.shape) {
|
||||
case RenderShape.BOX:
|
||||
return BEFORE_OVERLAY_BASE
|
||||
case RenderShape.CARD:
|
||||
return `before:rounded-tl-2xl before:rounded-br-2xl ${BEFORE_OVERLAY_BASE}`
|
||||
default:
|
||||
return `before:rounded-2xl ${BEFORE_OVERLAY_BASE}`
|
||||
return 'before:rounded-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
<template>
|
||||
<!-- Case 1: Subgraph + Error (Dual Tabs) -->
|
||||
<div
|
||||
v-if="isSubgraph && hasAnyError && showErrorsTabEnabled"
|
||||
:class="errorWrapperStyles"
|
||||
>
|
||||
<template v-if="isSubgraph && hasAnyError && showErrorsTabEnabled">
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
|
||||
errorRadiusClass
|
||||
getTabStyles(false),
|
||||
errorTabWidth,
|
||||
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
@@ -26,38 +23,37 @@
|
||||
data-testid="subgraph-enter-button"
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5',
|
||||
enterRadiusClass
|
||||
getTabStyles(true),
|
||||
enterTabFullWidth,
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('enterSubgraph')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
|
||||
<span class="truncate">{{ t('g.enter') }}</span>
|
||||
<i class="icon-[comfy--workflow] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Case 1b: Advanced + Error (Dual Tabs, Regular Nodes) -->
|
||||
<div
|
||||
<template
|
||||
v-else-if="
|
||||
!isSubgraph &&
|
||||
hasAnyError &&
|
||||
showErrorsTabEnabled &&
|
||||
(showAdvancedInputsButton || showAdvancedState)
|
||||
"
|
||||
:class="errorWrapperStyles"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
|
||||
errorRadiusClass
|
||||
getTabStyles(false),
|
||||
errorTabWidth,
|
||||
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
@@ -72,15 +68,15 @@
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5',
|
||||
enterRadiusClass
|
||||
getTabStyles(true),
|
||||
enterTabFullWidth,
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
|
||||
<span class="truncate">{{
|
||||
showAdvancedState
|
||||
? t('rightSidePanel.hideAdvancedShort')
|
||||
@@ -95,20 +91,17 @@
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Case 2: Error Only (Full Width) -->
|
||||
<div
|
||||
v-else-if="hasAnyError && showErrorsTabEnabled"
|
||||
:class="errorWrapperStyles"
|
||||
>
|
||||
<template v-else-if="hasAnyError && showErrorsTabEnabled">
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'box-border w-full rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
|
||||
footerRadiusClass
|
||||
getTabStyles(false),
|
||||
enterTabFullWidth,
|
||||
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
|
||||
)
|
||||
"
|
||||
@click.stop="$emit('openErrors')"
|
||||
@@ -118,27 +111,18 @@
|
||||
<i class="icon-[lucide--info] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Case 3: Subgraph only (Full Width) -->
|
||||
<div
|
||||
v-else-if="isSubgraph"
|
||||
:class="
|
||||
cn(
|
||||
footerWrapperBase,
|
||||
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template v-else-if="isSubgraph">
|
||||
<Button
|
||||
variant="textonly"
|
||||
data-testid="subgraph-enter-button"
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'box-border w-full rounded-none bg-node-component-header-surface',
|
||||
hasAnyError ? 'pt-9 pb-4' : 'pt-8 pb-4',
|
||||
footerRadiusClass
|
||||
getTabStyles(true),
|
||||
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@@ -149,47 +133,37 @@
|
||||
<i class="icon-[comfy--workflow] size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Case 4: Advanced Footer (Regular Nodes) -->
|
||||
<div
|
||||
<Button
|
||||
v-else-if="showAdvancedInputsButton || showAdvancedState"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
footerWrapperBase,
|
||||
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
|
||||
getTabStyles(true),
|
||||
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
|
||||
'-z-10 bg-node-component-header-surface'
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
tabStyles,
|
||||
'box-border w-full rounded-none bg-node-component-header-surface',
|
||||
hasAnyError ? 'pt-9 pb-4' : 'pt-8 pb-4',
|
||||
footerRadiusClass
|
||||
)
|
||||
"
|
||||
:style="headerColorStyle"
|
||||
@click.stop="$emit('toggleAdvanced')"
|
||||
>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<template v-if="showAdvancedState">
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.hideAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.showAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
|
||||
</template>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex size-full items-center justify-center gap-2">
|
||||
<template v-if="showAdvancedState">
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.hideAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="truncate">{{
|
||||
t('rightSidePanel.showAdvancedInputsButton')
|
||||
}}</span>
|
||||
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
|
||||
</template>
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -205,67 +179,67 @@ interface Props {
|
||||
isSubgraph: boolean
|
||||
hasAnyError: boolean
|
||||
showErrorsTabEnabled: boolean
|
||||
isCollapsed: boolean
|
||||
showAdvancedInputsButton?: boolean
|
||||
showAdvancedState?: boolean
|
||||
headerColor?: string
|
||||
shape?: RenderShape
|
||||
}
|
||||
|
||||
const {
|
||||
isSubgraph,
|
||||
hasAnyError,
|
||||
showErrorsTabEnabled,
|
||||
showAdvancedInputsButton,
|
||||
showAdvancedState,
|
||||
headerColor,
|
||||
shape
|
||||
} = defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
enterSubgraph: []
|
||||
openErrors: []
|
||||
toggleAdvanced: []
|
||||
(e: 'enterSubgraph'): void
|
||||
(e: 'openErrors'): void
|
||||
(e: 'toggleAdvanced'): void
|
||||
}>()
|
||||
|
||||
// Static lookup to keep class names scannable by Tailwind
|
||||
const RADIUS_CLASS = {
|
||||
'rounded-b-17': 'rounded-b-[17px]',
|
||||
'rounded-b-20': 'rounded-b-[20px]',
|
||||
'rounded-br-17': 'rounded-br-[17px]',
|
||||
'rounded-br-20': 'rounded-br-[20px]'
|
||||
} as const
|
||||
const footerRadiusClass = computed(() => {
|
||||
const isExpanded = props.hasAnyError
|
||||
|
||||
function getBottomRadius(
|
||||
nodeShape: RenderShape | undefined,
|
||||
size: '17px' | '20px',
|
||||
corners: 'both' | 'right' = 'both'
|
||||
): string {
|
||||
if (nodeShape === RenderShape.BOX) return ''
|
||||
const prefix =
|
||||
nodeShape === RenderShape.CARD || corners === 'right'
|
||||
? 'rounded-br'
|
||||
: 'rounded-b'
|
||||
const key =
|
||||
`${prefix}-${size === '17px' ? '17' : '20'}` as keyof typeof RADIUS_CLASS
|
||||
return RADIUS_CLASS[key]
|
||||
switch (props.shape) {
|
||||
case RenderShape.BOX:
|
||||
return ''
|
||||
case RenderShape.CARD:
|
||||
return isExpanded ? 'rounded-br-[20px]' : 'rounded-br-2xl'
|
||||
default:
|
||||
return isExpanded ? 'rounded-b-[20px]' : 'rounded-b-2xl'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Returns shared size/position classes for footer tabs
|
||||
* @param isBackground If true, calculates styles for the background/right tab (Enter Subgraph)
|
||||
*/
|
||||
const getTabStyles = (isBackground = false) => {
|
||||
let sizeClasses = ''
|
||||
if (props.isCollapsed) {
|
||||
let pt = 'pt-10'
|
||||
if (isBackground) {
|
||||
pt = props.hasAnyError ? 'pt-10.5' : 'pt-9'
|
||||
}
|
||||
sizeClasses = cn('-mt-7.5 h-15', pt)
|
||||
} else {
|
||||
let pt = 'pt-12.5'
|
||||
if (isBackground) {
|
||||
pt = props.hasAnyError ? 'pt-12.5' : 'pt-11.5'
|
||||
}
|
||||
sizeClasses = cn('-mt-10 h-17.5', pt)
|
||||
}
|
||||
|
||||
return cn(
|
||||
'pointer-events-auto absolute top-full left-0 text-xs',
|
||||
footerRadiusClass.value,
|
||||
sizeClasses,
|
||||
props.hasAnyError ? '-translate-x-1 translate-y-0.5' : 'translate-y-0.5'
|
||||
)
|
||||
}
|
||||
|
||||
const footerRadiusClass = computed(() =>
|
||||
getBottomRadius(shape, hasAnyError ? '20px' : '17px')
|
||||
)
|
||||
|
||||
const errorRadiusClass = computed(() => getBottomRadius(shape, '20px'))
|
||||
|
||||
const enterRadiusClass = computed(() => getBottomRadius(shape, '20px', 'right'))
|
||||
|
||||
const tabStyles = 'pointer-events-auto h-9 text-xs'
|
||||
const footerWrapperBase = 'isolate -z-1 -mt-5 box-border flex'
|
||||
const errorWrapperStyles = cn(
|
||||
footerWrapperBase,
|
||||
'-mx-1 -mb-2 w-[calc(100%+8px)] pb-1'
|
||||
)
|
||||
|
||||
const headerColorStyle = computed(() =>
|
||||
headerColor ? { backgroundColor: headerColor } : undefined
|
||||
props.headerColor ? { backgroundColor: props.headerColor } : undefined
|
||||
)
|
||||
|
||||
// Case 1 context: Split widths
|
||||
const errorTabWidth = 'w-[calc(50%+4px)]'
|
||||
const enterTabFullWidth = 'w-[calc(100%+8px)]'
|
||||
</script>
|
||||
|
||||
@@ -43,14 +43,11 @@ const testState = vi.hoisted(() => ({
|
||||
nodeLayouts: new Map<NodeId, NodeLayout>(),
|
||||
batchUpdateNodeBounds: vi.fn(),
|
||||
setSource: vi.fn(),
|
||||
syncNodeSlotLayoutsFromDOM: vi.fn(),
|
||||
updateNodeCollapsedSize: vi.fn(),
|
||||
clearNodeCollapsedSize: vi.fn()
|
||||
syncNodeSlotLayoutsFromDOM: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible'),
|
||||
createSharedComposable: <T>(fn: T) => fn
|
||||
useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible')
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
@@ -70,9 +67,7 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
batchUpdateNodeBounds: testState.batchUpdateNodeBounds,
|
||||
setSource: testState.setSource,
|
||||
getNodeLayoutRef: (nodeId: NodeId): Ref<NodeLayout | null> =>
|
||||
ref<NodeLayout | null>(testState.nodeLayouts.get(nodeId) ?? null),
|
||||
clearNodeCollapsedSize: testState.clearNodeCollapsedSize,
|
||||
updateNodeCollapsedSize: testState.updateNodeCollapsedSize
|
||||
ref<NodeLayout | null>(testState.nodeLayouts.get(nodeId) ?? null)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -89,7 +84,6 @@ function createResizeEntry(options?: {
|
||||
left?: number
|
||||
top?: number
|
||||
collapsed?: boolean
|
||||
bodyWidth?: number
|
||||
}) {
|
||||
const {
|
||||
nodeId = 'test-node',
|
||||
@@ -97,8 +91,7 @@ function createResizeEntry(options?: {
|
||||
height = 180,
|
||||
left = 100,
|
||||
top = 200,
|
||||
collapsed = false,
|
||||
bodyWidth
|
||||
collapsed = false
|
||||
} = options ?? {}
|
||||
|
||||
const element = document.createElement('div')
|
||||
@@ -106,14 +99,6 @@ function createResizeEntry(options?: {
|
||||
if (collapsed) {
|
||||
element.dataset.collapsed = ''
|
||||
}
|
||||
if (bodyWidth !== undefined) {
|
||||
const body = document.createElement('div')
|
||||
body.setAttribute('data-node-body', '')
|
||||
Object.defineProperty(body, 'offsetWidth', { value: bodyWidth })
|
||||
element.appendChild(body)
|
||||
}
|
||||
Object.defineProperty(element, 'offsetWidth', { value: width })
|
||||
Object.defineProperty(element, 'offsetHeight', { value: height })
|
||||
const rectSpy = vi.fn(() => new DOMRect(left, top, width, height))
|
||||
element.getBoundingClientRect = rectSpy
|
||||
const boxSizes = [{ inlineSize: width, blockSize: height }]
|
||||
@@ -173,8 +158,6 @@ describe('useVueNodeResizeTracking', () => {
|
||||
testState.batchUpdateNodeBounds.mockReset()
|
||||
testState.setSource.mockReset()
|
||||
testState.syncNodeSlotLayoutsFromDOM.mockReset()
|
||||
testState.updateNodeCollapsedSize.mockReset()
|
||||
testState.clearNodeCollapsedSize.mockReset()
|
||||
resizeObserverState.observe.mockReset()
|
||||
resizeObserverState.unobserve.mockReset()
|
||||
resizeObserverState.disconnect.mockReset()
|
||||
@@ -281,96 +264,18 @@ describe('useVueNodeResizeTracking', () => {
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
|
||||
})
|
||||
|
||||
it('stores collapsed size and resyncs slots for collapsed nodes', () => {
|
||||
it('resyncs slot anchors for collapsed nodes without writing bounds', () => {
|
||||
const nodeId = 'test-node'
|
||||
const width = 200
|
||||
const height = 40
|
||||
const { entry, rectSpy } = createResizeEntry({
|
||||
nodeId,
|
||||
width,
|
||||
height,
|
||||
collapsed: true
|
||||
})
|
||||
|
||||
seedNodeLayout({ nodeId, left: 100, top: 200, width: 240, height: 180 })
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(rectSpy).not.toHaveBeenCalled()
|
||||
expect(testState.setSource).not.toHaveBeenCalled()
|
||||
expect(testState.batchUpdateNodeBounds).not.toHaveBeenCalled()
|
||||
expect(testState.updateNodeCollapsedSize).toHaveBeenCalledWith(nodeId, {
|
||||
width,
|
||||
height
|
||||
})
|
||||
expect(testState.syncNodeSlotLayoutsFromDOM).toHaveBeenCalledWith(nodeId)
|
||||
})
|
||||
|
||||
it('uses body element width for collapsed size when inner wrapper exists', () => {
|
||||
const nodeId = 'test-node'
|
||||
const rootWidth = 260
|
||||
const bodyWidth = 200
|
||||
const height = 40
|
||||
const { entry } = createResizeEntry({
|
||||
nodeId,
|
||||
width: rootWidth,
|
||||
height,
|
||||
collapsed: true,
|
||||
bodyWidth
|
||||
})
|
||||
|
||||
seedNodeLayout({ nodeId, left: 100, top: 200, width: 240, height: 180 })
|
||||
|
||||
resizeObserverState.callback?.([entry], createObserverMock())
|
||||
|
||||
expect(testState.updateNodeCollapsedSize).toHaveBeenCalledWith(nodeId, {
|
||||
width: bodyWidth,
|
||||
height
|
||||
})
|
||||
})
|
||||
|
||||
it('clears collapsed size then updates bounds on collapse-to-expand transition', () => {
|
||||
const nodeId = 'test-node'
|
||||
const collapsedWidth = 200
|
||||
const collapsedHeight = 40
|
||||
|
||||
// Seed with smaller size so expand triggers a real bounds update
|
||||
seedNodeLayout({ nodeId, left: 100, top: 200, width: 220, height: 140 })
|
||||
|
||||
// Step 1: Collapse
|
||||
const { entry: collapsedEntry } = createResizeEntry({
|
||||
nodeId,
|
||||
width: collapsedWidth,
|
||||
height: collapsedHeight,
|
||||
left: 100,
|
||||
top: 200,
|
||||
collapsed: true
|
||||
})
|
||||
resizeObserverState.callback?.([collapsedEntry], createObserverMock())
|
||||
|
||||
expect(testState.updateNodeCollapsedSize).toHaveBeenCalledWith(nodeId, {
|
||||
width: collapsedWidth,
|
||||
height: collapsedHeight
|
||||
})
|
||||
|
||||
testState.updateNodeCollapsedSize.mockReset()
|
||||
testState.clearNodeCollapsedSize.mockReset()
|
||||
testState.batchUpdateNodeBounds.mockReset()
|
||||
testState.setSource.mockReset()
|
||||
|
||||
// Step 2: Expand — same node, no collapsed attribute, larger size
|
||||
const { entry: expandedEntry } = createResizeEntry({
|
||||
nodeId,
|
||||
width: 240,
|
||||
height: 180,
|
||||
left: 100,
|
||||
top: 200
|
||||
})
|
||||
resizeObserverState.callback?.([expandedEntry], createObserverMock())
|
||||
|
||||
expect(testState.clearNodeCollapsedSize).toHaveBeenCalledWith(nodeId)
|
||||
expect(testState.updateNodeCollapsedSize).not.toHaveBeenCalled()
|
||||
expect(testState.setSource).toHaveBeenCalledWith(LayoutSource.DOM)
|
||||
expect(testState.batchUpdateNodeBounds).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* Supports different element types (nodes, slots, widgets, etc.) with
|
||||
* customizable data attributes and update handlers.
|
||||
*/
|
||||
import { getCurrentInstance, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { getCurrentInstance, onMounted, onUnmounted, toValue, watch } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { useDocumentVisibility } from '@vueuse/core'
|
||||
|
||||
@@ -138,37 +139,25 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
const nodeId: NodeId | undefined =
|
||||
elementType === 'node' ? elementId : undefined
|
||||
|
||||
// Collapsed nodes: preserve expanded size but store collapsed
|
||||
// dimensions separately in layoutStore for selection bounds.
|
||||
// Skip collapsed nodes — their DOM height is just the header, and writing
|
||||
// that back to the layout store would overwrite the stored expanded size.
|
||||
if (elementType === 'node' && element.dataset.collapsed != null) {
|
||||
if (nodeId) {
|
||||
markElementForFreshMeasurement(element)
|
||||
const body = element.querySelector('[data-node-body]')
|
||||
const collapsedWidth =
|
||||
body instanceof HTMLElement ? body.offsetWidth : element.offsetWidth
|
||||
const collapsedHeight = element.offsetHeight
|
||||
const collapsedSize = { width: collapsedWidth, height: collapsedHeight }
|
||||
if (collapsedWidth === 0 && collapsedHeight === 0) continue
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (nodeLayout) {
|
||||
layoutStore.updateNodeCollapsedSize(nodeId, collapsedSize)
|
||||
}
|
||||
nodesNeedingSlotResync.add(nodeId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Clear stale collapsedSize on collapse→expand transition only
|
||||
if (elementType === 'node' && nodeId) {
|
||||
layoutStore.clearNodeCollapsedSize(nodeId)
|
||||
}
|
||||
|
||||
// Measure the full root element (including footer in flow).
|
||||
// min-height is applied to the root, so footer height in node.size
|
||||
// does not accumulate on Vue/legacy mode switching.
|
||||
const width = Math.max(0, element.offsetWidth)
|
||||
const height = Math.max(0, element.offsetHeight)
|
||||
|
||||
// Use borderBoxSize when available; fall back to contentRect for older engines/tests
|
||||
// Border box is the border included FULL wxh DOM value.
|
||||
const borderBox = Array.isArray(entry.borderBoxSize)
|
||||
? entry.borderBoxSize[0]
|
||||
: {
|
||||
inlineSize: entry.contentRect.width,
|
||||
blockSize: entry.contentRect.height
|
||||
}
|
||||
const width = Math.max(0, borderBox.inlineSize)
|
||||
const height = Math.max(0, borderBox.blockSize)
|
||||
const nodeLayout = nodeId
|
||||
? layoutStore.getNodeLayoutRef(nodeId).value
|
||||
: null
|
||||
@@ -292,9 +281,10 @@ const resizeObserver = new ResizeObserver((entries) => {
|
||||
* ```
|
||||
*/
|
||||
export function useVueElementTracking(
|
||||
appIdentifier: string,
|
||||
appIdentifierMaybe: MaybeRefOrGetter<string>,
|
||||
trackingType: string
|
||||
) {
|
||||
const appIdentifier = toValue(appIdentifierMaybe)
|
||||
onMounted(() => {
|
||||
const element = getCurrentInstance()?.proxy?.$el
|
||||
if (!(element instanceof HTMLElement) || !appIdentifier) return
|
||||
@@ -319,7 +309,6 @@ export function useVueElementTracking(
|
||||
delete element.dataset[config.dataAttribute]
|
||||
cachedNodeMeasurements.delete(element)
|
||||
elementsNeedingFreshMeasurement.delete(element)
|
||||
deferredElements.delete(element)
|
||||
resizeObserver.unobserve(element)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export function isTransparent(color: string) {
|
||||
return false
|
||||
}
|
||||
|
||||
function rgbToHsl({ r, g, b }: RGB): HSL {
|
||||
export function rgbToHsl({ r, g, b }: RGB): HSL {
|
||||
r /= 255
|
||||
g /= 255
|
||||
b /= 255
|
||||
|
||||