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

Node Dimensions

-
-
- - -
-
- - -
-
-
- Tip: Minimum size will be enforced based on node content -
-
- `; - - 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; diff --git a/web/ui/README.md b/web/ui/README.md index 5281437..30b6c0c 100644 --- a/web/ui/README.md +++ b/web/ui/README.md @@ -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. diff --git a/web/ui/controls.css b/web/ui/controls.css index 87ea85e..defaf70 100644 --- a/web/ui/controls.css +++ b/web/ui/controls.css @@ -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); } } diff --git a/web/ui/dialog.js b/web/ui/dialog.js new file mode 100644 index 0000000..7270827 --- /dev/null +++ b/web/ui/dialog.js @@ -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; +} diff --git a/web/views/node-dimensions.js b/web/views/node-dimensions.js new file mode 100644 index 0000000..a695c64 --- /dev/null +++ b/web/views/node-dimensions.js @@ -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; +}