Compare commits

..
Author SHA1 Message Date
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
19 changed files with 1295 additions and 1047 deletions
+5
View File
@@ -97,6 +97,11 @@ 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.
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
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}`);
+291
View File
@@ -255,6 +255,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);
@@ -272,6 +414,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: '' });
+159
View File
@@ -47,6 +47,165 @@ test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
});
});
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();
+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');
});
+32 -8
View File
@@ -54,10 +54,10 @@
"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,
@@ -66,10 +66,22 @@
"statements": 12
},
"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,
@@ -83,12 +95,24 @@
"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/seed-history.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"width_height_swap.js": {
"lines": 60,
"branches": 79,
+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());
},
});
+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;
};
},
});
+45
View File
@@ -41,3 +41,48 @@ 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.
+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('');
}
+48
View File
@@ -116,6 +116,54 @@
.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; } }
@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; }
+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();
}
}
+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();
},
};
}