feat(ui): migrate Node Dimensions to shared dialog controls

This commit is contained in:
Vito Sansevero
2026-10-16 13:00:36 -07:00
parent 1255c4e9cc
commit b5694ecfe5
11 changed files with 423 additions and 186 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+117
View File
@@ -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);
});
+68
View File
@@ -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);
});
+15
View File
@@ -0,0 +1,15 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveNodeSize } from '../../web/views/node-dimensions.js';
test('node dimensions accept whole pixels and respect content minimums', () => {
assert.deepEqual(resolveNodeSize(['640', '480'], [180, 120]), [640, 480]);
assert.deepEqual(resolveNodeSize(['1', '10'], [180, 120]), [180, 120]);
});
test('node dimensions reject missing, fractional, unsafe and nonpositive values', () => {
for (const value of ['', ' ', '0', '-1', '1.5', 'Infinity', 'NaN', '9007199254740992']) {
assert.equal(resolveNodeSize([value, '480'], [180, 120]), null, value);
assert.equal(resolveNodeSize(['640', value], [180, 120]), null, value);
}
});
+15 -3
View File
@@ -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
View File
@@ -1,197 +1,26 @@
import { app } from "../../scripts/app.js";
import { app } from '../../scripts/app.js';
import { createLegacyDialog } from './ui/dialog.js';
import { showNodeDimensionsDialog } from './views/node-dimensions.js';
/**
* KikoTools Extensions - Adds utility features to all ComfyAssets nodes
*/
app.registerExtension({
name: "ComfyAssets.Extensions",
// New API: return items to append to node right-click menu
name: 'ComfyAssets.Extensions',
getNodeMenuItems(node) {
// Only add our menu items to ComfyAssets nodes
if (!node.constructor.category || !node.constructor.category.includes("ComfyAssets")) {
return [];
}
return [
null, // separator
{
content: "🎨 Node Dimensions",
callback: () => {
KikoToolsExtensions.showNodeDimensionsDialog(node);
}
}
];
}
if (!node.constructor.category || !node.constructor.category.includes('ComfyAssets')) return [];
return [null, {
content: '🎨 Node Dimensions',
callback: () => KikoToolsExtensions.showNodeDimensionsDialog(node),
}];
},
});
/**
* KikoTools Extensions utilities
*/
class KikoToolsExtensions {
/**
* Create a dialog for settings
*/
static createDialog(htmlContent, onOK, onCancel) {
const dialog = document.createElement("div");
dialog.className = "kikotools-dialog";
dialog.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #202020;
border: 2px solid #444;
border-radius: 8px;
padding: 20px;
z-index: 10000;
font-family: Arial, sans-serif;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
`;
// innerHTML used here with hardcoded templates only (numeric node dimensions)
dialog.innerHTML = htmlContent;
// Create button container
const buttonContainer = document.createElement("div");
buttonContainer.style.cssText = `
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 15px;
`;
// Create OK button
const okButton = document.createElement("button");
okButton.textContent = "OK";
okButton.style.cssText = `
padding: 8px 20px;
background: #4A90E2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
`;
okButton.onmouseover = () => okButton.style.background = "#5BA0F2";
okButton.onmouseout = () => okButton.style.background = "#4A90E2";
// Create Cancel button
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.style.cssText = `
padding: 8px 20px;
background: #666;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
`;
cancelButton.onmouseover = () => cancelButton.style.background = "#777";
cancelButton.onmouseout = () => cancelButton.style.background = "#666";
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(okButton);
dialog.appendChild(buttonContainer);
// Dialog close function
dialog.close = function() {
if (dialog.parentNode) {
dialog.parentNode.removeChild(dialog);
}
};
// Get all inputs
const inputs = Array.from(dialog.querySelectorAll("input, select"));
// Handle keyboard events
inputs.forEach(input => {
input.addEventListener("keydown", function(e) {
if (e.keyCode === 27) { // ESC
onCancel && onCancel();
dialog.close();
} else if (e.keyCode === 13) { // Enter
onOK && onOK(dialog, inputs.map(input => input.value));
dialog.close();
}
e.stopPropagation();
});
});
// Button click handlers
okButton.onclick = () => {
onOK && onOK(dialog, inputs.map(input => input.value));
dialog.close();
};
cancelButton.onclick = () => {
onCancel && onCancel();
dialog.close();
};
// Add to document
document.body.appendChild(dialog);
// Focus first input
if (inputs.length > 0) {
inputs[0].focus();
inputs[0].select();
}
return dialog;
return createLegacyDialog(htmlContent, onOK, onCancel, app.ui.settings, app.canvas.canvas);
}
/**
* Show node dimensions dialog
*/
static showNodeDimensionsDialog(node) {
const nodeWidth = Math.round(node.size[0]);
const nodeHeight = Math.round(node.size[1]);
const htmlContent = `
<div style="color: #ddd; margin-bottom: 15px;">
<h3 style="margin: 0 0 15px 0; color: #4A90E2;">Node Dimensions</h3>
<div style="display: flex; gap: 20px; align-items: center;">
<div>
<label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">Width:</label>
<input type="number" class="width" value="${nodeWidth}"
style="width: 100px; padding: 5px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;">
</div>
<div>
<label style="display: block; margin-bottom: 5px; font-size: 12px; color: #aaa;">Height:</label>
<input type="number" class="height" value="${nodeHeight}"
style="width: 100px; padding: 5px; background: #333; color: white; border: 1px solid #555; border-radius: 4px;">
</div>
</div>
<div style="margin-top: 10px; font-size: 11px; color: #888;">
Tip: Minimum size will be enforced based on node content
</div>
</div>
`;
this.createDialog(
htmlContent,
function(dialog, values) {
const widthValue = Number(values[0]) || nodeWidth;
const heightValue = Number(values[1]) || nodeHeight;
// Calculate minimum size based on node content
const minSize = node.computeSize();
// Apply new size (respecting minimums)
node.setSize([
Math.max(minSize[0], widthValue),
Math.max(minSize[1], heightValue)
]);
// Mark canvas as dirty to trigger redraw
node.setDirtyCanvas(true, true);
},
null
);
return showNodeDimensionsDialog(node, app);
}
}
// Export for global access
window.KikoToolsExtensions = KikoToolsExtensions;
+20
View File
@@ -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.
+17
View File
@@ -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); } }
+85
View File
@@ -0,0 +1,85 @@
import { createScope, mountTheme, createButton } from './core.js';
let nextDialog = 0;
export function createDialog({ title, settings, fallbackFocus, onCancel }) {
const scope = createScope();
const element = document.createElement('dialog');
element.id = `ca-dialog-${++nextDialog}`;
element.className = 'ca-tools ca-dialog kikotools-dialog';
const header = document.createElement('header');
const heading = document.createElement('h2');
heading.id = `${element.id}-title`;
heading.textContent = title;
element.setAttribute('aria-labelledby', heading.id);
const brand = document.createElement('span');
brand.textContent = 'ComfyAssets';
header.append(heading, brand);
const body = document.createElement('div');
body.className = 'ca-dialog-body';
const actions = document.createElement('footer');
element.append(header, body, actions);
mountTheme(element, settings, scope);
let opener;
const nativeClose = element.close.bind(element);
const finish = reason => {
if (scope.signal.aborted) return;
scope.dispose();
if (opener?.isConnected && opener !== document.body) opener.focus();
else fallbackFocus?.focus();
if (reason === 'cancel') onCancel?.();
};
element.close = (reason = 'dismiss') => {
if (scope.signal.aborted) return;
nativeClose(reason);
finish(reason);
};
scope.cleanup(() => element.remove());
scope.listen(element, 'close', () => finish(element.returnValue));
scope.listen(element, 'cancel', event => {
event.preventDefault();
element.close('cancel');
});
scope.listen(element, 'keydown', event => event.stopPropagation());
scope.listen(element, 'keyup', event => event.stopPropagation());
return {
element, scope, body, actions,
close: element.close,
open(initialFocus) {
opener = document.activeElement;
document.body.append(element);
element.showModal();
initialFocus?.focus();
},
};
}
export function createLegacyDialog(htmlContent, onOK, onCancel, settings, fallbackFocus) {
const dialog = createDialog({ title: 'KikoTools', settings, fallbackFocus, onCancel });
const form = document.createElement('form');
form.id = `${dialog.element.id}-form`;
form.innerHTML = htmlContent;
dialog.body.append(form);
const cancel = createButton('Cancel');
const ok = createButton('OK', 'ca-primary');
ok.type = 'submit';
ok.setAttribute('form', form.id);
dialog.actions.append(cancel, ok);
dialog.scope.listen(cancel, 'click', () => dialog.close('cancel'));
dialog.scope.listen(form, 'keydown', event => {
if (event.key === 'Enter' && !event.isComposing && event.target.matches('select')) {
event.preventDefault();
event.stopPropagation();
form.requestSubmit();
}
});
dialog.scope.listen(form, 'submit', event => {
event.preventDefault();
onOK?.(dialog.element, [...form.querySelectorAll('input, select')].map(input => input.value));
dialog.close('apply');
});
const firstInput = form.querySelector('input, select');
dialog.open(firstInput);
if (firstInput?.tagName === 'INPUT') firstInput.select();
return dialog.element;
}
+74
View File
@@ -0,0 +1,74 @@
import { createButton, createField } from '../ui/core.js';
import { createDialog } from '../ui/dialog.js';
const activeDialogs = new WeakMap();
export function resolveNodeSize(values, minimum) {
const size = values.map(Number);
if (values.some(value => String(value).trim() === '') || size.some(value => !Number.isSafeInteger(value) || value <= 0)) return null;
return size.map((value, index) => Math.max(value, minimum[index]));
}
export function showNodeDimensionsDialog(node, app) {
const existing = activeDialogs.get(node);
if (existing) {
existing.querySelector('.width').focus();
return existing;
}
const dialog = createDialog({ title: 'Node Dimensions', settings: app.ui.settings, fallbackFocus: app.canvas.canvas });
const form = document.createElement('form');
form.id = `${dialog.element.id}-form`;
form.noValidate = true;
const hint = document.createElement('p');
hint.id = `${form.id}-hint`;
hint.textContent = 'Minimum size is enforced based on node content.';
hint.className = 'ca-dialog-hint';
const fields = document.createElement('div');
fields.className = 'ca-dimension-fields';
const error = document.createElement('p');
error.id = `${form.id}-error`;
error.className = 'ca-dialog-error';
error.setAttribute('aria-live', 'polite');
const inputs = ['Width', 'Height'].map((name, index) => {
const input = document.createElement('input');
Object.assign(input, { type: 'number', min: '1', step: '1', required: true, value: String(Math.round(node.size[index])), className: name.toLowerCase() });
input.setAttribute('aria-describedby', `${hint.id} ${error.id}`);
fields.append(createField(`${name} (px)`, input, `${form.id}-${name.toLowerCase()}`));
return input;
});
form.append(hint, fields, error);
dialog.body.append(form);
const cancel = createButton('Cancel');
const apply = createButton('Apply', 'ca-primary');
apply.type = 'submit';
apply.setAttribute('form', form.id);
dialog.actions.append(cancel, apply);
dialog.scope.listen(cancel, 'click', () => dialog.close('cancel'));
dialog.scope.listen(form, 'submit', event => {
event.preventDefault();
const size = resolveNodeSize(inputs.map(input => input.value), node.computeSize());
if (!size) {
error.textContent = 'Enter positive whole numbers for width and height.';
for (const input of inputs) input.setAttribute('aria-invalid', String(!resolveNodeSize([input.value], [1])));
inputs.find(input => input.getAttribute('aria-invalid') === 'true').focus();
return;
}
node.setSize(size);
node.setDirtyCanvas(true, true);
dialog.close('apply');
});
const original = node.onRemoved;
const onRemoved = function (...args) {
dialog.close('removed');
return original?.apply(this, args);
};
node.onRemoved = onRemoved;
activeDialogs.set(node, dialog.element);
dialog.scope.cleanup(() => {
activeDialogs.delete(node);
if (node.onRemoved === onRemoved) node.onRemoved = original;
});
dialog.open(inputs[0]);
inputs[0].select();
return dialog.element;
}