diff --git a/web/kiko_workflow_timer_ui.js b/web/kiko_workflow_timer_ui.js new file mode 100644 index 0000000..8ae736f --- /dev/null +++ b/web/kiko_workflow_timer_ui.js @@ -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);