feat(ui): migrate Node Dimensions to shared dialog controls
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
@@ -870,3 +870,120 @@ test('gallery directory autocomplete handles keyboard selection escape and navig
|
||||
await input.press('Enter');
|
||||
await expect(page.getByAltText('forest.png', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
async function openDimensions(page) {
|
||||
await page.evaluate(() => {
|
||||
const node = kikoHost.app.graph._nodes.at(-1);
|
||||
node.computeSize = () => [180, 120];
|
||||
node.setSize = size => { node.size = size; node.applied = (node.applied || 0) + 1; };
|
||||
const opener = document.createElement('button');
|
||||
opener.textContent = 'Edit dimensions';
|
||||
opener.onclick = () => kikoHost.extensions.find(item => item.name === 'ComfyAssets.Extensions').getNodeMenuItems(node)[1].callback();
|
||||
document.body.append(opener);
|
||||
});
|
||||
await page.getByRole('button', { name: 'Edit dimensions', exact: true }).click();
|
||||
return page.getByRole('dialog', { name: 'Node Dimensions', exact: true });
|
||||
}
|
||||
|
||||
test('Node Dimensions validates, applies once and enforces content minimums', async ({ page }) => {
|
||||
await openHost(page);
|
||||
await createNode(page, 'WidthHeightSelector');
|
||||
const dialog = await openDimensions(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Edit dimensions', exact: true }).evaluate(element => element.focus());
|
||||
await expect(dialog.getByLabel('Width (px)', { exact: true })).toBeFocused();
|
||||
for (const value of ['', '-1', '1.5']) {
|
||||
await dialog.getByLabel('Width (px)', { exact: true }).fill(value);
|
||||
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(dialog.getByText('Enter positive whole numbers for width and height.')).toBeVisible();
|
||||
expect(await page.evaluate(() => kikoHost.app.graph._nodes.at(-1).applied || 0)).toBe(0);
|
||||
}
|
||||
await dialog.getByLabel('Width (px)', { exact: true }).fill('1');
|
||||
await dialog.getByLabel('Height (px)', { exact: true }).fill('10');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(await page.evaluate(() => ({ size: kikoHost.app.graph._nodes.at(-1).size, applied: kikoHost.app.graph._nodes.at(-1).applied }))).toEqual({ size: [180, 120], applied: 1 });
|
||||
await expect(page.getByRole('button', { name: 'Edit dimensions', exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
test('Node Dimensions follows themes, fits narrow screens and cleans up on removal', async ({ page }) => {
|
||||
await openHost(page);
|
||||
await createNode(page, 'WidthHeightSelector');
|
||||
const dialog = await openDimensions(page);
|
||||
for (const [color, theme] of [['#fafafa', 'light'], ['#202020', 'dark']]) {
|
||||
await page.evaluate(color => document.body.style.setProperty('--bg-color', color), color);
|
||||
await expect(dialog).toHaveAttribute('data-ca-theme', theme);
|
||||
}
|
||||
await page.setViewportSize({ width: 320, height: 480 });
|
||||
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
|
||||
await page.evaluate(() => window.KikoToolsExtensions.showNodeDimensionsDialog(kikoHost.app.graph._nodes.at(-1)));
|
||||
await expect(dialog).toHaveCount(1);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Edit dimensions', exact: true }).click();
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Edit dimensions', exact: true }).click();
|
||||
await page.evaluate(() => kikoHost.app.graph._nodes.at(-1).onRemoved());
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(await page.evaluate(() => kikoHost.app.graph._nodes.at(-1).applied || 0)).toBe(0);
|
||||
});
|
||||
|
||||
test('legacy dialog helpers preserve values, cancellation and manual close', async ({ page }) => {
|
||||
await openHost(page);
|
||||
await page.evaluate(() => {
|
||||
window.dialogCalls = [];
|
||||
window.openLegacy = () => window.legacyDialog = window.KikoToolsExtensions.createDialog('<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);
|
||||
});
|
||||
|
||||
@@ -397,3 +397,71 @@ test('actual host preserves the widget through renderer changes, theme changes a
|
||||
});
|
||||
await expect(loader).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Node Dimensions applies and persists across Classic and Nodes 2.0', async ({ page }, testInfo) => {
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = LiteGraph.createNode('WidthHeightSelector');
|
||||
app.graph.add(node);
|
||||
node.pos = [300, 250];
|
||||
app.canvas.centerOnNode(node);
|
||||
});
|
||||
const dialog = page.getByRole('dialog', { name: 'Node Dimensions', exact: true });
|
||||
for (const renderer of [false, true, false]) {
|
||||
await page.evaluate(async renderer => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
}, renderer);
|
||||
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
|
||||
const point = await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const node = app.graph._nodes[0];
|
||||
const canvas = app.canvas.canvas.getBoundingClientRect();
|
||||
return { x: canvas.left + (node.pos[0] + app.canvas.ds.offset[0] + 60) * app.canvas.ds.scale, y: canvas.top + (node.pos[1] + app.canvas.ds.offset[1] - 15) * app.canvas.ds.scale };
|
||||
});
|
||||
await page.mouse.click(point.x, point.y, { button: 'right' });
|
||||
await page.getByText('🎨 Node Dimensions', { exact: true }).click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByLabel('Width (px)', { exact: true }).fill('900');
|
||||
await dialog.getByLabel('Height (px)', { exact: true }).fill('600');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return Array.from(app.graph._nodes[0].size);
|
||||
})).toEqual([900, 600]);
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
window.KikoToolsExtensions.showNodeDimensionsDialog(app.graph._nodes[0]);
|
||||
});
|
||||
await dialog.getByLabel('Width (px)', { exact: true }).fill('1200');
|
||||
await page.keyboard.press('Escape');
|
||||
expect(await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
return Array.from(app.graph._nodes[0].size);
|
||||
})).toEqual([900, 600]);
|
||||
}
|
||||
const size = await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
const workflow = app.graph.serialize();
|
||||
await app.loadGraphData(workflow);
|
||||
app.canvas.centerOnNode(app.graph._nodes[0]);
|
||||
window.KikoToolsExtensions.showNodeDimensionsDialog(app.graph._nodes[0]);
|
||||
return Array.from(app.graph._nodes[0].size);
|
||||
});
|
||||
expect(size).toEqual([900, 600]);
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.evaluate(async theme => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
|
||||
}, theme);
|
||||
await expect(dialog).toHaveAttribute('data-ca-theme', theme);
|
||||
await page.screenshot({ path: testInfo.outputPath(`node-dimensions-${theme}.png`) });
|
||||
}
|
||||
await page.evaluate(async () => {
|
||||
const { app } = await import('/scripts/app.js');
|
||||
app.graph.remove(app.graph._nodes[0]);
|
||||
});
|
||||
await expect(dialog).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -60,10 +60,10 @@
|
||||
"statements": 98
|
||||
},
|
||||
"kikotools_extensions.js": {
|
||||
"lines": 12,
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 0,
|
||||
"statements": 12
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"seed_history_ui.js": {
|
||||
"lines": 95,
|
||||
@@ -89,6 +89,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"ui/dialog.js": {
|
||||
"lines": 98,
|
||||
"branches": 85,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"ui/dimension-swap.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
@@ -119,6 +125,12 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"views/node-dimensions.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"views/seed-history.js": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
|
||||
+12
-183
@@ -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;
|
||||
|
||||
@@ -103,3 +103,23 @@ 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.
|
||||
|
||||
@@ -171,3 +171,20 @@
|
||||
.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); } }
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user