feat(timer): add Sketch-style color picker widget
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user