diff --git a/docs/screenshots/node-dimensions/dark.png b/docs/screenshots/node-dimensions/dark.png new file mode 100644 index 0000000..3c6fd48 Binary files /dev/null and b/docs/screenshots/node-dimensions/dark.png differ diff --git a/docs/screenshots/node-dimensions/light.png b/docs/screenshots/node-dimensions/light.png new file mode 100644 index 0000000..540b687 Binary files /dev/null and b/docs/screenshots/node-dimensions/light.png differ diff --git a/tests/browser/workflows.spec.mjs b/tests/browser/workflows.spec.mjs index 934e1b9..88a5466 100644 --- a/tests/browser/workflows.spec.mjs +++ b/tests/browser/workflows.spec.mjs @@ -870,3 +870,120 @@ test('gallery directory autocomplete handles keyboard selection escape and navig await input.press('Enter'); await expect(page.getByAltText('forest.png', { exact: true })).toBeVisible(); }); + +async function openDimensions(page) { + await page.evaluate(() => { + const node = kikoHost.app.graph._nodes.at(-1); + node.computeSize = () => [180, 120]; + node.setSize = size => { node.size = size; node.applied = (node.applied || 0) + 1; }; + const opener = document.createElement('button'); + opener.textContent = 'Edit dimensions'; + opener.onclick = () => kikoHost.extensions.find(item => item.name === 'ComfyAssets.Extensions').getNodeMenuItems(node)[1].callback(); + document.body.append(opener); + }); + await page.getByRole('button', { name: 'Edit dimensions', exact: true }).click(); + return page.getByRole('dialog', { name: 'Node Dimensions', exact: true }); +} + +test('Node Dimensions validates, applies once and enforces content minimums', async ({ page }) => { + await openHost(page); + await createNode(page, 'WidthHeightSelector'); + const dialog = await openDimensions(page); + await expect(dialog).toBeVisible(); + await page.getByRole('button', { name: 'Edit dimensions', exact: true }).evaluate(element => element.focus()); + await expect(dialog.getByLabel('Width (px)', { exact: true })).toBeFocused(); + for (const value of ['', '-1', '1.5']) { + await dialog.getByLabel('Width (px)', { exact: true }).fill(value); + await dialog.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect(dialog.getByText('Enter positive whole numbers for width and height.')).toBeVisible(); + expect(await page.evaluate(() => kikoHost.app.graph._nodes.at(-1).applied || 0)).toBe(0); + } + await dialog.getByLabel('Width (px)', { exact: true }).fill('1'); + await dialog.getByLabel('Height (px)', { exact: true }).fill('10'); + await page.keyboard.press('Enter'); + await expect(dialog).toHaveCount(0); + expect(await page.evaluate(() => ({ size: kikoHost.app.graph._nodes.at(-1).size, applied: kikoHost.app.graph._nodes.at(-1).applied }))).toEqual({ size: [180, 120], applied: 1 }); + await expect(page.getByRole('button', { name: 'Edit dimensions', exact: true })).toBeFocused(); +}); + +test('Node Dimensions follows themes, fits narrow screens and cleans up on removal', async ({ page }) => { + await openHost(page); + await createNode(page, 'WidthHeightSelector'); + const dialog = await openDimensions(page); + for (const [color, theme] of [['#fafafa', 'light'], ['#202020', 'dark']]) { + await page.evaluate(color => document.body.style.setProperty('--bg-color', color), color); + await expect(dialog).toHaveAttribute('data-ca-theme', theme); + } + await page.setViewportSize({ width: 320, height: 480 }); + expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); + await page.evaluate(() => window.KikoToolsExtensions.showNodeDimensionsDialog(kikoHost.app.graph._nodes.at(-1))); + await expect(dialog).toHaveCount(1); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await page.getByRole('button', { name: 'Edit dimensions', exact: true }).click(); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(dialog).toHaveCount(0); + await page.getByRole('button', { name: 'Edit dimensions', exact: true }).click(); + await page.evaluate(() => kikoHost.app.graph._nodes.at(-1).onRemoved()); + await expect(dialog).toHaveCount(0); + expect(await page.evaluate(() => kikoHost.app.graph._nodes.at(-1).applied || 0)).toBe(0); +}); + +test('legacy dialog helpers preserve values, cancellation and manual close', async ({ page }) => { + await openHost(page); + await page.evaluate(() => { + window.dialogCalls = []; + window.openLegacy = () => window.legacyDialog = window.KikoToolsExtensions.createDialog('', (dialog, values) => window.dialogCalls.push([dialog.tagName, values]), () => window.dialogCalls.push('cancel')); + window.openLegacy(); + }); + await page.getByLabel('Legacy value').fill('after'); + await page.keyboard.press('Enter'); + expect(await page.evaluate(() => window.dialogCalls)).toEqual([['DIALOG', ['after']]]); + await page.evaluate(() => window.openLegacy()); + await page.keyboard.press('Escape'); + await page.evaluate(() => { window.openLegacy(); window.legacyDialog.close(); }); + expect(await page.evaluate(() => window.dialogCalls)).toEqual([['DIALOG', ['after']], 'cancel']); + await page.evaluate(() => window.openLegacy()); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + expect(await page.evaluate(() => window.dialogCalls)).toEqual([['DIALOG', ['after']], 'cancel', 'cancel']); +}); + +test('dimension dialogs preserve node hooks and restrict menu ownership', async ({ page }) => { + await openHost(page); + await createNode(page, 'WidthHeightSelector'); + const result = await page.evaluate(() => { + const extension = kikoHost.extensions.find(item => item.name === 'ComfyAssets.Extensions'); + const missing = extension.getNodeMenuItems({ constructor: {} }); + const other = extension.getNodeMenuItems({ constructor: { category: 'Other' } }); + const node = kikoHost.app.graph._nodes.at(-1); + const original = function (...args) { window.removedArgs = [this === node, ...args]; return 'removed'; }; + node.onRemoved = original; + const dialog = window.KikoToolsExtensions.showNodeDimensionsDialog(node); + window.detachedApply = dialog.querySelector('button[type="submit"]'); + const returned = node.onRemoved('a', 2); + return { missing, other, returned, restored: node.onRemoved === original, args: window.removedArgs }; + }); + expect(result).toEqual({ missing: [], other: [], returned: 'removed', restored: true, args: [true, 'a', 2] }); + await page.evaluate(() => window.detachedApply.click()); + await expect(page.getByRole('dialog')).toHaveCount(0); + await page.evaluate(() => window.KikoToolsExtensions.createDialog('')); + await page.getByRole('button', { name: 'OK', exact: true }).click(); + await page.evaluate(() => window.KikoToolsExtensions.createDialog('No fields')); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toHaveCount(0); +}); + + +test('legacy selects retain Enter confirmation', async ({ page }) => { + await openHost(page); + await page.evaluate(() => window.KikoToolsExtensions.createDialog('', (dialog, values) => window.selectedLegacy = values)); + await page.evaluate(() => { + window.leakedDialogKeys = 0; + document.addEventListener('keydown', () => window.leakedDialogKeys++); + }); + await page.getByLabel('Choice', { exact: true }).focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog')).toHaveCount(0); + expect(await page.evaluate(() => window.selectedLegacy)).toEqual(['chosen']); + expect(await page.evaluate(() => window.leakedDialogKeys)).toBe(0); +}); diff --git a/tests/comfyui/image-browser.spec.mjs b/tests/comfyui/image-browser.spec.mjs index a991e5d..3e75081 100644 --- a/tests/comfyui/image-browser.spec.mjs +++ b/tests/comfyui/image-browser.spec.mjs @@ -397,3 +397,71 @@ test('actual host preserves the widget through renderer changes, theme changes a }); await expect(loader).toHaveCount(0); }); + +test('Node Dimensions applies and persists across Classic and Nodes 2.0', async ({ page }, testInfo) => { + await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + const node = LiteGraph.createNode('WidthHeightSelector'); + app.graph.add(node); + node.pos = [300, 250]; + app.canvas.centerOnNode(node); + }); + const dialog = page.getByRole('dialog', { name: 'Node Dimensions', exact: true }); + for (const renderer of [false, true, false]) { + await page.evaluate(async renderer => { + const { app } = await import('/scripts/app.js'); + await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer); + app.canvas.centerOnNode(app.graph._nodes[0]); + }, renderer); + await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer); + const point = await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + const node = app.graph._nodes[0]; + const canvas = app.canvas.canvas.getBoundingClientRect(); + return { x: canvas.left + (node.pos[0] + app.canvas.ds.offset[0] + 60) * app.canvas.ds.scale, y: canvas.top + (node.pos[1] + app.canvas.ds.offset[1] - 15) * app.canvas.ds.scale }; + }); + await page.mouse.click(point.x, point.y, { button: 'right' }); + await page.getByText('🎨 Node Dimensions', { exact: true }).click(); + await expect(dialog).toBeVisible(); + await dialog.getByLabel('Width (px)', { exact: true }).fill('900'); + await dialog.getByLabel('Height (px)', { exact: true }).fill('600'); + await page.keyboard.press('Enter'); + await expect(dialog).toHaveCount(0); + expect(await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + return Array.from(app.graph._nodes[0].size); + })).toEqual([900, 600]); + await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + window.KikoToolsExtensions.showNodeDimensionsDialog(app.graph._nodes[0]); + }); + await dialog.getByLabel('Width (px)', { exact: true }).fill('1200'); + await page.keyboard.press('Escape'); + expect(await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + return Array.from(app.graph._nodes[0].size); + })).toEqual([900, 600]); + } + const size = await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + const workflow = app.graph.serialize(); + await app.loadGraphData(workflow); + app.canvas.centerOnNode(app.graph._nodes[0]); + window.KikoToolsExtensions.showNodeDimensionsDialog(app.graph._nodes[0]); + return Array.from(app.graph._nodes[0].size); + }); + expect(size).toEqual([900, 600]); + for (const theme of ['light', 'dark']) { + await page.evaluate(async theme => { + const { app } = await import('/scripts/app.js'); + await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme); + }, theme); + await expect(dialog).toHaveAttribute('data-ca-theme', theme); + await page.screenshot({ path: testInfo.outputPath(`node-dimensions-${theme}.png`) }); + } + await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + app.graph.remove(app.graph._nodes[0]); + }); + await expect(dialog).toHaveCount(0); +}); diff --git a/tests/js/node-dimensions.test.mjs b/tests/js/node-dimensions.test.mjs new file mode 100644 index 0000000..78b9b6e --- /dev/null +++ b/tests/js/node-dimensions.test.mjs @@ -0,0 +1,15 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveNodeSize } from '../../web/views/node-dimensions.js'; + +test('node dimensions accept whole pixels and respect content minimums', () => { + assert.deepEqual(resolveNodeSize(['640', '480'], [180, 120]), [640, 480]); + assert.deepEqual(resolveNodeSize(['1', '10'], [180, 120]), [180, 120]); +}); + +test('node dimensions reject missing, fractional, unsafe and nonpositive values', () => { + for (const value of ['', ' ', '0', '-1', '1.5', 'Infinity', 'NaN', '9007199254740992']) { + assert.equal(resolveNodeSize([value, '480'], [180, 120]), null, value); + assert.equal(resolveNodeSize(['640', value], [180, 120]), null, value); + } +}); diff --git a/tools/testing/frontend-coverage-baseline.json b/tools/testing/frontend-coverage-baseline.json index 50d986c..2867cda 100644 --- a/tools/testing/frontend-coverage-baseline.json +++ b/tools/testing/frontend-coverage-baseline.json @@ -60,10 +60,10 @@ "statements": 98 }, "kikotools_extensions.js": { - "lines": 12, + "lines": 98, "branches": 98, - "functions": 0, - "statements": 12 + "functions": 98, + "statements": 98 }, "seed_history_ui.js": { "lines": 95, @@ -89,6 +89,12 @@ "functions": 98, "statements": 98 }, + "ui/dialog.js": { + "lines": 98, + "branches": 85, + "functions": 98, + "statements": 98 + }, "ui/dimension-swap.js": { "lines": 98, "branches": 98, @@ -119,6 +125,12 @@ "functions": 98, "statements": 98 }, + "views/node-dimensions.js": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 + }, "views/seed-history.js": { "lines": 98, "branches": 98, diff --git a/web/kikotools_extensions.js b/web/kikotools_extensions.js index f206af4..789013b 100644 --- a/web/kikotools_extensions.js +++ b/web/kikotools_extensions.js @@ -1,197 +1,26 @@ -import { app } from "../../scripts/app.js"; +import { app } from '../../scripts/app.js'; +import { createLegacyDialog } from './ui/dialog.js'; +import { showNodeDimensionsDialog } from './views/node-dimensions.js'; -/** - * KikoTools Extensions - Adds utility features to all ComfyAssets nodes - */ app.registerExtension({ - name: "ComfyAssets.Extensions", - - // New API: return items to append to node right-click menu + name: 'ComfyAssets.Extensions', getNodeMenuItems(node) { - // Only add our menu items to ComfyAssets nodes - if (!node.constructor.category || !node.constructor.category.includes("ComfyAssets")) { - return []; - } - - return [ - null, // separator - { - content: "🎨 Node Dimensions", - callback: () => { - KikoToolsExtensions.showNodeDimensionsDialog(node); - } - } - ]; - } + if (!node.constructor.category || !node.constructor.category.includes('ComfyAssets')) return []; + return [null, { + content: '🎨 Node Dimensions', + callback: () => KikoToolsExtensions.showNodeDimensionsDialog(node), + }]; + }, }); -/** - * KikoTools Extensions utilities - */ class KikoToolsExtensions { - /** - * Create a dialog for settings - */ static createDialog(htmlContent, onOK, onCancel) { - const dialog = document.createElement("div"); - dialog.className = "kikotools-dialog"; - dialog.style.cssText = ` - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background: #202020; - border: 2px solid #444; - border-radius: 8px; - padding: 20px; - z-index: 10000; - font-family: Arial, sans-serif; - box-shadow: 0 4px 20px rgba(0,0,0,0.5); - `; - - // innerHTML used here with hardcoded templates only (numeric node dimensions) - dialog.innerHTML = htmlContent; - - // Create button container - const buttonContainer = document.createElement("div"); - buttonContainer.style.cssText = ` - display: flex; - justify-content: flex-end; - gap: 10px; - margin-top: 15px; - `; - - // Create OK button - const okButton = document.createElement("button"); - okButton.textContent = "OK"; - okButton.style.cssText = ` - padding: 8px 20px; - background: #4A90E2; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - `; - okButton.onmouseover = () => okButton.style.background = "#5BA0F2"; - okButton.onmouseout = () => okButton.style.background = "#4A90E2"; - - // Create Cancel button - const cancelButton = document.createElement("button"); - cancelButton.textContent = "Cancel"; - cancelButton.style.cssText = ` - padding: 8px 20px; - background: #666; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 14px; - `; - cancelButton.onmouseover = () => cancelButton.style.background = "#777"; - cancelButton.onmouseout = () => cancelButton.style.background = "#666"; - - buttonContainer.appendChild(cancelButton); - buttonContainer.appendChild(okButton); - dialog.appendChild(buttonContainer); - - // Dialog close function - dialog.close = function() { - if (dialog.parentNode) { - dialog.parentNode.removeChild(dialog); - } - }; - - // Get all inputs - const inputs = Array.from(dialog.querySelectorAll("input, select")); - - // Handle keyboard events - inputs.forEach(input => { - input.addEventListener("keydown", function(e) { - if (e.keyCode === 27) { // ESC - onCancel && onCancel(); - dialog.close(); - } else if (e.keyCode === 13) { // Enter - onOK && onOK(dialog, inputs.map(input => input.value)); - dialog.close(); - } - e.stopPropagation(); - }); - }); - - // Button click handlers - okButton.onclick = () => { - onOK && onOK(dialog, inputs.map(input => input.value)); - dialog.close(); - }; - - cancelButton.onclick = () => { - onCancel && onCancel(); - dialog.close(); - }; - - // Add to document - document.body.appendChild(dialog); - - // Focus first input - if (inputs.length > 0) { - inputs[0].focus(); - inputs[0].select(); - } - - return dialog; + return createLegacyDialog(htmlContent, onOK, onCancel, app.ui.settings, app.canvas.canvas); } - /** - * Show node dimensions dialog - */ static showNodeDimensionsDialog(node) { - const nodeWidth = Math.round(node.size[0]); - const nodeHeight = Math.round(node.size[1]); - - const htmlContent = ` -