Compare commits

...
Author SHA1 Message Date
Vito Sansevero b0c0d53676 feat(ui): unify Display Text and Display Any output views 2026-10-16 13:20:28 -07:00
Vito b9103ad9c4 Merge pull request #93 from ComfyAssets/feat/shared-node-dimensions-dialog
feat(ui): migrate Node Dimensions to shared dialog controls
2026-10-16 13:09:13 -07:00
Vito Sansevero b5694ecfe5 feat(ui): migrate Node Dimensions to shared dialog controls 2026-10-16 13:00:36 -07:00
Vito 1255c4e9cc Merge pull request #91 from ComfyAssets/feat/shared-dimension-swap
feat(ui): unify dimension swap controls for both nodes
2026-10-16 12:45:22 -07:00
Vito Sansevero 26557a99b6 feat(ui): unify dimension swap controls for both nodes 2026-10-16 12:35:23 -07:00
Vito 848674d474 Merge pull request #89 from ComfyAssets/feat/shared-workflow-timer
feat(ui): migrate Workflow Timer to shared controls
2026-10-16 12:23:27 -07:00
31 changed files with 1333 additions and 1139 deletions
+3
View File
@@ -102,6 +102,9 @@ and scrolling on resize, palettes, saved history/node dimensions, serialization
and polling cleanup on removal.
Workflow Timer covers execution events, Classic/Nodes 2.0 switching, themes,
custom node colors, persisted time/dimensions and the actual settings picker.
Both dimension swap nodes cover keyboard swaps across renderers and zoom,
responsive button sizing, palettes, native workflow values/saved sizes and
removal cleanup. Latent batch size is preserved.
Screenshots/traces appear in
`tools/testing/test-results/comfyui/`. This opt-in smoke suite is separate from
the required fixture CI job; fully provisioned, pinned ComfyUI CI and testing
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+309 -12
View File
@@ -144,17 +144,72 @@ test('actual swap handlers preserve preset values and custom dimensions', async
const id = await createNode(page, 'WidthHeightSelector', { preset: '832×1216 - portrait', width: 832, height: 1216 });
const swapped = await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.onMouseDown({ canvasX: 475, canvasY: 140 });
return node.widgets.map(widget => widget.value);
node.swapDimensions();
return node.widgets.filter(widget => ['preset', 'width', 'height'].includes(widget.name)).map(widget => widget.value);
}, id);
expect(swapped).toEqual(['1216×832 - landscape', '1216', '832']);
await createNode(page, 'EmptyLatentBatch', { preset: 'custom', width: 640, height: 960 });
await page.getByRole('button', { name: /Swap W/ }).click();
const latent = page.locator('[data-node="EmptyLatentBatch"]');
await expect(latent.getByLabel('width')).toHaveValue('960');
await expect(latent.getByLabel('height')).toHaveValue('640');
await latent.getByRole('button', { name: /Swap/ }).click();
await expect(latent.getByLabel('width', { exact: true })).toHaveValue('960');
await expect(latent.getByLabel('height', { exact: true })).toHaveValue('640');
});
for (const name of ['WidthHeightSelector', 'EmptyLatentBatch']) {
test(`${name}: shared swap supports keyboard, callbacks and live themes`, async ({ page }) => {
await openHost(page);
const id = await createNode(page, name, { preset: '832×1216 - portrait', width: 832, height: 1216, batch_size: 3 });
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.element.style.width = '220px';
window.swapNotifications = [];
for (const widget of node.widgets.filter(widget => ['width', 'height'].includes(widget.name))) {
widget.callback = function (value, canvas, owner) { swapNotifications.push({ name: this.name, value, canvas: canvas === kikoHost.app.canvas, owner: owner === node }); };
}
}, id);
const panel = page.locator('.ca-dimension-swap');
const button = panel.getByRole('button', { name: 'Swap width and height', exact: true });
await expect(button).toHaveCSS('background-color', 'rgb(8, 119, 187)');
for (const theme of ['light', 'dark']) {
await page.evaluate(theme => document.body.style.setProperty('--bg-color', theme === 'light' ? '#fff' : '#202020'), theme);
await expect(panel).toHaveAttribute('data-ca-theme', theme);
}
await button.focus();
await page.keyboard.press('Enter');
expect(await page.evaluate(() => swapNotifications)).toEqual([
{ name: 'width', value: '1216', canvas: true, owner: true },
{ name: 'height', value: '832', canvas: true, owner: true },
]);
await page.keyboard.press('Space');
const node = page.locator(`[data-node="${name}"]`);
await expect(node.getByLabel('width', { exact: true })).toHaveValue('832');
await expect(node.getByLabel('height', { exact: true })).toHaveValue('1216');
await expect(node.getByLabel('batch_size', { exact: true })).toHaveValue('3');
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
});
test(`${name}: swap mounting is idempotent and removal disposes its control`, async ({ page }) => {
await openHost(page);
const id = await createNode(page, name, { preset: 'custom', width: 640, height: 960 });
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.createSwapButton();
node.createSwapButton();
}, id);
await expect(page.locator('.ca-dimension-swap')).toHaveCount(1);
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
window.detachedSwap = node.swapButtonWidget.element;
window.swapTheme = detachedSwap.dataset.caTheme;
window.removedSwapNode = kikoHost.removeNode(id);
detachedSwap.querySelector('button').click();
document.body.style.setProperty('--bg-color', swapTheme === 'light' ? '#202020' : '#fff');
}, id);
expect(await page.evaluate(() => ({ aborted: removedSwapNode.swapScope.signal.aborted, theme: detachedSwap.dataset.caTheme, width: removedSwapNode.widgets.find(widget => widget.name === 'width').value, removed: removedSwapNode.removed }))).toEqual({ aborted: true, theme: await page.evaluate(() => swapTheme), width: '640', removed: true });
await expect(page.locator('.ca-dimension-swap')).toHaveCount(0);
});
}
test('local image selection persists and the real node loads the selected PNG tensor', async ({ page, request }) => {
await openHost(page);
const id = await createNode(page, 'KikoLocalImageLoader');
@@ -225,14 +280,14 @@ test('real display node output renders as text and replaces previous widgets', a
await expect(response).toBeOK();
const data = await response.json();
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: data.ui });
await expect(page.getByLabel('text_positive')).toHaveValue('<img src=x onerror="window.injected=true">');
await expect(page.getByLabel('text_negative')).toHaveValue('blur');
await expect(page.getByLabel('Positive prompt', { exact: true })).toHaveValue('<img src=x onerror="window.injected=true">');
await expect(page.getByLabel('Negative prompt', { exact: true })).toHaveValue('blur');
expect(await page.evaluate(() => window.injected)).toBeUndefined();
const next = await (await request.post('/__test__/execute/DisplayText', { data: { text: 'Replacement' } })).json();
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: next.ui });
await expect(page.locator('textarea')).toHaveCount(1);
await expect(page.getByLabel('text_display')).toHaveValue('Replacement');
await expect(page.getByLabel('text_display')).toHaveAttribute('readonly', '');
await expect(page.getByLabel('Text output', { exact: true })).toHaveValue('Replacement');
await expect(page.getByLabel('Text output', { exact: true })).toHaveAttribute('readonly', '');
});
test('seed history persists across reloads and removal clears its watcher', async ({ page }) => {
@@ -755,15 +810,15 @@ test('Display Any renders real multiline output and restores serialized values w
await expect(response).toBeOK();
const result = await response.json();
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: result.ui });
await expect(page.locator('textarea')).toHaveCount(3);
await expect(page.locator('textarea')).toHaveCount(1);
expect(await page.locator('textarea').evaluateAll(elements => elements.map(element => element.value).join('\n'))).toBe(result.ui.text[0]);
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.configure({ widgets_values: ['Restored value'] });
node.onConfigure();
node.onConfigure?.();
}, id);
await expect(page.locator('textarea')).toHaveCount(1);
await expect(page.getByLabel('display_0')).toHaveValue('Restored value');
await expect(page.getByLabel('Value output', { exact: true })).toHaveValue('Restored value');
});
test('gallery pagination sorting and hidden-folder controls use real directory pages', async ({ page, request }) => {
@@ -815,3 +870,245 @@ 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('<label>Legacy value<input value="before"></label>', (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('<label>Choice<select><option>chosen</option></select></label>'));
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('<label for="legacy-choice">Choice</label><select id="legacy-choice"><option>chosen</option></select>', (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);
});
for (const name of ['DisplayText', 'DisplayAny']) {
test(`${name}: shared output follows themes, resizes, copies and preserves empty output`, async ({ page }) => {
await openHost(page);
const id = await createNode(page, name, name === 'DisplayAny' ? { mode: 'tensor shape' } : {});
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.onExecuted({ text: ['first\nsecond <b>literal</b>'] });
window.copiedOutput = [];
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async text => window.copiedOutput.push(text) } });
}, id);
const panel = page.locator('.ca-text-output');
await expect(panel).toBeVisible();
for (const [color, theme] of [['#fafafa', 'light'], ['#202020', 'dark']]) {
await page.evaluate(color => document.body.style.setProperty('--bg-color', color), color);
await expect(panel).toHaveAttribute('data-ca-theme', theme);
}
const field = panel.getByRole('textbox');
await expect(field).toHaveAttribute('readonly', '');
await expect(field).toHaveValue('first\nsecond <b>literal</b>');
await field.focus();
await page.keyboard.press('ControlOrMeta+A');
expect(await field.evaluate(element => element.value.substring(element.selectionStart, element.selectionEnd))).toBe('first\nsecond <b>literal</b>');
await panel.getByRole('button', { name: /Copy/ }).focus();
await page.keyboard.press('Enter');
await expect(panel.getByRole('status')).toHaveText('Copied.');
expect(await page.evaluate(() => window.copiedOutput)).toEqual(['first\nsecond <b>literal</b>']);
for (const width of [240, 450, 800]) {
await panel.evaluate((element, width) => { element.style.width = `${width}px`; element.style.height = '400px'; }, width);
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
}
await page.evaluate(id => kikoHost.nodes.get(String(id)).onExecuted({ text: [''] }), id);
await expect(panel.getByRole('textbox')).toHaveValue('');
await expect(panel.getByRole('status')).toBeEmpty();
const snapshot = await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
const data = {};
node.onSerialize(data);
node.onExecuted({ text: ['replacement'] });
node.configure(data);
return data;
}, id);
expect(snapshot.kikoDisplay).toEqual({ version: 1, text: '' });
await expect(panel.getByRole('textbox')).toHaveValue('');
await page.evaluate(id => kikoHost.removeNode(id), id);
await expect(panel).toHaveCount(0);
});
}
test('display prompt copy feedback handles failure, replacement and removal safely', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'DisplayText');
await page.evaluate(id => {
kikoHost.nodes.get(String(id)).onExecuted({ text: ['Positive prompt: sun\nNegative prompt: rain'] });
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: () => Promise.reject(new Error('denied')) } });
}, id);
const panel = page.locator('.ca-text-output');
await panel.getByRole('button', { name: 'Copy negative', exact: true }).click();
await expect(panel.getByRole('status')).toContainText('Could not copy');
await expect(panel.getByRole('textbox', { name: 'Negative prompt', exact: true })).toHaveValue('rain');
await page.evaluate(() => { navigator.clipboard.writeText = () => new Promise(resolve => window.finishCopy = resolve); });
await panel.getByRole('button', { name: 'Copy positive', exact: true }).click();
await page.evaluate(id => {
kikoHost.nodes.get(String(id)).onExecuted({ text: ['new output'] });
window.finishCopy();
}, id);
await expect(panel.getByRole('status')).toBeEmpty();
await panel.getByRole('button', { name: 'Copy text', exact: true }).click();
await page.evaluate(id => { kikoHost.removeNode(id); window.finishCopy(); }, id);
await expect(panel).toHaveCount(0);
});
test('display adapters preserve lifecycle context, arguments, results and saved snapshots', async ({ page }) => {
await openHost(page);
const result = await page.evaluate(async () => {
const calls = [];
let node;
class HookNode {
constructor() { this.widgets = []; this.size = [600, 500]; this.inputs = []; }
addDOMWidget(name, type, element) { document.body.append(element); const widget = { name, element }; this.widgets.push(widget); return widget; }
setSize(size) { this.size = size; }
setDirtyCanvas() {}
onNodeCreated(...args) { calls.push(['created', this === node, args]); return 'created-result'; }
onExecuted(...args) { calls.push(['executed', this === node, args]); return 'executed-result'; }
configure(...args) { calls.push(['configure', this === node, args]); return 'configure-result'; }
onSerialize(...args) { calls.push(['serialize', this === node, args.length]); args[0].external = 'retained'; return 'serialize-result'; }
onRemoved(...args) { calls.push(['removed', this === node, args]); return 'removed-result'; }
}
await kikoHost.extensions.find(extension => extension.name === 'ComfyAssets.DisplayAny').beforeRegisterNodeDef(HookNode, { name: 'DisplayAny' }, kikoHost.app);
node = new HookNode();
const created = node.onNodeCreated('creation');
const executed = node.onExecuted({ text: ['first'] }, 'execution');
const data = {};
const serialized = node.onSerialize(data, 'serialization');
const configured = node.configure({ kikoDisplay: { version: 1, text: 'restored' } }, 'configuration');
const removed = node.onRemoved('removal');
node.onExecuted({ text: ['too late'] });
return { created, executed, serialized, configured, removed, data, text: node.displayOutput.text, calls, disposed: node.displayOutput.scope.signal.aborted, size: node.size };
});
expect(result).toMatchObject({ created: 'created-result', executed: 'executed-result', serialized: 'serialize-result', configured: 'configure-result', removed: 'removed-result', data: { external: 'retained', kikoDisplay: { version: 1, text: 'first' } }, text: 'restored', disposed: true, size: [600, 500] });
expect(result.calls.slice(0, 5)).toEqual([
['created', true, ['creation']],
['executed', true, [{ text: ['first'] }, 'execution']],
['serialize', true, 2],
['configure', true, [{ kikoDisplay: { version: 1, text: 'restored' } }, 'configuration']],
['removed', true, ['removal']],
]);
});
test('display copy feedback expires and malformed execution messages retain the latest output', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'DisplayText');
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.onExecuted({ text: ['latest'] });
for (const message of [null, {}, { text: [] }, { text: [12] }]) node.onExecuted(message);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => {} } });
}, id);
const panel = page.locator('.ca-text-output');
await expect(panel.getByRole('textbox')).toHaveValue('latest');
await panel.getByRole('button', { name: 'Copy text', exact: true }).click();
await expect(panel.getByRole('status')).toHaveText('Copied.');
await expect(panel.getByRole('status')).toBeEmpty({ timeout: 4000 });
});
+247
View File
@@ -47,6 +47,88 @@ test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
});
});
for (const name of ['WidthHeightSelector', 'EmptyLatentBatch']) {
test(`${name}: shared swap survives renderers, zoom, themes and workflow reload`, async ({ page }, testInfo) => {
await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(name);
app.graph.add(node);
const preset = node.widgets.find(widget => widget.name === 'preset');
const options = typeof preset.options.values === 'function' ? preset.options.values() : preset.options.values;
preset.value = options.find(option => option.startsWith('832×1216'));
preset.callback(preset.value, app.canvas, node, [0, 0], null);
const batch = node.widgets.find(widget => widget.name === 'batch_size');
if (batch) batch.value = 3;
app.canvas.centerOnNode(node);
}, name);
const panel = page.locator('.ca-dimension-swap');
const button = panel.getByRole('button', { name: 'Swap width and height', exact: true });
await expect(button).toBeVisible();
for (const renderer of [false, true, false]) {
const before = await page.evaluate(async ({ name, renderer }) => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
const node = app.graph._nodes.find(node => node.type === name);
app.canvas.ds.scale = .7;
app.canvas.centerOnNode(node);
return node.widgets.filter(widget => ['width', 'height'].includes(widget.name)).map(widget => widget.value);
}, { name, renderer });
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(panel).toHaveCount(1);
await button.focus();
await page.keyboard.press('Enter');
expect(await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
return app.graph._nodes.find(node => node.type === name).widgets.filter(widget => ['width', 'height'].includes(widget.name)).map(widget => widget.value);
}, name)).toEqual([...before].reverse());
}
await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes.find(node => node.type === name);
node.setSize([220, 280]);
app.canvas.ds.scale = 1;
app.canvas.centerOnNode(node);
}, name);
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
const narrow = await button.boundingBox();
await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
app.graph._nodes.find(node => node.type === name).setSize([500, 300]);
}, name);
await expect.poll(async () => (await button.boundingBox()).width).toBeGreaterThan(narrow.width);
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(panel).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`${name}-${theme}.png`) });
}
const restored = await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
const saved = workflow.nodes.find(node => node.type === name);
const nativeCount = name === 'EmptyLatentBatch' ? 4 : 3;
if (saved.widgets_values.slice(nativeCount).some(value => value !== '' && value != null)) throw new Error('Swap UI leaked into workflow values');
await app.loadGraphData(workflow);
const node = app.graph._nodes.find(node => node.type === name);
app.canvas.centerOnNode(node);
return { saved: saved.widgets_values.slice(0, nativeCount), restored: node.widgets.filter(widget => ['preset', 'width', 'height', 'batch_size'].includes(widget.name)).map(widget => widget.value), size: Array.from(node.size), savedSize: Array.from(saved.size) };
}, name);
expect(restored.restored).toEqual(restored.saved);
expect(restored.size).toEqual(restored.savedSize);
await expect(panel).toHaveCount(1);
await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes.find(node => node.type === name);
window.removedSwap = node;
app.graph.remove(node);
}, name);
await expect(panel).toHaveCount(0);
expect(await page.evaluate(() => removedSwap.swapScope.signal.aborted)).toBe(true);
});
}
test('workflow timer preserves appearance and elapsed time through renderer switching and settings', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
@@ -315,3 +397,168 @@ 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);
});
for (const name of ['DisplayText', 'DisplayAny']) {
test(`${name}: shared output persists through renderers, themes and legacy reload`, async ({ page }, testInfo) => {
const output = name === 'DisplayText'
? 'Positive prompt: A blue cabin beside a quiet lake, soft morning light, detailed reflections.\nNegative prompt: blur, noise, watermark'
: Array.from({ length: 45 }, (_, index) => `Line ${index}: tensor / diagnostic output`).join('\n');
await page.evaluate(async ({ name, output }) => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(name);
app.graph.add(node);
node.setSize([560, 520]);
const mode = node.widgets.find(widget => widget.name === 'mode');
if (mode) mode.value = 'tensor shape';
node.onExecuted({ text: [output] });
app.canvas.centerOnNode(node);
window.displayCopies = [];
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async text => window.displayCopies.push(text) } });
}, { name, output });
const panel = page.locator('.ca-text-output');
await expect(panel).toHaveCount(1);
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.ds.scale = .85;
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(panel).toBeVisible();
await expect(panel).toHaveCount(1);
await panel.getByRole('button', { name: name === 'DisplayText' ? 'Copy negative' : 'Copy value', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(panel.getByRole('status')).toHaveText('Copied.');
}
expect(await page.evaluate(() => window.displayCopies)).toEqual(Array(3).fill(name === 'DisplayText' ? 'blur, noise, watermark' : output));
const restored = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
const saved = workflow.nodes[0];
await app.loadGraphData(workflow);
app.canvas.centerOnNode(app.graph._nodes[0]);
return { snapshot: saved.kikoDisplay, size: Array.from(app.graph._nodes[0].size), mode: app.graph._nodes[0].widgets.find(widget => widget.name === 'mode')?.value };
});
expect(restored.snapshot).toEqual({ version: 1, text: output });
expect(restored.size).toEqual([560, 520]);
if (name === 'DisplayAny') {
expect(restored.mode).toBe('tensor shape');
await expect(panel.getByLabel('Value output', { exact: true })).toHaveValue(output);
} else {
await expect(panel.getByLabel('Positive prompt', { exact: true })).toHaveValue('A blue cabin beside a quiet lake, soft morning light, detailed reflections.');
await expect(panel.getByLabel('Negative prompt', { exact: true })).toHaveValue('blur, noise, watermark');
}
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(panel).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`${name}-${theme}.png`) });
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes[0].setSize([280, 520]);
app.canvas.centerOnNode(app.graph._nodes[0]);
});
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
delete workflow.nodes[0].kikoDisplay;
workflow.nodes[0].widgets_values = name === 'DisplayText'
? ['✓ Positive Prompt', 'legacy positive', null, '✗ Negative Prompt', 'legacy negative', null]
: ['tensor shape', 'legacy first', 'legacy second', null];
await app.loadGraphData(workflow);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, name);
await expect(panel).toHaveCount(1);
if (name === 'DisplayText') {
await expect(panel.getByLabel('Positive prompt', { exact: true })).toHaveValue('legacy positive');
await expect(panel.getByLabel('Negative prompt', { exact: true })).toHaveValue('legacy negative');
} else {
await expect(panel.getByLabel('Value output', { exact: true })).toHaveValue('legacy first\nlegacy second');
expect(await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
return app.graph._nodes[0].widgets.find(widget => widget.name === 'mode').value;
})).toBe('tensor shape');
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes[0];
window.removedDisplay = node;
app.graph.remove(node);
});
await expect(panel).toHaveCount(0);
expect(await page.evaluate(() => window.removedDisplay.displayOutput.scope.signal.aborted)).toBe(true);
});
}
+60
View File
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { bindDimensionPresets, dimensionsFromPreset, swapDimensions } from '../../web/ui/dimensions.js';
const app = { canvas: {} };
test('resolution parsing accepts supplied formats and rejects invalid dimensions', () => {
assert.deepEqual(dimensionsFromPreset(' 928x1664 - Qwen'), [928, 1664]);
assert.deepEqual(dimensionsFromPreset('1664×928'), [1664, 928]);
for (const value of [null, {}, 'custom', '0x100', '12.5x100', 'x100', '999999999999999999999x100']) assert.equal(dimensionsFromPreset(value), null);
});
test('preset callbacks preserve returns and default host context without changing custom dimensions', () => {
const calls = [];
const node = { widgets: [
{ name: 'preset', callback() { return 'original'; } },
{ name: 'width', value: 64, callback(value, canvas, owner) { calls.push([this.name, value, canvas, owner]); } },
{ name: 'height', value: 128 },
] };
bindDimensionPresets(node, app);
assert.equal(node.widgets[0].callback('custom'), 'original');
assert.equal(node.widgets[0].callback('928×1664 - Qwen'), 'original');
assert.deepEqual(calls, [['width', 928, app.canvas, node]]);
assert.equal(node.widgets[2].value, 1664);
});
test('incomplete widgets and unavailable reverse options are safe', () => {
bindDimensionPresets({ widgets: [] }, app);
swapDimensions({ widgets: [] }, app);
const node = { widgets: [{ name: 'preset', value: '640×960' }, { name: 'width', value: 1 }, { name: 'height', value: 2 }] };
bindDimensionPresets(node, app);
swapDimensions(node, app);
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 960, 640]);
node.widgets.pop();
node.widgets[0].callback('640×960');
swapDimensions(node, app);
assert.equal(node.widgets[1].value, 960);
});
test('dynamic and array option lists match reverse dimensions', () => {
for (const options of [['custom', '960x640'], { values: () => ['custom', '960×640'] }]) {
const node = { widgets: [{ name: 'preset', value: '640×960', options }, { name: 'width', value: 1 }, { name: 'height', value: 2 }] };
bindDimensionPresets(node, app);
swapDimensions(node, app);
assert.equal(node.widgets[0].value, Array.isArray(options) ? '960x640' : '960×640');
}
});
test('a throwing or recursive preset callback does not leave swapping locked', () => {
let fail = true;
const node = { widgets: [
{ name: 'preset', value: '640×960', options: { values: ['640×960', '960×640'] }, callback() { swapDimensions(node, app); if (fail) throw new Error('callback failed'); } },
{ name: 'width', value: 640 }, { name: 'height', value: 960 },
] };
bindDimensionPresets(node, app);
assert.throws(() => swapDimensions(node, app), /callback failed/);
fail = false;
swapDimensions(node, app);
assert.deepEqual(node.widgets.map(widget => widget.value), ['640×960', 640, 960]);
});
+38
View File
@@ -0,0 +1,38 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { outputSections } from '../../web/views/text-output.js';
import { restoreDisplayOutput } from '../../web/controllers/display-node.js';
test('display text recognizes paired prompts and preserves ordinary text verbatim', () => {
assert.deepEqual(outputSections('text', 'Positive prompt: bright ** metadata\nNegative prompt: blur ** metadata').map(section => section.text), ['bright', 'blur']);
assert.deepEqual(outputSections('text', 'POSITIVE PROMPT: sun\nNEGATIVE PROMPT: rain').map(section => section.text), ['sun', 'rain']);
for (const text of ['', 'Positive prompt: alone', '<script>hello</script>\nnext']) {
assert.deepEqual(outputSections('text', text).map(section => section.text), [text]);
}
const long = Array.from({ length: 45 }, (_, index) => `Line ${index}`).join('\n');
assert.deepEqual(outputSections('any', long).map(section => section.text), [long]);
});
test('versioned display snapshots preserve complete output including empty text', () => {
for (const kind of ['text', 'any']) {
for (const text of ['', 'mode\nfull\noutput']) {
assert.equal(restoreDisplayOutput({ kikoDisplay: { version: 1, text }, widgets_values: ['stale'] }, kind, false), text);
}
assert.equal(restoreDisplayOutput({}, kind, false), null);
assert.equal(restoreDisplayOutput({ widgets_values: [] }, kind, false), null);
}
});
test('legacy display workflows restore prompt pairs, converted inputs and every chunk', () => {
assert.equal(restoreDisplayOutput({ widgets_values: ['✓ Positive Prompt', 'sun', null, '✗ Negative Prompt', 'rain', null] }, 'text', false), 'Positive prompt: sun\nNegative prompt: rain');
assert.equal(restoreDisplayOutput({ widgets_values: ['converted', 'saved text', null] }, 'text', true), 'saved text');
assert.equal(restoreDisplayOutput({ widgets_values: ['raw value', 'first\nchunk', 'second\nchunk', null] }, 'any', false), 'first\nchunk\nsecond\nchunk');
assert.equal(restoreDisplayOutput({ widgets_values: ['converted', 'tensor shape', 'shape', null] }, 'any', true), 'shape');
assert.equal(restoreDisplayOutput({ widgets_values: ['tensor shape'] }, 'any', false), null);
assert.equal(restoreDisplayOutput({ widgets_values: [''] }, 'text', false), '');
assert.equal(restoreDisplayOutput({ kikoDisplay: { version: 99, text: 'unknown' }, widgets_values: ['legacy'] }, 'text', false), 'legacy');
});
test('prompt detection retains leading markdown markers accepted by existing workflows', () => {
assert.equal(outputSections('text', 'Positive prompt: **sun\nNegative prompt: rain')[0].text, '**sun');
});
+65 -13
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
import { bindDimensionPresets, extractResolutionFromPreset, swapDimensions } from '../../web/ui/dimensions.js';
function load(file, extra = {}) {
const extensions = [];
@@ -19,8 +20,8 @@ function load(file, extra = {}) {
return { extension: extensions[0], app, timers, context };
}
async function dimensionNode(preset, options = ['custom', '832×1216 - portrait', '1216×832 - landscape']) {
const { extension, app } = load('width_height_swap.js');
async function dimensionNode(preset, options = ['custom', '832×1216 - portrait', '1216×832 - landscape'], file = 'width_height_swap.js') {
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
let originalCreated = 0;
class Node {
constructor() {
@@ -33,15 +34,17 @@ async function dimensionNode(preset, options = ['custom', '832×1216 - portrait'
}
onNodeCreated() { originalCreated++; }
}
await extension.beforeRegisterNodeDef(Node, { name: 'WidthHeightSelector' }, app);
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
Node.prototype.createSwapButton = () => {};
const node = new Node();
node.onNodeCreated();
assert.equal(originalCreated, 1);
return node;
}
test('custom dimensions swap and callbacks receive the actual node', async () => {
const node = await dimensionNode('custom');
for (const file of ['width_height_swap.js', 'empty_latent_batch_swap.js']) {
test(`${file}: custom dimensions swap and callbacks receive the actual node`, async () => {
const node = await dimensionNode('custom', undefined, file);
const calls = [];
for (const widget of node.widgets.slice(1)) widget.callback = (value, canvas, owner) => calls.push({ value, owner });
node.swapDimensions();
@@ -50,34 +53,83 @@ test('custom dimensions swap and callbacks receive the actual node', async () =>
for (const call of calls) assert.equal(call.owner, node);
});
test('formatted preset swaps to its matching landscape preset without throwing', async () => {
const node = await dimensionNode('832×1216 - portrait');
test(`${file}: formatted preset swaps to its matching landscape preset without throwing`, async () => {
const node = await dimensionNode('832×1216 - portrait', undefined, file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216×832 - landscape', 1216, 832]);
});
test('a preset without a reverse switches to custom dimensions', async () => {
const node = await dimensionNode('832×1216 - portrait', ['custom', '832×1216 - portrait']);
test(`${file}: a preset without a reverse switches to custom dimensions`, async () => {
const node = await dimensionNode('832×1216 - portrait', ['custom', '832×1216 - portrait'], file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 1216, 832]);
});
test('preset changes preserve original callbacks and update both dimensions', async () => {
const node = await dimensionNode('custom');
test(`${file}: preset changes preserve original callbacks and update both dimensions`, async () => {
const node = await dimensionNode('custom', undefined, file);
node.widgets[0].callback('1216×832 - landscape', {}, node);
assert.deepEqual(node.widgets.slice(1).map(widget => widget.value), [1216, 832]);
assert.equal(node.extractResolutionFromPreset('custom'), null);
assert.equal(node.originalPresetCalls, 1);
});
test('unrelated node definitions retain their lifecycle methods', async () => {
const { extension, app } = load('width_height_swap.js');
test(`${file}: a preset swap notifies each dimension callback exactly once`, async () => {
const node = await dimensionNode('832×1216 - portrait', undefined, file);
const calls = [];
for (const widget of node.widgets.slice(1)) widget.callback = function (value, canvas, owner) { calls.push({ name: this.name, value, owner }); };
node.swapDimensions();
assert.deepEqual(calls.map(call => [call.name, call.value]), [['width', 1216], ['height', 832]]);
assert.equal(node.originalPresetCalls, 1);
for (const call of calls) assert.equal(call.owner, node);
});
test(`${file}: ASCII resolutions match reversed presets and unfamiliar formatted presets update dimensions`, async () => {
const node = await dimensionNode('832x1216 - portrait', ['custom', '832x1216 - portrait', '1216x832 - landscape'], file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216x832 - landscape', 1216, 832]);
node.widgets[0].callback('2040×1080 - future preset', {}, node);
assert.deepEqual(node.widgets.slice(1).map(widget => widget.value), [2040, 1080]);
});
test(`${file}: malformed presets do not write invalid dimensions`, async () => {
const node = await dimensionNode('invalidx960', undefined, file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['invalidx960', 832, 1216]);
});
test(`${file}: unrelated node definitions retain their lifecycle methods`, async () => {
const { extension, app } = load(file);
class Node { onNodeCreated() {} }
const original = Node.prototype.onNodeCreated;
await extension.beforeRegisterNodeDef(Node, { name: 'OtherNode' }, app);
assert.equal(Node.prototype.onNodeCreated, original);
});
test(`${file}: creation preserves hook arguments, results and existing canvas hooks`, async () => {
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
class Node {
constructor() { this.widgets = []; }
onNodeCreated(value) { this.received = value; return 'original result'; }
onDrawForeground() {}
onMouseDown() {}
onMouseMove() {}
}
const hooks = [Node.prototype.onDrawForeground, Node.prototype.onMouseDown, Node.prototype.onMouseMove];
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
const node = new Node();
assert.equal(node.onNodeCreated('original argument'), 'original result');
assert.equal(node.received, 'original argument');
assert.deepEqual([Node.prototype.onDrawForeground, Node.prototype.onMouseDown, Node.prototype.onMouseMove], hooks);
});
test(`${file}: nodes without an existing creation hook initialize safely`, async () => {
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
class Node { constructor() { this.widgets = []; } }
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
assert.equal(new Node().onNodeCreated(), undefined);
});
}
test('Gemini repeated execution restores the user color and removal cancels its timer', async () => {
const { extension, app, timers } = load('gemini_prompt_ui.js');
let removed = 0;
+15
View File
@@ -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);
}
});
+55 -19
View File
@@ -1,21 +1,27 @@
{
"controllers/display-node.js": {
"lines": 98,
"branches": 95,
"functions": 86,
"statements": 98
},
"display_any.js": {
"lines": 84,
"branches": 66,
"functions": 83,
"statements": 84
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"display_text.js": {
"lines": 71,
"branches": 74,
"functions": 42,
"statements": 71
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"empty_latent_batch_swap.js": {
"lines": 41,
"branches": 91,
"functions": 64,
"statements": 41
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"gemini_prompt_ui.js": {
"lines": 52,
@@ -60,10 +66,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 +95,24 @@
"functions": 98,
"statements": 98
},
"ui/dialog.js": {
"lines": 98,
"branches": 85,
"functions": 98,
"statements": 98
},
"ui/dimension-swap.js": {
"lines": 98,
"branches": 98,
"functions": 48,
"statements": 98
},
"ui/dimensions.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"ui/floating-window.js": {
"lines": 93,
"branches": 80,
@@ -107,16 +131,28 @@
"functions": 98,
"statements": 98
},
"views/node-dimensions.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"views/seed-history.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"views/text-output.js": {
"lines": 98,
"branches": 94,
"functions": 98,
"statements": 98
},
"width_height_swap.js": {
"lines": 60,
"branches": 79,
"functions": 73,
"statements": 60
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
}
}
+63
View File
@@ -0,0 +1,63 @@
import { createScope } from '../ui/core.js';
import { createTextOutputView } from '../views/text-output.js';
export function restoreDisplayOutput(data, kind, hasInputWidget) {
if (data.kikoDisplay?.version === 1 && typeof data.kikoDisplay.text === 'string') return data.kikoDisplay.text;
if (!Array.isArray(data.widgets_values)) return null;
const values = data.widgets_values.slice(hasInputWidget && data.widgets_values.length > 1 ? 1 : 0);
if (kind === 'any' && ['raw value', 'tensor shape'].includes(values[0])) values.shift();
const strings = values.filter(value => typeof value === 'string');
if (!strings.length) return null;
if (kind === 'text' && strings[0] === '✓ Positive Prompt' && strings[2] === '✗ Negative Prompt') {
return `Positive prompt: ${strings[1]}\nNegative prompt: ${strings[3]}`;
}
return kind === 'any' ? strings.join('\n') : strings[0];
}
function updateOutput(node, text) {
if (node.displayOutput.scope.signal.aborted) return;
node.displayOutput.text = text;
node.displayOutput.view.update(text);
node.setDirtyCanvas(true, true);
}
export function registerDisplayNode(nodeType, app, kind) {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
const scope = createScope();
const view = createTextOutputView(scope, kind, app.ui.settings);
this.displayOutput = { scope, view, text: '' };
this.addDOMWidget('kiko_display_output', 'div', view.element, {
serialize: false, hideOnZoom: false,
getMinHeight: () => kind === 'text' ? 240 : 180,
});
this.setSize([Math.max(this.size[0], kind === 'text' ? 400 : 450), Math.max(this.size[1], kind === 'text' ? 300 : 250)]);
updateOutput(this, kind === 'text' ? 'Text will appear here after execution...' : 'Value will appear here...');
return result;
};
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
const result = onExecuted?.apply(this, arguments);
if (typeof message?.text?.[0] === 'string') updateOutput(this, message.text[0]);
return result;
};
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function (data) {
const text = restoreDisplayOutput(data, kind, Boolean(this.inputs?.[0]?.widget));
const result = configure?.apply(this, arguments);
if (text !== null) updateOutput(this, text);
return result;
};
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function (data) {
const result = onSerialize?.apply(this, arguments);
data.kikoDisplay = { version: 1, text: this.displayOutput.text };
return result;
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
this.displayOutput.scope.dispose();
return onRemoved?.apply(this, arguments);
};
}
+5 -122
View File
@@ -1,126 +1,9 @@
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
import { app } from '../../scripts/app.js';
import { registerDisplayNode } from './controllers/display-node.js';
app.registerExtension({
name: "ComfyAssets.DisplayAny",
name: 'ComfyAssets.DisplayAny',
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "DisplayAny") {
function populate(text) {
// Remove existing display widgets
if (this.widgets) {
const toRemove = [];
for (let i = 0; i < this.widgets.length; i++) {
if (this.widgets[i].name?.startsWith("display_") || this.widgets[i].name === "copy_value") {
this.widgets[i].onRemove?.();
toRemove.push(i);
}
}
// Remove in reverse order to maintain indices
for (let i = toRemove.length - 1; i >= 0; i--) {
this.widgets.splice(toRemove[i], 1);
}
}
// Split text into multiple widgets if it's too long or has multiple lines
const lines = text ? text.split('\n') : [''];
const maxLinesPerWidget = 20;
const chunks = [];
for (let i = 0; i < lines.length; i += maxLinesPerWidget) {
chunks.push(lines.slice(i, i + maxLinesPerWidget).join('\n'));
}
// Create a widget for each chunk
chunks.forEach((chunk, index) => {
const w = ComfyWidgets["STRING"](this, `display_${index}`, ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.8;
w.inputEl.style.fontFamily = "monospace";
w.value = chunk;
});
// Add copy button widget
const copyWidget = {
type: "button",
name: "copy_value",
text: "📋 Copy Value",
callback: () => {
navigator.clipboard.writeText(text || '').then(() => {
copyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
copyWidget.text = "📋 Copy Value";
this.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(copyWidget);
requestAnimationFrame(() => {
const sz = this.computeSize();
if (sz[0] < this.size[0]) {
sz[0] = this.size[0];
}
if (sz[1] < this.size[1]) {
sz[1] = this.size[1];
}
this.onResize?.(sz);
app.graph.setDirtyCanvas(true, false);
});
}
// When the node is executed we will be sent the display text
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
console.log("DisplayAny onExecuted message:", message);
if (message?.text && message.text.length > 0) {
const displayText = message.text[0];
console.log("DisplayAny displayText:", displayText);
populate.call(this, displayText);
} else {
console.log("DisplayAny no text in message");
}
};
const VALUES = Symbol();
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function () {
this[VALUES] = arguments[0]?.widgets_values;
return configure?.apply(this, arguments);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
const widgets_values = this[VALUES];
if (widgets_values?.length) {
requestAnimationFrame(() => {
const startIdx = widgets_values.length > 1 && this.inputs?.[0]?.widget ? 1 : 0;
if (widgets_values[startIdx]) {
populate.call(this, widgets_values[startIdx]);
}
});
}
};
// Initialize on node creation
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
onNodeCreated?.apply(this, arguments);
// Set minimum size - make it wider for better JSON display
this.size[0] = Math.max(this.size[0], 450);
this.size[1] = Math.max(this.size[1], 250);
// Add placeholder text
populate.call(this, "Value will appear here...");
};
}
}
if (nodeData.name === 'DisplayAny') registerDisplayNode(nodeType, app, 'any');
},
});
+5 -195
View File
@@ -1,199 +1,9 @@
import { app } from "../../scripts/app.js";
import { ComfyWidgets } from "../../scripts/widgets.js";
import { app } from '../../scripts/app.js';
import { registerDisplayNode } from './controllers/display-node.js';
app.registerExtension({
name: "ComfyAssets.DisplayText",
name: 'ComfyAssets.DisplayText',
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "DisplayText") {
function populate(text) {
// Remove existing widgets
if (this.widgets) {
const toRemove = [];
for (let i = 0; i < this.widgets.length; i++) {
if (this.widgets[i].name?.startsWith("text_") || this.widgets[i].name?.startsWith("copy_")) {
this.widgets[i].onRemove?.();
toRemove.push(i);
}
}
// Remove in reverse order to maintain indices
for (let i = toRemove.length - 1; i >= 0; i--) {
this.widgets.splice(toRemove[i], 1);
}
}
// Parse the text to detect positive/negative prompt format
const posMatch = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
const negMatch = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
if (posMatch && negMatch) {
// Extract prompt content
let positiveText = posMatch[1].trim();
let negativeText = negMatch[1].trim();
// Remove any trailing ** markers
const posEndIndex = positiveText.indexOf('**');
if (posEndIndex > 0) {
positiveText = positiveText.substring(0, posEndIndex).trim();
}
const negEndIndex = negativeText.indexOf('**');
if (negEndIndex > 0) {
negativeText = negativeText.substring(0, negEndIndex).trim();
}
// Create header widget for positive prompt
const posHeader = ComfyWidgets["STRING"](this, "text_pos_header", ["STRING", { multiline: false }], app).widget;
posHeader.inputEl.readOnly = true;
posHeader.inputEl.style.opacity = 0.7;
posHeader.inputEl.style.backgroundColor = "#2a4a2a";
posHeader.inputEl.style.color = "#8f8";
posHeader.inputEl.style.fontWeight = "bold";
posHeader.value = "✓ Positive Prompt";
// Create positive prompt widget
const posWidget = ComfyWidgets["STRING"](this, "text_positive", ["STRING", { multiline: true }], app).widget;
posWidget.inputEl.readOnly = true;
posWidget.inputEl.style.opacity = 0.9;
posWidget.value = positiveText;
// Create copy button widget for positive prompt
const posCopyWidget = {
type: "button",
name: "copy_positive",
text: "📋 Copy Positive",
callback: () => {
navigator.clipboard.writeText(positiveText).then(() => {
posCopyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
posCopyWidget.text = "📋 Copy Positive";
this.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(posCopyWidget);
// Create header widget for negative prompt
const negHeader = ComfyWidgets["STRING"](this, "text_neg_header", ["STRING", { multiline: false }], app).widget;
negHeader.inputEl.readOnly = true;
negHeader.inputEl.style.opacity = 0.7;
negHeader.inputEl.style.backgroundColor = "#4a2a2a";
negHeader.inputEl.style.color = "#f88";
negHeader.inputEl.style.fontWeight = "bold";
negHeader.value = "✗ Negative Prompt";
// Create negative prompt widget
const negWidget = ComfyWidgets["STRING"](this, "text_negative", ["STRING", { multiline: true }], app).widget;
negWidget.inputEl.readOnly = true;
negWidget.inputEl.style.opacity = 0.9;
negWidget.value = negativeText;
// Create copy button widget for negative prompt
const negCopyWidget = {
type: "button",
name: "copy_negative",
text: "📋 Copy Negative",
callback: () => {
navigator.clipboard.writeText(negativeText).then(() => {
negCopyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
negCopyWidget.text = "📋 Copy Negative";
this.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(negCopyWidget);
} else {
// Single text display with ComfyUI's standard STRING widget
const w = ComfyWidgets["STRING"](this, "text_display", ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.9;
w.value = text;
// Create copy button widget
const copyWidget = {
type: "button",
name: "copy_text",
text: "📋 Copy",
callback: () => {
navigator.clipboard.writeText(text).then(() => {
copyWidget.text = "✓ Copied!";
this.setDirtyCanvas(true);
setTimeout(() => {
copyWidget.text = "📋 Copy";
this.setDirtyCanvas(true);
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
};
this.addCustomWidget(copyWidget);
}
requestAnimationFrame(() => {
const sz = this.computeSize();
if (sz[0] < this.size[0]) {
sz[0] = this.size[0];
}
if (sz[1] < this.size[1]) {
sz[1] = this.size[1];
}
this.onResize?.(sz);
app.graph.setDirtyCanvas(true, false);
});
}
// When the node is executed we will be sent the input text
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
if (message?.text) {
populate.call(this, message.text[0]);
}
};
const VALUES = Symbol();
const configure = nodeType.prototype.configure;
nodeType.prototype.configure = function () {
this[VALUES] = arguments[0]?.widgets_values;
return configure?.apply(this, arguments);
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
onConfigure?.apply(this, arguments);
const widgets_values = this[VALUES];
if (widgets_values?.length) {
requestAnimationFrame(() => {
const startIdx = widgets_values.length > 1 && this.inputs?.[0]?.widget ? 1 : 0;
if (widgets_values[startIdx]) {
populate.call(this, widgets_values[startIdx]);
}
});
}
};
// Initialize on node creation
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
onNodeCreated?.apply(this, arguments);
// Set default size
this.size[0] = Math.max(this.size[0], 400);
this.size[1] = Math.max(this.size[1], 300);
// Add placeholder text
populate.call(this, "Text will appear here after execution...");
};
}
}
if (nodeData.name === 'DisplayText') registerDisplayNode(nodeType, app, 'text');
},
});
+16 -275
View File
@@ -1,279 +1,20 @@
// ComfyUI-KikoTools - Empty Latent Batch with Swap Button
import { app } from "../../scripts/app.js";
import { app } from '../../scripts/app.js';
import { bindDimensionPresets, extractResolutionFromPreset, swapDimensions } from './ui/dimensions.js';
import { mountDimensionSwap } from './ui/dimension-swap.js';
app.registerExtension({
name: "comfyassets.EmptyLatentBatch",
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
if (nodeData.name === "EmptyLatentBatch") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) onNodeCreated.apply(this, []);
// Helper function to extract resolution from formatted preset string
this.extractResolutionFromPreset = function (presetValue) {
if (presetValue === "custom") return null;
// If it contains formatting metadata, extract the resolution part
if (presetValue.includes(" - ")) {
// Format is: "1024×1024 - 1:1 (1.0MP) - SDXL"
return presetValue.split(" - ")[0];
}
// Otherwise assume it's already a raw resolution
return presetValue;
name: 'comfyassets.EmptyLatentBatch',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== 'EmptyLatentBatch') return;
nodeType.prototype.createSwapButton = function () { return mountDimensionSwap(this, app); };
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
this.extractResolutionFromPreset = extractResolutionFromPreset;
this.swapDimensions = () => swapDimensions(this, app);
bindDimensionPresets(this, app);
this.createSwapButton();
return result;
};
// Create swap button as DOM widget
this.createSwapButton();
// Override preset callback to update width/height widgets when preset changes
const presetWidget = this.widgets.find((w) => w.name === "preset");
if (presetWidget) {
const originalCallback = presetWidget.callback;
presetWidget.callback = function (
value,
graphcanvas,
node,
pos,
event,
) {
// Call original callback first
if (originalCallback) {
originalCallback.call(this, value, graphcanvas, node, pos, event);
}
// Update width/height widgets based on preset
const widthWidget = node.widgets.find((w) => w.name === "width");
const heightWidget = node.widgets.find((w) => w.name === "height");
if (widthWidget && heightWidget && value !== "custom") {
// Extract raw resolution from formatted preset
const rawResolution = node.extractResolutionFromPreset(value);
// Define all available presets from our preset system
const presetDimensions = {
// SDXL Presets
"1024×1024": [1024, 1024],
"896×1152": [896, 1152],
"832×1216": [832, 1216],
"768×1344": [768, 1344],
"640×1536": [640, 1536],
"1152×896": [1152, 896],
"1216×832": [1216, 832],
"1344×768": [1344, 768],
"1536×640": [1536, 640],
// FLUX Presets
"1920×1080": [1920, 1080],
"1536×1536": [1536, 1536],
"1280×768": [1280, 768],
"768×1280": [768, 1280],
"1440×1080": [1440, 1080],
"1080×1440": [1080, 1440],
"1728×1152": [1728, 1152],
"1152×1728": [1152, 1728],
// Ultra-Wide Presets
"2560×1080": [2560, 1080],
"2048×768": [2048, 768],
"1792×768": [1792, 768],
"2304×768": [2304, 768],
"1080×2560": [1080, 2560],
"768×2048": [768, 2048],
"768×1792": [768, 1792],
"768×2304": [768, 2304],
// Qwen Presets
"1328×1328": [1328, 1328],
"1664×928": [1664, 928],
"928×1664": [928, 1664],
"1472×1104": [1472, 1104],
"1104×1472": [1104, 1472],
"1584×1056": [1584, 1056],
"1056×1584": [1056, 1584],
"2080×688": [2080, 688],
"688×2080": [688, 2080],
};
if (rawResolution && presetDimensions[rawResolution]) {
const [w, h] = presetDimensions[rawResolution];
widthWidget.value = w;
heightWidget.value = h;
// Trigger widget callbacks to update the UI
if (widthWidget.callback) {
widthWidget.callback(w, graphcanvas, node, pos, event);
}
if (heightWidget.callback) {
heightWidget.callback(h, graphcanvas, node, pos, event);
}
}
}
};
}
// Add swap functionality
this.swapDimensions = function () {
const widthWidget = this.widgets.find((w) => w.name === "width");
const heightWidget = this.widgets.find((w) => w.name === "height");
const presetWidget = this.widgets.find((w) => w.name === "preset");
if (widthWidget && heightWidget && presetWidget) {
// Handle preset swapping first
if (presetWidget.value !== "custom") {
const currentPreset = presetWidget.value;
// Extract raw resolution from formatted preset
const rawResolution =
this.extractResolutionFromPreset(currentPreset);
if (!rawResolution) return;
// Parse current preset dimensions (handle both × and x separators)
let w, h;
if (rawResolution.includes("×")) {
[w, h] = rawResolution.split("×").map((v) => parseInt(v));
} else if (rawResolution.includes("x")) {
[w, h] = rawResolution.split("x").map((v) => parseInt(v));
} else {
return; // Invalid preset format
}
const swappedRawPreset = `${h}×${w}`;
// Find the formatted version of the swapped preset from available options
const availablePresets =
presetWidget.options.values || presetWidget.options;
let swappedFormattedPreset = null;
for (const option of availablePresets) {
if (option === "custom") continue;
const extractedRes = this.extractResolutionFromPreset(option);
if (extractedRes === swappedRawPreset) {
swappedFormattedPreset = option;
break;
}
}
if (swappedFormattedPreset) {
// Swapped preset exists, use the formatted version
presetWidget.value = swappedFormattedPreset;
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback(
swappedFormattedPreset,
app.canvas,
this,
[0, 0],
null
);
}
if (widthWidget.callback) {
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
}
} else {
// Swapped preset doesn't exist, switch to custom and swap manual values
presetWidget.value = "custom";
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback("custom", app.canvas, this, [0, 0], null);
}
if (widthWidget.callback) {
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
}
}
} else {
// Custom preset - just swap the width and height values
const tempWidth = widthWidget.value;
widthWidget.value = heightWidget.value;
heightWidget.value = tempWidth;
// Trigger widget change events
if (widthWidget.callback) {
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
}
}
// Mark the graph as changed
this.graph?.setDirtyCanvas(true, true);
}
};
};
// Create swap button as DOM widget
nodeType.prototype.createSwapButton = function () {
// Create button container
const buttonContainer = document.createElement("div");
buttonContainer.style.cssText = `
padding: 4px;
text-align: center;
`;
// Create swap button
const swapButton = document.createElement("button");
swapButton.innerHTML = "↔️ Swap W×H";
swapButton.style.cssText = `
background: #4A90E2;
color: white;
border: none;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-size: 11px;
font-weight: bold;
transition: background 0.2s;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
`;
// Button hover effects
swapButton.addEventListener("mouseenter", () => {
swapButton.style.background = "#5BA0F2";
swapButton.style.transform = "translateY(-1px)";
swapButton.style.boxShadow = "0 3px 6px rgba(0,0,0,0.3)";
});
swapButton.addEventListener("mouseleave", () => {
swapButton.style.background = "#4A90E2";
swapButton.style.transform = "translateY(0)";
swapButton.style.boxShadow = "0 2px 4px rgba(0,0,0,0.2)";
});
// Button click effect and functionality
swapButton.addEventListener("mousedown", () => {
swapButton.style.background = "#3A80D2";
swapButton.style.transform = "translateY(1px)";
swapButton.style.boxShadow = "0 1px 2px rgba(0,0,0,0.2)";
});
swapButton.addEventListener("mouseup", () => {
swapButton.style.background = "#5BA0F2";
swapButton.style.transform = "translateY(-1px)";
swapButton.style.boxShadow = "0 3px 6px rgba(0,0,0,0.3)";
});
// Main click functionality
swapButton.addEventListener("click", () => {
this.swapDimensions();
});
buttonContainer.appendChild(swapButton);
// Add as DOM widget
this.swapButtonWidget = this.addDOMWidget(
"swap_button",
"div",
buttonContainer
);
};
}
},
},
});
+12 -183
View File
@@ -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 = `
<div style="color: #ddd; margin-bottom: 15px;">
<h3 style="margin: 0 0 15px 0; color: #4A90E2;">Node Dimensions</h3>
<div style="display: flex; gap: 20px; align-items: center;">
<div>
<label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">Width:</label>
<input type="number" class="width" value="${nodeWidth}"
style="width: 100px; padding: 5px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;">
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">Height:</label>
<input type="number" class="height" value="${nodeHeight}"
style="width: 100px; padding: 5px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;">
</div>
</div>
<div style="margin-top: 10px; font-size: 11px; color: #888;">
Tip: Minimum size will be enforced based on node content
</div>
</div>
`;
this.createDialog(
htmlContent,
function(dialog, values) {
const widthValue = Number(values[0]) || nodeWidth;
const heightValue = Number(values[1]) || nodeHeight;
// Calculate minimum size based on node content
const minSize = node.computeSize();
// Apply new size (respecting minimums)
node.setSize([
Math.max(minSize[0], widthValue),
Math.max(minSize[1], heightValue)
]);
// Mark canvas as dirty to trigger redraw
node.setDirtyCanvas(true, true);
},
null
);
return showNodeDimensionsDialog(node, app);
}
}
// Export for global access
window.KikoToolsExtensions = KikoToolsExtensions;
+55
View File
@@ -86,3 +86,58 @@ The setting renderer reuses its control across value updates. Its dialog lives
in the document body so those re-renders preserve focus and native slider
interaction. Cleanup waits one microtask to distinguish synchronous reparenting
from actually closing/removing the settings control.
## Dimension swapping
Width Height Selector and Empty Latent Batch share `dimension-swap.js` and
`dimensions.js`. Both use the same native blue Swap W×H button with keyboard and
touch behavior. The selector no longer adds canvas drawing or mouse hit areas.
Repeated mounting reuses one control; node removal disposes its theme/listeners.
The supplied preset labels determine dimensions, including Unicode `×`, ASCII
`x` and formatted metadata. A matching reversed preset is retained; otherwise
the node switches to custom dimensions. Custom values swap directly. Malformed
preset dimensions leave the node unchanged. Each width/height callback receives
the host context exactly once, and existing preset callback results are retained.
The control does not change node dimensions, batch size, native input order or
backend interfaces. Workflow persistence remains with ComfyUI's native widgets;
the DOM control does not serialize its UI state.
## Node Dimensions and modal dialogs
The ComfyAssets-only Node Dimensions context menu uses `dialog.js` for a native
modal shell, shared theme tokens, focus restoration and scoped cleanup. Native
modality contains keyboard focus; Escape and Cancel close without applying.
`../views/node-dimensions.js` owns the form and node lifecycle binding.
Enter or Apply accepts positive whole pixel values and clamps them to the node's
computed content minimum. Invalid values keep the form open with an associated
error. Opening the same node again reuses its dialog; node removal closes it and
preserves the previous removal hook's context, arguments and return value.
ComfyUI continues to serialize the resulting node size.
The global `KikoToolsExtensions.createDialog(htmlContent, onOK, onCancel)` helper
remains available for trusted HTML templates. It returns the native dialog with
an immediate `close()` method. OK/Enter supplies the dialog and input/select
values; Cancel/Escape invokes cancellation once. Manual `close()` dismisses
without invoking cancellation. New views should build DOM through shared
factories rather than pass HTML to this compatibility helper.
## Display Text and Display Any
Both nodes use `../views/text-output.js` for labelled, selectable read-only text
areas and shared Copy buttons. Display Text detects paired positive/negative
prompts and offers a copy action for each. Display Any preserves the complete
formatted output in one scrollable monospace field instead of separate chunks.
The view fills the available node area; updating output preserves node size.
Copy success and failure appear in a polite status area. Replacement and removal
cancel delayed feedback and ignore pending clipboard results from the old view.
`../controllers/display-node.js` preserves native input/mode widgets and existing
lifecycle hook arguments, context and return values. Complete output, including
empty text, is saved in a `kikoDisplay: { version: 1, text }` workflow snapshot.
Legacy widget values remain readable: converted input prefixes, prompt header
pairs, native Display Any modes and all saved output chunks are recognized.
The new output DOM widget does not serialize presentation state into native
widget values. Node removal disposes the view, its listeners and theme observers.
+32
View File
@@ -164,8 +164,40 @@
.ca-color-dialog button.ca-color-preset[aria-pressed="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.ca-color-done { align-self: end; }
@media (pointer: coarse) { .ca-color-dialog button.ca-color-preset { min-block-size: 40px; } }
.ca-dimension-swap { box-sizing: border-box; inline-size: 100%; block-size: 100%; display: flex; align-items: center; padding-block: 3px; }
.ca-dimension-swap button { inline-size: 100%; }
@media (forced-colors: active) {
.ca-tools { --ca-accent: Highlight; --ca-focus: Highlight; --ca-border: ButtonText; --ca-line: CanvasText; }
.ca-tools .ca-switch input:checked::before { background: HighlightText; }
.ca-tools button.kiko-selected { outline: 2px solid Highlight; }
}
.ca-dialog { box-sizing: border-box; inline-size: 400px; max-inline-size: calc(100dvw - 24px); max-block-size: calc(100dvh - 24px); padding: 0; border: 1px solid var(--ca-border); border-radius: 8px; overflow: auto; box-shadow: 0 12px 40px #0006; }
.ca-dialog[open] { display: flex; flex-direction: column; }
.ca-dialog::backdrop { background: #0007; }
.ca-dialog > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-block-end: 1px solid var(--ca-line); }
.ca-dialog h2 { margin: 0; font-size: 14px; font-weight: 550; }
.ca-dialog > header span { color: var(--ca-muted); font-size: 10px; }
.ca-dialog-body { padding: 16px; min-inline-size: 0; }
.ca-dialog > footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding: 12px 16px; border-block-start: 1px solid var(--ca-line); }
.ca-dialog-hint { color: var(--ca-muted); margin: 0 0 12px; font-size: 12px; }
.ca-dimension-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.ca-dimension-fields .ca-field { display: flex; flex-direction: column; align-items: stretch; }
.ca-dimension-fields input { inline-size: 100%; }
.ca-dialog-error { margin: 12px 0 0; color: var(--ca-error-text); }
.ca-dialog-error:empty { display: none; }
.ca-dialog input[aria-invalid="true"] { border-color: var(--ca-error); }
@media (max-width: 360px) { .ca-dimension-fields { grid-template-columns: minmax(0, 1fr); } }
.ca-text-output { display: flex; flex-direction: column; gap: 10px; inline-size: 100%; block-size: 100%; min-block-size: 180px; padding: 12px; border-radius: 6px; contain: size; overflow: auto; box-sizing: border-box; }
.ca-text-output > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-block-end: 8px; border-block-end: 1px solid var(--ca-line); }
.ca-text-output h2 { font-size: 13px; font-weight: 550; margin: 0; }
.ca-text-output > header span { color: var(--ca-muted); font-size: 10px; }
.ca-output-content { display: flex; flex-direction: column; flex: 1; gap: 12px; min-block-size: 0; overflow: auto; }
.ca-output-section { display: flex; flex-direction: column; flex: 1 0 110px; gap: 6px; min-inline-size: 0; }
.ca-output-toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 6px; }
.ca-output-toolbar label { font-size: 12px; font-weight: 550; }
.ca-text-output textarea { inline-size: 100%; flex: 1; min-block-size: 64px; resize: none; opacity: 1; }
.ca-text-output[data-output-kind="any"] textarea { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.ca-text-output > p { font-size: 11px; color: var(--ca-muted); min-block-size: 17px; margin: 0; }
.ca-text-output > p[data-error="true"] { color: var(--ca-error-text); }
+85
View File
@@ -0,0 +1,85 @@
import { createScope, mountTheme, createButton } from './core.js';
let nextDialog = 0;
export function createDialog({ title, settings, fallbackFocus, onCancel }) {
const scope = createScope();
const element = document.createElement('dialog');
element.id = `ca-dialog-${++nextDialog}`;
element.className = 'ca-tools ca-dialog kikotools-dialog';
const header = document.createElement('header');
const heading = document.createElement('h2');
heading.id = `${element.id}-title`;
heading.textContent = title;
element.setAttribute('aria-labelledby', heading.id);
const brand = document.createElement('span');
brand.textContent = 'ComfyAssets';
header.append(heading, brand);
const body = document.createElement('div');
body.className = 'ca-dialog-body';
const actions = document.createElement('footer');
element.append(header, body, actions);
mountTheme(element, settings, scope);
let opener;
const nativeClose = element.close.bind(element);
const finish = reason => {
if (scope.signal.aborted) return;
scope.dispose();
if (opener?.isConnected && opener !== document.body) opener.focus();
else fallbackFocus?.focus();
if (reason === 'cancel') onCancel?.();
};
element.close = (reason = 'dismiss') => {
if (scope.signal.aborted) return;
nativeClose(reason);
finish(reason);
};
scope.cleanup(() => element.remove());
scope.listen(element, 'close', () => finish(element.returnValue));
scope.listen(element, 'cancel', event => {
event.preventDefault();
element.close('cancel');
});
scope.listen(element, 'keydown', event => event.stopPropagation());
scope.listen(element, 'keyup', event => event.stopPropagation());
return {
element, scope, body, actions,
close: element.close,
open(initialFocus) {
opener = document.activeElement;
document.body.append(element);
element.showModal();
initialFocus?.focus();
},
};
}
export function createLegacyDialog(htmlContent, onOK, onCancel, settings, fallbackFocus) {
const dialog = createDialog({ title: 'KikoTools', settings, fallbackFocus, onCancel });
const form = document.createElement('form');
form.id = `${dialog.element.id}-form`;
form.innerHTML = htmlContent;
dialog.body.append(form);
const cancel = createButton('Cancel');
const ok = createButton('OK', 'ca-primary');
ok.type = 'submit';
ok.setAttribute('form', form.id);
dialog.actions.append(cancel, ok);
dialog.scope.listen(cancel, 'click', () => dialog.close('cancel'));
dialog.scope.listen(form, 'keydown', event => {
if (event.key === 'Enter' && !event.isComposing && event.target.matches('select')) {
event.preventDefault();
event.stopPropagation();
form.requestSubmit();
}
});
dialog.scope.listen(form, 'submit', event => {
event.preventDefault();
onOK?.(dialog.element, [...form.querySelectorAll('input, select')].map(input => input.value));
dialog.close('apply');
});
const firstInput = form.querySelector('input, select');
dialog.open(firstInput);
if (firstInput?.tagName === 'INPUT') firstInput.select();
return dialog.element;
}
+25
View File
@@ -0,0 +1,25 @@
import { createButton, createScope, mountTheme } from './core.js';
export function mountDimensionSwap(node, app) {
if (node.swapScope && !node.swapScope.signal.aborted) return node.swapButtonWidget;
const scope = createScope();
node.swapScope = scope;
const container = document.createElement('div');
container.className = 'ca-tools ca-dimension-swap';
const button = createButton('Swap W×H', 'ca-primary');
button.setAttribute('aria-label', 'Swap width and height');
button.title = 'Swap width and height';
container.append(button);
mountTheme(container, app.ui.settings, scope);
scope.listen(button, 'click', () => node.swapDimensions());
node.swapButtonWidget = node.addDOMWidget('swap_button', 'div', container, {
serialize: false, hideOnZoom: false, getMinHeight: () => 46, getMaxHeight: () => 46,
});
scope.cleanup(() => container.remove());
const onRemoved = node.onRemoved;
node.onRemoved = function () {
scope.dispose();
return onRemoved?.apply(this, arguments);
};
return node.swapButtonWidget;
}
+67
View File
@@ -0,0 +1,67 @@
const swapping = new WeakSet();
export function extractResolutionFromPreset(value) {
return typeof value === 'string' && value !== 'custom' ? value.split(' - ')[0].trim() : null;
}
export function dimensionsFromPreset(value) {
const match = extractResolutionFromPreset(value)?.match(/^(\d+)\s*[×x]\s*(\d+)$/);
if (!match) return null;
const dimensions = match.slice(1).map(Number);
return dimensions.every(value => Number.isSafeInteger(value) && value > 0) ? dimensions : null;
}
function widgets(node) {
return ['preset', 'width', 'height'].map(name => node.widgets.find(widget => widget.name === name));
}
function notifyDimensions(width, height, canvas, node, pos, event) {
for (const widget of [width, height]) widget.callback?.(widget.value, canvas, node, pos, event);
}
export function bindDimensionPresets(node, app) {
const [preset] = widgets(node);
if (!preset) return;
const callback = preset.callback;
preset.callback = function (value, canvas = app.canvas, owner = node, pos = [0, 0], event = null) {
const result = callback?.apply(this, arguments);
if (swapping.has(node)) return result;
const dimensions = dimensionsFromPreset(value);
const [, width, height] = widgets(node);
if (dimensions && width && height) {
[width.value, height.value] = dimensions;
notifyDimensions(width, height, canvas, owner, pos, event);
}
return result;
};
}
export function swapDimensions(node, app) {
const [preset, width, height] = widgets(node);
if (!preset || !width || !height || swapping.has(node)) return;
const custom = preset.value === 'custom';
const dimensions = custom ? [width.value, height.value] : dimensionsFromPreset(preset.value);
if (!dimensions) return;
const [w, h] = dimensions;
let match;
if (!custom) {
let options = Array.isArray(preset.options) ? preset.options : preset.options?.values;
if (typeof options === 'function') options = options.call(preset);
match = (Array.isArray(options) ? options : []).find(value => {
const parsed = dimensionsFromPreset(value);
return parsed?.[0] === h && parsed?.[1] === w;
});
}
swapping.add(node);
try {
[width.value, height.value] = [h, w];
if (!custom) {
preset.value = match || 'custom';
preset.callback?.(preset.value, app.canvas, node, [0, 0], null);
}
notifyDimensions(width, height, app.canvas, node, [0, 0], null);
node.graph?.setDirtyCanvas?.(true, true);
} finally {
swapping.delete(node);
}
}
+74
View File
@@ -0,0 +1,74 @@
import { createButton, createField } from '../ui/core.js';
import { createDialog } from '../ui/dialog.js';
const activeDialogs = new WeakMap();
export function resolveNodeSize(values, minimum) {
const size = values.map(Number);
if (values.some(value => String(value).trim() === '') || size.some(value => !Number.isSafeInteger(value) || value <= 0)) return null;
return size.map((value, index) => Math.max(value, minimum[index]));
}
export function showNodeDimensionsDialog(node, app) {
const existing = activeDialogs.get(node);
if (existing) {
existing.querySelector('.width').focus();
return existing;
}
const dialog = createDialog({ title: 'Node Dimensions', settings: app.ui.settings, fallbackFocus: app.canvas.canvas });
const form = document.createElement('form');
form.id = `${dialog.element.id}-form`;
form.noValidate = true;
const hint = document.createElement('p');
hint.id = `${form.id}-hint`;
hint.textContent = 'Minimum size is enforced based on node content.';
hint.className = 'ca-dialog-hint';
const fields = document.createElement('div');
fields.className = 'ca-dimension-fields';
const error = document.createElement('p');
error.id = `${form.id}-error`;
error.className = 'ca-dialog-error';
error.setAttribute('aria-live', 'polite');
const inputs = ['Width', 'Height'].map((name, index) => {
const input = document.createElement('input');
Object.assign(input, { type: 'number', min: '1', step: '1', required: true, value: String(Math.round(node.size[index])), className: name.toLowerCase() });
input.setAttribute('aria-describedby', `${hint.id} ${error.id}`);
fields.append(createField(`${name} (px)`, input, `${form.id}-${name.toLowerCase()}`));
return input;
});
form.append(hint, fields, error);
dialog.body.append(form);
const cancel = createButton('Cancel');
const apply = createButton('Apply', 'ca-primary');
apply.type = 'submit';
apply.setAttribute('form', form.id);
dialog.actions.append(cancel, apply);
dialog.scope.listen(cancel, 'click', () => dialog.close('cancel'));
dialog.scope.listen(form, 'submit', event => {
event.preventDefault();
const size = resolveNodeSize(inputs.map(input => input.value), node.computeSize());
if (!size) {
error.textContent = 'Enter positive whole numbers for width and height.';
for (const input of inputs) input.setAttribute('aria-invalid', String(!resolveNodeSize([input.value], [1])));
inputs.find(input => input.getAttribute('aria-invalid') === 'true').focus();
return;
}
node.setSize(size);
node.setDirtyCanvas(true, true);
dialog.close('apply');
});
const original = node.onRemoved;
const onRemoved = function (...args) {
dialog.close('removed');
return original?.apply(this, args);
};
node.onRemoved = onRemoved;
activeDialogs.set(node, dialog.element);
dialog.scope.cleanup(() => {
activeDialogs.delete(node);
if (node.onRemoved === onRemoved) node.onRemoved = original;
});
dialog.open(inputs[0]);
inputs[0].select();
return dialog.element;
}
+86
View File
@@ -0,0 +1,86 @@
import { createButton, createScope, mountTheme } from '../ui/core.js';
let nextOutput = 0;
export function outputSections(kind, text) {
if (kind === 'text') {
const positive = text.match(/Positive prompt:\s*([\s\S]*?)(?=Negative prompt:|$)/i);
const negative = text.match(/Negative prompt:\s*([\s\S]*?)(?=\*\*|$)/i);
if (positive && negative) {
let positiveText = positive[1].trim();
const marker = positiveText.indexOf('**');
if (marker > 0) positiveText = positiveText.slice(0, marker).trim();
return [
{ label: 'Positive prompt', copy: 'Copy positive', text: positiveText },
{ label: 'Negative prompt', copy: 'Copy negative', text: negative[1].trim() },
];
}
}
return [{ label: kind === 'text' ? 'Text output' : 'Value output', copy: kind === 'text' ? 'Copy text' : 'Copy value', text }];
}
export function createTextOutputView(scope, kind, settings) {
const element = document.createElement('div');
element.className = 'ca-tools ca-text-output';
element.dataset.outputKind = kind;
const header = document.createElement('header');
const title = document.createElement('h2');
title.textContent = kind === 'text' ? 'Display Text' : 'Display Any';
const brand = document.createElement('span');
brand.textContent = 'ComfyAssets';
header.append(title, brand);
const content = document.createElement('div');
content.className = 'ca-output-content';
const status = document.createElement('p');
status.role = 'status';
element.append(header, content, status);
mountTheme(element, settings, scope);
let renderScope;
scope.cleanup(() => {
renderScope?.dispose();
element.remove();
});
return {
element,
update(text) {
renderScope?.dispose();
const current = createScope();
renderScope = current;
content.replaceChildren();
status.textContent = '';
delete status.dataset.error;
const clearStatus = current.debounce(() => { status.textContent = ''; }, 1500);
for (const section of outputSections(kind, text)) {
const row = document.createElement('div');
row.className = 'ca-output-section';
const toolbar = document.createElement('div');
toolbar.className = 'ca-output-toolbar';
const input = document.createElement('textarea');
input.id = `ca-output-${++nextOutput}`;
input.readOnly = true;
input.spellcheck = false;
input.value = section.text;
const label = document.createElement('label');
label.htmlFor = input.id;
label.textContent = section.label;
const copy = createButton(section.copy, 'ca-primary');
toolbar.append(label, copy);
row.append(toolbar, input);
content.append(row);
current.listen(copy, 'click', async () => {
try {
await navigator.clipboard.writeText(section.text);
if (current.signal.aborted) return;
delete status.dataset.error;
status.textContent = 'Copied.';
clearStatus();
} catch {
if (current.signal.aborted) return;
status.dataset.error = 'true';
status.textContent = 'Could not copy. Select the text and copy it manually.';
}
});
}
},
};
}
+16 -320
View File
@@ -1,324 +1,20 @@
// ComfyUI-KikoTools - Width Height Selector with Swap Button
import { app } from "../../scripts/app.js";
import { app } from '../../scripts/app.js';
import { bindDimensionPresets, extractResolutionFromPreset, swapDimensions } from './ui/dimensions.js';
import { mountDimensionSwap } from './ui/dimension-swap.js';
app.registerExtension({
name: "comfyassets.WidthHeightSelector",
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
if (nodeData.name === "WidthHeightSelector") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) onNodeCreated.apply(this, []);
// Track button click state for visual feedback
this.swapButtonPressed = false;
// Helper function to extract resolution from formatted preset string
this.extractResolutionFromPreset = function(presetValue) {
if (presetValue === "custom") return null;
// If it contains formatting metadata, extract the resolution part
if (presetValue.includes(" - ")) {
// Format is: "1024×1024 - 1:1 (1.0MP) - SDXL"
return presetValue.split(" - ")[0];
}
// Otherwise assume it's already a raw resolution
return presetValue;
name: 'comfyassets.WidthHeightSelector',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== 'WidthHeightSelector') return;
nodeType.prototype.createSwapButton = function () { return mountDimensionSwap(this, app); };
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
this.extractResolutionFromPreset = extractResolutionFromPreset;
this.swapDimensions = () => swapDimensions(this, app);
bindDimensionPresets(this, app);
this.createSwapButton();
return result;
};
// Override preset callback to update width/height widgets when preset changes
const presetWidget = this.widgets.find(w => w.name === "preset");
if (presetWidget) {
const originalCallback = presetWidget.callback;
presetWidget.callback = function(value, graphcanvas, node, pos, event) {
// Call original callback first
if (originalCallback) {
originalCallback.call(this, value, graphcanvas, node, pos, event);
}
// Update width/height widgets based on preset
const widthWidget = node.widgets.find(w => w.name === "width");
const heightWidget = node.widgets.find(w => w.name === "height");
if (widthWidget && heightWidget && value !== "custom") {
// Extract raw resolution from formatted preset
const rawResolution = node.extractResolutionFromPreset(value);
// Define all available presets from our preset system
const presetDimensions = {
// SDXL Presets
"1024×1024": [1024, 1024], "896×1152": [896, 1152], "832×1216": [832, 1216],
"768×1344": [768, 1344], "640×1536": [640, 1536], "1152×896": [1152, 896],
"1216×832": [1216, 832], "1344×768": [1344, 768], "1536×640": [1536, 640],
// FLUX Presets
"1920×1080": [1920, 1080], "1536×1536": [1536, 1536], "1280×768": [1280, 768],
"768×1280": [768, 1280], "1440×1080": [1440, 1080], "1080×1440": [1080, 1440],
"1728×1152": [1728, 1152], "1152×1728": [1152, 1728],
// Ultra-Wide Presets
"2560×1080": [2560, 1080], "2048×768": [2048, 768], "1792×768": [1792, 768],
"2304×768": [2304, 768], "1080×2560": [1080, 2560], "768×2048": [768, 2048],
"768×1792": [768, 1792], "768×2304": [768, 2304]
};
if (rawResolution && presetDimensions[rawResolution]) {
const [w, h] = presetDimensions[rawResolution];
widthWidget.value = w;
heightWidget.value = h;
// Trigger widget callbacks to update the UI
if (widthWidget.callback) {
widthWidget.callback(w, graphcanvas, node, pos, event);
}
if (heightWidget.callback) {
heightWidget.callback(h, graphcanvas, node, pos, event);
}
}
}
};
}
// Add swap functionality
this.swapDimensions = function() {
const widthWidget = this.widgets.find(w => w.name === "width");
const heightWidget = this.widgets.find(w => w.name === "height");
const presetWidget = this.widgets.find(w => w.name === "preset");
if (widthWidget && heightWidget && presetWidget) {
// Handle preset swapping first
if (presetWidget.value !== "custom") {
const currentPreset = presetWidget.value;
// Extract raw resolution from formatted preset
const rawResolution = this.extractResolutionFromPreset(currentPreset);
if (!rawResolution) return;
// Parse current preset dimensions (handle both × and x separators)
let w, h;
if (rawResolution.includes('×')) {
[w, h] = rawResolution.split('×').map(v => parseInt(v));
} else if (rawResolution.includes('x')) {
[w, h] = rawResolution.split('x').map(v => parseInt(v));
} else {
return; // Invalid preset format
}
const swappedRawPreset = `${h}×${w}`;
// Find the formatted version of the swapped preset from available options
const availablePresets = presetWidget.options.values || presetWidget.options;
let swappedFormattedPreset = null;
for (const option of availablePresets) {
if (option === "custom") continue;
const extractedRes = this.extractResolutionFromPreset(option);
if (extractedRes === swappedRawPreset) {
swappedFormattedPreset = option;
break;
}
}
if (swappedFormattedPreset) {
// Swapped preset exists, use the formatted version
presetWidget.value = swappedFormattedPreset;
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback(swappedFormattedPreset, app.canvas, this, [0, 0], null);
}
if (widthWidget.callback) {
widthWidget.callback(h, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(w, app.canvas, this, [0, 0], null);
}
} else {
// Swapped preset doesn't exist, switch to custom and swap manual values
presetWidget.value = "custom";
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback("custom", app.canvas, this, [0, 0], null);
}
if (widthWidget.callback) {
widthWidget.callback(h, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(w, app.canvas, this, [0, 0], null);
}
}
} else {
// Custom preset - just swap the width and height values
const tempWidth = widthWidget.value;
widthWidget.value = heightWidget.value;
heightWidget.value = tempWidth;
// Trigger widget change events
if (widthWidget.callback) {
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
}
}
// Mark the graph as changed
this.graph?.setDirtyCanvas(true, true);
}
};
};
const onDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
if (onDrawForeground) {
onDrawForeground.apply(this, arguments);
}
if (this.flags.collapsed) return;
// Draw swap button with consistent spacing from widgets
const swapButtonSize = 24;
const margin = 6;
const swapButtonX = this.size[0] - swapButtonSize - margin;
// Calculate button position based on widget spacing rather than bottom margin
// Estimate widget area height and add consistent spacing
const estimatedWidgetHeight = 90; // Approximate height for 3 widgets
const topMargin = 35; // Space from top to first widget
const buttonSpacing = 10; // Space between last widget and button
const swapButtonY = topMargin + estimatedWidgetHeight + buttonSpacing;
// Button background - change color based on pressed state
if (this.swapButtonPressed) {
// Darker when pressed
ctx.fillStyle = "rgba(30, 120, 200, 0.9)"; // Darker blue when clicked
} else {
// Normal state
ctx.fillStyle = "rgba(66, 165, 245, 0.8)"; // Material blue
}
ctx.beginPath();
ctx.roundRect(swapButtonX, swapButtonY, swapButtonSize, swapButtonSize, 4);
ctx.fill();
// Button border with subtle highlight
ctx.strokeStyle = this.swapButtonPressed ? "rgba(20, 100, 180, 1.0)" : "rgba(33, 150, 243, 0.9)";
ctx.lineWidth = 1;
ctx.stroke();
// Draw swap icon - modern double arrow design
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
ctx.lineWidth = 2;
ctx.lineCap = "round";
const centerX = swapButtonX + 12;
const centerY = swapButtonY + 12;
// Top arrow (pointing right) - width to height
ctx.beginPath();
ctx.moveTo(centerX - 7, centerY - 3);
ctx.lineTo(centerX + 5, centerY - 3);
ctx.stroke();
// Top arrow head
ctx.beginPath();
ctx.moveTo(centerX + 5, centerY - 3);
ctx.lineTo(centerX + 2, centerY - 5);
ctx.moveTo(centerX + 5, centerY - 3);
ctx.lineTo(centerX + 2, centerY - 1);
ctx.stroke();
// Bottom arrow (pointing left) - height to width
ctx.beginPath();
ctx.moveTo(centerX + 5, centerY + 3);
ctx.lineTo(centerX - 7, centerY + 3);
ctx.stroke();
// Bottom arrow head
ctx.beginPath();
ctx.moveTo(centerX - 7, centerY + 3);
ctx.lineTo(centerX - 4, centerY + 1);
ctx.moveTo(centerX - 7, centerY + 3);
ctx.lineTo(centerX - 4, centerY + 5);
ctx.stroke();
// Add subtle tooltip text when hovering (if we had hover state)
// This could be extended with hover detection for better UX
};
const onMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (e) {
// Check if click is on swap button
const swapButtonSize = 24;
const margin = 6;
const swapButtonX = this.pos[0] + this.size[0] - swapButtonSize - margin;
// Use same positioning logic as drawing
const estimatedWidgetHeight = 90;
const topMargin = 35;
const buttonSpacing = 10;
const swapButtonY = this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing;
if (
e.canvasX >= swapButtonX &&
e.canvasX <= swapButtonX + swapButtonSize &&
e.canvasY >= swapButtonY &&
e.canvasY <= swapButtonY + swapButtonSize
) {
// Visual feedback - set button as pressed
this.swapButtonPressed = true;
this.setDirtyCanvas(true, true);
// Execute swap
this.swapDimensions();
// Reset button state after a short delay for visual feedback
setTimeout(() => {
this.swapButtonPressed = false;
this.setDirtyCanvas(true, true);
}, 150);
return true; // Consume the event
}
// Call original onMouseDown if not clicking swap button
if (onMouseDown) {
return onMouseDown.apply(this, arguments);
}
};
// Optional: Add hover effect for better user feedback
const onMouseMove = nodeType.prototype.onMouseMove;
nodeType.prototype.onMouseMove = function (e) {
// Check if hovering over swap button
const swapButtonSize = 24;
const margin = 6;
const swapButtonX = this.pos[0] + this.size[0] - swapButtonSize - margin;
// Use same positioning logic as drawing
const estimatedWidgetHeight = 90;
const topMargin = 35;
const buttonSpacing = 10;
const swapButtonY = this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing;
const isHovering = (
e.canvasX >= swapButtonX &&
e.canvasX <= swapButtonX + swapButtonSize &&
e.canvasY >= swapButtonY &&
e.canvasY <= swapButtonY + swapButtonSize
);
// Update cursor style for better UX (safely)
if (isHovering && this.graph && this.graph.canvas && this.graph.canvas.canvas) {
this.graph.canvas.canvas.style.cursor = "pointer";
} else if (this.graph && this.graph.canvas && this.graph.canvas.canvas) {
this.graph.canvas.canvas.style.cursor = "default";
}
// Call original onMouseMove
if (onMouseMove) {
return onMouseMove.apply(this, arguments);
}
};
}
},
},
});