Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c0d53676 | ||
|
|
b9103ad9c4 | ||
|
|
b5694ecfe5 | ||
|
|
1255c4e9cc | ||
|
|
26557a99b6 | ||
|
|
848674d474 | ||
|
|
0c47dc6085 | ||
|
|
ee7d1e9011 |
@@ -100,6 +100,11 @@ Its `/view` requests also use disposable fixture output rather than live files.
|
||||
Seed History checks keyboard-operable rows through renderer switches, list growth
|
||||
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
|
||||
|
||||
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -1,6 +1,7 @@
|
||||
// A test double for the ComfyUI host; extension implementations load unchanged.
|
||||
const extensions = [];
|
||||
const settings = new Map();
|
||||
const settingDefinitions = new Map();
|
||||
const nodes = new Map();
|
||||
let nextId = 1;
|
||||
function el(tag, props = {}, children = []) {
|
||||
@@ -19,8 +20,9 @@ const app = {
|
||||
extensions.push(extension);
|
||||
},
|
||||
ui: { settings: {
|
||||
addSetting(setting) { if (!settings.has(setting.id)) settings.set(setting.id, setting.defaultValue); },
|
||||
addSetting(setting) { settingDefinitions.set(setting.id, setting); if (!settings.has(setting.id)) settings.set(setting.id, setting.defaultValue); },
|
||||
getSettingValue: id => settings.get(id),
|
||||
setSettingValue(id, value) { settings.set(id, value); settingDefinitions.get(id)?.onChange?.(value); },
|
||||
}, dialog: { show: text => { const dialog = el('div', { className: 'host-dialog', textContent: text }); document.body.append(dialog); } } },
|
||||
graph: {
|
||||
_nodes: [],
|
||||
@@ -106,7 +108,7 @@ function removeNode(id) {
|
||||
app.graph._nodes = app.graph._nodes.filter(item => item !== node);
|
||||
return node;
|
||||
}
|
||||
window.kikoHost = { app, api, widgets, el, extensions, settings, nodes, createNode, removeNode,
|
||||
window.kikoHost = { app, api, widgets, el, extensions, settings, settingDefinitions, nodes, createNode, removeNode,
|
||||
emit: (name, detail) => api.dispatchEvent(new CustomEvent(name, { detail })) };
|
||||
const manifest = await (await fetch('/__test__/state')).json();
|
||||
for (const name of manifest.extensions) await import(`/extensions/kikotools/${name}`);
|
||||
|
||||
@@ -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 }) => {
|
||||
@@ -414,6 +469,155 @@ test('workflow timer responds to execution events and detaches removed nodes', a
|
||||
await expect(page.locator('.kiko-timer-display')).toHaveCount(0);
|
||||
});
|
||||
|
||||
async function showTimerColorSetting(page) {
|
||||
await page.evaluate(() => {
|
||||
const id = 'kikotools.workflow_timer.color';
|
||||
const definition = kikoHost.settingDefinitions.get(id);
|
||||
const setValue = value => {
|
||||
kikoHost.app.ui.settings.setSettingValue(id, value);
|
||||
const replacement = definition.type(definition.name, setValue, value);
|
||||
timerColorSetting.replaceWith(replacement);
|
||||
window.timerColorSetting = replacement;
|
||||
};
|
||||
window.timerColorSetting = definition.type(definition.name, setValue, kikoHost.settings.get(id));
|
||||
document.body.append(timerColorSetting);
|
||||
});
|
||||
}
|
||||
|
||||
test('shared workflow timer follows themes, saved color and reduced motion', async ({ page }) => {
|
||||
await openHost(page);
|
||||
await page.evaluate(() => kikoHost.app.ui.settings.setSettingValue('kikotools.workflow_timer.color', '#ff9966'));
|
||||
const id = await createNode(page, 'KikoWorkflowTimer');
|
||||
const panel = page.locator('.ca-workflow-timer');
|
||||
await expect(panel).toBeVisible();
|
||||
const timer = panel.getByRole('timer', { name: 'Elapsed workflow time' });
|
||||
await expect(timer).toHaveCSS('color', 'rgb(255, 153, 102)');
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.evaluate(theme => document.body.style.setProperty('--bg-color', theme === 'light' ? '#ffffff' : '#202020'), theme);
|
||||
await expect(panel).toHaveAttribute('data-ca-theme', theme);
|
||||
}
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.evaluate(() => kikoHost.emit('execution_start'));
|
||||
await expect(timer).toHaveCSS('animation-name', 'none');
|
||||
await page.evaluate(() => kikoHost.emit('execution_interrupted'));
|
||||
await expect(timer).toHaveCSS('animation-name', 'none');
|
||||
await page.evaluate(id => kikoHost.nodes.get(String(id)).onConfigure({ properties: { elapsed_time_str: '02:03:456' } }), id);
|
||||
await expect(timer).toHaveText('02:03:456');
|
||||
expect(await page.evaluate(id => {
|
||||
const node = kikoHost.nodes.get(String(id));
|
||||
const saved = { properties: { other: 'kept' } };
|
||||
node.onSerialize(saved);
|
||||
node.properties.elapsed_time_str = 'changed after saving';
|
||||
return saved.properties;
|
||||
}, id)).toEqual({ other: 'kept', elapsed_time_str: '02:03:456' });
|
||||
});
|
||||
|
||||
test('workflow timers join active execution and apply live appearance until every end event', async ({ page }) => {
|
||||
await openHost(page);
|
||||
await page.clock.install();
|
||||
await createNode(page, 'KikoWorkflowTimer');
|
||||
await page.evaluate(() => kikoHost.emit('execution_start'));
|
||||
await page.clock.runFor(1200);
|
||||
await createNode(page, 'KikoWorkflowTimer');
|
||||
const displays = page.locator('.kiko-timer-display');
|
||||
await expect(displays.nth(1)).toHaveClass(/running/);
|
||||
await expect.poll(() => displays.evaluateAll(elements => new Set(elements.map(element => element.textContent)).size)).toBe(1);
|
||||
await page.evaluate(() => {
|
||||
kikoHost.app.ui.settings.setSettingValue('kikotools.workflow_timer.color', '#123456');
|
||||
kikoHost.app.ui.settings.setSettingValue('kikotools.workflow_timer.glow', false);
|
||||
});
|
||||
for (const display of await displays.all()) {
|
||||
await expect(display).toHaveCSS('color', 'rgb(18, 52, 86)');
|
||||
await expect(display).toHaveClass(/no-glow/);
|
||||
}
|
||||
for (const event of ['executing', 'execution_error', 'execution_interrupted']) {
|
||||
await page.evaluate(() => kikoHost.emit('execution_start'));
|
||||
await page.clock.runFor(1234);
|
||||
await page.evaluate(event => kikoHost.emit(event, null), event);
|
||||
await expect(displays.nth(0)).not.toHaveClass(/running/);
|
||||
const stopped = await displays.nth(0).textContent();
|
||||
await page.clock.runFor(1000);
|
||||
await expect(displays.nth(1)).toHaveText(stopped);
|
||||
expect(await page.evaluate(() => kikoHost.app.graph._nodes.map(node => node.properties.elapsed_time_str))).toEqual([stopped, stopped]);
|
||||
}
|
||||
});
|
||||
|
||||
test('timer color controls validate hex, support keyboard sliders and presets and close with Escape', async ({ page }) => {
|
||||
await openHost(page);
|
||||
await createNode(page, 'KikoWorkflowTimer');
|
||||
await showTimerColorSetting(page);
|
||||
const trigger = page.getByRole('button', { name: 'Choose timer color', exact: true });
|
||||
await trigger.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const dialog = page.getByRole('dialog', { name: 'Timer color', exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
const hex = dialog.getByLabel('Hex color', { exact: true });
|
||||
await hex.fill('not-a-color');
|
||||
await hex.press('Tab');
|
||||
await expect(hex).toHaveAttribute('aria-invalid', 'true');
|
||||
expect(await page.evaluate(() => kikoHost.settings.get('kikotools.workflow_timer.color'))).toBe('#0877bb');
|
||||
await hex.fill('112233');
|
||||
await hex.press('Tab');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(hex).toHaveAttribute('aria-invalid', 'false');
|
||||
await expect(page.locator('.kiko-timer-display')).toHaveCSS('color', 'rgb(17, 34, 51)');
|
||||
await dialog.getByRole('button', { name: 'Use color #4A90D9', exact: true }).focus();
|
||||
await page.keyboard.press('Space');
|
||||
await expect(dialog.getByRole('button', { name: 'Use color #4A90D9', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
const before = await hex.inputValue();
|
||||
const hue = dialog.getByRole('slider', { name: 'Hue', exact: true });
|
||||
await hue.hover();
|
||||
expect(await hex.inputValue()).toBe(before);
|
||||
await hue.focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
expect(await hex.inputValue()).not.toBe(before);
|
||||
await expect(hue).toHaveAttribute('aria-valuetext', /degrees/);
|
||||
for (const label of ['Saturation', 'Brightness']) {
|
||||
const slider = dialog.getByRole('slider', { name: label, exact: true });
|
||||
await slider.focus();
|
||||
await page.keyboard.press('End');
|
||||
await expect(slider).toHaveValue('100');
|
||||
}
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(trigger).toBeFocused();
|
||||
await trigger.click();
|
||||
await dialog.getByRole('button', { name: 'Done', exact: true }).click();
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
|
||||
test('timer removal releases polling and color setting removal closes and disposes its dialog', async ({ page }) => {
|
||||
await openHost(page);
|
||||
await page.clock.install();
|
||||
await page.evaluate(() => {
|
||||
window.timerIntervals = new Set();
|
||||
const start = window.setInterval;
|
||||
const stop = window.clearInterval;
|
||||
window.setInterval = (...args) => { const id = start(...args); timerIntervals.add(id); return id; };
|
||||
window.clearInterval = id => { timerIntervals.delete(id); stop(id); };
|
||||
});
|
||||
const first = await createNode(page, 'KikoWorkflowTimer');
|
||||
const second = await createNode(page, 'KikoWorkflowTimer');
|
||||
await page.evaluate(() => kikoHost.emit('execution_start'));
|
||||
expect(await page.evaluate(() => timerIntervals.size)).toBe(1);
|
||||
await page.evaluate(id => kikoHost.removeNode(id), first);
|
||||
expect(await page.evaluate(() => timerIntervals.size)).toBe(1);
|
||||
await page.evaluate(id => { window.removedTimer = kikoHost.removeNode(id); }, second);
|
||||
expect(await page.evaluate(() => ({ intervals: timerIntervals.size, aborted: removedTimer.timerScope?.signal.aborted }))).toEqual({ intervals: 0, aborted: true });
|
||||
await showTimerColorSetting(page);
|
||||
await page.getByRole('button', { name: 'Choose timer color', exact: true }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'Timer color', exact: true })).toBeVisible();
|
||||
await page.evaluate(() => {
|
||||
window.detachedTimerPicker = timerColorSetting;
|
||||
timerColorSetting.remove();
|
||||
});
|
||||
await expect(page.getByRole('dialog', { name: 'Timer color', exact: true })).toHaveCount(0);
|
||||
expect(await page.evaluate(() => detachedTimerPicker.scope.signal.aborted)).toBe(true);
|
||||
await page.evaluate(() => document.body.append(detachedTimerPicker));
|
||||
await page.getByRole('button', { name: 'Choose timer color', exact: true }).click();
|
||||
await expect(page.getByRole('dialog', { name: 'Timer color', exact: true })).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('autocomplete inserts embedding paths from the real plugin endpoint and cleans up its widget', async ({ page }) => {
|
||||
await openHost(page);
|
||||
const id = await createNode(page, 'TextInput', { text: '' });
|
||||
@@ -606,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 }) => {
|
||||
@@ -666,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 });
|
||||
});
|
||||
|
||||
@@ -47,6 +47,182 @@ 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');
|
||||
const { api } = await import('/scripts/api.js');
|
||||
await app.ui.settings.setSettingValue('kikotools.workflow_timer.color', '#123456');
|
||||
const node = LiteGraph.createNode('KikoWorkflowTimer');
|
||||
app.graph.add(node);
|
||||
node.color = '#334455';
|
||||
node.bgcolor = '#556677';
|
||||
node.setSize([440, 240]);
|
||||
node.properties.elapsed_time_str = '01:02:345';
|
||||
node.onConfigure({ properties: node.properties });
|
||||
app.canvas.centerOnNode(node);
|
||||
api.dispatchEvent(new CustomEvent('execution_start'));
|
||||
});
|
||||
const panel = page.locator('.ca-workflow-timer');
|
||||
const timer = panel.getByRole('timer', { name: 'Elapsed workflow time' });
|
||||
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);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
await expect(panel).toHaveCount(1);
|
||||
await expect(timer).toHaveClass(/running/);
|
||||
await expect(timer).toHaveCSS('color', 'rgb(18, 52, 86)');
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const { api } = await import('/scripts/api.js');
|
||||
api.dispatchEvent(new CustomEvent('execution_interrupted'));
|
||||
});
|
||||
const stopped = await timer.textContent();
|
||||
const initialFontSize = await timer.evaluate(element => parseFloat(getComputedStyle(element).fontSize));
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer').setSize([220, 160]);
|
||||
});
|
||||
await expect.poll(() => timer.evaluate(element => parseFloat(getComputedStyle(element).fontSize))).toBeLessThan(initialFontSize);
|
||||
expect(await timer.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer').setSize([600, 320]);
|
||||
});
|
||||
await expect.poll(() => timer.evaluate(element => parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThan(initialFontSize);
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer').setSize([440, 240]);
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('kikotools.workflow_timer.color', '#0877bb');
|
||||
});
|
||||
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(`timer-${theme}.png`) });
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const workflow = app.graph.serialize();
|
||||
await app.loadGraphData(workflow);
|
||||
const node = app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer');
|
||||
if (node.color !== '#334455' || node.bgcolor !== '#556677') throw new Error('Custom node colors changed');
|
||||
if (node.size[0] !== 440 || node.size[1] !== 240) throw new Error('Saved timer dimensions changed');
|
||||
app.canvas.centerOnNode(node);
|
||||
});
|
||||
await expect(timer).toHaveText(stopped);
|
||||
await page.getByRole('button', { name: 'Settings (Ctrl + ,)', exact: true }).click();
|
||||
const search = page.getByPlaceholder(/search/i);
|
||||
await expect(search).toBeVisible();
|
||||
await search.fill('Workflow Timer');
|
||||
await page.getByRole('button', { name: 'Choose timer color', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Timer color', exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByLabel('Hex color', { exact: true }).fill('#4a90d9');
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(timer).toHaveCSS('color', 'rgb(74, 144, 217)');
|
||||
await page.screenshot({ path: testInfo.outputPath('timer-color-picker.png') });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Choose timer color', exact: true })).toBeFocused();
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer');
|
||||
window.removedTimer = node;
|
||||
app.graph.remove(node);
|
||||
});
|
||||
await expect(panel).toHaveCount(0);
|
||||
expect(await page.evaluate(() => removedTimer.timerScope.signal.aborted)).toBe(true);
|
||||
});
|
||||
|
||||
test('seed history survives renderer switching, resizing and workflow reload', async ({ page }, testInfo) => {
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
@@ -221,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { WorkflowClock, formatElapsed } from '../../web/ui/workflow-clock.js';
|
||||
import { normalizeHex, hexToHsv, hsvToHex } from '../../web/ui/color.js';
|
||||
|
||||
function fixture() {
|
||||
let now = 0;
|
||||
const intervals = new Map();
|
||||
const settings = new Map();
|
||||
const clock = new WorkflowClock({ getSettingValue: id => settings.get(id) }, {
|
||||
Date: { now: () => now },
|
||||
setInterval(callback) { const id = Symbol(); intervals.set(id, callback); return id; },
|
||||
clearInterval(id) { intervals.delete(id); },
|
||||
});
|
||||
const node = () => ({ properties: {}, timerDisplay: {
|
||||
textContent: '',
|
||||
style: { setProperty(name, value) { this[name] = value; } },
|
||||
classList: { values: new Set(), toggle(name, enabled) { enabled ? this.values.add(name) : this.values.delete(name); } },
|
||||
} });
|
||||
return { clock, settings, intervals, node, advance(ms) { now += ms; for (const callback of intervals.values()) callback(); } };
|
||||
}
|
||||
|
||||
test('elapsed formatting handles minutes, milliseconds and invalid durations', () => {
|
||||
assert.equal(formatElapsed(0), '00:00:000');
|
||||
assert.equal(formatElapsed(-1), '00:00:000');
|
||||
assert.equal(formatElapsed(NaN), '00:00:000');
|
||||
assert.equal(formatElapsed(61234.7), '01:01:234');
|
||||
assert.equal(formatElapsed(3600000), '60:00:000');
|
||||
});
|
||||
|
||||
test('one clock synchronizes late joining nodes and persists the final duration', () => {
|
||||
const f = fixture();
|
||||
const first = f.node();
|
||||
f.clock.registerNode(first);
|
||||
f.clock.start();
|
||||
f.advance(1200);
|
||||
f.clock.start();
|
||||
const second = f.node();
|
||||
f.clock.registerNode(second);
|
||||
assert.equal(f.intervals.size, 1);
|
||||
assert.equal(first.timerDisplay.textContent, '00:01:200');
|
||||
assert.equal(second.timerDisplay.textContent, first.timerDisplay.textContent);
|
||||
assert.equal(second.timerDisplay.classList.values.has('running'), true);
|
||||
f.advance(34);
|
||||
f.clock.stop();
|
||||
f.clock.stop();
|
||||
assert.equal(f.intervals.size, 0);
|
||||
assert.equal(f.clock.intervalId, null);
|
||||
for (const node of [first, second]) {
|
||||
assert.equal(node.properties.elapsed_time_str, '00:01:234');
|
||||
assert.equal(node.timerDisplay.classList.values.has('running'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('removing the final node releases polling while late nodes can rejoin an active run', () => {
|
||||
const f = fixture();
|
||||
f.clock.start();
|
||||
assert.equal(f.intervals.size, 0);
|
||||
f.advance(1000);
|
||||
const first = f.node();
|
||||
f.clock.registerNode(first);
|
||||
assert.equal(first.timerDisplay.textContent, '00:01:000');
|
||||
assert.equal(f.intervals.size, 1);
|
||||
f.clock.unregisterNode(first);
|
||||
assert.equal(f.intervals.size, 0);
|
||||
f.advance(1000);
|
||||
const next = f.node();
|
||||
f.clock.registerNode(next);
|
||||
assert.equal(next.timerDisplay.textContent, '00:02:000');
|
||||
f.clock.stop();
|
||||
f.clock.start();
|
||||
assert.equal(next.timerDisplay.textContent, '00:00:000');
|
||||
f.clock.unregisterNode(next);
|
||||
assert.equal(f.intervals.size, 0);
|
||||
});
|
||||
|
||||
test('configured colors and glow update during a run and saved durations restore', () => {
|
||||
const f = fixture();
|
||||
const node = f.node();
|
||||
node.properties.elapsed_time_str = '02:03:456';
|
||||
f.clock.registerNode(node);
|
||||
assert.equal(node.timerDisplay.textContent, '02:03:456');
|
||||
assert.equal(node.timerDisplay.style['--ca-timer-color'], '#0877bb');
|
||||
f.clock.start();
|
||||
f.settings.set('kikotools.workflow_timer.color', '#Ff9966');
|
||||
f.settings.set('kikotools.workflow_timer.glow', false);
|
||||
f.clock.refresh();
|
||||
assert.equal(node.timerDisplay.style['--ca-timer-color'], '#ff9966');
|
||||
assert.equal(node.timerDisplay.classList.values.has('no-glow'), true);
|
||||
f.settings.set('kikotools.workflow_timer.color', 'invalid');
|
||||
f.clock.refresh();
|
||||
assert.equal(node.timerDisplay.style['--ca-timer-color'], '#0877bb');
|
||||
});
|
||||
|
||||
test('hex colors normalize strictly and HSV conversion round trips representative colors', () => {
|
||||
assert.equal(normalizeHex('Ff9966'), '#ff9966');
|
||||
for (const invalid of ['', '#xyzxyz', '#12345', null, {}, '#ffffff; color:red']) assert.equal(normalizeHex(invalid), null);
|
||||
for (const hex of ['#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#00ffff', '#ff00ff', '#ff9966', '#0877bb']) {
|
||||
const { h, s, v } = hexToHsv(hex);
|
||||
assert.equal(hsvToHex(h, s, v), hex);
|
||||
}
|
||||
assert.equal(hsvToHex(360, 100, 100), '#ff0000');
|
||||
assert.equal(hsvToHex(-120, 100, 100), '#0000ff');
|
||||
assert.equal(hsvToHex(0, 120, -1), '#000000');
|
||||
});
|
||||
@@ -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,
|
||||
@@ -54,16 +60,16 @@
|
||||
"statements": 84
|
||||
},
|
||||
"kiko_workflow_timer_ui.js": {
|
||||
"lines": 44,
|
||||
"branches": 73,
|
||||
"functions": 34,
|
||||
"statements": 44
|
||||
"lines": 98,
|
||||
"branches": 78,
|
||||
"functions": 88,
|
||||
"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,
|
||||
@@ -71,34 +77,82 @@
|
||||
"functions": 88,
|
||||
"statements": 95
|
||||
},
|
||||
"ui/color-picker.js": {
|
||||
"lines": 98,
|
||||
"branches": 94,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"ui/color.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"ui/core.js": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"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,
|
||||
"functions": 98,
|
||||
"statements": 93
|
||||
},
|
||||
"ui/workflow-clock.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"views/image-browser.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,541 +1,96 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { $el } from "../../scripts/ui.js";
|
||||
import { app } from '../../scripts/app.js';
|
||||
import { api } from '../../scripts/api.js';
|
||||
import { createScope, mountTheme } from './ui/core.js';
|
||||
import { createColorPicker } from './ui/color-picker.js';
|
||||
import { WorkflowClock } from './ui/workflow-clock.js';
|
||||
|
||||
/**
|
||||
* Sketch-style Color Picker for Settings
|
||||
*/
|
||||
class SketchColorPicker {
|
||||
constructor(initialColor = '#ff9966', onChange = () => {}) {
|
||||
this.color = this.hexToHsv(initialColor);
|
||||
this.hex = initialColor;
|
||||
this.onChange = onChange;
|
||||
this.isOpen = false;
|
||||
|
||||
this.presetColors = [
|
||||
'#D0021B', '#F5A623', '#F8E71C', '#8B572A', '#7ED321',
|
||||
'#417505', '#BD10E0', '#9013FE', '#4A90D9', '#50E3C2',
|
||||
'#B8E986', '#000000', '#4A4A4A', '#9B9B9B', '#FFFFFF',
|
||||
];
|
||||
|
||||
this.createElements();
|
||||
}
|
||||
|
||||
createElements() {
|
||||
// Swatch button
|
||||
this.swatch = document.createElement('div');
|
||||
this.swatch.style.cssText = `
|
||||
width: 50px; height: 28px; border-radius: 4px;
|
||||
background: ${this.hex}; cursor: pointer;
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,.2), inset 0 0 0 1px rgba(0,0,0,.1);
|
||||
display: inline-block;
|
||||
`;
|
||||
this.swatch.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggle();
|
||||
});
|
||||
|
||||
// Popup container
|
||||
this.popup = document.createElement('div');
|
||||
this.popup.style.cssText = `
|
||||
position: absolute; z-index: 10000;
|
||||
background: #2a2a2a; border-radius: 6px;
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,.1), 0 8px 24px rgba(0,0,0,.4);
|
||||
padding: 12px; display: none; width: 220px;
|
||||
bottom: 100%; margin-bottom: 8px; right: 0;
|
||||
`;
|
||||
|
||||
// Saturation/Brightness picker
|
||||
this.satBright = document.createElement('div');
|
||||
this.satBright.style.cssText = `
|
||||
width: 196px; height: 140px; position: relative;
|
||||
border-radius: 4px; cursor: crosshair; margin-bottom: 12px;
|
||||
`;
|
||||
this.updateSatBrightBackground();
|
||||
|
||||
this.satBrightPointer = document.createElement('div');
|
||||
this.satBrightPointer.style.cssText = `
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.3), 0 2px 4px rgba(0,0,0,.3);
|
||||
position: absolute; transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
`;
|
||||
this.satBright.appendChild(this.satBrightPointer);
|
||||
|
||||
// Hue slider
|
||||
this.hueSlider = document.createElement('div');
|
||||
this.hueSlider.style.cssText = `
|
||||
width: 196px; height: 14px; border-radius: 4px;
|
||||
background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
|
||||
position: relative; cursor: pointer; margin-bottom: 12px;
|
||||
`;
|
||||
this.huePointer = document.createElement('div');
|
||||
this.huePointer.style.cssText = `
|
||||
width: 8px; height: 18px; border-radius: 3px;
|
||||
background: #fff; border: 1px solid rgba(0,0,0,.3);
|
||||
position: absolute; top: -2px; transform: translateX(-50%);
|
||||
pointer-events: none; box-shadow: 0 1px 3px rgba(0,0,0,.3);
|
||||
`;
|
||||
this.hueSlider.appendChild(this.huePointer);
|
||||
|
||||
// Hex input row
|
||||
this.hexRow = document.createElement('div');
|
||||
this.hexRow.style.cssText = 'display: flex; align-items: center; margin-bottom: 12px; gap: 8px;';
|
||||
|
||||
this.hexInput = document.createElement('input');
|
||||
this.hexInput.type = 'text';
|
||||
this.hexInput.value = this.hex;
|
||||
this.hexInput.style.cssText = `
|
||||
flex: 1; padding: 6px 8px; border: 1px solid #444;
|
||||
border-radius: 4px; font-size: 13px; font-family: monospace;
|
||||
text-transform: uppercase; background: #1a1a1a; color: #eee;
|
||||
`;
|
||||
this.hexInput.addEventListener('change', () => this.setHex(this.hexInput.value));
|
||||
|
||||
const hexLabel = document.createElement('span');
|
||||
hexLabel.textContent = 'Hex';
|
||||
hexLabel.style.cssText = 'font-size: 12px; color: #888; min-width: 28px;';
|
||||
|
||||
this.hexRow.appendChild(this.hexInput);
|
||||
this.hexRow.appendChild(hexLabel);
|
||||
|
||||
// Preset swatches
|
||||
this.presetsContainer = document.createElement('div');
|
||||
this.presetsContainer.style.cssText = `
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
border-top: 1px solid #444; padding-top: 12px;
|
||||
`;
|
||||
|
||||
this.presetColors.forEach(color => {
|
||||
const preset = document.createElement('div');
|
||||
preset.style.cssText = `
|
||||
width: 18px; height: 18px; border-radius: 3px;
|
||||
background: ${color}; cursor: pointer;
|
||||
box-shadow: inset 0 0 0 1px rgba(0,0,0,.2);
|
||||
transition: transform 0.1s;
|
||||
`;
|
||||
preset.addEventListener('mouseenter', () => preset.style.transform = 'scale(1.15)');
|
||||
preset.addEventListener('mouseleave', () => preset.style.transform = 'scale(1)');
|
||||
preset.addEventListener('click', () => this.setHex(color));
|
||||
this.presetsContainer.appendChild(preset);
|
||||
});
|
||||
|
||||
// Assemble popup
|
||||
this.popup.appendChild(this.satBright);
|
||||
this.popup.appendChild(this.hueSlider);
|
||||
this.popup.appendChild(this.hexRow);
|
||||
this.popup.appendChild(this.presetsContainer);
|
||||
|
||||
// Event handlers
|
||||
this.setupDrag(this.satBright, this.handleSatBrightChange.bind(this));
|
||||
this.setupDrag(this.hueSlider, this.handleHueChange.bind(this));
|
||||
|
||||
// Close on outside click
|
||||
this.closeHandler = (e) => {
|
||||
if (!this.popup.contains(e.target) && e.target !== this.swatch) {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setupDrag(element, handler) {
|
||||
const onMove = (e) => {
|
||||
e.preventDefault();
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||
handler(x, y);
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
// Click and drag behavior
|
||||
element.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
onMove(e);
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
|
||||
// Hover preview - color follows mouse pointer
|
||||
element.addEventListener('mousemove', (e) => {
|
||||
if (e.buttons === 0) { // Only on hover, not during drag
|
||||
const rect = element.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||
handler(x, y);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleSatBrightChange(x, y) {
|
||||
this.color.s = x * 100;
|
||||
this.color.v = (1 - y) * 100;
|
||||
this.updateFromHsv();
|
||||
}
|
||||
|
||||
handleHueChange(x) {
|
||||
this.color.h = x * 360;
|
||||
this.updateFromHsv();
|
||||
}
|
||||
|
||||
updateFromHsv() {
|
||||
this.hex = this.hsvToHex(this.color.h, this.color.s, this.color.v);
|
||||
this.updateUI();
|
||||
this.onChange(this.hex);
|
||||
}
|
||||
|
||||
updateUI() {
|
||||
this.swatch.style.background = this.hex;
|
||||
this.updateSatBrightBackground();
|
||||
this.satBrightPointer.style.left = `${this.color.s}%`;
|
||||
this.satBrightPointer.style.top = `${100 - this.color.v}%`;
|
||||
this.huePointer.style.left = `${(this.color.h / 360) * 100}%`;
|
||||
this.hexInput.value = this.hex.toUpperCase();
|
||||
}
|
||||
|
||||
updateSatBrightBackground() {
|
||||
const hueColor = this.hsvToHex(this.color.h, 100, 100);
|
||||
this.satBright.style.background = `
|
||||
linear-gradient(to top, #000, transparent),
|
||||
linear-gradient(to right, #fff, ${hueColor})
|
||||
`;
|
||||
}
|
||||
|
||||
setHex(hex) {
|
||||
if (!/^#?[0-9A-Fa-f]{6}$/.test(hex)) return;
|
||||
if (!hex.startsWith('#')) hex = '#' + hex;
|
||||
this.hex = hex;
|
||||
this.color = this.hexToHsv(hex);
|
||||
this.updateUI();
|
||||
this.onChange(this.hex);
|
||||
}
|
||||
|
||||
toggle() { this.isOpen ? this.close() : this.open(); }
|
||||
|
||||
open() {
|
||||
this.isOpen = true;
|
||||
this.popup.style.display = 'block';
|
||||
this.updateUI();
|
||||
setTimeout(() => document.addEventListener('click', this.closeHandler), 0);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.popup.style.display = 'none';
|
||||
document.removeEventListener('click', this.closeHandler);
|
||||
}
|
||||
|
||||
getElement() {
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'position: relative; display: inline-block;';
|
||||
container.appendChild(this.swatch);
|
||||
container.appendChild(this.popup);
|
||||
return container;
|
||||
}
|
||||
|
||||
hexToHsv(hex) {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
const v = max * 100, d = max - min;
|
||||
const s = max === 0 ? 0 : (d / max) * 100;
|
||||
let h = 0;
|
||||
if (d !== 0) {
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) * 60; break;
|
||||
case g: h = ((b - r) / d + 2) * 60; break;
|
||||
case b: h = ((r - g) / d + 4) * 60; break;
|
||||
}
|
||||
}
|
||||
return { h, s, v };
|
||||
}
|
||||
|
||||
hsvToHex(h, s, v) {
|
||||
s /= 100; v /= 100;
|
||||
const c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c;
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (h < 60) { r = c; g = x; }
|
||||
else if (h < 120) { r = x; g = c; }
|
||||
else if (h < 180) { g = c; b = x; }
|
||||
else if (h < 240) { g = x; b = c; }
|
||||
else if (h < 300) { r = x; b = c; }
|
||||
else { r = c; b = x; }
|
||||
const toHex = (n) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global Timer Manager
|
||||
*/
|
||||
const KikoGlobalTimer = {
|
||||
startTime: 0,
|
||||
intervalId: null,
|
||||
isRunning: false,
|
||||
activeNodes: new Set(),
|
||||
|
||||
formatTime(ms) {
|
||||
if (ms < 0) ms = 0;
|
||||
const minutes = String(Math.floor(ms / 60000)).padStart(2, '0');
|
||||
const seconds = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0');
|
||||
const milliseconds = String(ms % 1000).padStart(3, '0');
|
||||
return `${minutes}:${seconds}:${milliseconds}`;
|
||||
},
|
||||
|
||||
getTimerColor() {
|
||||
return app.ui.settings.getSettingValue("kikotools.workflow_timer.color") || '#ff9966';
|
||||
},
|
||||
|
||||
isGlowEnabled() {
|
||||
const val = app.ui.settings.getSettingValue("kikotools.workflow_timer.glow");
|
||||
return val !== undefined ? val : true;
|
||||
},
|
||||
|
||||
start() {
|
||||
if (this.isRunning) return;
|
||||
this.isRunning = true;
|
||||
this.startTime = Date.now();
|
||||
|
||||
const timerColor = this.getTimerColor();
|
||||
this.activeNodes.forEach(node => {
|
||||
if (node.timerDisplay) {
|
||||
node.timerDisplay.style.color = timerColor;
|
||||
node.timerDisplay.classList.add('running');
|
||||
}
|
||||
});
|
||||
|
||||
this.intervalId = setInterval(() => {
|
||||
const elapsed = Date.now() - this.startTime;
|
||||
const timeString = this.formatTime(elapsed);
|
||||
this.activeNodes.forEach(node => {
|
||||
if (node.timerDisplay) {
|
||||
node.timerDisplay.textContent = timeString;
|
||||
}
|
||||
});
|
||||
}, 33);
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (!this.isRunning) return;
|
||||
this.isRunning = false;
|
||||
clearInterval(this.intervalId);
|
||||
|
||||
const finalTime = Date.now() - this.startTime;
|
||||
const finalTimeString = this.formatTime(finalTime);
|
||||
const timerColor = this.getTimerColor();
|
||||
|
||||
this.activeNodes.forEach(node => {
|
||||
if (node.timerDisplay) {
|
||||
node.timerDisplay.textContent = finalTimeString;
|
||||
node.timerDisplay.style.color = timerColor;
|
||||
node.timerDisplay.classList.remove('running');
|
||||
}
|
||||
node.properties.elapsed_time_str = finalTimeString;
|
||||
});
|
||||
},
|
||||
|
||||
updateColor(color) {
|
||||
if (this.isRunning) return;
|
||||
this.activeNodes.forEach(node => {
|
||||
if (node.timerDisplay) {
|
||||
node.timerDisplay.style.color = color;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
updateGlow(enabled) {
|
||||
this.activeNodes.forEach(node => {
|
||||
if (node.timerDisplay) {
|
||||
node.timerDisplay.classList.toggle('no-glow', !enabled);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
registerNode(node) { this.activeNodes.add(node); },
|
||||
unregisterNode(node) { this.activeNodes.delete(node); },
|
||||
};
|
||||
|
||||
/**
|
||||
* ComfyUI Extension for KikoWorkflow Timer
|
||||
*/
|
||||
const KikoWorkflowTimerExtension = {
|
||||
name: "ComfyAssets.KikoWorkflowTimer",
|
||||
const clock = new WorkflowClock(app.ui.settings);
|
||||
let colorPicker;
|
||||
|
||||
app.registerExtension({
|
||||
name: 'ComfyAssets.KikoWorkflowTimer',
|
||||
async init() {
|
||||
// Color picker setting with custom render
|
||||
app.ui.settings.addSetting({
|
||||
id: "kikotools.workflow_timer.color",
|
||||
name: "🫶 Workflow Timer: Color",
|
||||
id: 'kikotools.workflow_timer.color',
|
||||
name: '🫶 Workflow Timer: Color',
|
||||
type: (name, setter, value) => {
|
||||
const container = $el("div", {
|
||||
style: { display: "flex", alignItems: "center", gap: "10px" }
|
||||
});
|
||||
|
||||
const picker = new SketchColorPicker(value || '#ff9966', (newColor) => {
|
||||
setter(newColor);
|
||||
KikoGlobalTimer.updateColor(newColor);
|
||||
});
|
||||
|
||||
container.appendChild(picker.getElement());
|
||||
|
||||
// Also show hex value as text
|
||||
const hexDisplay = $el("span", {
|
||||
textContent: value || '#ff9966',
|
||||
style: { color: "#888", fontFamily: "monospace", fontSize: "12px" }
|
||||
});
|
||||
container.appendChild(hexDisplay);
|
||||
|
||||
// Update hex display when color changes
|
||||
const origOnChange = picker.onChange;
|
||||
picker.onChange = (newColor) => {
|
||||
origOnChange(newColor);
|
||||
hexDisplay.textContent = newColor.toUpperCase();
|
||||
};
|
||||
|
||||
return container;
|
||||
if (!colorPicker || colorPicker.scope?.signal.aborted) {
|
||||
colorPicker = createColorPicker(value, setter, app.ui.settings, 'Timer color');
|
||||
}
|
||||
colorPicker.onChange = setter;
|
||||
return colorPicker;
|
||||
},
|
||||
defaultValue: "#ff9966",
|
||||
tooltip: "Color of the timer display when not running",
|
||||
defaultValue: '#0877bb',
|
||||
tooltip: 'Color of the workflow timer display',
|
||||
onChange: () => clock.refresh(),
|
||||
});
|
||||
|
||||
// Glow toggle
|
||||
app.ui.settings.addSetting({
|
||||
id: "kikotools.workflow_timer.glow",
|
||||
name: "🫶 Workflow Timer: Enable Glow",
|
||||
type: "boolean",
|
||||
id: 'kikotools.workflow_timer.glow',
|
||||
name: '🫶 Workflow Timer: Enable Glow',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
tooltip: "Enable pulsing glow effect on the timer",
|
||||
onChange: (value) => {
|
||||
KikoGlobalTimer.updateGlow(value);
|
||||
},
|
||||
tooltip: 'Enable the pulsing glow unless reduced motion is preferred',
|
||||
onChange: () => clock.refresh(),
|
||||
});
|
||||
},
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === "KikoWorkflowTimer") {
|
||||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
origOnNodeCreated?.apply(this, arguments);
|
||||
|
||||
this.bgcolor = "#1a1a2e";
|
||||
this.color = "#16213e";
|
||||
this.title = "Workflow Timer";
|
||||
this.properties = this.properties || {};
|
||||
this.size = [300, 100];
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "kiko-timer-container";
|
||||
|
||||
this.timerDisplay = document.createElement("div");
|
||||
this.timerDisplay.className = "kiko-timer-display";
|
||||
this.timerDisplay.textContent = this.properties.elapsed_time_str || "00:00:000";
|
||||
|
||||
const timerColor = KikoGlobalTimer.getTimerColor();
|
||||
this.timerDisplay.style.color = timerColor;
|
||||
|
||||
if (!KikoGlobalTimer.isGlowEnabled()) {
|
||||
this.timerDisplay.classList.add('no-glow');
|
||||
}
|
||||
|
||||
container.appendChild(this.timerDisplay);
|
||||
this.addDOMWidget("kikoTimer", "Kiko Timer", container, { serialize: false });
|
||||
|
||||
KikoGlobalTimer.registerNode(this);
|
||||
};
|
||||
|
||||
const origOnRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function() {
|
||||
KikoGlobalTimer.unregisterNode(this);
|
||||
return origOnRemoved?.apply(this, arguments);
|
||||
};
|
||||
|
||||
const origOnSerialize = nodeType.prototype.onSerialize;
|
||||
nodeType.prototype.onSerialize = function(o) {
|
||||
origOnSerialize?.apply(this, arguments);
|
||||
o.properties = this.properties;
|
||||
};
|
||||
|
||||
const origOnConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function(info) {
|
||||
origOnConfigure?.apply(this, arguments);
|
||||
this.properties = { ...(this.properties || {}), ...(info?.properties || {}) };
|
||||
|
||||
if (this.timerDisplay) {
|
||||
this.timerDisplay.textContent = this.properties.elapsed_time_str || "00:00:000";
|
||||
const timerColor = KikoGlobalTimer.getTimerColor();
|
||||
this.timerDisplay.style.color = timerColor;
|
||||
|
||||
if (!KikoGlobalTimer.isGlowEnabled()) {
|
||||
this.timerDisplay.classList.add('no-glow');
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
if (nodeData.name !== 'KikoWorkflowTimer') return;
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const result = onNodeCreated?.apply(this, arguments);
|
||||
this.timerScope = createScope();
|
||||
this.properties = this.properties || {};
|
||||
const container = document.createElement('div');
|
||||
container.className = 'ca-tools ca-workflow-timer kiko-timer-container';
|
||||
const header = document.createElement('header');
|
||||
header.className = 'ca-timer-header';
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = 'Workflow Timer';
|
||||
const brand = document.createElement('span');
|
||||
brand.textContent = 'ComfyAssets';
|
||||
header.append(title, brand);
|
||||
this.timerDisplay = document.createElement('div');
|
||||
this.timerDisplay.className = 'kiko-timer-display';
|
||||
this.timerDisplay.setAttribute('role', 'timer');
|
||||
this.timerDisplay.setAttribute('aria-label', 'Elapsed workflow time');
|
||||
this.timerDisplay.setAttribute('aria-live', 'off');
|
||||
container.append(header, this.timerDisplay);
|
||||
mountTheme(container, app.ui.settings, this.timerScope);
|
||||
this.addDOMWidget('kikoTimer', 'Kiko Timer', container, {
|
||||
serialize: false, hideOnZoom: false, getMinHeight: () => 90,
|
||||
});
|
||||
this.setSize([340, 180]);
|
||||
clock.registerNode(this);
|
||||
this.timerScope.cleanup(() => {
|
||||
clock.unregisterNode(this);
|
||||
container.remove();
|
||||
});
|
||||
return result;
|
||||
};
|
||||
const onRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function () {
|
||||
this.timerScope.dispose();
|
||||
return onRemoved?.apply(this, arguments);
|
||||
};
|
||||
const onSerialize = nodeType.prototype.onSerialize;
|
||||
nodeType.prototype.onSerialize = function (data) {
|
||||
const result = onSerialize?.apply(this, arguments);
|
||||
data.properties = { ...data.properties, elapsed_time_str: this.properties.elapsed_time_str };
|
||||
return result;
|
||||
};
|
||||
const onConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function (data) {
|
||||
const result = onConfigure?.apply(this, arguments);
|
||||
this.properties = { ...(this.properties || {}), ...(data?.properties || {}) };
|
||||
if (this.timerDisplay) clock.renderNode(this);
|
||||
return result;
|
||||
};
|
||||
},
|
||||
|
||||
setup() {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
@keyframes kiko-timer-pulse {
|
||||
0%, 100% { text-shadow: 0 0 15px currentColor; opacity: 0.9; }
|
||||
50% { text-shadow: 0 0 25px currentColor; opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes kiko-timer-running-pulse {
|
||||
0%, 100% { text-shadow: 0 0 15px currentColor; opacity: 0.9; }
|
||||
50% { text-shadow: 0 0 25px currentColor; opacity: 1; }
|
||||
}
|
||||
|
||||
.kiko-timer-container {
|
||||
width: 100%; height: 100%;
|
||||
position: relative; display: flex;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
.kiko-timer-display {
|
||||
text-align: center; width: 100%; height: 100%;
|
||||
position: absolute; top: 0; left: 0;
|
||||
background: transparent; border: none;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', monospace;
|
||||
box-sizing: border-box; outline: none; margin: 0;
|
||||
overflow: hidden; display: flex;
|
||||
justify-content: center; align-items: center;
|
||||
font-size: 42px;
|
||||
animation: kiko-timer-pulse 4s infinite ease-in-out;
|
||||
transition: color 0.3s ease-in-out;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap; font-weight: 600;
|
||||
}
|
||||
|
||||
.kiko-timer-display.no-glow {
|
||||
animation: none;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.kiko-timer-display.running {
|
||||
animation: kiko-timer-running-pulse 2s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.kiko-timer-display.running.no-glow {
|
||||
animation: none;
|
||||
text-shadow: none;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
api.addEventListener("execution_start", () => KikoGlobalTimer.start());
|
||||
api.addEventListener("executing", ({ detail }) => {
|
||||
if (detail === null) KikoGlobalTimer.stop();
|
||||
});
|
||||
api.addEventListener("execution_error", () => KikoGlobalTimer.stop());
|
||||
api.addEventListener("execution_interrupted", () => KikoGlobalTimer.stop());
|
||||
}
|
||||
};
|
||||
|
||||
app.registerExtension(KikoWorkflowTimerExtension);
|
||||
api.addEventListener('execution_start', () => clock.start());
|
||||
api.addEventListener('executing', ({ detail }) => { if (detail === null) clock.stop(); });
|
||||
for (const event of ['execution_error', 'execution_interrupted']) api.addEventListener(event, () => clock.stop());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -60,3 +60,84 @@ ignored. Browser storage failures leave in-memory/workflow history usable.
|
||||
New nodes start at 320 × 420; saved workflows retain their node dimensions.
|
||||
Node removal disposes theme observers, event listeners, polling and setup/hide
|
||||
timers through `createScope()`.
|
||||
|
||||
## Workflow Timer and color controls
|
||||
|
||||
Workflow Timer uses the shared shell and theme without overriding node title or
|
||||
background colors. New nodes start at 340 × 180; saved dimensions and the
|
||||
`elapsed_time_str` property are restored with the workflow. Serialization saves
|
||||
a plain snapshot instead of a reference to live node properties.
|
||||
|
||||
`workflow-clock.js` coordinates one polling interval for all visible timer
|
||||
nodes. New timers join the current execution; the interval stops when the last
|
||||
node is removed. Completion, interruption and errors freeze the same elapsed
|
||||
time across nodes. Color/glow changes apply immediately during execution.
|
||||
|
||||
The existing `kikotools.workflow_timer.color` and `.glow` settings are retained.
|
||||
Configured colors are preserved; the default is shared blue. Glow also respects
|
||||
the system's reduced-motion preference.
|
||||
|
||||
`createColorPicker()` provides native Hue/Saturation/Brightness sliders, validated
|
||||
six-digit hex input and keyboard-operable presets inside a native modal dialog.
|
||||
Changes apply immediately on input; hovering does not change the saved color.
|
||||
Done or Escape closes the picker and returns focus to its trigger. Disconnecting
|
||||
the settings control closes the dialog and disposes its listeners/theme scope.
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createButton, createField, createScope, createSlider, mountTheme } from './core.js';
|
||||
import { hexToHsv, hsvToHex, normalizeHex } from './color.js';
|
||||
|
||||
let nextPickerId = 0;
|
||||
const presets = ['#0877BB', '#D0021B', '#F5A623', '#F8E71C', '#8B572A', '#7ED321',
|
||||
'#417505', '#BD10E0', '#9013FE', '#4A90D9', '#50E3C2', '#B8E986', '#000000', '#4A4A4A', '#9B9B9B', '#FFFFFF'];
|
||||
|
||||
class ColorPicker extends HTMLElement {
|
||||
connectedCallback() {
|
||||
if (this.scope && !this.scope.signal.aborted) return;
|
||||
this.scope = createScope();
|
||||
this.classList.add('ca-tools', 'ca-color-picker');
|
||||
mountTheme(this, this.settings, this.scope);
|
||||
const id = `ca-color-${++nextPickerId}`;
|
||||
this.trigger = createButton(`Choose ${this.label.toLowerCase()}`, 'ca-color-trigger');
|
||||
const swatch = document.createElement('span');
|
||||
swatch.className = 'ca-color-swatch';
|
||||
swatch.setAttribute('aria-hidden', 'true');
|
||||
this.trigger.prepend(swatch);
|
||||
const value = document.createElement('span');
|
||||
value.className = 'ca-color-value';
|
||||
this.dialog = document.createElement('dialog');
|
||||
this.dialog.className = 'ca-tools ca-color-dialog';
|
||||
mountTheme(this.dialog, this.settings, this.scope);
|
||||
this.dialog.setAttribute('aria-labelledby', `${id}-title`);
|
||||
const heading = document.createElement('h2');
|
||||
heading.id = `${id}-title`;
|
||||
heading.textContent = this.label;
|
||||
const hint = document.createElement('p');
|
||||
hint.textContent = 'Changes apply immediately.';
|
||||
hint.className = 'ca-color-hint';
|
||||
this.hexInput = document.createElement('input');
|
||||
Object.assign(this.hexInput, { type: 'text', maxLength: 7, autocomplete: 'off', spellcheck: false, pattern: '#?[0-9a-fA-F]{6}' });
|
||||
this.hexInput.setAttribute('aria-invalid', 'false');
|
||||
this.error = document.createElement('p');
|
||||
this.error.id = `${id}-error`;
|
||||
this.error.className = 'ca-color-error';
|
||||
this.error.setAttribute('role', 'alert');
|
||||
this.hexInput.setAttribute('aria-describedby', this.error.id);
|
||||
this.dialog.append(heading, hint, createField('Hex color', this.hexInput, `${id}-hex`), this.error);
|
||||
this.sliders = [];
|
||||
for (const [key, label, max, unit] of [['h', 'Hue', 360, 'degrees'], ['s', 'Saturation', 100, '%'], ['v', 'Brightness', 100, '%']]) {
|
||||
const field = createSlider(label, { id: `${id}-${key}`, min: 0, max, step: 1, value: 0, unit }, this.scope, next => {
|
||||
this.hsv[key] = next;
|
||||
this.hex = hsvToHex(this.hsv.h, this.hsv.s, this.hsv.v);
|
||||
this.sync();
|
||||
this.onChange(this.hex);
|
||||
});
|
||||
this.sliders.push({ field, key, unit });
|
||||
this.dialog.append(field);
|
||||
}
|
||||
const palette = document.createElement('div');
|
||||
palette.className = 'ca-color-presets';
|
||||
for (const color of presets) {
|
||||
const button = createButton('', 'ca-color-preset');
|
||||
button.setAttribute('aria-label', `Use color ${color}`);
|
||||
button.style.setProperty('--ca-picked-color', color);
|
||||
button.dataset.color = color.toLowerCase();
|
||||
this.scope.listen(button, 'click', () => this.applyHex(color));
|
||||
palette.append(button);
|
||||
}
|
||||
const done = createButton('Done', 'ca-primary');
|
||||
done.classList.add('ca-color-done');
|
||||
this.dialog.append(palette, done);
|
||||
this.replaceChildren(this.trigger, value);
|
||||
document.body.append(this.dialog);
|
||||
this.valueDisplay = value;
|
||||
this.scope.listen(this.trigger, 'click', () => this.dialog.showModal());
|
||||
this.scope.listen(done, 'click', () => this.dialog.close());
|
||||
this.scope.listen(this.dialog, 'keydown', event => event.stopPropagation());
|
||||
this.scope.listen(this.dialog, 'close', () => { if (this.isConnected) this.trigger.focus(); });
|
||||
this.scope.listen(this.hexInput, 'change', () => this.applyHex(this.hexInput.value));
|
||||
this.scope.cleanup(() => {
|
||||
if (this.dialog.open) this.dialog.close();
|
||||
this.dialog.remove();
|
||||
this.replaceChildren();
|
||||
});
|
||||
this.sync();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
const scope = this.scope;
|
||||
queueMicrotask(() => { if (!this.isConnected) scope.dispose(); });
|
||||
}
|
||||
|
||||
applyHex(value) {
|
||||
const hex = normalizeHex(value);
|
||||
if (!hex) {
|
||||
this.hexInput.setAttribute('aria-invalid', 'true');
|
||||
this.error.textContent = 'Use six hexadecimal digits, for example #0877BB.';
|
||||
return;
|
||||
}
|
||||
this.hex = hex;
|
||||
this.hsv = hexToHsv(hex);
|
||||
this.sync();
|
||||
this.onChange(hex);
|
||||
}
|
||||
|
||||
sync() {
|
||||
this.style.setProperty('--ca-picked-color', this.hex);
|
||||
this.hexInput.value = this.hex.toUpperCase();
|
||||
this.hexInput.setAttribute('aria-invalid', 'false');
|
||||
this.error.textContent = '';
|
||||
this.valueDisplay.textContent = this.hex.toUpperCase();
|
||||
for (const { field, key, unit } of this.sliders) {
|
||||
const value = Math.round(this.hsv[key]);
|
||||
const input = field.querySelector('input');
|
||||
input.value = value;
|
||||
input.setAttribute('aria-valuetext', `${value} ${unit}`);
|
||||
field.querySelector('output').value = key === 'h' ? `${value}°` : `${value} %`;
|
||||
}
|
||||
this.dialog.querySelectorAll('button[data-color]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.color === this.hex)));
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('ca-color-picker')) customElements.define('ca-color-picker', ColorPicker);
|
||||
|
||||
export function createColorPicker(value, onChange, settings, label = 'Color') {
|
||||
const picker = document.createElement('ca-color-picker');
|
||||
picker.hex = normalizeHex(value) || '#0877bb';
|
||||
picker.hsv = hexToHsv(picker.hex);
|
||||
picker.onChange = onChange;
|
||||
picker.settings = settings;
|
||||
picker.label = label;
|
||||
return picker;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function normalizeHex(value) {
|
||||
return typeof value === 'string' && /^#?[\da-f]{6}$/i.test(value)
|
||||
? `#${value.replace(/^#/, '').toLowerCase()}` : null;
|
||||
}
|
||||
|
||||
export function hexToHsv(hex) {
|
||||
const [r, g, b] = hex.slice(1).match(/../g).map(value => parseInt(value, 16) / 255);
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
const delta = max - min;
|
||||
let h = 0;
|
||||
if (delta) {
|
||||
if (max === r) h = (g - b) / delta + (g < b ? 6 : 0);
|
||||
else if (max === g) h = (b - r) / delta + 2;
|
||||
else h = (r - g) / delta + 4;
|
||||
}
|
||||
return { h: h * 60, s: max ? delta / max * 100 : 0, v: max * 100 };
|
||||
}
|
||||
|
||||
export function hsvToHex(h, s, v) {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
s = Math.max(0, Math.min(100, s)) / 100;
|
||||
v = Math.max(0, Math.min(100, v)) / 100;
|
||||
const c = v * s, x = c * (1 - Math.abs((h / 60) % 2 - 1)), m = v - c;
|
||||
const channels = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x]
|
||||
: h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
|
||||
return '#' + channels.map(value => Math.round((value + m) * 255).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
@@ -133,8 +133,71 @@
|
||||
.ca-seed-empty { margin: 0; padding: 12px 4px; color: var(--ca-muted); font-size: 12px; text-align: center; }
|
||||
.ca-seed-status { min-block-size: 20px; font-size: 11px; overflow-wrap: anywhere; color: var(--ca-muted); flex-shrink: 0; }
|
||||
.ca-seed-status[data-kind="error"] { color: var(--ca-error-text); background: var(--ca-error-bg); }
|
||||
.ca-workflow-timer { box-sizing: border-box; inline-size: 100%; block-size: 100%; min-block-size: 90px; padding: 10px; border: 1px solid var(--ca-line); border-radius: 6px; display: flex; flex-direction: column; overflow: hidden; container-type: inline-size; contain: size; }
|
||||
.ca-timer-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; border-block-end: 1px solid var(--ca-line); padding-block-end: 6px; flex-shrink: 0; }
|
||||
.ca-timer-header h3 { font-size: 13px; font-weight: 550; margin: 0; }
|
||||
.ca-timer-header > span { color: var(--ca-muted); font-size: 10px; }
|
||||
.ca-workflow-timer .kiko-timer-display { flex: 1; min-block-size: 0; display: flex; align-items: center; justify-content: center; color: var(--ca-timer-color, var(--ca-accent)); font: 600 clamp(16px, 10cqi, 64px)/1.3 ui-monospace, monospace; font-variant-numeric: tabular-nums; white-space: nowrap; animation: ca-timer-glow 4s ease-in-out infinite; }
|
||||
.ca-workflow-timer .kiko-timer-display.running { animation-duration: 2s; }
|
||||
.ca-workflow-timer .kiko-timer-display.no-glow { animation: none; text-shadow: none; }
|
||||
@keyframes ca-timer-glow { 0%, 100% { text-shadow: 0 0 8px currentColor; } 50% { text-shadow: 0 0 16px currentColor; } }
|
||||
@media (prefers-reduced-motion: reduce) { .ca-workflow-timer .kiko-timer-display { animation: none; text-shadow: none; } }
|
||||
.ca-color-picker { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.ca-color-value { font-size: 12px; color: var(--ca-muted); font-family: ui-monospace, monospace; }
|
||||
.ca-color-swatch { inline-size: 24px; block-size: 20px; border: 1px solid var(--ca-border); border-radius: 3px; background: var(--ca-picked-color); }
|
||||
.ca-color-dialog { box-sizing: border-box; inline-size: 360px; max-inline-size: calc(100dvw - 32px); max-block-size: calc(100dvh - 32px); overflow: auto; padding: 16px; border: 1px solid var(--ca-border); border-radius: 8px; background: var(--ca-surface); color: var(--ca-text); box-shadow: 0 8px 28px #00000040; }
|
||||
.ca-color-dialog[open] { display: flex; flex-direction: column; gap: 12px; }
|
||||
.ca-color-dialog::backdrop { background: #0008; }
|
||||
.ca-color-dialog h2 { margin: 0; font-size: 16px; }
|
||||
.ca-color-dialog :where(.ca-color-hint, .ca-color-error) { margin: 0; font-size: 12px; }
|
||||
.ca-color-hint { color: var(--ca-muted); }
|
||||
.ca-color-error { color: var(--ca-error-text); }
|
||||
.ca-color-error:empty { display: none; }
|
||||
.ca-color-dialog .ca-field { justify-content: space-between; }
|
||||
.ca-color-dialog input[type="text"] { inline-size: 130px; font-family: ui-monospace, monospace; }
|
||||
.ca-color-dialog input[aria-invalid="true"] { border-color: var(--ca-error); }
|
||||
.ca-color-dialog .ca-range { display: grid; grid-template-columns: 75px minmax(0, 1fr) 65px; gap: 8px; align-items: center; }
|
||||
.ca-color-dialog .ca-range :where(label, output) { font-size: 11px; color: var(--ca-muted); }
|
||||
.ca-color-dialog .ca-range input[type="range"] { inline-size: 100%; padding: 0; border: 0; background: transparent; }
|
||||
.ca-color-presets { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 6px; padding: 3px; }
|
||||
.ca-color-dialog button.ca-color-preset { min-inline-size: 24px; min-block-size: 30px; padding: 0; background: var(--ca-picked-color); border-color: var(--ca-border); }
|
||||
.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); }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { normalizeHex } from './color.js';
|
||||
|
||||
export function formatElapsed(ms) {
|
||||
ms = Number.isFinite(ms) ? Math.max(0, Math.floor(ms)) : 0;
|
||||
return `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}:${String(ms % 1000).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
export class WorkflowClock {
|
||||
constructor(settings, environment = globalThis) {
|
||||
this.settings = settings;
|
||||
this.environment = environment;
|
||||
this.activeNodes = new Set();
|
||||
this.isRunning = false;
|
||||
this.startTime = 0;
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
getTimerColor() {
|
||||
return normalizeHex(this.settings.getSettingValue('kikotools.workflow_timer.color')) || '#0877bb';
|
||||
}
|
||||
|
||||
renderNode(node) {
|
||||
node.timerDisplay.textContent = this.isRunning
|
||||
? formatElapsed(this.environment.Date.now() - this.startTime)
|
||||
: node.properties.elapsed_time_str || '00:00:000';
|
||||
node.timerDisplay.style.setProperty('--ca-timer-color', this.getTimerColor());
|
||||
node.timerDisplay.classList.toggle('running', this.isRunning);
|
||||
node.timerDisplay.classList.toggle('no-glow', this.settings.getSettingValue('kikotools.workflow_timer.glow') === false);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.activeNodes.forEach(node => this.renderNode(node));
|
||||
}
|
||||
|
||||
ensureInterval() {
|
||||
if (!this.isRunning || !this.activeNodes.size || this.intervalId !== null) return;
|
||||
this.intervalId = this.environment.setInterval(() => {
|
||||
const elapsed = formatElapsed(this.environment.Date.now() - this.startTime);
|
||||
this.activeNodes.forEach(node => { node.timerDisplay.textContent = elapsed; });
|
||||
}, 33);
|
||||
}
|
||||
|
||||
clearInterval() {
|
||||
this.environment.clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.isRunning) return;
|
||||
this.isRunning = true;
|
||||
this.startTime = this.environment.Date.now();
|
||||
this.refresh();
|
||||
this.ensureInterval();
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.isRunning) return;
|
||||
this.isRunning = false;
|
||||
this.clearInterval();
|
||||
const elapsed = formatElapsed(this.environment.Date.now() - this.startTime);
|
||||
this.activeNodes.forEach(node => { node.properties.elapsed_time_str = elapsed; });
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
registerNode(node) {
|
||||
this.activeNodes.add(node);
|
||||
this.refresh();
|
||||
this.ensureInterval();
|
||||
}
|
||||
|
||||
unregisterNode(node) {
|
||||
this.activeNodes.delete(node);
|
||||
if (!this.activeNodes.size) this.clearInterval();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.';
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||