Files
ComfyAssets-ComfyUI-KikoTools/web/kiko_workflow_timer_ui.js

540 lines
19 KiB
JavaScript

import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { $el } from "../../scripts/ui.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",
async init() {
// Color picker setting with custom render
app.ui.settings.addSetting({
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;
},
defaultValue: "#ff9966",
tooltip: "Color of the timer display when not running",
});
// Glow toggle
app.ui.settings.addSetting({
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);
},
});
},
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);
};
nodeType.prototype.onRemoved = function() {
KikoGlobalTimer.unregisterNode(this);
};
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 = 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');
}
}
};
}
},
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);