Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c47dc6085 | ||
|
|
ee7d1e9011 | ||
|
|
b7a29a1731 | ||
|
|
3cabbffb40 |
@@ -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 |
@@ -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}`);
|
||||
|
||||
@@ -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: '' });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user