Compare commits

...
Author SHA1 Message Date
Vito Sansevero b5694ecfe5 feat(ui): migrate Node Dimensions to shared dialog controls 2026-10-16 13:00:36 -07:00
Vito 1255c4e9cc Merge pull request #91 from ComfyAssets/feat/shared-dimension-swap
feat(ui): unify dimension swap controls for both nodes
2026-10-16 12:45:22 -07:00
Vito Sansevero 26557a99b6 feat(ui): unify dimension swap controls for both nodes 2026-10-16 12:35:23 -07:00
Vito 848674d474 Merge pull request #89 from ComfyAssets/feat/shared-workflow-timer
feat(ui): migrate Workflow Timer to shared controls
2026-10-16 12:23:27 -07:00
Vito Sansevero 0c47dc6085 feat(ui): migrate Workflow Timer to shared controls 2026-10-16 12:18:02 -07:00
Vito ee7d1e9011 Merge pull request #87 from ComfyAssets/feat/shared-seed-history
feat(ui): migrate Seed History to shared controls
2026-10-16 12:00:07 -07:00
Vito Sansevero b7a29a1731 feat(ui): migrate Seed History to shared controls 2026-10-16 11:55:57 -07:00
Vito 3cabbffb40 Merge pull request #85 from ComfyAssets/feat/shared-image-viewer
feat(ui): migrate Save Image viewer to shared window controls
2026-10-16 11:37:46 -07:00
Vito Sansevero 47dc177517 feat(ui): migrate saved-image viewer to shared window controls 2026-10-16 11:30:11 -07:00
Vito ddb356fe17 Merge pull request #83 from ComfyAssets/feat/live-image-loader-ui
feat(ui): ship shared ComfyAssets controls in Local Image Loader
2026-10-16 11:08:36 -07:00
Vito Sansevero 9e2b271824 fix(ui): keep gallery thumbnails large and adjustable 2026-10-16 11:03:33 -07:00
Vito Sansevero 2e7fac4b70 docs(ui): capture the loader in both ComfyUI renderers 2026-10-16 10:53:07 -07:00
Vito Sansevero 740eccec27 feat(ui): migrate Local Image Loader to shared ComfyAssets controls 2026-10-16 10:52:28 -07:00
Vito 55bce1004b Merge pull request #79 from ComfyAssets/dependabot/github_actions/actions/setup-node-7
build(deps): bump actions/setup-node from 6 to 7
2026-10-16 09:18:54 -07:00
Vito 63a8596b81 Merge branch 'main' into dependabot/github_actions/actions/setup-node-7 2026-10-16 09:14:46 -07:00
Vito 11494c747c Merge pull request #78 from ComfyAssets/dependabot/github_actions/actions/setup-python-7
build(deps): bump actions/setup-python from 6 to 7
2026-10-16 09:14:32 -07:00
Vito 88a9238ee2 Merge branch 'main' into dependabot/github_actions/actions/setup-python-7 2026-10-16 09:10:46 -07:00
Vito 3879f18d79 Merge pull request #72 from ComfyAssets/feat/checkpoint-folder-iterator
feat(xyz_helpers): Checkpoint Folder Iterator and Model Compare Sheet
2026-10-16 09:10:28 -07:00
dependabot[bot] 46999c8d90 build(deps): bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 15:47:59 +00:00
dependabot[bot] 852864a270 build(deps): bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 15:47:47 +00:00
54 changed files with 3535 additions and 3302 deletions
+3 -3
View File
@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.10'
@@ -145,7 +145,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.10'
@@ -176,7 +176,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.10'
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.10'
+8 -7
View File
@@ -23,7 +23,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
cache: pip
@@ -60,7 +60,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
- uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip
@@ -93,14 +93,15 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "24"
# Check every shipped module, including files without behavioral tests.
- name: Check web/*.js parse as ES modules
- name: Check all shipped JavaScript modules parse
run: |
for f in web/*.js; do
shopt -s globstar nullglob
for f in web/**/*.js web/**/*.mjs; do
node --input-type=module --check < "$f" || { echo "Syntax error in $f"; exit 1; }
done
@@ -110,7 +111,7 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
- uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip
@@ -120,7 +121,7 @@ jobs:
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements-dev.txt
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
+37
View File
@@ -73,6 +73,43 @@ The browser host is a test double; these workflows do not run the real ComfyUI
frontend or certify every node's UI. Type checking and security jobs retain their
existing advisory behavior.
### Actual ComfyUI frontend smoke test
The Local Image Loader also has an opt-in suite using the actual ComfyUI frontend,
graph, DOM widgets, renderer switch and palette settings. Start a local ComfyUI
0.39.0 with frontend 1.55.14 and KikoTools installed, then run:
```sh
KIKO_COMFY_URL=http://127.0.0.1:8188/ node \
tools/testing/node_modules/@playwright/test/cli.js test \
--config tools/testing/comfyui.config.mjs
```
The suite requires those exact versions and records them in its artifacts. It
serves the current checkout's extensions to a separate browser context and loads
only KikoTools extensions. Media requests use the existing real Python routes
with disposable fixture data on port 8771. Other HTTP writes are intercepted,
so user settings, saved workflows and selections are not written to the live
server. It does not execute a workflow or download models.
Checks cover Classic/Nodes 2.0/live switching, theme changes, workflow save/reload
and removal for the loader. Save Image additionally checks real image output,
window close/reopen through the node control across renderer switches, theme
changes, exclusion of the launcher from serialized values and owner removal.
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
alongside other node packs remain follow-up work in issue #81.
## Required merge checks
The manifest in `.github/branch-protection.json` requires all Python matrix jobs,
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+4 -2
View File
@@ -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}`);
+709 -5
View File
@@ -40,22 +40,176 @@ test('plugin startup registers every frontend extension and exposes real autocom
expect(embeddings).toContainEqual(expect.objectContaining({ name: 'cat', path: 'portraits/cat' }));
});
test('shared loader controls follow host themes, resize and support keyboard selection', async ({ page }) => {
await openHost(page);
await createNode(page, 'KikoLocalImageLoader');
const loader = page.locator('.ca-image-browser');
await expect(loader).toBeVisible();
await page.evaluate(() => document.body.style.setProperty('--bg-color', '#202020'));
await expect(loader).toHaveAttribute('data-ca-theme', 'dark');
await page.evaluate(() => document.body.style.setProperty('--bg-color', '#fafafa'));
await expect(loader).toHaveAttribute('data-ca-theme', 'light');
const toggle = loader.getByRole('switch', { name: 'Hide dot folders' });
await toggle.focus();
await page.keyboard.press('Space');
await expect(toggle).not.toBeChecked();
await expect(page.locator('.kiko-folder-name').filter({ hasText: /^\.hidden$/ })).toBeVisible();
await loader.getByRole('button', { name: 'Select image cat & dog.png', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(loader.locator('[data-selection]')).toContainText('cat & dog.png');
for (const width of [320, 480, 800]) {
await loader.evaluate((element, width) => { element.style.width = `${width}px`; }, width);
expect(await loader.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
}
await loader.evaluate(element => element.style.removeProperty('width'));
await loader.getByRole('radio', { name: 'List', exact: true }).check();
await expect(loader.locator('.kiko-cardholder')).toHaveAttribute('data-view', 'list');
await loader.getByRole('searchbox', { name: 'Filter this page' }).fill('not-in-folder');
await expect(loader.getByText('No matching items on this page')).toBeVisible();
await loader.getByRole('searchbox', { name: 'Filter this page' }).fill('cat');
await expect(loader.locator('.kiko-gallery-card')).toHaveCount(1);
});
test('a slower directory response cannot overwrite the latest folder', async ({ page, request }) => {
await openHost(page);
await createNode(page, 'KikoLocalImageLoader');
await expect(page.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
const info = await state(request);
const original = await (await request.get(`/kiko_local_image_loader/images?directory=${encodeURIComponent(info.input)}`)).json();
const held = [];
await page.route('**/kiko_local_image_loader/images?*', route => {
if (new URL(route.request().url()).searchParams.get('directory') === info.input) held.push(route);
else return route.continue();
});
await page.locator('.kiko-refresh').click();
await expect.poll(() => held.length).toBe(1);
await page.locator('.kiko-path-input').fill(`${info.input}/sub`);
await page.locator('.kiko-browse').click();
await expect(page.getByAltText('forest.png', { exact: true })).toBeVisible();
await held[0].fulfill({ json: original });
await page.waitForTimeout(80);
await expect(page.locator('.kiko-path-input')).toHaveValue(`${info.input}/sub`);
await expect(page.getByAltText('cat & dog.png', { exact: true })).toHaveCount(0);
});
test('large galleries scroll instead of squeezing thumbnails and size is adjustable', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 52 } })).toBeOK();
await openHost(page);
await createNode(page, 'KikoLocalImageLoader');
await page.locator('.kiko-folder-name').filter({ hasText: /^bulk$/ }).click();
await expect(page.locator('.kiko-card-info-panel')).toHaveCount(50);
const loader = page.locator('.ca-image-browser');
await loader.evaluate(element => { element.style.width = '800px'; element.style.height = '650px'; });
const size = loader.getByRole('slider', { name: 'Thumbnail size' });
await expect(size).toHaveValue('224');
const media = loader.locator('.kiko-card-media-wrapper').nth(1);
const before = await media.boundingBox();
expect(before.height).toBeGreaterThanOrEqual(200);
expect(await loader.locator('.kiko-cardholder').evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true);
await size.fill('320');
await size.dispatchEvent('input');
expect((await media.boundingBox()).height).toBeGreaterThan(before.height);
await expect(loader.locator('.ca-range output')).toHaveText('320 px');
await size.focus();
await page.keyboard.press('ArrowLeft');
await expect(size).toHaveValue('304');
await loader.evaluate(element => { element.style.width = '320px'; });
expect(await loader.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await expect(media.locator('img')).toHaveCSS('object-fit', 'contain');
});
test('removing a loader cancels delayed autocomplete and detaches theme observers', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'KikoLocalImageLoader');
await expect(page.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
await page.evaluate(() => document.body.style.setProperty('--bg-color', '#202020'));
await expect(page.locator('.ca-image-browser')).toHaveAttribute('data-ca-theme', 'dark');
await page.clock.install();
const suggestions = [];
page.on('request', request => { if (request.url().includes('/list_directories?')) suggestions.push(request.url()); });
await page.locator('.kiko-path-input').fill('/pending');
await page.evaluate(id => {
window.removedGallery = document.querySelector('.ca-image-browser');
kikoHost.removeNode(id);
document.body.style.setProperty('--bg-color', '#fafafa');
}, id);
await page.clock.fastForward(500);
expect(suggestions).toEqual([]);
expect(await page.evaluate(() => window.removedGallery.dataset.caTheme)).toBe('dark');
await expect(page.locator('.ca-image-browser')).toHaveCount(0);
});
test('actual swap handlers preserve preset values and custom dimensions', async ({ page }) => {
await openHost(page);
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');
@@ -156,6 +310,148 @@ test('seed history persists across reloads and removal clears its watcher', asyn
}, id)).toEqual({ watcher: null, registered: false, removed: true });
});
test('shared seed history supports keyboard selection, themes and local status feedback', async ({ page }) => {
await openHost(page);
await page.evaluate(() => Object.defineProperty(crypto, 'randomUUID', { value: undefined }));
const id = await createNode(page, 'SeedHistory', { seed: 42 });
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.addSeedToHistory(123);
node.addSeedToHistory(456);
}, id);
const panel = page.locator('.ca-seed-history');
await expect(panel).toHaveCount(1);
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);
}
const entry = panel.getByRole('button', { name: 'Use seed 123', exact: true });
await entry.focus();
await page.keyboard.press('Enter');
await expect(entry).toHaveAttribute('aria-pressed', 'true');
await expect(panel.getByRole('status')).toHaveText('Loaded: 123');
expect(await page.evaluate(id => Number(kikoHost.nodes.get(String(id)).widgets.find(widget => widget.name === 'seed').value), id)).toBe(123);
await page.evaluate(id => kikoHost.nodes.get(String(id)).addSeedToHistory(789), id);
await expect(entry).toBeFocused();
await panel.getByRole('button', { name: 'Generate', exact: true }).click();
await expect(panel.getByRole('status')).toContainText('Generated:');
await panel.getByRole('button', { name: 'Clear', exact: true }).click();
await expect(panel.getByRole('status')).toHaveText('History cleared');
await expect(panel.getByText('No seeds tracked')).toBeVisible();
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('comfyui_kikotools_seed_history')))).toEqual([]);
});
test('seed history auto-hide protects keyboard focus and its disclosure reopens with Space', async ({ page }) => {
await openHost(page);
await page.clock.install();
const id = await createNode(page, 'SeedHistory', { seed: 42 });
await page.evaluate(id => kikoHost.nodes.get(String(id)).addSeedToHistory(123), id);
const panel = page.locator('.ca-seed-history');
const entry = panel.getByRole('button', { name: 'Use seed 123', exact: true });
await entry.focus();
await page.clock.runFor(3000);
await expect(entry).toBeVisible();
await expect(entry).toBeFocused();
await panel.getByRole('button', { name: 'Generate', exact: true }).focus();
await page.clock.runFor(2600);
const restore = panel.getByRole('button', { name: 'Show history', exact: true });
await expect(restore).toHaveAttribute('aria-expanded', 'false');
await restore.focus();
await page.keyboard.press('Space');
await expect(panel.getByRole('list', { name: 'Recent seeds' })).toBeVisible();
await expect(panel.getByRole('button', { name: 'Hide history', exact: true })).toHaveAttribute('aria-expanded', 'true');
await entry.hover();
await page.clock.runFor(3000);
await expect(entry).toBeVisible();
await page.mouse.move(0, 0);
await page.clock.runFor(2600);
await expect(restore).toBeVisible();
});
test('seed history removal cancels delayed setup and retries and disposes theme listeners', async ({ page }) => {
await openHost(page);
await page.clock.install();
const early = await createNode(page, 'SeedHistory', { seed: 42 });
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
window.removedSeedNode = node;
window.seedSetups = 0;
node.setupSeedWidgetCallbacks = () => window.seedSetups++;
node.addSeedToHistory(123);
kikoHost.removeNode(id);
}, early);
await page.clock.runFor(3000);
expect(await page.evaluate(() => ({ setups: seedSetups, watcher: removedSeedNode.seedValueWatcher, hide: removedSeedNode.hideTimer, aborted: removedSeedNode.seedScope.signal.aborted }))).toEqual({ setups: 0, watcher: null, hide: null, aborted: true });
const retry = await createNode(page, 'SeedHistory');
await page.clock.runFor(101);
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
window.detachedSeedPanel = node.seedWidget.element;
window.detachedSeedTheme = detachedSeedPanel.dataset.caTheme;
window.retrySeedNode = kikoHost.removeNode(id);
document.body.style.setProperty('--bg-color', detachedSeedTheme === 'light' ? '#202020' : '#ffffff');
}, retry);
await page.clock.runFor(3000);
expect(await page.evaluate(() => ({ watcher: retrySeedNode.seedValueWatcher, setup: retrySeedNode.seedSetupTimer, theme: detachedSeedPanel.dataset.caTheme === detachedSeedTheme, registered: seedHistoryNodes.length }))).toEqual({ watcher: null, setup: null, theme: true, registered: 0 });
});
test('seed history keeps ten unique valid seeds and restores workflow history safely', async ({ page }) => {
await openHost(page);
await page.evaluate(() => localStorage.setItem('comfyui_kikotools_seed_history', '{broken'));
const id = await createNode(page, 'SeedHistory', { seed: 42 });
expect(await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
for (let seed = 0; seed < 12; seed++) node.addSeedToHistory(seed);
node.addSeedToHistory(0);
node.addSeedToHistory(0);
node.addSeedToHistory('invalid');
const data = node.serialize();
node.clearSeedHistory();
node.configure(data);
return node.seedHistory.map(item => item.seed);
}, id)).toEqual([0, 11, 10, 9, 8, 7, 6, 5, 4, 3]);
const panel = page.locator('.ca-seed-history');
await expect(panel.getByRole('button', { name: 'Use seed 0', exact: true })).toBeVisible();
await page.evaluate(id => kikoHost.nodes.get(String(id)).configure({ seedHistory: [{ seed: '<img src=x onerror=alert(1)>', timestamp: 0 }] }), id);
await expect(panel.getByText('No seeds tracked')).toBeVisible();
await expect(panel.locator('img')).toHaveCount(0);
});
test('seed actions pass the host callback context and polling tracks manual values once', async ({ page }) => {
await openHost(page);
await page.clock.install();
const id = await createNode(page, 'SeedHistory', { seed: 42 });
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
const widget = node.widgets.find(widget => widget.name === 'seed');
window.seedCallbacks = [];
widget.callback = function (value, canvas, owner) {
seedCallbacks.push({ value, canvas: canvas === kikoHost.app.canvas, owner: owner === node, widget: this === widget });
};
node.addSeedToHistory(123);
}, id);
const panel = page.locator('.ca-seed-history');
await panel.getByRole('button', { name: 'Use seed 123', exact: true }).click();
await panel.getByRole('button', { name: 'Generate', exact: true }).click();
expect(await page.evaluate(() => seedCallbacks)).toEqual([
{ value: 123, canvas: true, owner: true, widget: true },
{ value: expect.any(Number), canvas: true, owner: true, widget: true },
]);
await page.clock.runFor(101);
await page.evaluate(id => { kikoHost.nodes.get(String(id)).widgets.find(widget => widget.name === 'seed').value = 987; }, id);
await page.clock.runFor(1100);
await expect(panel.getByRole('button', { name: 'Use seed 987', exact: true })).toHaveCount(1);
await page.evaluate(id => {
Storage.prototype.setItem = () => { throw new DOMException('Storage unavailable', 'SecurityError'); };
kikoHost.nodes.get(String(id)).addSeedToHistory(654);
}, id);
await expect(panel.getByRole('button', { name: 'Use seed 654', exact: true })).toBeVisible();
await page.evaluate(id => { kikoHost.nodes.get(String(id)).widgets.find(widget => widget.name === 'seed').value = 555; }, id);
await panel.getByRole('button', { name: 'Clear', exact: true }).click();
await page.clock.runFor(1100);
await expect(panel.getByText('No seeds tracked')).toBeVisible();
});
test('workflow timer responds to execution events and detaches removed nodes', async ({ page }) => {
await page.clock.install();
await openHost(page);
@@ -173,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: '' });
@@ -216,6 +661,148 @@ test('real image save output opens a preview, closes, and reuses the viewer on e
await expect(viewer.locator('.kiko-viewer-container')).toBeHidden();
});
async function savedImageResult(page, request, count = 1) {
const id = await createNode(page, 'KikoSaveImage', { format: 'PNG', quality: 90 });
const response = await request.post('/__test__/execute/KikoSaveImage', { data: { filename_prefix: 'Window test' } });
await expect(response).toBeOK();
const message = (await response.json()).ui;
message.kiko_enhanced = Array.from({ length: count }, () => ({ ...message.kiko_enhanced[0] }));
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message });
return { id, message };
}
test('shared saved-image window follows themes and supports keyboard close and reopen', async ({ page, request }) => {
await openHost(page);
const { id } = await savedImageResult(page, request);
const viewer = page.getByRole('dialog', { name: 'Saved images', exact: true });
await expect(viewer).toBeVisible();
await page.evaluate(() => document.body.style.setProperty('--bg-color', '#fafafa'));
await expect(viewer).toHaveAttribute('data-ca-theme', 'light');
await viewer.getByTitle('Minimize', { exact: true }).click();
await expect(viewer.locator('.kiko-viewer-container')).toBeHidden();
await viewer.getByTitle('Restore', { exact: true }).click();
await expect(viewer.locator('.kiko-viewer-container')).toBeVisible();
await viewer.getByTitle('Close', { exact: true }).focus();
await page.keyboard.press('Escape');
await expect(viewer).toBeHidden();
await page.locator(`[data-node="KikoSaveImage"]`).getByRole('button', { name: 'Show images', exact: true }).click();
await expect(viewer).toBeVisible();
expect(await page.evaluate(id => kikoHost.nodes.get(String(id)).kikoViewer.isConnected, id)).toBe(true);
});
test('saved-image ownership transfers safely and deletion cleans up only the owner window', async ({ page, request }) => {
await openHost(page);
const first = await savedImageResult(page, request);
const second = await savedImageResult(page, request);
expect(await page.evaluate(id => Boolean(kikoHost.nodes.get(String(id)).kikoViewer), second.id)).toBe(true);
await page.evaluate(id => kikoHost.removeNode(id), first.id);
await expect(page.locator('kiko-image-viewer')).toBeVisible();
await page.evaluate(id => kikoHost.removeNode(id), second.id);
await expect(page.locator('kiko-image-viewer')).toHaveCount(0);
});
test('saved-image window moves and resizes by keyboard and remains inside a shrinking viewport', async ({ page, request }) => {
await page.setViewportSize({ width: 1200, height: 900 });
await openHost(page);
await savedImageResult(page, request);
const viewer = page.locator('kiko-image-viewer');
const move = viewer.getByRole('button', { name: 'Move image viewer with arrow keys', exact: true });
await move.focus();
const before = await viewer.boundingBox();
await page.keyboard.press('ArrowLeft');
expect((await viewer.boundingBox()).x).toBeLessThan(before.x);
const resize = viewer.getByRole('button', { name: 'Resize image viewer with arrow keys', exact: true });
await resize.focus();
await page.keyboard.press('ArrowRight');
expect((await viewer.boundingBox()).width).toBeGreaterThan(before.width);
const imageBefore = await viewer.locator('img').boundingBox();
await page.keyboard.press('ArrowDown');
expect((await viewer.locator('img').boundingBox()).height).toBeGreaterThan(imageBefore.height);
const info = await viewer.locator('.kiko-image-info').boundingBox();
const content = await viewer.locator('.kiko-viewer-container').boundingBox();
expect(info.y + info.height).toBeLessThanOrEqual(content.y + content.height);
await page.setViewportSize({ width: 360, height: 500 });
await expect.poll(async () => {
const rect = await viewer.boundingBox();
return rect.x >= 0 && rect.y >= 0 && rect.x + rect.width <= 361 && rect.y + rect.height <= 501;
}).toBe(true);
});
test('saved-image batch selection downloads and resets for new execution results', async ({ page, request }) => {
await openHost(page);
const { id, message } = await savedImageResult(page, request, 2);
const viewer = page.locator('kiko-image-viewer');
const choices = viewer.getByRole('checkbox');
await expect(choices).toHaveCount(2);
await choices.first().check();
await expect(viewer.locator('.selected-count')).toHaveText('1');
await viewer.getByRole('button', { name: 'Select all', exact: true }).click();
await expect(choices.nth(1)).toBeChecked();
const download = page.waitForEvent('download');
await viewer.getByRole('button', { name: /Download image 1:/ }).click();
expect((await download).suggestedFilename()).toBe(message.kiko_enhanced[0].filename);
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: { kiko_enhanced: [message.kiko_enhanced[0]] } });
expect(await viewer.evaluate(element => element.selectedImages.size)).toBe(0);
await expect(viewer.getByRole('checkbox')).toHaveCount(0);
});
test('saved-image window pointer handles drag resize and cancel without losing reachability', async ({ page, request }) => {
await page.setViewportSize({ width: 1200, height: 900 });
await openHost(page);
await savedImageResult(page, request);
const viewer = page.locator('kiko-image-viewer');
const before = await viewer.boundingBox();
const handle = await viewer.locator('[data-window-move]').boundingBox();
await page.mouse.move(handle.x + 20, handle.y + 15);
await page.mouse.down();
await page.mouse.move(handle.x - 100, handle.y + 35, { steps: 4 });
await page.mouse.up();
const moved = await viewer.boundingBox();
expect(moved.x).toBeLessThan(before.x);
await expect(viewer).not.toHaveClass(/ca-dragging/);
const resize = await viewer.locator('[data-window-resize]').boundingBox();
await page.mouse.move(resize.x + 12, resize.y + 12);
await page.mouse.down();
await page.mouse.move(resize.x + 60, resize.y + 15, { steps: 4 });
await page.mouse.up();
expect((await viewer.boundingBox()).width).toBeGreaterThan(moved.width);
const next = await viewer.locator('[data-window-move]').boundingBox();
await page.mouse.move(next.x + 20, next.y + 15);
await page.mouse.down();
await viewer.dispatchEvent('pointercancel');
await expect(viewer).not.toHaveClass(/ca-dragging/);
await page.mouse.up();
});
test('delayed selected-image opens stop on close replacement and owner removal', async ({ page, request }) => {
await openHost(page);
const { id, message } = await savedImageResult(page, request, 2);
await page.clock.install();
await page.evaluate(() => {
window.openedImages = [];
window.open = url => { window.openedImages.push(url); return {}; };
const viewer = document.querySelector('kiko-image-viewer');
viewer.handleBulkAction('select-all');
viewer.handleBulkAction('open-all');
});
await page.clock.runFor(200);
expect(await page.evaluate(() => window.openedImages.length)).toBe(2);
for (const action of ['close', 'replace', 'remove']) {
await page.evaluate(({ id, message, action }) => {
const node = kikoHost.nodes.get(String(id));
node.onExecuted(message);
const viewer = document.querySelector('kiko-image-viewer');
viewer.handleBulkAction('select-all');
viewer.handleBulkAction('open-all');
if (action === 'close') viewer.close();
if (action === 'replace') node.onExecuted(message);
if (action === 'remove') kikoHost.removeNode(id);
}, { id, message, action });
await page.clock.runFor(500);
expect(await page.evaluate(() => window.openedImages.length)).toBe(2);
}
});
test('Display Any renders real multiline output and restores serialized values without duplicate widgets', async ({ page, request }) => {
await openHost(page);
const id = await createNode(page, 'DisplayAny');
@@ -283,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);
});
+467
View File
@@ -0,0 +1,467 @@
import { test, expect } from '../../tools/testing/node_modules/@playwright/test/index.mjs';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { resolve, sep } from 'node:path';
const fixture = 'http://127.0.0.1:8771';
const web = resolve(fileURLToPath(new URL('../../web/', import.meta.url)));
test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
const info = await (await request.get(`${baseURL}system_stats`)).json();
expect(info.system.comfyui_version).toBe('0.39.0');
expect(info.system.required_frontend_version).toBe('1.55.14');
expect(info.system.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe('1.55.14');
await testInfo.attach('host-versions.json', { body: JSON.stringify(info.system, null, 2), contentType: 'application/json' });
await expect(await request.post(`${fixture}/__test__/reset`)).toBeOK();
await context.route('**/*', async route => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname.replace(/^\/api\//, '/');
if (path.startsWith('/kiko_local_image_loader/') || path === '/view') {
const response = await route.fetch({ url: `${fixture}${path}${url.search}` });
return route.fulfill({ response });
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) return route.fulfill({ json: {} });
if (path === '/extensions') {
const response = await route.fetch();
const extensions = (await response.json()).filter(path => path.startsWith('/extensions/kikotools/'));
return route.fulfill({ json: extensions });
}
if (path.startsWith('/extensions/kikotools/')) {
const source = resolve(web, decodeURIComponent(path.slice('/extensions/kikotools/'.length)));
if (!source.startsWith(web + sep)) return route.abort();
if (!/\.(js|css)$/.test(source)) return route.abort();
return route.fulfill({ body: await readFile(source), contentType: source.endsWith('.css') ? 'text/css' : 'text/javascript' });
}
return route.continue();
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
await page.waitForFunction(async () => {
const { app } = await import('/scripts/app.js');
return !!app.graph && !!globalThis.LiteGraph?.registered_node_types?.KikoLocalImageLoader;
});
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph.clear();
});
});
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');
const node = LiteGraph.createNode('SeedHistory');
app.graph.add(node);
node.setSize([380, 560]);
for (let seed = 100; seed < 110; seed++) node.addSeedToHistory(seed);
app.canvas.centerOnNode(node);
});
const panel = page.locator('.ca-seed-history');
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 panel.getByRole('button', { name: 'Use seed 105', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(panel.getByRole('button', { name: 'Use seed 105', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(panel.getByRole('status')).toHaveText('Loaded: 105');
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes.find(node => node.type === 'SeedHistory').setSize([280, 350]);
});
const list = panel.getByRole('list', { name: 'Recent seeds' });
await expect.poll(() => list.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true);
const smallHeight = await list.evaluate(element => element.clientHeight);
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes.find(node => node.type === 'SeedHistory').setSize([420, 650]);
});
await expect.poll(() => list.evaluate(element => element.clientHeight)).toBeGreaterThan(smallHeight);
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(`seed-${theme}.png`) });
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
const saved = workflow.nodes.find(node => node.type === 'SeedHistory');
if (saved.widgets_values[0] !== 105 || saved.widgets_values.slice(2).some(value => value !== '' && value != null)) throw new Error('History UI leaked into serialized widget values');
await app.loadGraphData(workflow);
const node = app.graph._nodes.find(node => node.type === 'SeedHistory');
if (node.size[0] !== saved.size[0] || node.size[1] !== saved.size[1]) throw new Error('Saved node size changed');
app.canvas.centerOnNode(node);
});
await expect(panel).toHaveCount(1);
await expect(panel.getByRole('button', { name: 'Use seed 105', exact: true })).toBeVisible();
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes.find(node => node.type === 'SeedHistory');
window.removedSeedNode = node;
app.graph.remove(node);
});
await expect(panel).toHaveCount(0);
expect(await page.evaluate(() => removedSeedNode.seedScope.signal.aborted && removedSeedNode.seedValueWatcher === null)).toBe(true);
});
test('saved-image window survives real renderer switching and is removed with its owner', async ({ page, request }, testInfo) => {
const result = await request.post(`${fixture}/__test__/execute/KikoSaveImage`, { data: { filename_prefix: 'Real host viewer' } });
await expect(result).toBeOK();
const message = (await result.json()).ui;
const id = await page.evaluate(async message => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('KikoSaveImage');
app.graph.add(node);
node.onExecuted(message);
app.canvas.centerOnNode(node);
return node.id;
}, message);
const viewer = page.getByRole('dialog', { name: 'Saved images', exact: true });
await expect(viewer).toBeVisible();
await expect.poll(() => viewer.locator('img').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(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);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(viewer).toHaveCount(1);
await viewer.getByTitle('Close', { exact: true }).click();
await page.getByRole('button', { name: 'Show images', exact: true }).click();
await expect(viewer).toBeVisible();
expect(await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
return app.graph.serialize().nodes.find(node => String(node.id) === String(id)).widgets_values.includes('Show images');
}, id)).toBe(false);
}
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(viewer).toHaveAttribute('data-ca-theme', theme);
const info = await viewer.locator('.kiko-image-info').boundingBox();
const content = await viewer.locator('.kiko-viewer-container').boundingBox();
expect(info.y + info.height).toBeLessThanOrEqual(content.y + content.height);
await page.screenshot({ path: testInfo.outputPath(`viewer-${theme}.png`) });
}
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
app.graph.remove(app.graph.getNodeById(id));
}, id);
await expect(viewer).toHaveCount(0);
});
test('actual host preserves the widget through renderer changes, theme changes and save/reload', async ({ page, request }, testInfo) => {
await expect(await request.post(`${fixture}/__test__/reset`, { data: { extra_images: 52 } })).toBeOK();
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', false);
const node = LiteGraph.createNode('KikoLocalImageLoader');
node.pos = [250, 140];
app.graph.add(node);
app.canvas.centerOnNode(node);
});
const loader = page.locator('.ca-image-browser');
await expect(loader).toBeVisible();
await expect(loader.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
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(loader).toHaveCount(1);
await expect(loader).toBeVisible();
await loader.getByRole('button', { name: 'Select image cat & dog.png', exact: true }).click();
await expect(loader.locator('[data-selection]')).toHaveText('cat & dog.png');
const node = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
return app.graph.serialize().nodes.find(node => node.type === 'KikoLocalImageLoader');
});
expect(node).toBeTruthy();
expect(node.widgets_values || []).not.toContainEqual(expect.objectContaining({ className: 'ca-image-browser' }));
await loader.getByRole('button', { name: 'Open folder bulk', exact: true }).click();
await expect(loader.locator('.kiko-card-info-panel')).toHaveCount(50);
const media = await loader.locator('.kiko-card-media-wrapper').nth(1).boundingBox();
expect(media.height / media.width).toBeCloseTo(1, 1);
expect(media.height).toBeGreaterThan(150);
expect(await loader.locator('.kiko-cardholder').evaluate(element => element.clientHeight)).toBeGreaterThanOrEqual(await loader.locator('.kiko-card-media-wrapper').nth(1).evaluate(element => element.offsetHeight));
expect(await loader.locator('.kiko-cardholder').evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true);
await page.screenshot({ path: testInfo.outputPath(renderer ? 'nodes2.png' : 'classic.png') });
await loader.getByRole('button', { name: 'Open folder ..', exact: true }).click();
await expect(loader.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
}
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(loader).toHaveAttribute('data-ca-theme', theme);
}
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]);
});
await expect(loader).toHaveCount(1);
await expect(loader.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph.remove(app.graph._nodes[0]);
});
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);
});
+14 -1
View File
@@ -2,7 +2,9 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
const sourceRoot = join(process.cwd(), 'web');
import { checkCoverage } from '../../tools/testing/frontend-coverage.mjs';
import { checkCoverage, discoverSources } from '../../tools/testing/frontend-coverage.mjs';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
const metrics = value => Object.fromEntries(['lines', 'branches', 'functions', 'statements'].map(key => [key, { pct: value }]));
const summary = { total: metrics(90), [join(sourceRoot, 'a.js')]: metrics(85) };
const baseline = { 'a.js': { lines: 80, branches: 80, functions: 80, statements: 80 } };
@@ -20,3 +22,14 @@ test('coverage gate rejects invalid baselines and missing metrics', () => {
assert.throws(() => checkCoverage(summary, { 'a.js': { lines: -1 } }, sourceRoot, ['a.js']), /baseline/i);
assert.match(checkCoverage({ total: metrics(100), [join(sourceRoot, 'a.js')]: {} }, baseline, sourceRoot, ['a.js']).join('\n'), /lines/);
});
test('source discovery includes nested UI modules in the coverage gate', () => {
const root = mkdtempSync(join(tmpdir(), 'kiko-coverage-'));
try {
mkdirSync(join(root, 'ui'));
writeFileSync(join(root, 'entry.js'), '');
writeFileSync(join(root, 'ui/core.js'), '');
writeFileSync(join(root, 'ui/theme.css'), '');
assert.deepEqual(discoverSources(root), ['entry.js', 'ui/core.js']);
} finally { rmSync(root, { recursive: true, force: true }); }
});
+60
View File
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { bindDimensionPresets, dimensionsFromPreset, swapDimensions } from '../../web/ui/dimensions.js';
const app = { canvas: {} };
test('resolution parsing accepts supplied formats and rejects invalid dimensions', () => {
assert.deepEqual(dimensionsFromPreset(' 928x1664 - Qwen'), [928, 1664]);
assert.deepEqual(dimensionsFromPreset('1664×928'), [1664, 928]);
for (const value of [null, {}, 'custom', '0x100', '12.5x100', 'x100', '999999999999999999999x100']) assert.equal(dimensionsFromPreset(value), null);
});
test('preset callbacks preserve returns and default host context without changing custom dimensions', () => {
const calls = [];
const node = { widgets: [
{ name: 'preset', callback() { return 'original'; } },
{ name: 'width', value: 64, callback(value, canvas, owner) { calls.push([this.name, value, canvas, owner]); } },
{ name: 'height', value: 128 },
] };
bindDimensionPresets(node, app);
assert.equal(node.widgets[0].callback('custom'), 'original');
assert.equal(node.widgets[0].callback('928×1664 - Qwen'), 'original');
assert.deepEqual(calls, [['width', 928, app.canvas, node]]);
assert.equal(node.widgets[2].value, 1664);
});
test('incomplete widgets and unavailable reverse options are safe', () => {
bindDimensionPresets({ widgets: [] }, app);
swapDimensions({ widgets: [] }, app);
const node = { widgets: [{ name: 'preset', value: '640×960' }, { name: 'width', value: 1 }, { name: 'height', value: 2 }] };
bindDimensionPresets(node, app);
swapDimensions(node, app);
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 960, 640]);
node.widgets.pop();
node.widgets[0].callback('640×960');
swapDimensions(node, app);
assert.equal(node.widgets[1].value, 960);
});
test('dynamic and array option lists match reverse dimensions', () => {
for (const options of [['custom', '960x640'], { values: () => ['custom', '960×640'] }]) {
const node = { widgets: [{ name: 'preset', value: '640×960', options }, { name: 'width', value: 1 }, { name: 'height', value: 2 }] };
bindDimensionPresets(node, app);
swapDimensions(node, app);
assert.equal(node.widgets[0].value, Array.isArray(options) ? '960x640' : '960×640');
}
});
test('a throwing or recursive preset callback does not leave swapping locked', () => {
let fail = true;
const node = { widgets: [
{ name: 'preset', value: '640×960', options: { values: ['640×960', '960×640'] }, callback() { swapDimensions(node, app); if (fail) throw new Error('callback failed'); } },
{ name: 'width', value: 640 }, { name: 'height', value: 960 },
] };
bindDimensionPresets(node, app);
assert.throws(() => swapDimensions(node, app), /callback failed/);
fail = false;
swapDimensions(node, app);
assert.deepEqual(node.widgets.map(widget => widget.value), ['640×960', 640, 960]);
});
+65 -13
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
import { bindDimensionPresets, extractResolutionFromPreset, swapDimensions } from '../../web/ui/dimensions.js';
function load(file, extra = {}) {
const extensions = [];
@@ -19,8 +20,8 @@ function load(file, extra = {}) {
return { extension: extensions[0], app, timers, context };
}
async function dimensionNode(preset, options = ['custom', '832×1216 - portrait', '1216×832 - landscape']) {
const { extension, app } = load('width_height_swap.js');
async function dimensionNode(preset, options = ['custom', '832×1216 - portrait', '1216×832 - landscape'], file = 'width_height_swap.js') {
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
let originalCreated = 0;
class Node {
constructor() {
@@ -33,15 +34,17 @@ async function dimensionNode(preset, options = ['custom', '832×1216 - portrait'
}
onNodeCreated() { originalCreated++; }
}
await extension.beforeRegisterNodeDef(Node, { name: 'WidthHeightSelector' }, app);
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
Node.prototype.createSwapButton = () => {};
const node = new Node();
node.onNodeCreated();
assert.equal(originalCreated, 1);
return node;
}
test('custom dimensions swap and callbacks receive the actual node', async () => {
const node = await dimensionNode('custom');
for (const file of ['width_height_swap.js', 'empty_latent_batch_swap.js']) {
test(`${file}: custom dimensions swap and callbacks receive the actual node`, async () => {
const node = await dimensionNode('custom', undefined, file);
const calls = [];
for (const widget of node.widgets.slice(1)) widget.callback = (value, canvas, owner) => calls.push({ value, owner });
node.swapDimensions();
@@ -50,34 +53,83 @@ test('custom dimensions swap and callbacks receive the actual node', async () =>
for (const call of calls) assert.equal(call.owner, node);
});
test('formatted preset swaps to its matching landscape preset without throwing', async () => {
const node = await dimensionNode('832×1216 - portrait');
test(`${file}: formatted preset swaps to its matching landscape preset without throwing`, async () => {
const node = await dimensionNode('832×1216 - portrait', undefined, file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216×832 - landscape', 1216, 832]);
});
test('a preset without a reverse switches to custom dimensions', async () => {
const node = await dimensionNode('832×1216 - portrait', ['custom', '832×1216 - portrait']);
test(`${file}: a preset without a reverse switches to custom dimensions`, async () => {
const node = await dimensionNode('832×1216 - portrait', ['custom', '832×1216 - portrait'], file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 1216, 832]);
});
test('preset changes preserve original callbacks and update both dimensions', async () => {
const node = await dimensionNode('custom');
test(`${file}: preset changes preserve original callbacks and update both dimensions`, async () => {
const node = await dimensionNode('custom', undefined, file);
node.widgets[0].callback('1216×832 - landscape', {}, node);
assert.deepEqual(node.widgets.slice(1).map(widget => widget.value), [1216, 832]);
assert.equal(node.extractResolutionFromPreset('custom'), null);
assert.equal(node.originalPresetCalls, 1);
});
test('unrelated node definitions retain their lifecycle methods', async () => {
const { extension, app } = load('width_height_swap.js');
test(`${file}: a preset swap notifies each dimension callback exactly once`, async () => {
const node = await dimensionNode('832×1216 - portrait', undefined, file);
const calls = [];
for (const widget of node.widgets.slice(1)) widget.callback = function (value, canvas, owner) { calls.push({ name: this.name, value, owner }); };
node.swapDimensions();
assert.deepEqual(calls.map(call => [call.name, call.value]), [['width', 1216], ['height', 832]]);
assert.equal(node.originalPresetCalls, 1);
for (const call of calls) assert.equal(call.owner, node);
});
test(`${file}: ASCII resolutions match reversed presets and unfamiliar formatted presets update dimensions`, async () => {
const node = await dimensionNode('832x1216 - portrait', ['custom', '832x1216 - portrait', '1216x832 - landscape'], file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216x832 - landscape', 1216, 832]);
node.widgets[0].callback('2040×1080 - future preset', {}, node);
assert.deepEqual(node.widgets.slice(1).map(widget => widget.value), [2040, 1080]);
});
test(`${file}: malformed presets do not write invalid dimensions`, async () => {
const node = await dimensionNode('invalidx960', undefined, file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['invalidx960', 832, 1216]);
});
test(`${file}: unrelated node definitions retain their lifecycle methods`, async () => {
const { extension, app } = load(file);
class Node { onNodeCreated() {} }
const original = Node.prototype.onNodeCreated;
await extension.beforeRegisterNodeDef(Node, { name: 'OtherNode' }, app);
assert.equal(Node.prototype.onNodeCreated, original);
});
test(`${file}: creation preserves hook arguments, results and existing canvas hooks`, async () => {
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
class Node {
constructor() { this.widgets = []; }
onNodeCreated(value) { this.received = value; return 'original result'; }
onDrawForeground() {}
onMouseDown() {}
onMouseMove() {}
}
const hooks = [Node.prototype.onDrawForeground, Node.prototype.onMouseDown, Node.prototype.onMouseMove];
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
const node = new Node();
assert.equal(node.onNodeCreated('original argument'), 'original result');
assert.equal(node.received, 'original argument');
assert.deepEqual([Node.prototype.onDrawForeground, Node.prototype.onMouseDown, Node.prototype.onMouseMove], hooks);
});
test(`${file}: nodes without an existing creation hook initialize safely`, async () => {
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
class Node { constructor() { this.widgets = []; } }
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
assert.equal(new Node().onNodeCreated(), undefined);
});
}
test('Gemini repeated execution restores the user color and removal cancels its timer', async () => {
const { extension, app, timers } = load('gemini_prompt_ui.js');
let removed = 0;
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fitWindowRect } from '../../web/ui/floating-window.js';
test('window bounds keep its controls reachable after moving outside the viewport', () => {
const rect = fitWindowRect({ x: -100, y: 900, width: 420, height: 400 }, { width: 1000, height: 700 });
assert.equal(rect.x, 8);
assert.equal(rect.y + rect.height, 692);
});
test('window fitting respects minimum sizes and small viewports', () => {
const small = fitWindowRect({ x: 0, y: 0, width: 10, height: 10 }, { width: 1000, height: 700 });
assert.equal(small.width, 280);
assert.equal(small.height, 200);
const narrow = fitWindowRect({ x: 800, y: 600, width: 900, height: 800 }, { width: 260, height: 180 });
assert.equal(narrow.width, 244);
assert.equal(narrow.height, 164);
assert.equal(narrow.x, 8);
assert.equal(narrow.y, 8);
});
+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);
}
});
+42
View File
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createScope, themeFromColor } from '../../web/ui/core.js';
test('host background luminance determines mode without assuming palette names', () => {
for (const color of ['#fff', '#F4F4F6', 'rgb(240, 240, 240)']) assert.equal(themeFromColor(color), 'light');
for (const color of ['#222', '#202020', 'rgb(30, 32, 34)']) assert.equal(themeFromColor(color), 'dark');
assert.equal(themeFromColor(''), null);
assert.equal(themeFromColor('transparent'), null);
});
test('scope cancels requests and callbacks, removes listeners and disposes once', () => {
const scope = createScope();
const target = new EventTarget();
let events = 0;
let cleanups = 0;
scope.listen(target, 'change', () => events++);
scope.cleanup(() => cleanups++);
target.dispatchEvent(new Event('change'));
assert.equal(events, 1);
scope.dispose();
scope.dispose();
target.dispatchEvent(new Event('change'));
assert.equal(events, 1);
assert.equal(cleanups, 1);
assert.equal(scope.signal.aborted, true);
});
test('debounced work runs once with the latest value and stops after disposal', async () => {
const scope = createScope();
const values = [];
const change = scope.debounce(value => values.push(value), 1);
change('old');
change('new');
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(values, ['new']);
change('cancelled');
scope.dispose();
change('after removal');
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(values, ['new']);
});
+105
View File
@@ -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 -1
View File
@@ -15,7 +15,7 @@ export async function saveCoverage(page, browserName) {
const entries = await page.coverage.stopJSCoverage();
const result = [];
for (const entry of entries) {
const match = entry.url.match(/\/extensions\/kikotools\/([a-z0-9_-]+\.js)(?:\?.*)?$/i);
const match = entry.url.match(/\/extensions\/kikotools\/((?:[a-z0-9_-]+\/)*[a-z0-9_-]+\.(?:js|mjs))(?:\?.*)?$/i);
if (match) result.push({ ...entry, url: pathToFileURL(join(repositoryRoot, 'web', match[1])).href });
}
mkdirSync(process.env.KIKO_JS_COVERAGE, { recursive: true });
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from './node_modules/@playwright/test/index.mjs';
import { fileURLToPath } from 'node:url';
if (!process.env.KIKO_COMFY_URL) throw new Error('Set KIKO_COMFY_URL to a local ComfyUI instance');
const host = new URL(process.env.KIKO_COMFY_URL);
if (!['127.0.0.1', 'localhost', '[::1]'].includes(host.hostname)) throw new Error('The ComfyUI smoke suite only accepts a loopback host');
export default defineConfig({
testDir: '../../tests/comfyui',
outputDir: './test-results/comfyui',
timeout: 60000,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: 0,
use: { baseURL: host.href, browserName: 'chromium', viewport: { width: 1600, height: 1100 }, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
webServer: {
command: 'node tools/testing/start-browser-server.mjs --port 8771',
cwd: fileURLToPath(new URL('../../', import.meta.url)),
url: 'http://127.0.0.1:8771/__test__/',
reuseExistingServer: false,
gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 },
},
});
+93 -27
View File
@@ -12,10 +12,10 @@
"statements": 71
},
"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,
@@ -42,39 +42,105 @@
"statements": 60
},
"kiko_local_image_loader.js": {
"lines": 72,
"branches": 69,
"functions": 40,
"statements": 72
"lines": 77,
"branches": 73,
"functions": 70,
"statements": 77
},
"kiko_save_image_ui.js": {
"lines": 66,
"branches": 49,
"functions": 57,
"statements": 66
"lines": 84,
"branches": 74,
"functions": 78,
"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": 63,
"branches": 58,
"functions": 56,
"statements": 63
"lines": 95,
"branches": 85,
"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
},
"width_height_swap.js": {
"lines": 60,
"branches": 79,
"functions": 73,
"statements": 60
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
}
}
+8 -2
View File
@@ -5,6 +5,12 @@ import { spawnSync } from 'node:child_process';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const metrics = ['lines', 'branches', 'functions', 'statements'];
export function discoverSources(root) {
return readdirSync(root, { recursive: true, withFileTypes: true })
.filter(entry => entry.isFile() && /\.(?:js|mjs)$/.test(entry.name))
.map(entry => relative(root, join(entry.parentPath, entry.name)).replaceAll('\\', '/'))
.sort();
}
export function checkCoverage(summary, baseline, sourceRoot, files) {
const failures = [];
for (const file of files) {
@@ -35,7 +41,7 @@ async function main() {
const reportRoot = join(repositoryRoot, 'coverage/js');
const raw = join(reportRoot, 'raw');
const sourceRoot = join(repositoryRoot, 'web');
const files = readdirSync(sourceRoot).filter(file => file.endsWith('.js')).sort();
const files = discoverSources(sourceRoot);
rmSync(reportRoot, { recursive: true, force: true });
mkdirSync(raw, { recursive: true });
const tests = readdirSync(join(repositoryRoot, 'tests/js')).filter(file => file.endsWith('.test.mjs')).map(file => `tests/js/${file}`);
@@ -44,7 +50,7 @@ async function main() {
run([playwright, 'test', '--config', join(tools, 'playwright.config.mjs'),
'--project', 'chromium', '--project', 'touch'], { KIKO_JS_COVERAGE: raw });
run([join(tools, 'node_modules/c8/bin/c8.js'), 'report', '--all', '--src', sourceRoot,
'--include', 'web/*.js', '--temp-directory', raw, '--reports-dir', reportRoot,
'--include', 'web/**/*.js', '--include', 'web/**/*.mjs', '--temp-directory', raw, '--reports-dir', reportRoot,
'--reporter', 'text', '--reporter', 'json-summary', '--reporter', 'html']);
const summary = JSON.parse(readFileSync(join(reportRoot, 'coverage-summary.json'), 'utf8'));
const baselinePath = join(tools, 'frontend-coverage-baseline.json');
+16 -275
View File
@@ -1,279 +1,20 @@
// ComfyUI-KikoTools - Empty Latent Batch with Swap Button
import { app } from "../../scripts/app.js";
import { app } from '../../scripts/app.js';
import { bindDimensionPresets, extractResolutionFromPreset, swapDimensions } from './ui/dimensions.js';
import { mountDimensionSwap } from './ui/dimension-swap.js';
app.registerExtension({
name: "comfyassets.EmptyLatentBatch",
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
if (nodeData.name === "EmptyLatentBatch") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) onNodeCreated.apply(this, []);
// Helper function to extract resolution from formatted preset string
this.extractResolutionFromPreset = function (presetValue) {
if (presetValue === "custom") return null;
// If it contains formatting metadata, extract the resolution part
if (presetValue.includes(" - ")) {
// Format is: "1024×1024 - 1:1 (1.0MP) - SDXL"
return presetValue.split(" - ")[0];
}
// Otherwise assume it's already a raw resolution
return presetValue;
name: 'comfyassets.EmptyLatentBatch',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== 'EmptyLatentBatch') return;
nodeType.prototype.createSwapButton = function () { return mountDimensionSwap(this, app); };
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
this.extractResolutionFromPreset = extractResolutionFromPreset;
this.swapDimensions = () => swapDimensions(this, app);
bindDimensionPresets(this, app);
this.createSwapButton();
return result;
};
// Create swap button as DOM widget
this.createSwapButton();
// Override preset callback to update width/height widgets when preset changes
const presetWidget = this.widgets.find((w) => w.name === "preset");
if (presetWidget) {
const originalCallback = presetWidget.callback;
presetWidget.callback = function (
value,
graphcanvas,
node,
pos,
event,
) {
// Call original callback first
if (originalCallback) {
originalCallback.call(this, value, graphcanvas, node, pos, event);
}
// Update width/height widgets based on preset
const widthWidget = node.widgets.find((w) => w.name === "width");
const heightWidget = node.widgets.find((w) => w.name === "height");
if (widthWidget && heightWidget && value !== "custom") {
// Extract raw resolution from formatted preset
const rawResolution = node.extractResolutionFromPreset(value);
// Define all available presets from our preset system
const presetDimensions = {
// SDXL Presets
"1024×1024": [1024, 1024],
"896×1152": [896, 1152],
"832×1216": [832, 1216],
"768×1344": [768, 1344],
"640×1536": [640, 1536],
"1152×896": [1152, 896],
"1216×832": [1216, 832],
"1344×768": [1344, 768],
"1536×640": [1536, 640],
// FLUX Presets
"1920×1080": [1920, 1080],
"1536×1536": [1536, 1536],
"1280×768": [1280, 768],
"768×1280": [768, 1280],
"1440×1080": [1440, 1080],
"1080×1440": [1080, 1440],
"1728×1152": [1728, 1152],
"1152×1728": [1152, 1728],
// Ultra-Wide Presets
"2560×1080": [2560, 1080],
"2048×768": [2048, 768],
"1792×768": [1792, 768],
"2304×768": [2304, 768],
"1080×2560": [1080, 2560],
"768×2048": [768, 2048],
"768×1792": [768, 1792],
"768×2304": [768, 2304],
// Qwen Presets
"1328×1328": [1328, 1328],
"1664×928": [1664, 928],
"928×1664": [928, 1664],
"1472×1104": [1472, 1104],
"1104×1472": [1104, 1472],
"1584×1056": [1584, 1056],
"1056×1584": [1056, 1584],
"2080×688": [2080, 688],
"688×2080": [688, 2080],
};
if (rawResolution && presetDimensions[rawResolution]) {
const [w, h] = presetDimensions[rawResolution];
widthWidget.value = w;
heightWidget.value = h;
// Trigger widget callbacks to update the UI
if (widthWidget.callback) {
widthWidget.callback(w, graphcanvas, node, pos, event);
}
if (heightWidget.callback) {
heightWidget.callback(h, graphcanvas, node, pos, event);
}
}
}
};
}
// Add swap functionality
this.swapDimensions = function () {
const widthWidget = this.widgets.find((w) => w.name === "width");
const heightWidget = this.widgets.find((w) => w.name === "height");
const presetWidget = this.widgets.find((w) => w.name === "preset");
if (widthWidget && heightWidget && presetWidget) {
// Handle preset swapping first
if (presetWidget.value !== "custom") {
const currentPreset = presetWidget.value;
// Extract raw resolution from formatted preset
const rawResolution =
this.extractResolutionFromPreset(currentPreset);
if (!rawResolution) return;
// Parse current preset dimensions (handle both × and x separators)
let w, h;
if (rawResolution.includes("×")) {
[w, h] = rawResolution.split("×").map((v) => parseInt(v));
} else if (rawResolution.includes("x")) {
[w, h] = rawResolution.split("x").map((v) => parseInt(v));
} else {
return; // Invalid preset format
}
const swappedRawPreset = `${h}×${w}`;
// Find the formatted version of the swapped preset from available options
const availablePresets =
presetWidget.options.values || presetWidget.options;
let swappedFormattedPreset = null;
for (const option of availablePresets) {
if (option === "custom") continue;
const extractedRes = this.extractResolutionFromPreset(option);
if (extractedRes === swappedRawPreset) {
swappedFormattedPreset = option;
break;
}
}
if (swappedFormattedPreset) {
// Swapped preset exists, use the formatted version
presetWidget.value = swappedFormattedPreset;
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback(
swappedFormattedPreset,
app.canvas,
this,
[0, 0],
null
);
}
if (widthWidget.callback) {
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
}
} else {
// Swapped preset doesn't exist, switch to custom and swap manual values
presetWidget.value = "custom";
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback("custom", app.canvas, this, [0, 0], null);
}
if (widthWidget.callback) {
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
}
}
} else {
// Custom preset - just swap the width and height values
const tempWidth = widthWidget.value;
widthWidget.value = heightWidget.value;
heightWidget.value = tempWidth;
// Trigger widget change events
if (widthWidget.callback) {
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
}
}
// Mark the graph as changed
this.graph?.setDirtyCanvas(true, true);
}
};
};
// Create swap button as DOM widget
nodeType.prototype.createSwapButton = function () {
// Create button container
const buttonContainer = document.createElement("div");
buttonContainer.style.cssText = `
padding: 4px;
text-align: center;
`;
// Create swap button
const swapButton = document.createElement("button");
swapButton.innerHTML = "↔️ Swap W×H";
swapButton.style.cssText = `
background: #4A90E2;
color: white;
border: none;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-size: 11px;
font-weight: bold;
transition: background 0.2s;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
`;
// Button hover effects
swapButton.addEventListener("mouseenter", () => {
swapButton.style.background = "#5BA0F2";
swapButton.style.transform = "translateY(-1px)";
swapButton.style.boxShadow = "0 3px 6px rgba(0,0,0,0.3)";
});
swapButton.addEventListener("mouseleave", () => {
swapButton.style.background = "#4A90E2";
swapButton.style.transform = "translateY(0)";
swapButton.style.boxShadow = "0 2px 4px rgba(0,0,0,0.2)";
});
// Button click effect and functionality
swapButton.addEventListener("mousedown", () => {
swapButton.style.background = "#3A80D2";
swapButton.style.transform = "translateY(1px)";
swapButton.style.boxShadow = "0 1px 2px rgba(0,0,0,0.2)";
});
swapButton.addEventListener("mouseup", () => {
swapButton.style.background = "#5BA0F2";
swapButton.style.transform = "translateY(-1px)";
swapButton.style.boxShadow = "0 3px 6px rgba(0,0,0,0.3)";
});
// Main click functionality
swapButton.addEventListener("click", () => {
this.swapDimensions();
});
buttonContainer.appendChild(swapButton);
// Add as DOM widget
this.swapButtonWidget = this.addDOMWidget(
"swap_button",
"div",
buttonContainer
);
};
}
},
},
});
+78 -506
View File
@@ -1,5 +1,7 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { createScope } from "./ui/core.js";
import { createImageBrowser } from "./views/image-browser.js";
// Escape a value for safe interpolation into HTML text or attribute contexts
function escapeHtml(value) {
@@ -29,131 +31,6 @@ async function describeHttpError(response) {
return detail || `Request failed (HTTP ${response.status})`;
}
// Setup global lightbox for image preview
function setupGlobalLightbox() {
if (document.getElementById('kiko-image-lightbox')) return;
const lightboxId = 'kiko-image-lightbox';
const lightboxHTML = `
<div id="${lightboxId}" class="lightbox-overlay">
<button class="lightbox-close">&times;</button>
<button class="lightbox-prev">&lt;</button>
<button class="lightbox-next">&gt;</button>
<div class="lightbox-content">
<img src="" alt="Preview" style="display: none;">
<video src="" controls autoplay style="display: none;"></video>
<audio src="" controls autoplay style="display: none;"></audio>
</div>
<div class="lightbox-dimensions"></div>
</div>
`;
const lightboxCSS = `
#${lightboxId} {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.85);
display: none;
align-items: center;
justify-content: center;
z-index: 10000;
box-sizing: border-box;
-webkit-user-select: none;
user-select: none;
}
#${lightboxId} .lightbox-content {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
#${lightboxId} img, #${lightboxId} video {
max-width: 95%;
max-height: 95%;
object-fit: contain;
transition: transform 0.1s ease-out;
transform: scale(1) translate(0, 0);
}
#${lightboxId} audio {
width: 80%;
max-width: 600px;
}
#${lightboxId} img {
cursor: grab;
}
#${lightboxId} img.panning {
cursor: grabbing;
}
#${lightboxId} .lightbox-close {
position: absolute;
top: 15px;
right: 20px;
width: 35px;
height: 35px;
background-color: rgba(0,0,0,0.5);
color: #fff;
border-radius: 50%;
border: 2px solid #fff;
font-size: 24px;
line-height: 30px;
text-align: center;
cursor: pointer;
z-index: 10002;
}
#${lightboxId} .lightbox-prev, #${lightboxId} .lightbox-next {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 45px;
height: 60px;
background-color: rgba(0,0,0,0.4);
color: #fff;
border: none;
font-size: 30px;
cursor: pointer;
z-index: 10001;
transition: background-color 0.2s;
}
#${lightboxId} .lightbox-prev:hover, #${lightboxId} .lightbox-next:hover {
background-color: rgba(0,0,0,0.7);
}
#${lightboxId} .lightbox-prev {
left: 15px;
}
#${lightboxId} .lightbox-next {
right: 15px;
}
#${lightboxId} [disabled] {
display: none;
}
#${lightboxId} .lightbox-dimensions {
position: absolute;
bottom: 0px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.7);
color: #fff;
padding: 2px 4px;
border-radius: 5px;
font-size: 14px;
z-index: 10001;
}
`;
document.body.insertAdjacentHTML('beforeend', lightboxHTML);
const styleEl = document.createElement('style');
styleEl.textContent = lightboxCSS;
document.head.appendChild(styleEl);
}
setupGlobalLightbox();
app.registerExtension({
name: "KikoTools.LocalImageLoader",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
@@ -162,314 +39,30 @@ app.registerExtension({
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated?.apply(this, arguments);
const galleryContainer = document.createElement("div");
const scope = createScope();
const uniqueId = `kiko-gallery-${Math.random().toString(36).substring(2, 9)}`;
const galleryContainer = createImageBrowser(uniqueId, app.ui.settings, scope);
galleryContainer.id = uniqueId;
const folderSVG = `<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><path d="M928 320H488L416 232c-15.1-18.9-38.3-29.9-63.1-29.9H128c-35.3 0-64 28.7-64 64v512c0 35.3 28.7 64 64 64h800c35.3 0 64-28.7 64-64V384c0-35.3-28.7-64-64-64z" fill="#F4D03F"></path></svg>`;
const videoSVG = `<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><path d="M895.9 203.4H128.1c-35.3 0-64 28.7-64 64v489.2c0 35.3 28.7 64 64 64h767.8c35.3 0 64-28.7 64-64V267.4c0-35.3-28.7-64-64-64zM384 691.2V332.8L668.1 512 384 691.2z" fill="#AED6F1"></path></svg>`;
const audioSVG = `<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><path d="M768 256H256c-35.3 0-64 28.7-64 64v384c0 35.3 28.7 64 64 64h512c35.3 0 64-28.7 64-64V320c0-35.3-28.7-64-64-64zM512 665.6c-84.8 0-153.6-68.8-153.6-153.6S427.2 358.4 512 358.4s153.6 68.8 153.6 153.6-68.8 153.6-153.6 153.6z" fill="#A9DFBF"></path><path d="M512 409.6c-56.5 0-102.4 45.9-102.4 102.4s45.9 102.4 102.4 102.4 102.4-45.9 102.4-102.4-45.9-102.4-102.4-102.4z" fill="#A9DFBF"></path></svg>`;
galleryContainer.innerHTML = `
<style>
#${uniqueId} {
width: 100%;
height: 100%;
overflow: hidden;
box-sizing: border-box;
}
#${uniqueId} .kiko-container-wrapper {
width: 100%;
height: 100%;
font-family: sans-serif;
color: #ccc;
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: 5px;
overflow: hidden;
}
#${uniqueId} .kiko-controls {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 5px;
align-items: center;
flex-shrink: 0;
}
#${uniqueId} .kiko-controls label {
margin-left: 0px;
font-size: 11px;
white-space: nowrap;
}
#${uniqueId} .kiko-controls input,
#${uniqueId} .kiko-controls select,
#${uniqueId} .kiko-controls button {
background-color: #333;
color: #ccc;
border: 1px solid #555;
border-radius: 4px;
padding: 2px 4px;
font-size: 11px;
}
#${uniqueId} .kiko-controls input[type=text] {
flex-grow: 1;
min-width: 100px;
}
#${uniqueId} .kiko-path-controls {
flex-grow: 1;
display: flex;
gap: 3px;
}
#${uniqueId} .kiko-path-presets {
flex-grow: 1;
}
#${uniqueId} .kiko-controls button {
cursor: pointer;
}
#${uniqueId} .kiko-controls button:hover {
background-color: #444;
}
#${uniqueId} .kiko-controls button:disabled {
background-color: #222;
cursor: not-allowed;
}
#${uniqueId} .kiko-cardholder {
position: relative;
overflow-y: auto;
overflow-x: hidden;
background: #222;
padding: 3px;
border-radius: 5px;
flex-grow: 1;
flex-shrink: 1;
min-height: 200px;
width: 100%;
transition: opacity 0.2s ease-in-out;
}
#${uniqueId} .kiko-gallery-card {
position: absolute;
border: 3px solid transparent;
border-radius: 8px;
box-sizing: border-box;
transition: all 0.3s ease;
cursor: pointer;
display: flex;
flex-direction: column;
background-color: #2a2a2a;
}
#${uniqueId} .kiko-gallery-card.kiko-selected {
border-color: #00FFC9;
}
#${uniqueId} .kiko-card-media-wrapper {
flex-grow: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 100px;
}
#${uniqueId} .kiko-gallery-card img,
#${uniqueId} .kiko-gallery-card video {
width: 100%;
height: auto;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
display: block;
}
#${uniqueId} .kiko-folder-card,
#${uniqueId} .kiko-audio-card {
background-color: transparent;
flex-grow: 1;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
#${uniqueId} .kiko-folder-card:hover,
#${uniqueId} .kiko-audio-card:hover {
background-color: #444;
}
#${uniqueId} .kiko-parent-folder-card {
background-color: transparent;
}
#${uniqueId} .kiko-parent-folder-card:hover {
background-color: #555;
}
#${uniqueId} .kiko-folder-icon,
#${uniqueId} .kiko-audio-icon {
width: 60%;
height: 60%;
margin-bottom: 8px;
}
#${uniqueId} .kiko-folder-name,
#${uniqueId} .kiko-audio-name {
font-size: 12px;
word-break: break-all;
user-select: none;
}
#${uniqueId} .kiko-video-card-overlay {
position: absolute;
top: 5px;
left: 5px;
width: 24px;
height: 24px;
opacity: 0.8;
pointer-events: none;
}
#${uniqueId} .kiko-card-info-panel {
flex-shrink: 0;
background-color: #353535;
padding: 4px;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
min-height: 24px;
font-size: 10px;
text-align: center;
color: #aaa;
}
#${uniqueId} .kiko-pagination {
display: flex;
justify-content: center;
gap: 3px;
margin-top: 3px;
flex-shrink: 0;
padding-bottom: 2px;
}
#${uniqueId} .kiko-status-message {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px;
border-radius: 5px;
display: none;
z-index: 1000;
}
#${uniqueId} .kiko-path-input-wrapper {
position: relative;
flex-grow: 1;
min-width: 100px;
}
#${uniqueId} .kiko-path-input-wrapper input {
width: 100%;
box-sizing: border-box;
}
#${uniqueId} .kiko-autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 250px;
overflow-y: auto;
background-color: #2a2a2a;
border: 1px solid #555;
border-top: none;
border-radius: 0 0 4px 4px;
z-index: 2000;
display: none;
}
#${uniqueId} .kiko-autocomplete-dropdown.visible {
display: block;
}
#${uniqueId} .kiko-autocomplete-item {
padding: 6px 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
border-bottom: 1px solid #333;
}
#${uniqueId} .kiko-autocomplete-item:last-child {
border-bottom: none;
}
#${uniqueId} .kiko-autocomplete-item:hover,
#${uniqueId} .kiko-autocomplete-item.selected {
background-color: #3a3a3a;
}
#${uniqueId} .kiko-autocomplete-item .folder-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
#${uniqueId} .kiko-autocomplete-item .path-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${uniqueId} .kiko-autocomplete-item .folder-name {
color: #fff;
}
#${uniqueId} .kiko-autocomplete-item .parent-path {
color: #888;
font-size: 10px;
}
</style>
<div class="kiko-container-wrapper">
<!-- Path controls -->
<div class="kiko-controls">
<div class="kiko-path-controls">
<select class="kiko-path-presets" title="Saved paths">
<option value="">-- Saved Paths --</option>
</select>
<button class="kiko-save-path" title="Save current path to favorites">💾</button>
<button class="kiko-delete-path" title="Remove selected path from favorites">✕</button>
</div>
</div>
<!-- Directory input -->
<div class="kiko-controls">
<button class="kiko-up-folder" title="Navigate to parent folder">⬆️</button>
<div class="kiko-path-input-wrapper">
<input type="text" class="kiko-path-input" placeholder="Enter directory path... (type / to browse)" />
<div class="kiko-autocomplete-dropdown"></div>
</div>
<button class="kiko-browse" title="Refresh folder">🔄</button>
</div>
<!-- View options -->
<div class="kiko-controls">
<label>
<input type="checkbox" class="kiko-hide-dot-folders" checked /> Hide .folders
</label>
<select class="kiko-sort-by">
<option value="name">Name</option>
<option value="date">Date</option>
<option value="size">Size</option>
</select>
<select class="kiko-sort-order">
<option value="asc">↑</option>
<option value="desc">↓</option>
</select>
<button class="kiko-refresh">🔄</button>
</div>
<!-- Gallery -->
<div class="kiko-status-message">Loading...</div>
<div class="kiko-cardholder"></div>
<!-- Pagination -->
<div class="kiko-pagination"></div>
</div>
`;
// Add the widget to the node
this.galleryWidget = this.addDOMWidget("kiko_local_image_gallery", "div", galleryContainer, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => 350,
});
// Set initial size for the node
this.size = [800, 600];
this.size = [800, 900];
this.setSize(this.size);
// Initialize gallery functionality
const node = this;
const fetchApi = (path, options = {}) => api.fetchApi(path, { ...options, signal: scope.signal });
let loadVersion = 0;
const container = galleryContainer.querySelector('.kiko-container-wrapper');
const pathInput = container.querySelector('.kiko-path-input');
const pathPresets = container.querySelector('.kiko-path-presets');
@@ -497,61 +90,19 @@ app.registerExtension({
audio: null
};
// Utility: Debounce function
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
};
// Apply responsive masonry layout
const applyMasonryLayout = () => {
const minCardWidth = 120;
const gap = 5;
const containerWidth = cardHolder.clientWidth - 10; // Account for padding
if (containerWidth <= 0) return;
const columnCount = Math.max(1, Math.floor(containerWidth / (minCardWidth + gap)));
const totalGapSpace = (columnCount - 1) * gap;
const actualCardWidth = Math.floor((containerWidth - totalGapSpace) / columnCount);
const columnHeights = new Array(columnCount).fill(0);
const cards = cardHolder.querySelectorAll('.kiko-gallery-card');
cards.forEach(card => {
card.style.width = `${actualCardWidth}px`;
const minHeight = Math.min(...columnHeights);
const columnIndex = columnHeights.indexOf(minHeight);
card.style.left = `${columnIndex * (actualCardWidth + gap)}px`;
card.style.top = `${minHeight}px`;
columnHeights[columnIndex] += card.offsetHeight + gap;
});
// Set container height - let scrollbar handle overflow
const maxHeight = Math.max(...columnHeights);
if (maxHeight > 0) {
cardHolder.style.minHeight = `${Math.min(200, maxHeight)}px`;
}
};
const debouncedLayout = debounce(applyMasonryLayout, 20);
// Set up ResizeObserver for responsive layout
this.kikoResizeObserver = new ResizeObserver(debouncedLayout);
this.kikoResizeObserver.observe(cardHolder);
const debounce = scope.debounce;
const originalOnRemoved = this.onRemoved;
this.onRemoved = function () {
if (this.kikoResizeObserver) {
this.kikoResizeObserver.disconnect();
this.kikoResizeObserver = null;
}
scope.dispose();
return originalOnRemoved?.apply(this, arguments);
};
scope.cleanup(() => { galleryContainer.remove(); });
scope.listen(container.querySelector('.ca-file-filter'), 'input', renderGallery);
const showStatus = (message) => {
if (scope.signal.aborted) return;
statusMessage.style.display = 'block';
statusMessage.dataset.state = 'error';
statusMessage.textContent = message;
};
@@ -562,7 +113,7 @@ app.registerExtension({
async function fetchDirectorySuggestions(path) {
try {
const response = await api.fetchApi(`/kiko_local_image_loader/list_directories?path=${encodeURIComponent(path)}`);
const response = await fetchApi(`/kiko_local_image_loader/list_directories?path=${encodeURIComponent(path)}`);
if (!response.ok) {
// Disallowed or invalid paths simply yield no suggestions
return [];
@@ -570,7 +121,7 @@ app.registerExtension({
const data = await response.json();
return data.directories || [];
} catch (error) {
console.error('Error fetching directories:', error);
if (!scope.signal.aborted) console.error('Error fetching directories:', error);
return [];
}
}
@@ -639,10 +190,13 @@ app.registerExtension({
});
}
let suggestionVersion = 0;
const debouncedAutocomplete = debounce(async (value) => {
const version = ++suggestionVersion;
const suggestions = await fetchDirectorySuggestions(value);
renderAutocompleteSuggestions(suggestions);
if (!scope.signal.aborted && version === suggestionVersion && pathInput.value === value && document.activeElement === pathInput) renderAutocompleteSuggestions(suggestions);
}, 150);
const delayedHideAutocomplete = debounce(hideAutocomplete, 150);
// Path input event handlers for autocomplete
pathInput.addEventListener('input', (e) => {
@@ -663,7 +217,7 @@ app.registerExtension({
pathInput.addEventListener('blur', () => {
// Delay hiding to allow click events to register
setTimeout(hideAutocomplete, 150);
delayedHideAutocomplete();
});
pathInput.addEventListener('keydown', (e) => {
@@ -713,11 +267,12 @@ app.registerExtension({
// Load saved paths
async function loadSavedPaths() {
try {
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
const response = await fetchApi('/kiko_local_image_loader/get_saved_paths');
if (!response.ok) {
throw new Error(await describeHttpError(response));
}
const data = await response.json();
if (scope.signal.aborted) return;
pathPresets.innerHTML = '<option value="">-- Saved Paths --</option>';
data.saved_paths?.forEach(path => {
@@ -728,7 +283,7 @@ app.registerExtension({
pathPresets.appendChild(option);
});
} catch (error) {
console.error('Error loading saved paths:', error);
if (!scope.signal.aborted) console.error('Error loading saved paths:', error);
}
}
@@ -737,7 +292,7 @@ app.registerExtension({
// Returns true on success.
async function addSavedPath(directory) {
try {
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
const response = await fetchApi('/kiko_local_image_loader/get_saved_paths');
if (!response.ok) {
throw new Error(await describeHttpError(response));
}
@@ -745,7 +300,7 @@ app.registerExtension({
const savedPaths = data.saved_paths || [];
if (!savedPaths.includes(directory)) {
const saveResponse = await api.fetchApi('/kiko_local_image_loader/save_paths', {
const saveResponse = await fetchApi('/kiko_local_image_loader/save_paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: [...savedPaths, directory] })
@@ -757,6 +312,7 @@ app.registerExtension({
}
return true;
} catch (error) {
if (scope.signal.aborted) return false;
console.error('Error saving path:', error);
showStatus(`Error saving path: ${error.message}`);
return false;
@@ -775,7 +331,7 @@ app.registerExtension({
if (!selectedPath) return;
try {
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
const response = await fetchApi('/kiko_local_image_loader/get_saved_paths');
if (!response.ok) {
throw new Error(await describeHttpError(response));
}
@@ -784,7 +340,7 @@ app.registerExtension({
const updatedPaths = savedPaths.filter(p => p !== selectedPath);
if (updatedPaths.length !== savedPaths.length) {
const saveResponse = await api.fetchApi('/kiko_local_image_loader/save_paths', {
const saveResponse = await fetchApi('/kiko_local_image_loader/save_paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: updatedPaths })
@@ -795,6 +351,7 @@ app.registerExtension({
await loadSavedPaths();
}
} catch (error) {
if (scope.signal.aborted) return;
console.error('Error deleting path:', error);
showStatus(`Error deleting path: ${error.message}`);
}
@@ -818,7 +375,9 @@ app.registerExtension({
pathLabel.textContent = directory;
const addBtn = document.createElement('button');
addBtn.textContent = 'Add folder & open';
addBtn.style.marginLeft = '8px';
addBtn.type = 'button';
addBtn.className = 'ca-primary';
statusMessage.dataset.state = 'error';
addBtn.onclick = async () => {
addBtn.disabled = true;
// On failure addSavedPath shows the server's reason
@@ -831,7 +390,9 @@ app.registerExtension({
// Load images from directory
async function loadImages(directory, page = 1) {
if (!directory) return;
if (!directory || scope.signal.aborted) return;
const version = ++loadVersion;
statusMessage.dataset.state = 'loading';
statusMessage.style.display = 'block';
statusMessage.textContent = 'Loading...';
@@ -848,19 +409,22 @@ app.registerExtension({
});
try {
const response = await api.fetchApi(`/kiko_local_image_loader/images?${params}`);
const response = await fetchApi(`/kiko_local_image_loader/images?${params}`);
if (scope.signal.aborted || version !== loadVersion) return;
if (response.status === 403) {
handleFolderNotAllowed(directory, page);
return;
}
if (!response.ok) {
statusMessage.textContent = await describeHttpError(response);
const message = await describeHttpError(response);
if (version === loadVersion) showStatus(message);
return;
}
const data = await response.json();
if (scope.signal.aborted || version !== loadVersion) return;
if (data.error) {
statusMessage.textContent = data.error;
showStatus(data.error);
return;
}
@@ -878,8 +442,9 @@ app.registerExtension({
statusMessage.style.display = 'none';
} catch (error) {
if (scope.signal.aborted || version !== loadVersion) return;
console.error('Error loading images:', error);
statusMessage.textContent = 'Error loading directory';
showStatus('Error loading directory');
}
}
@@ -887,18 +452,24 @@ app.registerExtension({
function renderGallery() {
cardHolder.innerHTML = '';
if (currentItems.length === 0 && !parentDirectory) {
cardHolder.innerHTML = '<div style="text-align: center; padding: 20px; color: #666;">No items found</div>';
return;
}
// Create items array with parent folder at the beginning if it exists
const query = container.querySelector('.ca-file-filter').value.toLowerCase();
const visibleItems = currentItems.filter(item => item.name.toLowerCase().includes(query));
if (visibleItems.length === 0) {
const empty = document.createElement('div');
empty.className = 'ca-empty';
empty.textContent = query ? 'No matching items on this page' : 'No items found';
cardHolder.append(empty);
}
const itemsToRender = parentDirectory
? [{ type: 'parent_dir', name: '..', path: parentDirectory }, ...currentItems]
: currentItems;
? [{ type: 'parent_dir', name: '..', path: parentDirectory }, ...visibleItems]
: visibleItems;
itemsToRender.forEach((item, index) => {
const card = document.createElement('div');
const card = document.createElement('button');
card.type = 'button';
card.setAttribute('aria-label', item.type === 'dir' || item.type === 'parent_dir' ? `Open folder ${item.name}` : `Select ${item.type} ${item.name}`);
if (!['dir', 'parent_dir'].includes(item.type)) card.setAttribute('aria-pressed', String(selectedPaths[item.type] === item.path));
card.className = 'kiko-gallery-card';
if (item.type === 'parent_dir') {
@@ -937,10 +508,6 @@ app.registerExtension({
`;
// Trigger layout when image loads
const img = card.querySelector('img');
if (img) {
img.onload = debouncedLayout;
}
// Single click to select
card.onclick = () => selectMedia(item, 'image', card);
@@ -962,10 +529,6 @@ app.registerExtension({
<div class="kiko-card-info-panel">${escapeHtml(item.name)}</div>
`;
const img = card.querySelector('img');
if (img) {
img.onload = debouncedLayout;
}
// Single click to select
card.onclick = () => selectMedia(item, 'video', card);
@@ -1007,24 +570,27 @@ app.registerExtension({
cardHolder.appendChild(card);
});
// Apply layout after all cards are added
requestAnimationFrame(debouncedLayout);
container.querySelector('[data-file-count]').textContent = `${visibleItems.length} of ${currentItems.length} items`;
}
// Select media
async function selectMedia(item, type, cardElement) {
// Update selection
const previousPath = selectedPaths[type];
selectedPaths[type] = item.path;
// Update UI
cardHolder.querySelectorAll('.kiko-gallery-card').forEach(card => {
card.classList.remove('kiko-selected');
if (card.hasAttribute('aria-pressed')) card.setAttribute('aria-pressed', 'false');
});
cardElement.classList.add('kiko-selected');
cardElement.setAttribute('aria-pressed', 'true');
// Send to backend
try {
const response = await api.fetchApi('/kiko_local_image_loader/set_node_selection', {
const response = await fetchApi('/kiko_local_image_loader/set_node_selection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -1034,13 +600,19 @@ app.registerExtension({
})
});
if (!response.ok) {
selectedPaths[type] = previousPath;
renderGallery();
showStatus(`Selection rejected: ${await describeHttpError(response)}`);
return;
}
// Trigger node update
if (scope.signal.aborted) return;
container.querySelector('[data-selection]').textContent = item.name;
node.setDirtyCanvas(true);
} catch (error) {
if (scope.signal.aborted) return;
selectedPaths[type] = previousPath;
renderGallery();
console.error('Error setting selection:', error);
showStatus('Error setting selection');
}
@@ -1055,9 +627,9 @@ app.registerExtension({
const createButton = (text, page) => {
const btn = document.createElement('button');
btn.textContent = text;
btn.style.cssText = 'background: #333; color: #ccc; border: 1px solid #555; padding: 1px 6px; cursor: pointer; font-size: 11px;';
btn.type = 'button';
if (page === currentPage) {
btn.style.background = '#555';
btn.setAttribute('aria-current', 'page');
}
btn.onclick = () => loadImages(currentDirectory, page);
return btn;
@@ -1073,7 +645,7 @@ app.registerExtension({
} else if (i === currentPage - 3 || i === currentPage + 3) {
const span = document.createElement('span');
span.textContent = '...';
span.style.padding = '0 5px';
pagination.appendChild(span);
}
}
@@ -1101,14 +673,14 @@ app.registerExtension({
loadSavedPaths();
// Try to load last path
api.fetchApi('/kiko_local_image_loader/get_last_path').then(async response => {
fetchApi('/kiko_local_image_loader/get_last_path').then(async response => {
if (!response.ok) return;
const data = await response.json();
if (data.last_path) {
if (data.last_path && loadVersion === 0 && !scope.signal.aborted) {
loadImages(data.last_path);
}
}).catch(error => {
console.error('Error loading last path:', error);
if (!scope.signal.aborted) console.error('Error loading last path:', error);
});
return r;
+252 -919
View File
File diff suppressed because it is too large Load Diff
+80 -525
View File
@@ -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());
},
});
+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;
+225 -512
View File
@@ -1,525 +1,238 @@
// ComfyUI-KikoTools - Seed History with Tracking UI
import { app } from "../../scripts/app.js";
import { app } from '../../scripts/app.js';
import { createScope, mountTheme } from './ui/core.js';
import { createSeedHistoryView } from './views/seed-history.js';
const storageKey = 'comfyui_kikotools_seed_history';
function seedValue(value) {
if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim())) return null;
const seed = Number(value);
return Number.isInteger(seed) && seed >= 0 && seed <= 0xFFFFFFFF ? seed : null;
}
function historyValue(value) {
if (!Array.isArray(value)) return [];
const seeds = new Set();
return value.flatMap(item => {
const seed = seedValue(item?.seed);
if (seed === null || seeds.has(seed)) return [];
seeds.add(seed);
return [{ ...item, seed, timestamp: Number.isFinite(item.timestamp) ? item.timestamp : Date.now() }];
}).slice(0, 10);
}
app.registerExtension({
name: "comfyassets.SeedHistory",
async setup() {
// Store reference to all SeedHistory nodes
window.seedHistoryNodes = window.seedHistoryNodes || [];
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "SeedHistory") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// Initialize seed history
this.seedHistory = this.loadSeedHistory();
this.hideTimer = null;
this.mouseOverHistory = false;
// Register this node in global registry
name: 'comfyassets.SeedHistory',
async setup() {
window.seedHistoryNodes = window.seedHistoryNodes || [];
window.seedHistoryNodes.push(this);
// Create UI container
const uiContainer = document.createElement("div");
uiContainer.style.padding = "8px";
uiContainer.style.backgroundColor = "#1e1e1e";
uiContainer.style.borderRadius = "6px";
uiContainer.style.marginTop = "6px";
uiContainer.style.border = "1px solid #404040";
this.buildSeedInterface(uiContainer);
// Add as widget
this.seedWidget = this.addDOMWidget(
"seed_history_ui",
"div",
uiContainer,
);
// Set node size
if (!this.hasBeenResized) {
this.size = [280, 320];
}
// Track manual resizing
const originalResize = this.onResize;
this.onResize = function (size) {
this.hasBeenResized = true;
if (originalResize) {
originalResize.call(this, size);
}
};
// Hook into seed widget callbacks to track all changes
setTimeout(() => {
this.setupSeedWidgetCallbacks();
}, 100);
// Hook directly into widget value changes
const originalOnWidgetChange = this.onWidgetChange;
this.onWidgetChange = function(name, value, oldValue, widget) {
if (name === "seed" && value !== oldValue) {
this.addSeedToHistory(value);
}
if (originalOnWidgetChange) {
return originalOnWidgetChange.call(this, name, value, oldValue, widget);
}
};
// Save/load data
const originalSerialize = this.serialize;
this.serialize = function () {
const data = originalSerialize ? originalSerialize.call(this) : {};
data.hasBeenResized = this.hasBeenResized;
data.seedHistory = this.seedHistory;
return data;
};
const originalConfigure = this.configure;
this.configure = function (data) {
if (originalConfigure) {
originalConfigure.call(this, data);
}
if (data.hasBeenResized) {
this.hasBeenResized = data.hasBeenResized;
}
if (data.seedHistory) {
this.seedHistory = data.seedHistory;
this.refreshHistoryDisplay();
}
};
// Cleanup interval on node removal
const originalOnRemoved = this.onRemoved;
this.onRemoved = function () {
if (this.seedValueWatcher) {
clearInterval(this.seedValueWatcher);
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== 'SeedHistory') return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
this.seedScope = createScope();
this.seedHistory = this.loadSeedHistory();
this.hideTimer = null;
this.seedSetupTimer = null;
this.seedValueWatcher = null;
}
// Clean up deduplication tracking
if (this.lastAddedSeed) {
this.lastAddedSeed = null;
}
// Remove from global registry
if (window.seedHistoryNodes) {
const index = window.seedHistoryNodes.indexOf(this);
if (index !== -1) {
window.seedHistoryNodes.splice(index, 1);
}
}
if (originalOnRemoved) {
originalOnRemoved.call(this);
}
this.mouseOverHistory = false;
window.seedHistoryNodes = window.seedHistoryNodes || [];
window.seedHistoryNodes.push(this);
this.buildSeedInterface();
this.seedWidget = this.addDOMWidget('seed_history_ui', 'div', this.seedView.element, {
serialize: false, hideOnZoom: false,
getMinHeight: () => 200,
});
if (!this.hasBeenResized) this.setSize([320, 420]);
const originalResize = this.onResize;
this.onResize = function () {
this.hasBeenResized = true;
return originalResize?.apply(this, arguments);
};
this.seedSetupTimer = setTimeout(() => {
this.seedSetupTimer = null;
this.setupSeedWidgetCallbacks();
}, 100);
const originalOnWidgetChange = this.onWidgetChange;
this.onWidgetChange = function (name, value, oldValue) {
if (name === 'seed' && value !== oldValue) this.addSeedToHistory(value);
return originalOnWidgetChange?.apply(this, arguments);
};
const originalSerialize = this.serialize;
this.serialize = function () {
const data = originalSerialize?.apply(this, arguments) || {};
data.hasBeenResized = this.hasBeenResized;
data.seedHistory = this.seedHistory;
return data;
};
const originalConfigure = this.configure;
this.configure = function (data) {
const result = originalConfigure?.apply(this, arguments);
if (data.hasBeenResized !== undefined) this.hasBeenResized = data.hasBeenResized;
if (data.seedHistory !== undefined) {
this.seedHistory = historyValue(data.seedHistory);
this.refreshHistoryDisplay();
}
return result;
};
this.seedScope.cleanup(() => {
clearTimeout(this.seedSetupTimer);
clearInterval(this.seedValueWatcher);
this.seedSetupTimer = null;
this.seedValueWatcher = null;
this.cancelAutoHide();
this.lastAddedSeed = null;
window.seedHistoryNodes = window.seedHistoryNodes.filter(node => node !== this);
this.seedView.element.remove();
});
const originalOnRemoved = this.onRemoved;
this.onRemoved = function () {
this.seedScope.dispose();
return originalOnRemoved?.apply(this, arguments);
};
this.setDirtyCanvas(true, true);
return result;
};
this.setDirtyCanvas(true, true);
};
// Setup seed widget callbacks to track increment/decrement/randomize
nodeType.prototype.setupSeedWidgetCallbacks = function () {
// Find the seed widget
const seedWidget = this.widgets?.find(w => w.name === "seed");
if (!seedWidget) {
setTimeout(() => this.setupSeedWidgetCallbacks(), 500);
return;
}
// Store the last known seed value to detect changes
this.lastSeedValue = seedWidget.value;
// Monitor for value changes that might not trigger callback
this.seedValueWatcher = setInterval(() => {
if (seedWidget.value !== this.lastSeedValue) {
nodeType.prototype.setupSeedWidgetCallbacks = function () {
if (this.seedScope.signal.aborted || this.seedValueWatcher !== null) return;
const seedWidget = this.widgets?.find(widget => widget.name === 'seed');
if (!seedWidget) {
this.seedSetupTimer = setTimeout(() => {
this.seedSetupTimer = null;
this.setupSeedWidgetCallbacks();
}, 500);
return;
}
this.lastSeedValue = seedWidget.value;
this.addSeedToHistory(seedWidget.value);
}
}, 1000);
};
this.seedValueWatcher = setInterval(() => {
if (seedWidget.value !== this.lastSeedValue) {
this.lastSeedValue = seedWidget.value;
this.addSeedToHistory(seedWidget.value);
}
}, 1000);
};
// Build the seed interface
nodeType.prototype.buildSeedInterface = function (container) {
container.innerHTML = "";
// Header
const headerDiv = document.createElement("div");
headerDiv.style.marginBottom = "8px";
const titleDiv = document.createElement("div");
titleDiv.style.fontWeight = "bold";
titleDiv.style.color = "#00d4ff";
titleDiv.style.textAlign = "center";
titleDiv.style.padding = "4px";
titleDiv.style.backgroundColor = "rgba(0, 212, 255, 0.1)";
titleDiv.style.borderRadius = "4px";
titleDiv.style.fontSize = "11px";
titleDiv.innerHTML = "🎲 Seed History";
headerDiv.appendChild(titleDiv);
// Action buttons
const buttonDiv = document.createElement("div");
buttonDiv.style.display = "flex";
buttonDiv.style.gap = "4px";
buttonDiv.style.marginTop = "6px";
// Generate button
const generateBtn = document.createElement("button");
generateBtn.textContent = "🎲 Generate";
generateBtn.style.flex = "1";
generateBtn.style.padding = "3px 6px";
generateBtn.style.backgroundColor = "#0088cc";
generateBtn.style.color = "white";
generateBtn.style.border = "none";
generateBtn.style.borderRadius = "3px";
generateBtn.style.cursor = "pointer";
generateBtn.style.fontSize = "10px";
generateBtn.addEventListener("click", () => this.generateRandomSeed());
buttonDiv.appendChild(generateBtn);
// Clear button
const clearBtn = document.createElement("button");
clearBtn.textContent = "🗑️ Clear";
clearBtn.style.flex = "1";
clearBtn.style.padding = "3px 6px";
clearBtn.style.backgroundColor = "#cc4444";
clearBtn.style.color = "white";
clearBtn.style.border = "none";
clearBtn.style.borderRadius = "3px";
clearBtn.style.cursor = "pointer";
clearBtn.style.fontSize = "10px";
clearBtn.addEventListener("click", () => this.clearSeedHistory());
buttonDiv.appendChild(clearBtn);
headerDiv.appendChild(buttonDiv);
container.appendChild(headerDiv);
// History display
const historyDiv = document.createElement("div");
historyDiv.style.maxHeight = "180px";
historyDiv.style.overflowY = "auto";
historyDiv.style.border = "1px solid #333";
historyDiv.style.borderRadius = "4px";
historyDiv.style.backgroundColor = "#2a2a2a";
historyDiv.style.padding = "6px";
historyDiv.style.fontSize = "10px";
historyDiv.style.fontFamily = "monospace";
// Mouse events for auto-hide
historyDiv.addEventListener("mouseenter", () => {
this.mouseOverHistory = true;
this.cancelAutoHide();
});
historyDiv.addEventListener("mouseleave", () => {
this.mouseOverHistory = false;
this.startAutoHide();
});
this.historyDisplay = historyDiv;
container.appendChild(historyDiv);
this.refreshHistoryDisplay();
};
// Load history from storage
nodeType.prototype.loadSeedHistory = function () {
try {
const stored = localStorage.getItem('comfyui_kikotools_seed_history');
return stored ? JSON.parse(stored) : [];
} catch (error) {
return [];
}
};
// Save history to storage
nodeType.prototype.saveSeedHistory = function () {
try {
localStorage.setItem('comfyui_kikotools_seed_history', JSON.stringify(this.seedHistory));
} catch (error) {
// Silently handle storage errors
}
};
// Add seed to history
nodeType.prototype.addSeedToHistory = function (seed) {
if (!seed || seed === 0) return;
const numSeed = typeof seed === 'string' ? parseInt(seed) : seed;
const now = Date.now();
// Deduplication: prevent adding the same seed within 500ms window
if (!this.lastAddedSeed) {
this.lastAddedSeed = { seed: null, timestamp: 0 };
}
const timeSinceLastAdd = now - this.lastAddedSeed.timestamp;
const isSameSeed = this.lastAddedSeed.seed === numSeed;
const isWithinDupeWindow = timeSinceLastAdd < 500; // 500ms window
if (isSameSeed && isWithinDupeWindow) {
return;
}
// Update deduplication tracking
this.lastAddedSeed = { seed: numSeed, timestamp: now };
// Remove if already exists in history
this.seedHistory = this.seedHistory.filter(item => item.seed !== numSeed);
// Add to front
this.seedHistory.unshift({
seed: numSeed,
timestamp: now,
dateString: new Date().toLocaleString()
});
// Keep only last 10
if (this.seedHistory.length > 10) {
this.seedHistory = this.seedHistory.slice(0, 10);
}
this.saveSeedHistory();
this.refreshHistoryDisplay();
this.startAutoHide();
};
// Generate new random seed
nodeType.prototype.generateRandomSeed = function () {
const newSeed = Math.floor(Math.random() * 0xFFFFFFFF); // 2**32 - 1
const seedWidget = this.widgets?.find(w => w.name === "seed");
if (seedWidget) {
seedWidget.value = newSeed;
if (seedWidget.callback) {
seedWidget.callback(newSeed, this, seedWidget);
}
}
this.addSeedToHistory(newSeed);
this.setDirtyCanvas(true, true);
this.showMessage(`Generated: ${newSeed}`, "success");
};
// Use seed from history
nodeType.prototype.useSeedFromHistory = function (historyItem, index) {
const seedWidget = this.widgets?.find(w => w.name === "seed");
if (seedWidget) {
seedWidget.value = historyItem.seed;
if (seedWidget.callback) {
seedWidget.callback(historyItem.seed, this, seedWidget);
}
}
this.highlightHistoryEntry(index);
this.setDirtyCanvas(true, true);
this.startAutoHide();
this.showMessage(`Loaded: ${historyItem.seed}`, "info");
};
// Clear history
nodeType.prototype.clearSeedHistory = function () {
this.seedHistory = [];
this.saveSeedHistory();
this.refreshHistoryDisplay();
this.showMessage("History cleared", "info");
};
// Refresh history display
nodeType.prototype.refreshHistoryDisplay = function () {
if (!this.historyDisplay) return;
if (!this.seedHistory || this.seedHistory.length === 0) {
this.historyDisplay.innerHTML =
'<div style="color: #888; text-align: center; padding: 15px;">No seeds tracked<br><small>Generate seeds to build history</small></div>';
return;
}
this.historyDisplay.innerHTML = "";
this.seedHistory.forEach((item, index) => {
const entryDiv = document.createElement("div");
entryDiv.style.padding = "4px";
entryDiv.style.marginBottom = "3px";
entryDiv.style.backgroundColor = "#333";
entryDiv.style.borderRadius = "2px";
entryDiv.style.cursor = "pointer";
entryDiv.style.border = "1px solid transparent";
entryDiv.style.lineHeight = "1.2";
entryDiv.addEventListener("mouseenter", () => {
entryDiv.style.backgroundColor = "#444";
entryDiv.style.border = "1px solid #555";
});
entryDiv.addEventListener("mouseleave", () => {
entryDiv.style.backgroundColor = "#333";
entryDiv.style.border = "1px solid transparent";
});
entryDiv.addEventListener("click", () => {
this.useSeedFromHistory(item, index);
});
const timeAgo = this.formatTimeAgo(item.timestamp);
// Build with textContent: history comes from localStorage and is untrusted
const seedDiv = document.createElement("div");
seedDiv.style.cssText = "color: #fff; font-weight: bold; margin-bottom: 1px;";
seedDiv.textContent = `🎲 ${Number(item.seed)}`;
const timeDiv = document.createElement("div");
timeDiv.style.cssText = "color: #999; font-size: 8px;";
timeDiv.textContent = `⏰ ${timeAgo}`;
entryDiv.appendChild(seedDiv);
entryDiv.appendChild(timeDiv);
this.historyDisplay.appendChild(entryDiv);
});
this.startAutoHide();
};
// Highlight selected entry
nodeType.prototype.highlightHistoryEntry = function (index) {
const entries = this.historyDisplay.querySelectorAll('div[style*="cursor: pointer"]');
entries.forEach((entry, i) => {
if (i === index) {
entry.style.backgroundColor = "#006600";
entry.style.border = "1px solid #00aa00";
} else {
entry.style.backgroundColor = "#333";
entry.style.border = "1px solid transparent";
}
});
};
// Auto-hide functionality
nodeType.prototype.startAutoHide = function () {
this.cancelAutoHide();
if (!this.mouseOverHistory) {
this.hideTimer = setTimeout(() => {
this.hideHistorySection();
}, 2500);
}
};
nodeType.prototype.cancelAutoHide = function () {
if (this.hideTimer) {
clearTimeout(this.hideTimer);
this.hideTimer = null;
}
};
nodeType.prototype.hideHistorySection = function () {
if (this.historyDisplay && !this.mouseOverHistory) {
this.historyDisplay.style.display = "none";
if (!this.restoreButton) {
const restoreDiv = document.createElement("div");
restoreDiv.style.padding = "10px";
restoreDiv.style.backgroundColor = "#2a2a2a";
restoreDiv.style.border = "1px solid #333";
restoreDiv.style.borderRadius = "4px";
restoreDiv.style.textAlign = "center";
restoreDiv.style.cursor = "pointer";
restoreDiv.style.color = "#888";
restoreDiv.style.fontSize = "10px";
restoreDiv.innerHTML = "🎲 History auto-hidden<br><small>Click to show</small>";
restoreDiv.addEventListener("mouseenter", () => {
restoreDiv.style.backgroundColor = "#333";
restoreDiv.style.color = "#bbb";
nodeType.prototype.buildSeedInterface = function () {
this.seedView = createSeedHistoryView(this.seedScope, {
generate: () => this.generateRandomSeed(),
clear: () => this.clearSeedHistory(),
select: index => this.useSeedFromHistory(this.seedHistory[index], index),
expand: expanded => expanded ? this.showHistorySection() : this.hideHistorySection(true),
});
restoreDiv.addEventListener("mouseleave", () => {
restoreDiv.style.backgroundColor = "#2a2a2a";
restoreDiv.style.color = "#888";
const { element, list, disclosure } = this.seedView;
this.historyDisplay = list;
this.restoreButton = disclosure;
mountTheme(element, app.ui.settings, this.seedScope);
this.seedScope.listen(list, 'mouseenter', () => {
this.mouseOverHistory = true;
this.cancelAutoHide();
});
restoreDiv.addEventListener("click", () => {
this.showHistorySection();
this.seedScope.listen(list, 'mouseleave', () => {
this.mouseOverHistory = false;
this.startAutoHide();
});
this.seedScope.listen(list, 'focusin', () => this.cancelAutoHide());
this.seedScope.listen(list, 'focusout', () => this.startAutoHide());
this.refreshHistoryDisplay();
};
this.restoreButton = restoreDiv;
this.historyDisplay.parentNode.insertBefore(
restoreDiv,
this.historyDisplay.nextSibling
);
}
}
};
nodeType.prototype.showHistorySection = function () {
if (this.historyDisplay) {
this.historyDisplay.style.display = "block";
if (this.restoreButton && this.restoreButton.parentNode) {
this.restoreButton.parentNode.removeChild(this.restoreButton);
this.restoreButton = null;
}
this.startAutoHide();
}
};
// Format time ago
nodeType.prototype.formatTimeAgo = function (timestamp) {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return `${seconds}s ago`;
};
// Show messages
nodeType.prototype.showMessage = function (message, type = "info") {
const notification = document.createElement("div");
notification.style.position = "fixed";
notification.style.top = "15px";
notification.style.right = "15px";
notification.style.padding = "6px 10px";
notification.style.borderRadius = "3px";
notification.style.color = "white";
notification.style.fontSize = "10px";
notification.style.zIndex = "10000";
notification.style.maxWidth = "200px";
notification.textContent = message;
switch (type) {
case "success":
notification.style.backgroundColor = "#28a745";
break;
case "error":
notification.style.backgroundColor = "#dc3545";
break;
case "warning":
notification.style.backgroundColor = "#ffc107";
break;
default:
notification.style.backgroundColor = "#17a2b8";
}
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
document.body.removeChild(notification);
}
}, 1800);
};
}
},
nodeType.prototype.loadSeedHistory = function () {
try { return historyValue(JSON.parse(localStorage.getItem(storageKey))); }
catch { return []; }
};
nodeType.prototype.saveSeedHistory = function () {
try { localStorage.setItem(storageKey, JSON.stringify(this.seedHistory)); }
catch { /* History remains available in the workflow if browser storage is unavailable. */ }
};
nodeType.prototype.addSeedToHistory = function (value) {
const seed = seedValue(value);
if (seed === null || this.seedScope.signal.aborted) return;
const now = Date.now();
if (this.lastAddedSeed?.seed === seed && now - this.lastAddedSeed.timestamp < 500) return;
this.lastAddedSeed = { seed, timestamp: now };
this.seedHistory = [{ seed, timestamp: now, dateString: new Date(now).toLocaleString() },
...this.seedHistory.filter(item => item.seed !== seed)].slice(0, 10);
this.saveSeedHistory();
this.refreshHistoryDisplay();
};
nodeType.prototype.generateRandomSeed = function () {
const seed = Math.floor(Math.random() * 0xFFFFFFFF);
const widget = this.widgets?.find(widget => widget.name === 'seed');
if (widget) {
widget.value = seed;
widget.callback?.(seed, app.canvas, this, [0, 0], null);
this.lastSeedValue = widget.value;
}
this.addSeedToHistory(seed);
this.setDirtyCanvas(true, true);
this.showMessage(`Generated: ${seed}`, 'success');
};
nodeType.prototype.useSeedFromHistory = function (item, index) {
const widget = this.widgets?.find(widget => widget.name === 'seed');
if (widget) {
widget.value = item.seed;
widget.callback?.(item.seed, app.canvas, this, [0, 0], null);
this.lastSeedValue = widget.value;
}
this.highlightHistoryEntry(index);
this.setDirtyCanvas(true, true);
this.startAutoHide();
this.showMessage(`Loaded: ${item.seed}`);
};
nodeType.prototype.clearSeedHistory = function () {
this.seedHistory = [];
this.lastAddedSeed = null;
this.lastSeedValue = this.widgets?.find(widget => widget.name === 'seed')?.value;
this.saveSeedHistory();
this.showHistorySection();
this.refreshHistoryDisplay();
this.showMessage('History cleared');
};
nodeType.prototype.refreshHistoryDisplay = function () {
if (!this.seedView) return;
const selected = seedValue(this.widgets?.find(widget => widget.name === 'seed')?.value);
this.seedView.render(this.seedHistory, selected, timestamp => this.formatTimeAgo(timestamp));
this.startAutoHide();
};
nodeType.prototype.highlightHistoryEntry = function (index) {
this.historyDisplay.querySelectorAll('button[data-seed-index]').forEach((entry, i) => {
entry.setAttribute('aria-pressed', String(i === index));
});
};
nodeType.prototype.startAutoHide = function () {
this.cancelAutoHide();
if (!this.seedScope.signal.aborted && !this.mouseOverHistory && this.seedHistory.length && !this.historyDisplay.hidden) {
this.hideTimer = setTimeout(() => {
this.hideTimer = null;
this.hideHistorySection();
}, 2500);
}
};
nodeType.prototype.cancelAutoHide = function () {
clearTimeout(this.hideTimer);
this.hideTimer = null;
};
nodeType.prototype.hideHistorySection = function (explicit = false) {
if (this.seedScope.signal.aborted || (!explicit && (this.mouseOverHistory || this.historyDisplay.contains(document.activeElement)))) return;
this.cancelAutoHide();
this.seedView.expand(false);
};
nodeType.prototype.showHistorySection = function () {
this.seedView.expand(true);
this.startAutoHide();
};
nodeType.prototype.formatTimeAgo = function (timestamp) {
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
if (seconds >= 86400) return `${Math.floor(seconds / 86400)}d ago`;
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ago`;
if (seconds >= 60) return `${Math.floor(seconds / 60)}m ago`;
return `${seconds}s ago`;
};
nodeType.prototype.showMessage = function (message, type = 'info') {
this.seedView.status.textContent = message;
this.seedView.status.dataset.kind = type;
};
},
});
+125
View File
@@ -0,0 +1,125 @@
# ComfyAssets controls: first production consumer
The Local Image Loader uses `core.js` and `controls.css` for the approved shared
blue controls and host-following light/dark themes. `../views/image-browser.js`
composes these primitives; `../kiko_local_image_loader.js` retains the controller,
API calls and existing selection persistence.
Grid thumbnails default to 224 px with square preview areas and preserve the
entire image. The Thumbnail size slider adjusts the grid from 128–384 px. A full
page scrolls within the gallery instead of compressing the rows to fit the node.
Thumbnail size is a per-node view setting for the current session.
New nodes start at 800 × 900 to leave room for the larger previews. Existing
workflows retain their saved node dimensions and can be resized normally.
`createScope()` owns listeners, delayed work and a fetch cancellation signal.
Dispose it on node removal. `mountTheme()` observes host palette changes and
removes its observers/listeners with that scope. The module has no ComfyUI imports;
the extension passes the host settings interface explicitly.
Both projects will use the same theme and controls. This is the first consumer,
not a published package yet. Keep routes, node definitions and business state out
of `ui/`; add abstractions only as demonstrated by the next consumer.
Architecture and control organization were inspired by
[Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma). This implementation and
styling are original; none of his toolkit code or assets are vendored.
## Floating windows
`floating-window.js` owns pointer capture, keyboard movement/resizing, viewport
bounds and observer cleanup. `window.css` consumes the same control tokens as the
loader. Kiko Save Image is its first consumer; the controller has no node, API or
gallery dependencies.
Drag the title or bottom-right resize handle. Focus either handle and use arrow
keys (Shift for larger steps). Escape closes the focused window. The viewer stays
nonmodal so the canvas remains usable. Window position and size survive new
results and minimize/reopen within the session; they are not written into a
workflow or persisted across browser reloads.
The owning node controls removal. Another node may take over the shared viewer,
but deleting the previous owner must not remove it. Closing or deleting a viewer,
or replacing its results, cancels delayed bulk-open actions.
## Seed History
`../views/seed-history.js` composes the shared buttons and theme tokens. The
extension keeps seed generation, callbacks, history and workflow persistence.
History entries are native buttons: Tab to an entry and press Enter or Space to
reuse its seed. The selected entry has a visible outline and pressed state.
The list fills the available node space and scrolls as needed. It automatically
hides after 2.5 seconds of inactivity, except while hovered or keyboard-focused.
Use Show history / Hide history to control it explicitly. Feedback appears in
the node's status area instead of temporary overlays on the canvas.
The existing browser storage key and serialized `seedHistory` field are retained.
Up to ten unique valid seeds are kept, including zero; malformed entries are
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.
+126
View File
@@ -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;
}
+27
View File
@@ -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('');
}
+190
View File
@@ -0,0 +1,190 @@
.ca-tools {
color-scheme: dark;
--ca-surface: #2b2b2e;
--ca-field: #202023;
--ca-control: #37373b;
--ca-hover: #45454b;
--ca-text: #eeeef1;
--ca-muted: #b5b5bd;
--ca-line: #48484f;
--ca-border: #7d7d87;
--ca-accent: #0877bb;
--ca-focus: #5bc2ff;
--ca-error: #d52430;
--ca-error-text: #ffadb3;
--ca-error-bg: #391d23;
font: 13px/1.5 Inter, ui-sans-serif, system-ui, sans-serif;
background: var(--ca-surface);
color: var(--ca-text);
}
.ca-tools[data-ca-theme="light"] {
color-scheme: light;
--ca-surface: #ffffff;
--ca-field: #f5f5f7;
--ca-control: #eeeef1;
--ca-hover: #e2e2e8;
--ca-text: #23232b;
--ca-muted: #5c5c68;
--ca-line: #d4d4dc;
--ca-border: #85858f;
--ca-focus: #0066a6;
--ca-error-text: #a20d20;
--ca-error-bg: #fff0f1;
}
.ca-tools :where(div, span, label, input, textarea, button, select, fieldset) { box-sizing: border-box; }
.ca-tools :where(button, input, select, textarea) { font: inherit; color: inherit; accent-color: var(--ca-accent); }
.ca-tools :where(button, select, input:not([type="radio"], [role="switch"]), textarea) { border: 1px solid var(--ca-border); border-radius: 5px; min-block-size: 30px; }
.ca-tools button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 10px; background: var(--ca-control); cursor: pointer; white-space: nowrap; }
.ca-tools button:hover:not(:disabled) { background: var(--ca-hover); }
.ca-tools button:disabled { opacity: .42; cursor: default; }
.ca-tools :where(input:not([type="radio"], [role="switch"]), select, textarea) { background: var(--ca-field); min-inline-size: 0; padding: 4px 8px; }
.ca-tools :where(button, input, select, textarea):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.ca-tools :where(input, textarea)::placeholder { color: var(--ca-muted); opacity: 1; }
.ca-tools .ca-primary, .ca-tools button[aria-current="page"] { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
.ca-tools .ca-primary:hover:not(:disabled) { background: #096ba6; }
.ca-tools .ca-danger { background: var(--ca-error); border-color: var(--ca-error); color: #fff; }
.ca-tools .ca-danger:hover:not(:disabled) { background: #b51c28; }
.ca-tools .ca-icon-button { min-inline-size: 30px; padding-inline: 6px; }
.ca-tools .ca-field-label { display: block; font-size: 11px; color: var(--ca-muted); }
.ca-tools .ca-field { display: inline-flex; align-items: center; gap: 6px; min-inline-size: 0; }
.ca-tools .ca-switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; min-block-size: 30px; font-size: 12px; }
.ca-tools .ca-switch input { appearance: none; position: relative; inline-size: 36px; min-block-size: 24px; border: 1px solid var(--ca-border); border-radius: 20px; background: var(--ca-control); margin: 0; cursor: pointer; flex-shrink: 0; }
.ca-tools .ca-switch input::before { content: ""; position: absolute; inline-size: 16px; block-size: 16px; background: var(--ca-muted); inset-block-start: 3px; inset-inline-start: 3px; border-radius: 50%; }
.ca-tools .ca-switch input:checked { background: var(--ca-accent); border-color: var(--ca-accent); }
.ca-tools .ca-switch input:checked::before { background: #fff; inset-inline-start: 15px; }
.ca-tools .ca-segments { border: 0; padding: 0; margin: 0; display: inline-flex; gap: 2px; min-inline-size: 0; }
.ca-tools .ca-segments label { position: relative; display: inline-flex; min-inline-size: 24px; min-block-size: 30px; cursor: pointer; }
.ca-tools .ca-segments input { position: absolute; inset: 0; margin: 0; opacity: 0; inline-size: 100%; block-size: 100%; cursor: pointer; }
.ca-tools .ca-segments label > span { padding: 4px 12px; background: var(--ca-field); border: 1px solid var(--ca-border); border-radius: 4px; color: var(--ca-text); font-size: 13px; }
.ca-tools .ca-segments input:checked + span { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
.ca-tools .ca-segments input:focus-visible + span { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.ca-tools .ca-visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
.ca-image-browser { inline-size: 100%; block-size: 100%; min-block-size: 350px; overflow: hidden; border-radius: 6px; container-type: inline-size; contain: size; }
.ca-image-browser .kiko-container-wrapper { display: flex; flex-direction: column; gap: 12px; padding: 12px; block-size: 100%; min-block-size: 350px; overflow: auto; }
.ca-image-browser .ca-browser-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; border-block-end: 1px solid var(--ca-line); padding-block-end: 8px; }
.ca-image-browser .ca-browser-header strong { font-size: 13px; font-weight: 550; }
.ca-image-browser .ca-browser-header span { font-size: 10px; color: var(--ca-muted); }
.ca-image-browser :where(.kiko-controls, .ca-sort, .ca-gallery-tools) { display: flex; align-items: center; gap: 7px; min-inline-size: 0; flex-shrink: 0; }
.ca-image-browser .kiko-path-presets { flex: 1; inline-size: 60px; }
.ca-image-browser .ca-path-row { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; margin-block-start: 4px; }
.ca-image-browser .kiko-path-input-wrapper { position: relative; min-inline-size: 0; }
.ca-image-browser .kiko-path-input { inline-size: 100%; }
.ca-image-browser .ca-options { justify-content: space-between; flex-wrap: wrap; }
.ca-image-browser .ca-sort { flex-wrap: wrap; }
.ca-image-browser .ca-sort select { font-size: 12px; }
.ca-image-browser .ca-gallery-tools { flex-wrap: wrap; }
.ca-image-browser .ca-file-filter { flex: 1; inline-size: 100px; }
.ca-image-browser .kiko-cardholder { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--ca-thumbnail-size, 224px)), 1fr)); grid-auto-rows: max-content; gap: 8px; align-items: start; align-content: start; overflow: auto; min-block-size: 100px; flex: 1; padding: 3px; }
.ca-image-browser .ca-empty { grid-column: 1 / -1; text-align: center; padding: 24px 8px; color: var(--ca-muted); font-size: 12px; }
.ca-image-browser button.kiko-gallery-card { display: flex; flex-direction: column; justify-content: start; min-inline-size: 0; overflow: hidden; border: 1px solid var(--ca-border); padding: 4px; white-space: normal; background: var(--ca-field); }
.ca-image-browser button.kiko-gallery-card.kiko-selected { border: 2px solid var(--ca-focus); padding: 3px; }
.ca-image-browser .kiko-card-media-wrapper { position: relative; inline-size: 100%; aspect-ratio: 1; flex-shrink: 0; overflow: hidden; border-radius: 3px; }
.ca-image-browser .kiko-card-media-wrapper img { display: block; inline-size: 100%; block-size: 100%; object-fit: contain; }
.ca-image-browser .kiko-card-info-panel { inline-size: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: start; padding: 6px 3px 3px; font-size: 11px; }
.ca-image-browser :where(.kiko-folder-card, .kiko-audio-card) { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; block-size: 100%; inline-size: 100%; background: var(--ca-field); }
.ca-image-browser :where(.kiko-folder-icon, .kiko-audio-icon) { inline-size: 40px; block-size: 40px; }
.ca-image-browser :where(.kiko-folder-name, .kiko-audio-name) { max-inline-size: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
.ca-image-browser .kiko-video-card-overlay { position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none; }
.ca-image-browser .kiko-video-card-overlay svg { inline-size: 36px; block-size: 36px; }
.ca-image-browser .kiko-cardholder[data-view="list"] { grid-template-columns: 1fr; }
.ca-image-browser .kiko-cardholder[data-view="list"] button.kiko-gallery-card { display: flex; flex-direction: row; align-items: center; gap: 10px; }
.ca-image-browser .kiko-cardholder[data-view="list"] .kiko-card-media-wrapper { inline-size: 64px; flex-shrink: 0; }
.ca-image-browser .ca-browser-footer { display: flex; align-items: center; justify-content: space-between; gap: 6px; flex-wrap: wrap; font-size: 11px; color: var(--ca-muted); }
.ca-image-browser .kiko-pagination { display: flex; flex-wrap: wrap; gap: 4px; }
.ca-image-browser .kiko-pagination button { font-size: 11px; min-inline-size: 30px; padding-inline: 5px; }
.ca-image-browser .ca-selection { border-block-start: 1px solid var(--ca-line); padding-block-start: 8px; }
.ca-image-browser .ca-selection strong { font-size: 12px; font-weight: 500; overflow-wrap: anywhere; }
.ca-tools .ca-range { display: flex; align-items: center; gap: 10px; flex-shrink: 0; color: var(--ca-muted); font-size: 11px; }
.ca-tools .ca-range input[type="range"] { flex: 1; inline-size: 60px; min-inline-size: 0; margin: 0; padding: 0; border: 0; min-block-size: 30px; cursor: pointer; }
.ca-tools .ca-range output { min-inline-size: 44px; font-variant-numeric: tabular-nums; }
.ca-image-browser .kiko-status-message { padding: 10px; color: var(--ca-muted); border: 1px solid var(--ca-line); border-radius: 5px; font-size: 12px; }
.ca-image-browser .kiko-status-message[data-state="error"] { color: var(--ca-error-text); background: var(--ca-error-bg); border-color: var(--ca-error); }
.ca-image-browser .kiko-status-message code { overflow-wrap: anywhere; }
.ca-image-browser .kiko-status-message button { margin-block-start: 6px; }
.ca-image-browser .kiko-autocomplete-dropdown { display: none; position: absolute; inset-block-start: 100%; inset-inline: 0; max-block-size: 180px; overflow: auto; background: var(--ca-field); border: 1px solid var(--ca-border); border-radius: 5px; z-index: 10; }
.ca-image-browser .kiko-autocomplete-dropdown.visible { display: block; }
.ca-image-browser .kiko-autocomplete-item { display: flex; align-items: center; gap: 7px; padding: 7px; cursor: pointer; }
.ca-image-browser .kiko-autocomplete-item:is(:hover, .selected) { background: var(--ca-hover); }
.ca-image-browser .kiko-autocomplete-item .folder-icon { inline-size: 16px; block-size: 16px; flex-shrink: 0; }
.ca-image-browser .kiko-autocomplete-item .path-text { min-inline-size: 0; overflow-wrap: anywhere; font-size: 12px; }
.ca-image-browser .kiko-autocomplete-item .parent-path { color: var(--ca-muted); }
@container (max-width: 440px) {
.ca-image-browser .ca-options > .ca-switch { flex-basis: 100%; }
.ca-image-browser .ca-sort { inline-size: 100%; gap: 6px; }
.ca-image-browser .ca-sort .ca-field { flex: 1; }
.ca-image-browser .ca-sort .ca-field-label { position: absolute; clip-path: inset(50%); }
.ca-image-browser .ca-sort select { inline-size: 100%; }
}
@media (pointer: coarse) { .ca-tools :where(button, select), .ca-tools .ca-segments label > span, .ca-tools .ca-switch { min-block-size: 40px; } }
.ca-seed-history { inline-size: 100%; block-size: 100%; min-block-size: 200px; display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px solid var(--ca-line); border-radius: 6px; overflow: auto; contain: size; }
.ca-seed-history [hidden] { display: none; }
.ca-seed-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; border-block-end: 1px solid var(--ca-line); padding-block-end: 6px; }
.ca-seed-header h3 { margin: 0; font-size: 13px; font-weight: 550; }
.ca-seed-header > span { font-size: 10px; color: var(--ca-muted); }
.ca-seed-actions { display: flex; gap: 7px; flex-wrap: wrap; }
.ca-seed-actions button { flex: 1; }
.ca-seed-history .ca-seed-disclosure { inline-size: 100%; flex-shrink: 0; font-size: 12px; }
.ca-seed-list { flex: 1; min-block-size: 60px; overflow: auto; padding: 3px; margin: 0; list-style: none; border: 1px solid var(--ca-line); border-radius: 5px; background: var(--ca-field); }
.ca-seed-list li + li { margin-block-start: 5px; }
.ca-seed-history button.ca-seed-entry { inline-size: 100%; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 3px 10px; white-space: normal; text-align: start; }
.ca-seed-entry[aria-pressed="true"] { border-color: var(--ca-focus); outline: 1px solid var(--ca-focus); outline-offset: -2px; }
.ca-seed-value { font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.ca-seed-time { font-size: 11px; color: var(--ca-muted); }
.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); } }
+138
View File
@@ -0,0 +1,138 @@
export function createScope() {
const controller = new AbortController();
const cleanups = [];
return {
signal: controller.signal,
listen(target, event, callback) {
target.addEventListener(event, callback, { signal: controller.signal });
},
cleanup(callback) { cleanups.push(callback); },
debounce(callback, delay) {
let timer;
cleanups.push(() => clearTimeout(timer));
return (...args) => {
clearTimeout(timer);
if (!controller.signal.aborted) timer = setTimeout(() => callback(...args), delay);
};
},
dispose() {
if (controller.signal.aborted) return;
controller.abort();
cleanups.splice(0).forEach(callback => callback());
},
};
}
export function themeFromColor(color) {
let values;
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
if (hex) {
const expanded = hex.length === 3 ? [...hex].map(value => value + value).join('') : hex;
values = expanded.match(/../g).map(value => parseInt(value, 16));
} else if (/^rgb\(/.test(color)) {
values = color.match(/[\d.]+/g)?.slice(0, 3).map(Number);
}
if (!values || values.length !== 3) return null;
const linear = values.map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
return linear[0] * .2126 + linear[1] * .7152 + linear[2] * .0722 > .35 ? 'light' : 'dark';
}
export function mountTheme(element, settings, scope) {
const stylesheet = new URL('./controls.css', import.meta.url).href;
if (![...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === stylesheet)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = stylesheet;
document.head.append(link);
}
const preference = matchMedia('(prefers-color-scheme: dark)');
const update = () => {
const background = getComputedStyle(document.body).getPropertyValue('--bg-color').trim();
element.dataset.caTheme = themeFromColor(background) || (preference.matches ? 'dark' : 'light');
};
const observer = new MutationObserver(update);
for (const target of [document.documentElement, document.body]) observer.observe(target, { attributes: true, attributeFilter: ['style', 'class', 'data-theme'] });
scope.cleanup(() => observer.disconnect());
scope.listen(preference, 'change', update);
if (settings?.addEventListener) scope.listen(settings, 'Comfy.ColorPalette.change', update);
update();
}
export function createButton(text, className = '') {
const element = document.createElement('button');
element.type = 'button';
element.className = className;
element.textContent = text;
return element;
}
export function createSlider(label, { id, min, max, step, value, unit }, scope, onInput) {
const field = document.createElement('div');
field.className = 'ca-range';
const caption = document.createElement('label');
caption.htmlFor = id;
caption.textContent = label;
const input = document.createElement('input');
Object.assign(input, { type: 'range', id, min, max, step, value });
const output = document.createElement('output');
output.setAttribute('for', id);
output.value = `${value} ${unit}`;
scope.listen(input, 'input', () => {
output.value = `${input.value} ${unit}`;
onInput(Number(input.value));
});
field.append(caption, input, output);
return field;
}
export function createField(label, input, id) {
const field = document.createElement('label');
field.className = 'ca-field';
field.htmlFor = id;
const caption = document.createElement('span');
caption.className = 'ca-field-label';
caption.textContent = label;
input.id = id;
field.append(caption, input);
return field;
}
export function createSwitch(label, className, id) {
const input = document.createElement('input');
input.type = 'checkbox';
input.role = 'switch';
input.checked = true;
input.className = className;
const field = createField(label, input, id);
field.className = 'ca-switch';
field.replaceChildren(input, document.createTextNode(label));
return field;
}
export function createSelect(options, className) {
const select = document.createElement('select');
select.className = className;
for (const [value, text] of options) select.add(new Option(text, value));
return select;
}
export function createSegments(label, options, name, scope, onChange) {
const fieldset = document.createElement('fieldset');
fieldset.className = 'ca-segments';
const legend = document.createElement('legend');
legend.className = 'ca-visually-hidden';
legend.textContent = label;
fieldset.append(legend);
options.forEach(([value, text], index) => {
const input = document.createElement('input');
input.type = 'radio';
input.name = name;
input.value = value;
input.checked = index === 0;
const field = createField(text, input, `${name}-${value}`);
field.replaceChildren(input, field.firstChild);
scope.listen(input, 'change', () => onChange(value));
fieldset.append(field);
});
return fieldset;
}
+85
View File
@@ -0,0 +1,85 @@
import { createScope, mountTheme, createButton } from './core.js';
let nextDialog = 0;
export function createDialog({ title, settings, fallbackFocus, onCancel }) {
const scope = createScope();
const element = document.createElement('dialog');
element.id = `ca-dialog-${++nextDialog}`;
element.className = 'ca-tools ca-dialog kikotools-dialog';
const header = document.createElement('header');
const heading = document.createElement('h2');
heading.id = `${element.id}-title`;
heading.textContent = title;
element.setAttribute('aria-labelledby', heading.id);
const brand = document.createElement('span');
brand.textContent = 'ComfyAssets';
header.append(heading, brand);
const body = document.createElement('div');
body.className = 'ca-dialog-body';
const actions = document.createElement('footer');
element.append(header, body, actions);
mountTheme(element, settings, scope);
let opener;
const nativeClose = element.close.bind(element);
const finish = reason => {
if (scope.signal.aborted) return;
scope.dispose();
if (opener?.isConnected && opener !== document.body) opener.focus();
else fallbackFocus?.focus();
if (reason === 'cancel') onCancel?.();
};
element.close = (reason = 'dismiss') => {
if (scope.signal.aborted) return;
nativeClose(reason);
finish(reason);
};
scope.cleanup(() => element.remove());
scope.listen(element, 'close', () => finish(element.returnValue));
scope.listen(element, 'cancel', event => {
event.preventDefault();
element.close('cancel');
});
scope.listen(element, 'keydown', event => event.stopPropagation());
scope.listen(element, 'keyup', event => event.stopPropagation());
return {
element, scope, body, actions,
close: element.close,
open(initialFocus) {
opener = document.activeElement;
document.body.append(element);
element.showModal();
initialFocus?.focus();
},
};
}
export function createLegacyDialog(htmlContent, onOK, onCancel, settings, fallbackFocus) {
const dialog = createDialog({ title: 'KikoTools', settings, fallbackFocus, onCancel });
const form = document.createElement('form');
form.id = `${dialog.element.id}-form`;
form.innerHTML = htmlContent;
dialog.body.append(form);
const cancel = createButton('Cancel');
const ok = createButton('OK', 'ca-primary');
ok.type = 'submit';
ok.setAttribute('form', form.id);
dialog.actions.append(cancel, ok);
dialog.scope.listen(cancel, 'click', () => dialog.close('cancel'));
dialog.scope.listen(form, 'keydown', event => {
if (event.key === 'Enter' && !event.isComposing && event.target.matches('select')) {
event.preventDefault();
event.stopPropagation();
form.requestSubmit();
}
});
dialog.scope.listen(form, 'submit', event => {
event.preventDefault();
onOK?.(dialog.element, [...form.querySelectorAll('input, select')].map(input => input.value));
dialog.close('apply');
});
const firstInput = form.querySelector('input, select');
dialog.open(firstInput);
if (firstInput?.tagName === 'INPUT') firstInput.select();
return dialog.element;
}
+25
View File
@@ -0,0 +1,25 @@
import { createButton, createScope, mountTheme } from './core.js';
export function mountDimensionSwap(node, app) {
if (node.swapScope && !node.swapScope.signal.aborted) return node.swapButtonWidget;
const scope = createScope();
node.swapScope = scope;
const container = document.createElement('div');
container.className = 'ca-tools ca-dimension-swap';
const button = createButton('Swap W×H', 'ca-primary');
button.setAttribute('aria-label', 'Swap width and height');
button.title = 'Swap width and height';
container.append(button);
mountTheme(container, app.ui.settings, scope);
scope.listen(button, 'click', () => node.swapDimensions());
node.swapButtonWidget = node.addDOMWidget('swap_button', 'div', container, {
serialize: false, hideOnZoom: false, getMinHeight: () => 46, getMaxHeight: () => 46,
});
scope.cleanup(() => container.remove());
const onRemoved = node.onRemoved;
node.onRemoved = function () {
scope.dispose();
return onRemoved?.apply(this, arguments);
};
return node.swapButtonWidget;
}
+67
View File
@@ -0,0 +1,67 @@
const swapping = new WeakSet();
export function extractResolutionFromPreset(value) {
return typeof value === 'string' && value !== 'custom' ? value.split(' - ')[0].trim() : null;
}
export function dimensionsFromPreset(value) {
const match = extractResolutionFromPreset(value)?.match(/^(\d+)\s*[×x]\s*(\d+)$/);
if (!match) return null;
const dimensions = match.slice(1).map(Number);
return dimensions.every(value => Number.isSafeInteger(value) && value > 0) ? dimensions : null;
}
function widgets(node) {
return ['preset', 'width', 'height'].map(name => node.widgets.find(widget => widget.name === name));
}
function notifyDimensions(width, height, canvas, node, pos, event) {
for (const widget of [width, height]) widget.callback?.(widget.value, canvas, node, pos, event);
}
export function bindDimensionPresets(node, app) {
const [preset] = widgets(node);
if (!preset) return;
const callback = preset.callback;
preset.callback = function (value, canvas = app.canvas, owner = node, pos = [0, 0], event = null) {
const result = callback?.apply(this, arguments);
if (swapping.has(node)) return result;
const dimensions = dimensionsFromPreset(value);
const [, width, height] = widgets(node);
if (dimensions && width && height) {
[width.value, height.value] = dimensions;
notifyDimensions(width, height, canvas, owner, pos, event);
}
return result;
};
}
export function swapDimensions(node, app) {
const [preset, width, height] = widgets(node);
if (!preset || !width || !height || swapping.has(node)) return;
const custom = preset.value === 'custom';
const dimensions = custom ? [width.value, height.value] : dimensionsFromPreset(preset.value);
if (!dimensions) return;
const [w, h] = dimensions;
let match;
if (!custom) {
let options = Array.isArray(preset.options) ? preset.options : preset.options?.values;
if (typeof options === 'function') options = options.call(preset);
match = (Array.isArray(options) ? options : []).find(value => {
const parsed = dimensionsFromPreset(value);
return parsed?.[0] === h && parsed?.[1] === w;
});
}
swapping.add(node);
try {
[width.value, height.value] = [h, w];
if (!custom) {
preset.value = match || 'custom';
preset.callback?.(preset.value, app.canvas, node, [0, 0], null);
}
notifyDimensions(width, height, app.canvas, node, [0, 0], null);
node.graph?.setDirtyCanvas?.(true, true);
} finally {
swapping.delete(node);
}
}
+80
View File
@@ -0,0 +1,80 @@
export function fitWindowRect(rect, viewport) {
const width = Math.min(Math.max(280, rect.width), Math.max(1, viewport.width - 16));
const height = Math.min(Math.max(200, rect.height), Math.max(1, viewport.height - 16));
return {
width, height,
x: Math.max(8, Math.min(rect.x, viewport.width - width - 8)),
y: Math.max(8, Math.min(rect.y, viewport.height - height - 8)),
};
}
export function mountFloatingWindow(element, scope, onClose) {
const stylesheet = new URL('./window.css', import.meta.url).href;
if (![...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === stylesheet)) {
const link = document.createElement('link');
Object.assign(link, { rel: 'stylesheet', href: stylesheet });
document.head.append(link);
}
let gesture;
const viewport = () => ({ width: innerWidth, height: innerHeight });
const move = (x, y) => {
const rect = element.getBoundingClientRect();
const next = fitWindowRect({ x, y, width: rect.width, height: rect.height }, viewport());
element.style.left = `${next.x}px`;
element.style.top = `${next.y}px`;
element.style.right = 'auto';
};
const resize = (width, height) => {
const rect = element.getBoundingClientRect();
const next = fitWindowRect({ x: rect.x, y: rect.y, width, height }, viewport());
element.style.setProperty('--ca-window-width', `${next.width}px`);
element.style.setProperty('--ca-window-height', `${next.height}px`);
move(next.x, next.y);
};
const end = () => {
if (gesture && element.hasPointerCapture(gesture.id)) element.releasePointerCapture(gesture.id);
gesture = null;
element.classList.remove('ca-dragging');
};
scope.listen(element, 'pointerdown', event => {
const handle = event.target.closest('[data-window-move], [data-window-resize]');
if (!handle || event.button !== 0) return;
const rect = element.getBoundingClientRect();
gesture = { id: event.pointerId, kind: handle.hasAttribute('data-window-resize') ? 'resize' : 'move', rect, x: event.clientX, y: event.clientY };
handle.focus();
element.setPointerCapture(event.pointerId);
element.classList.add('ca-dragging');
event.preventDefault();
});
scope.listen(element, 'pointermove', event => {
if (!gesture || event.pointerId !== gesture.id) return;
const dx = event.clientX - gesture.x;
const dy = event.clientY - gesture.y;
if (gesture.kind === 'resize') resize(gesture.rect.width + dx, gesture.rect.height + dy);
else move(gesture.rect.x + dx, gesture.rect.y + dy);
});
for (const event of ['pointerup', 'pointercancel', 'lostpointercapture']) scope.listen(element, event, end);
scope.listen(element, 'keydown', event => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose(); return; }
const handle = event.target.closest('[data-window-move], [data-window-resize]');
if (!handle || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
const delta = event.shiftKey ? 40 : 16;
const dx = event.key === 'ArrowLeft' ? -delta : event.key === 'ArrowRight' ? delta : 0;
const dy = event.key === 'ArrowUp' ? -delta : event.key === 'ArrowDown' ? delta : 0;
const rect = element.getBoundingClientRect();
if (handle.hasAttribute('data-window-resize')) resize(rect.width + dx, rect.height + dy);
else move(rect.x + dx, rect.y + dy);
});
const keepReachable = () => {
if (element.hidden) return;
const rect = element.getBoundingClientRect();
move(rect.x, rect.y);
};
scope.listen(window, 'resize', keepReachable);
const observer = new ResizeObserver(keepReachable);
observer.observe(element);
scope.cleanup(() => { observer.disconnect(); end(); });
return { fit: keepReachable };
}
+51
View File
@@ -0,0 +1,51 @@
.ca-floating-window {
box-sizing: border-box;
position: fixed;
inset-block-start: 100px;
right: 20px;
inline-size: min(var(--ca-window-width, 600px), calc(100dvw - 16px));
block-size: min(var(--ca-window-height, 560px), calc(100dvh - 16px));
min-inline-size: min(280px, calc(100dvw - 16px));
min-block-size: min(200px, calc(100dvh - 16px));
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--ca-border);
border-radius: 8px;
box-shadow: 0 8px 28px #00000040;
z-index: 10000;
}
.ca-floating-window[hidden], .ca-floating-window [hidden] { display: none; }
.ca-floating-window.ca-minimized { block-size: auto; min-block-size: 0; }
.ca-window-header { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border-block-end: 1px solid var(--ca-line); flex-shrink: 0; }
.ca-window-header .ca-window-handle { min-inline-size: 0; flex: 1; justify-content: start; border: 0; background: transparent; padding-inline: 0; overflow: hidden; text-overflow: ellipsis; cursor: grab; touch-action: none; -webkit-user-select: none; user-select: none; }
.ca-floating-window.ca-dragging [data-window-move] { cursor: grabbing; }
.ca-window-header .kiko-viewer-controls { display: flex; align-items: center; gap: 6px; }
.ca-window-header button.kiko-control-btn { min-inline-size: 30px; padding-inline: 6px; }
.ca-window-header .ca-window-brand { color: var(--ca-muted); font-size: 10px; }
.ca-floating-window .ca-window-resize { position: absolute; inset-inline-end: 2px; inset-block-end: 2px; min-inline-size: 24px; min-block-size: 24px; border: 0; background: var(--ca-surface); color: var(--ca-muted); cursor: nwse-resize; touch-action: none; padding: 0; }
.ca-floating-window .kiko-viewer-container { padding: 12px 12px 28px; overflow: auto; min-block-size: 0; flex: 1; }
.ca-floating-window .kiko-image-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 224px), 1fr)); gap: 10px; align-items: start; }
.ca-floating-window .kiko-image-item { min-inline-size: 0; border: 1px solid var(--ca-border); border-radius: 5px; background: var(--ca-field); padding: 6px; }
.ca-floating-window .kiko-image-open { padding: 0; border: 0; background: var(--ca-field); inline-size: 100%; display: block; }
.ca-floating-window .kiko-image-open img { display: block; inline-size: 100%; block-size: 230px; object-fit: contain; }
.ca-floating-window.ca-single-image .kiko-viewer-container:not([hidden]) { display: flex; flex-direction: column; }
.ca-floating-window.ca-single-image .kiko-image-grid { display: flex; flex: 1; min-block-size: 0; align-items: stretch; }
.ca-floating-window.ca-single-image .kiko-image-item { display: flex; flex-direction: column; flex: 1; min-block-size: 0; }
.ca-floating-window.ca-single-image .kiko-image-open { position: relative; flex: 1; min-block-size: 100px; overflow: hidden; }
.ca-floating-window.ca-single-image .kiko-image-open img { position: absolute; inset: 0; block-size: 100%; }
.ca-floating-window .kiko-image-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-block-start: 8px; flex-wrap: wrap; }
.ca-floating-window .kiko-image-selector { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; font-size: 12px; min-block-size: 30px; }
.ca-floating-window .kiko-image-selector input { inline-size: 18px; block-size: 18px; min-block-size: 18px; }
.ca-floating-window .kiko-image-info { padding-block-start: 6px; font-size: 11px; }
.ca-floating-window .kiko-image-filename { overflow-wrap: anywhere; }
.ca-floating-window .kiko-image-stats { color: var(--ca-muted); margin-block-start: 3px; }
.ca-floating-window .kiko-format-badge { display: inline-block; border: 1px solid var(--ca-border); border-radius: 3px; padding: 1px 4px; margin-inline-end: 3px; }
.ca-floating-window .kiko-bulk-actions { border-block-start: 1px solid var(--ca-line); margin-block-start: 12px; padding-block-start: 10px; font-size: 12px; color: var(--ca-muted); }
.ca-floating-window .kiko-bulk-buttons { display: flex; flex-wrap: wrap; gap: 7px; margin-block-start: 7px; }
.ca-floating-window .kiko-bulk-buttons button { font-size: 12px; }
.ca-floating-window .ca-window-status { color: var(--ca-muted); font-size: 11px; padding: 6px 30px 6px 12px; border-block-start: 1px solid var(--ca-line); flex-shrink: 0; }
.ca-viewer-launcher { padding: 3px 0; }
.ca-viewer-launcher button { inline-size: 100%; }
@media (pointer: coarse) { .ca-floating-window .ca-window-header button, .ca-floating-window .ca-window-resize { min-inline-size: 40px; min-block-size: 40px; } }
@media (forced-colors: active) { .ca-floating-window { box-shadow: none; border-color: CanvasText; } }
+75
View File
@@ -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();
}
}
+45
View File
@@ -0,0 +1,45 @@
import { createButton, createField, createSelect, createSegments, createSlider, createSwitch, mountTheme } from '../ui/core.js';
export function createImageBrowser(id, settings, scope) {
const element = document.createElement('div');
element.className = 'ca-tools ca-image-browser';
element.innerHTML = `<div class="kiko-container-wrapper">
<div class="ca-browser-header"><strong>Local Image Loader</strong><span>ComfyAssets</span></div>
<div class="kiko-controls kiko-path-controls"></div>
<div class="ca-directory"><label class="ca-field-label" for="${id}-path">Directory</label><div class="kiko-controls ca-path-row"><div class="kiko-path-input-wrapper"><input id="${id}-path" class="kiko-path-input" type="text" spellcheck="false" placeholder="Enter a directory…"><div class="kiko-autocomplete-dropdown"></div></div></div></div>
<div class="kiko-controls ca-options"><div class="ca-sort"></div></div>
<div class="ca-gallery-tools"></div>
<div class="kiko-status-message" role="status">Loading…</div>
<div class="kiko-cardholder" data-view="grid"></div>
<div class="ca-browser-footer"><span data-file-count></span><div class="kiko-pagination"></div></div>
<div class="ca-selection"><span class="ca-field-label">Selected media</span><strong data-selection>No media selected</strong></div>
</div>`;
const saved = createSelect([['', '— Saved paths —']], 'kiko-path-presets');
saved.setAttribute('aria-label', 'Saved paths');
const remove = createButton('×', 'kiko-delete-path ca-danger');
remove.setAttribute('aria-label', 'Remove saved path');
element.querySelector('.kiko-path-controls').append(saved, createButton('☆ Save path', 'kiko-save-path'), remove);
const up = createButton('↑', 'kiko-up-folder ca-icon-button');
up.setAttribute('aria-label', 'Parent folder');
const pathRow = element.querySelector('.ca-path-row');
pathRow.prepend(up);
pathRow.append(createButton('Browse', 'kiko-browse ca-primary'));
element.querySelector('.ca-options').prepend(createSwitch('Hide dot folders', 'kiko-hide-dot-folders', `${id}-hidden`));
const refresh = createButton('↻', 'kiko-refresh ca-icon-button');
refresh.setAttribute('aria-label', 'Refresh folder');
element.querySelector('.ca-sort').append(
createField('Sort', createSelect([['name', 'Name'], ['date', 'Date'], ['size', 'Size']], 'kiko-sort-by'), `${id}-sort`),
createField('Order', createSelect([['asc', '↑ Ascending'], ['desc', '↓ Descending']], 'kiko-sort-order'), `${id}-order`), refresh,
);
const filter = document.createElement('input');
filter.type = 'search';
filter.placeholder = 'Filter this page…';
filter.className = 'ca-file-filter';
filter.setAttribute('aria-label', 'Filter this page');
element.querySelector('.ca-gallery-tools').append(filter, createSegments('Gallery layout', [['grid', 'Grid'], ['list', 'List']], `${id}-view`, scope, value => { element.querySelector('.kiko-cardholder').dataset.view = value; }));
element.querySelector('.ca-browser-footer').after(createSlider('Thumbnail size', { id: `${id}-thumbnail-size`, min: 128, max: 384, step: 16, value: 224, unit: 'px' }, scope, value => {
element.style.setProperty('--ca-thumbnail-size', `${value}px`);
}));
mountTheme(element, settings, scope);
return 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;
}
+73
View File
@@ -0,0 +1,73 @@
import { createButton } from '../ui/core.js';
let nextHistoryId = 0;
export function createSeedHistoryView(scope, actions) {
const element = document.createElement('div');
element.className = 'ca-tools ca-seed-history';
const header = document.createElement('header');
header.className = 'ca-seed-header';
const title = document.createElement('h3');
title.textContent = 'Seed History';
const brand = document.createElement('span');
brand.textContent = 'ComfyAssets';
header.append(title, brand);
const toolbar = document.createElement('div');
toolbar.className = 'ca-seed-actions';
const generate = createButton('Generate', 'ca-primary');
const clear = createButton('Clear', 'ca-danger');
const disclosure = createButton('Hide history', 'ca-seed-disclosure');
disclosure.setAttribute('aria-expanded', 'true');
toolbar.append(generate, clear);
const list = document.createElement('ol');
list.className = 'ca-seed-list';
list.id = `ca-seed-list-${++nextHistoryId}`;
list.setAttribute('role', 'list');
list.setAttribute('aria-label', 'Recent seeds');
disclosure.setAttribute('aria-controls', list.id);
const empty = document.createElement('p');
empty.className = 'ca-seed-empty';
empty.textContent = 'No seeds tracked';
const status = document.createElement('div');
status.className = 'ca-seed-status';
status.setAttribute('role', 'status');
element.append(header, toolbar, disclosure, list, empty, status);
scope.listen(generate, 'click', actions.generate);
scope.listen(clear, 'click', actions.clear);
scope.listen(disclosure, 'click', () => actions.expand(list.hidden));
scope.listen(list, 'click', event => {
const button = event.target.closest('button[data-seed-index]');
if (button) actions.select(Number(button.dataset.seedIndex));
});
return {
element, list, disclosure, status,
expand(expanded) {
list.hidden = !expanded;
disclosure.textContent = expanded ? 'Hide history' : 'Show history';
disclosure.setAttribute('aria-expanded', String(expanded));
empty.hidden = !expanded || list.childElementCount > 0;
},
render(history, selectedSeed, formatTime) {
const focused = list.contains(document.activeElement) ? document.activeElement.dataset.seed : null;
list.replaceChildren(...history.map((item, index) => {
const row = document.createElement('li');
const button = createButton('', 'ca-seed-entry');
button.dataset.seedIndex = index;
button.dataset.seed = item.seed;
button.setAttribute('aria-label', `Use seed ${item.seed}`);
button.setAttribute('aria-pressed', String(item.seed === selectedSeed));
const seed = document.createElement('span');
seed.className = 'ca-seed-value';
seed.textContent = item.seed;
const time = document.createElement('span');
time.className = 'ca-seed-time';
time.textContent = formatTime(item.timestamp);
button.append(seed, time);
row.append(button);
return row;
}));
empty.hidden = list.hidden || history.length > 0;
if (focused !== null) list.querySelector(`button[data-seed="${focused}"]`)?.focus();
},
};
}
+16 -320
View File
@@ -1,324 +1,20 @@
// ComfyUI-KikoTools - Width Height Selector with Swap Button
import { app } from "../../scripts/app.js";
import { app } from '../../scripts/app.js';
import { bindDimensionPresets, extractResolutionFromPreset, swapDimensions } from './ui/dimensions.js';
import { mountDimensionSwap } from './ui/dimension-swap.js';
app.registerExtension({
name: "comfyassets.WidthHeightSelector",
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
if (nodeData.name === "WidthHeightSelector") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) onNodeCreated.apply(this, []);
// Track button click state for visual feedback
this.swapButtonPressed = false;
// Helper function to extract resolution from formatted preset string
this.extractResolutionFromPreset = function(presetValue) {
if (presetValue === "custom") return null;
// If it contains formatting metadata, extract the resolution part
if (presetValue.includes(" - ")) {
// Format is: "1024×1024 - 1:1 (1.0MP) - SDXL"
return presetValue.split(" - ")[0];
}
// Otherwise assume it's already a raw resolution
return presetValue;
name: 'comfyassets.WidthHeightSelector',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== 'WidthHeightSelector') return;
nodeType.prototype.createSwapButton = function () { return mountDimensionSwap(this, app); };
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
this.extractResolutionFromPreset = extractResolutionFromPreset;
this.swapDimensions = () => swapDimensions(this, app);
bindDimensionPresets(this, app);
this.createSwapButton();
return result;
};
// Override preset callback to update width/height widgets when preset changes
const presetWidget = this.widgets.find(w => w.name === "preset");
if (presetWidget) {
const originalCallback = presetWidget.callback;
presetWidget.callback = function(value, graphcanvas, node, pos, event) {
// Call original callback first
if (originalCallback) {
originalCallback.call(this, value, graphcanvas, node, pos, event);
}
// Update width/height widgets based on preset
const widthWidget = node.widgets.find(w => w.name === "width");
const heightWidget = node.widgets.find(w => w.name === "height");
if (widthWidget && heightWidget && value !== "custom") {
// Extract raw resolution from formatted preset
const rawResolution = node.extractResolutionFromPreset(value);
// Define all available presets from our preset system
const presetDimensions = {
// SDXL Presets
"1024×1024": [1024, 1024], "896×1152": [896, 1152], "832×1216": [832, 1216],
"768×1344": [768, 1344], "640×1536": [640, 1536], "1152×896": [1152, 896],
"1216×832": [1216, 832], "1344×768": [1344, 768], "1536×640": [1536, 640],
// FLUX Presets
"1920×1080": [1920, 1080], "1536×1536": [1536, 1536], "1280×768": [1280, 768],
"768×1280": [768, 1280], "1440×1080": [1440, 1080], "1080×1440": [1080, 1440],
"1728×1152": [1728, 1152], "1152×1728": [1152, 1728],
// Ultra-Wide Presets
"2560×1080": [2560, 1080], "2048×768": [2048, 768], "1792×768": [1792, 768],
"2304×768": [2304, 768], "1080×2560": [1080, 2560], "768×2048": [768, 2048],
"768×1792": [768, 1792], "768×2304": [768, 2304]
};
if (rawResolution && presetDimensions[rawResolution]) {
const [w, h] = presetDimensions[rawResolution];
widthWidget.value = w;
heightWidget.value = h;
// Trigger widget callbacks to update the UI
if (widthWidget.callback) {
widthWidget.callback(w, graphcanvas, node, pos, event);
}
if (heightWidget.callback) {
heightWidget.callback(h, graphcanvas, node, pos, event);
}
}
}
};
}
// Add swap functionality
this.swapDimensions = function() {
const widthWidget = this.widgets.find(w => w.name === "width");
const heightWidget = this.widgets.find(w => w.name === "height");
const presetWidget = this.widgets.find(w => w.name === "preset");
if (widthWidget && heightWidget && presetWidget) {
// Handle preset swapping first
if (presetWidget.value !== "custom") {
const currentPreset = presetWidget.value;
// Extract raw resolution from formatted preset
const rawResolution = this.extractResolutionFromPreset(currentPreset);
if (!rawResolution) return;
// Parse current preset dimensions (handle both × and x separators)
let w, h;
if (rawResolution.includes('×')) {
[w, h] = rawResolution.split('×').map(v => parseInt(v));
} else if (rawResolution.includes('x')) {
[w, h] = rawResolution.split('x').map(v => parseInt(v));
} else {
return; // Invalid preset format
}
const swappedRawPreset = `${h}×${w}`;
// Find the formatted version of the swapped preset from available options
const availablePresets = presetWidget.options.values || presetWidget.options;
let swappedFormattedPreset = null;
for (const option of availablePresets) {
if (option === "custom") continue;
const extractedRes = this.extractResolutionFromPreset(option);
if (extractedRes === swappedRawPreset) {
swappedFormattedPreset = option;
break;
}
}
if (swappedFormattedPreset) {
// Swapped preset exists, use the formatted version
presetWidget.value = swappedFormattedPreset;
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback(swappedFormattedPreset, app.canvas, this, [0, 0], null);
}
if (widthWidget.callback) {
widthWidget.callback(h, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(w, app.canvas, this, [0, 0], null);
}
} else {
// Swapped preset doesn't exist, switch to custom and swap manual values
presetWidget.value = "custom";
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback("custom", app.canvas, this, [0, 0], null);
}
if (widthWidget.callback) {
widthWidget.callback(h, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(w, app.canvas, this, [0, 0], null);
}
}
} else {
// Custom preset - just swap the width and height values
const tempWidth = widthWidget.value;
widthWidget.value = heightWidget.value;
heightWidget.value = tempWidth;
// Trigger widget change events
if (widthWidget.callback) {
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
}
}
// Mark the graph as changed
this.graph?.setDirtyCanvas(true, true);
}
};
};
const onDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function (ctx) {
if (onDrawForeground) {
onDrawForeground.apply(this, arguments);
}
if (this.flags.collapsed) return;
// Draw swap button with consistent spacing from widgets
const swapButtonSize = 24;
const margin = 6;
const swapButtonX = this.size[0] - swapButtonSize - margin;
// Calculate button position based on widget spacing rather than bottom margin
// Estimate widget area height and add consistent spacing
const estimatedWidgetHeight = 90; // Approximate height for 3 widgets
const topMargin = 35; // Space from top to first widget
const buttonSpacing = 10; // Space between last widget and button
const swapButtonY = topMargin + estimatedWidgetHeight + buttonSpacing;
// Button background - change color based on pressed state
if (this.swapButtonPressed) {
// Darker when pressed
ctx.fillStyle = "rgba(30, 120, 200, 0.9)"; // Darker blue when clicked
} else {
// Normal state
ctx.fillStyle = "rgba(66, 165, 245, 0.8)"; // Material blue
}
ctx.beginPath();
ctx.roundRect(swapButtonX, swapButtonY, swapButtonSize, swapButtonSize, 4);
ctx.fill();
// Button border with subtle highlight
ctx.strokeStyle = this.swapButtonPressed ? "rgba(20, 100, 180, 1.0)" : "rgba(33, 150, 243, 0.9)";
ctx.lineWidth = 1;
ctx.stroke();
// Draw swap icon - modern double arrow design
ctx.strokeStyle = "rgba(255, 255, 255, 0.95)";
ctx.lineWidth = 2;
ctx.lineCap = "round";
const centerX = swapButtonX + 12;
const centerY = swapButtonY + 12;
// Top arrow (pointing right) - width to height
ctx.beginPath();
ctx.moveTo(centerX - 7, centerY - 3);
ctx.lineTo(centerX + 5, centerY - 3);
ctx.stroke();
// Top arrow head
ctx.beginPath();
ctx.moveTo(centerX + 5, centerY - 3);
ctx.lineTo(centerX + 2, centerY - 5);
ctx.moveTo(centerX + 5, centerY - 3);
ctx.lineTo(centerX + 2, centerY - 1);
ctx.stroke();
// Bottom arrow (pointing left) - height to width
ctx.beginPath();
ctx.moveTo(centerX + 5, centerY + 3);
ctx.lineTo(centerX - 7, centerY + 3);
ctx.stroke();
// Bottom arrow head
ctx.beginPath();
ctx.moveTo(centerX - 7, centerY + 3);
ctx.lineTo(centerX - 4, centerY + 1);
ctx.moveTo(centerX - 7, centerY + 3);
ctx.lineTo(centerX - 4, centerY + 5);
ctx.stroke();
// Add subtle tooltip text when hovering (if we had hover state)
// This could be extended with hover detection for better UX
};
const onMouseDown = nodeType.prototype.onMouseDown;
nodeType.prototype.onMouseDown = function (e) {
// Check if click is on swap button
const swapButtonSize = 24;
const margin = 6;
const swapButtonX = this.pos[0] + this.size[0] - swapButtonSize - margin;
// Use same positioning logic as drawing
const estimatedWidgetHeight = 90;
const topMargin = 35;
const buttonSpacing = 10;
const swapButtonY = this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing;
if (
e.canvasX >= swapButtonX &&
e.canvasX <= swapButtonX + swapButtonSize &&
e.canvasY >= swapButtonY &&
e.canvasY <= swapButtonY + swapButtonSize
) {
// Visual feedback - set button as pressed
this.swapButtonPressed = true;
this.setDirtyCanvas(true, true);
// Execute swap
this.swapDimensions();
// Reset button state after a short delay for visual feedback
setTimeout(() => {
this.swapButtonPressed = false;
this.setDirtyCanvas(true, true);
}, 150);
return true; // Consume the event
}
// Call original onMouseDown if not clicking swap button
if (onMouseDown) {
return onMouseDown.apply(this, arguments);
}
};
// Optional: Add hover effect for better user feedback
const onMouseMove = nodeType.prototype.onMouseMove;
nodeType.prototype.onMouseMove = function (e) {
// Check if hovering over swap button
const swapButtonSize = 24;
const margin = 6;
const swapButtonX = this.pos[0] + this.size[0] - swapButtonSize - margin;
// Use same positioning logic as drawing
const estimatedWidgetHeight = 90;
const topMargin = 35;
const buttonSpacing = 10;
const swapButtonY = this.pos[1] + topMargin + estimatedWidgetHeight + buttonSpacing;
const isHovering = (
e.canvasX >= swapButtonX &&
e.canvasX <= swapButtonX + swapButtonSize &&
e.canvasY >= swapButtonY &&
e.canvasY <= swapButtonY + swapButtonSize
);
// Update cursor style for better UX (safely)
if (isHovering && this.graph && this.graph.canvas && this.graph.canvas.canvas) {
this.graph.canvas.canvas.style.cursor = "pointer";
} else if (this.graph && this.graph.canvas && this.graph.canvas.canvas) {
this.graph.canvas.canvas.style.cursor = "default";
}
// Call original onMouseMove
if (onMouseMove) {
return onMouseMove.apply(this, arguments);
}
};
}
},
},
});