Same treatment as the resource bars: the fill is a scaleX transform updated every frame from the eased percentage, while the text and dot keep the 10Hz refresh.
994 lines
47 KiB
JavaScript
994 lines
47 KiB
JavaScript
import { app } from "../../../scripts/app.js";
|
||
import { api } from "../../scripts/api.js";
|
||
|
||
class KayPromptExecution {
|
||
constructor(id) {
|
||
this.promptApi = null;
|
||
this.executedNodeIds = [];
|
||
this.totalNodes = 0;
|
||
this.currentlyExecuting = null;
|
||
this.errorDetails = null;
|
||
this.id = id;
|
||
}
|
||
setPrompt(prompt) {
|
||
this.promptApi = prompt.output;
|
||
this.totalNodes = Object.keys(this.promptApi).length;
|
||
}
|
||
getApiNode(nodeId) {
|
||
return this.promptApi?.[String(nodeId)] || null;
|
||
}
|
||
getNodeLabel(nodeId) {
|
||
const apiNode = this.getApiNode(nodeId);
|
||
let label = apiNode?._meta?.title || apiNode?.class_type || undefined;
|
||
if (!label) {
|
||
const graphNode = window?.app?.graph?.getNodeById(Number(nodeId));
|
||
label = graphNode?.title || graphNode?.type || undefined;
|
||
}
|
||
return label || "Unknown";
|
||
}
|
||
executing(nodeId, step, maxSteps) {
|
||
if (nodeId == null) {
|
||
this.currentlyExecuting = null;
|
||
return;
|
||
}
|
||
if (this.currentlyExecuting?.nodeId !== nodeId) {
|
||
if (this.currentlyExecuting != null && !this.executedNodeIds.includes(this.currentlyExecuting.nodeId)) {
|
||
this.executedNodeIds.push(this.currentlyExecuting.nodeId);
|
||
}
|
||
this.currentlyExecuting = { nodeId, nodeLabel: this.getNodeLabel(nodeId), pass: 0 };
|
||
const apiNode = this.getApiNode(nodeId);
|
||
if (apiNode?.class_type === "UltimateSDUpscale") {
|
||
this.currentlyExecuting.pass--;
|
||
this.currentlyExecuting.maxPasses = -1;
|
||
} else if (apiNode?.class_type === "IterativeImageUpscale") {
|
||
this.currentlyExecuting.maxPasses = apiNode?.inputs["steps"] ?? -1;
|
||
}
|
||
}
|
||
if (step != null && maxSteps != null) {
|
||
if (!this.currentlyExecuting.step || step < this.currentlyExecuting.step) {
|
||
this.currentlyExecuting.pass++;
|
||
}
|
||
this.currentlyExecuting.step = step;
|
||
this.currentlyExecuting.maxSteps = maxSteps;
|
||
}
|
||
}
|
||
error(details) {
|
||
this.errorDetails = details;
|
||
}
|
||
}
|
||
|
||
const KayResourceMonitor = {
|
||
toolbar: null,
|
||
chartCanvas: null,
|
||
dataDisplay: null,
|
||
menuButton: null,
|
||
workflowProgressEl: null,
|
||
dataHistory: { cpu: [], ram: [], gpu: [], gpuMem: [], gpuTemp: [] },
|
||
displayHistory: { cpu: [], ram: [], gpu: [], gpuMem: [], gpuTemp: [] },
|
||
current: {
|
||
cpu: 0, ram: 0, gpu: 0, gpuMem: 0, gpuTemp: 0,
|
||
ramUsed: 0, ramTotal: 0, gpuMemUsed: 0, gpuMemTotal: 0
|
||
},
|
||
target: {
|
||
cpu: 0, ram: 0, gpu: 0, gpuMem: 0, gpuTemp: 0,
|
||
ramTotal: 0, ramUsed: 0, gpuMemTotal: 0, gpuMemUsed: 0,
|
||
cpuName: "Unknown", gpuName: "Unknown"
|
||
},
|
||
workflowProgress: {
|
||
percentage: 0,
|
||
nodePercentage: 0,
|
||
currentNodePercentage: 0,
|
||
startTime: null,
|
||
totalDuration: null,
|
||
currentNode: "Idle",
|
||
currentNodeLabel: "Idle",
|
||
easeOutStartTime: null,
|
||
isInterrupted: false,
|
||
isProgressUnknown: false,
|
||
queueCount: 0,
|
||
completionTime: null,
|
||
resetAnimationStartTime: null,
|
||
|
||
},
|
||
currentWorkflow: { percentage: 0 },
|
||
promptsMap: new Map(),
|
||
currentExecution: null,
|
||
lastQueueRemaining: 0,
|
||
cpuCores: 4,
|
||
dragState: { isDragging: false, offsetX: 0, offsetY: 0 },
|
||
resizeState: { isResizing: false, startX: 0, startY: 0, startWidth: 0, startHeight: 0 },
|
||
position: { left: 0, top: 0 },
|
||
size: { width: 250, height: 219 },
|
||
lastUpdate: 0, lastCurveUpdate: 0, lastDisplayUpdate: 0,
|
||
maxPoints: 120,
|
||
curveInterval: 0,
|
||
// 文字每 100ms 更新一次(再快也读不清);条形则每帧用 transform 平滑跟随,
|
||
// 不触发布局。以前是每帧重写整块 innerHTML 并各自新建 canvas 量字宽。
|
||
displayInterval: 100,
|
||
measureCtx: null,
|
||
rowEls: null,
|
||
textAvailableWidth: 0,
|
||
smoothFactor: 0.1,
|
||
easeOutDuration: 500,
|
||
minWidth: 150,
|
||
// 标题 19 + 工作流行 11 + 画布外边距 15 + 五行数据 55 + 内边距 20 = 120,
|
||
// 再给曲线留 30px。原来的 90 连数据行都装不下,缩到底时靠 overflow 把它们藏掉。
|
||
minHeight: 150,
|
||
maxHeight: 219,
|
||
edgeThreshold: 10,
|
||
resizeHandleSize: 10,
|
||
colors: {
|
||
cpu: 'rgba(253, 170, 4, 0.86)',
|
||
ram: 'rgba(10, 124, 254, 0.86)',
|
||
gpu: 'rgba(255, 38, 0, 0.86)',
|
||
gpuMem: 'rgba(97, 208, 12, 0.86)',
|
||
gpuTemp: 'rgba(167, 43, 255, 0.86)',
|
||
header: 'rgba(128, 128, 128, 0.5)',
|
||
workflow: 'rgba(208, 255, 0, 0.86)',
|
||
},
|
||
styles: {
|
||
// will-change: transform 让面板拥有自己的合成层:它每帧重绘时不再连带重绘被它盖住的那块主画布。
|
||
toolbar: `position: fixed; display: flex; flex-direction: column; color: rgba(186, 186, 186, 0.8); padding: 10px; border-radius: 5px; z-index: 10000; user-select: none; pointer-events: none; max-height: 219px; will-change: transform;`,
|
||
header: `margin-bottom: 5px; font-weight: bold; font-size: 10px;`,
|
||
headerText: `cursor: grab; pointer-events: auto; display: inline-block;`,
|
||
canvas: `flex: 1 1 0; min-height: 0; width: 100%; margin-top: 5px; margin-bottom: 10px; pointer-events: none; display: block;`,
|
||
display: `display: flex; flex-direction: column; font-size: 10px; flex: 0 0 auto; overflow: hidden; pointer-events: none;`,
|
||
resizeHandle: `position: absolute; bottom: 0; right: 0; width: 8px; height: 8px; border-bottom: 1px solid; border-right: 1px solid; cursor: se-resize; pointer-events: auto;`,
|
||
dot: (color) => `display: inline-block; width: 3px; height: 3px; border-radius: 50%; background: ${color}; margin-right: 5px; flex-shrink: 0; transition: background 0.5s ease;`,
|
||
bar: (width) => `display: inline-block; width: ${width}px; height: 3px; background: rgba(200, 200, 200, 0.3); margin-right: 5px; position: relative; flex-shrink: 0;`,
|
||
fill: (color, percent) => `position: absolute; top: 0; left: 0; width: ${percent}%; height: 100%; background: ${color};`,
|
||
row: `display: flex; align-items: center; height: 11px; line-height: 11px; white-space: nowrap;`,
|
||
text: `overflow: hidden; text-overflow: ellipsis; font-size: 10px;`
|
||
},
|
||
isVisible: true,
|
||
animationFrameId: null,
|
||
isEnabled: true,
|
||
isInitialized: false,
|
||
edgeFadeEnabled: true,
|
||
// 硬编码菜单边界
|
||
menuOffsets: {
|
||
left: 0, // 左侧菜单宽度
|
||
top: 0 // 顶部菜单高度
|
||
},
|
||
easeOutQuad(t) {
|
||
return t * (2 - t);
|
||
},
|
||
async init() {
|
||
if (this.isInitialized) return;
|
||
this.isInitialized = true;
|
||
this.currentWorkflow.percentage = 0;
|
||
this.workflowProgress.currentNodePercentage = 0;
|
||
this.isEnabled = app.ui.settings.getSettingValue("KayTool.EnableResourceMonitor");
|
||
this.edgeFadeEnabled = app.ui.settings.getSettingValue("KayTool.ResourceMonitorEdgeFade");
|
||
this.loadVisibility();
|
||
this.startMonitoring();
|
||
this.bindEvents();
|
||
this.setupUI();
|
||
this.setupWorkflowListener();
|
||
// 页面切到后台就停,回来再拉:后台标签页没必要每秒打一次接口
|
||
document.addEventListener('visibilitychange', () => this.syncPolling());
|
||
this.syncPolling();
|
||
if (this.isEnabled && this.isVisible) {
|
||
this.startAnimation();
|
||
}
|
||
},
|
||
async loadSettings() {
|
||
this.isEnabled = app.ui.settings.getSettingValue("KayTool.EnableResourceMonitor");
|
||
this.edgeFadeEnabled = app.ui.settings.getSettingValue("KayTool.ResourceMonitorEdgeFade");
|
||
},
|
||
loadVisibility() {
|
||
const visible = localStorage.getItem('KayResourceMonitorVisible');
|
||
this.isVisible = visible === null ? true : visible === 'true';
|
||
},
|
||
saveVisibility() {
|
||
localStorage.setItem('KayResourceMonitorVisible', this.isVisible.toString());
|
||
},
|
||
setupUI() {
|
||
this.toolbar = document.createElement('div');
|
||
this.toolbar.id = 'kay-resource-monitor';
|
||
const opacity = app.ui.settings.getSettingValue("KayTool.ResourceMonitorBackgroundOpacity") / 100;
|
||
Object.assign(this.toolbar.style, {
|
||
cssText: `${this.styles.toolbar} width: ${this.size.width}px; height: ${this.size.height}px; left: ${this.position.left}px; top: ${this.position.top}px; display: ${this.isVisible && this.isEnabled ? 'flex' : 'none'}; background: rgba(0, 0, 0, ${opacity});`
|
||
});
|
||
|
||
const header = document.createElement('div');
|
||
Object.assign(header.style, { cssText: this.styles.header });
|
||
const headerText = document.createElement('span');
|
||
Object.assign(headerText.style, { cssText: this.styles.headerText });
|
||
headerText.textContent = "𝙆 Resource Monitor";
|
||
header.appendChild(headerText);
|
||
this.toolbar.appendChild(header);
|
||
|
||
this.workflowProgressEl = document.createElement('div');
|
||
Object.assign(this.workflowProgressEl.style, { cssText: this.styles.row });
|
||
this.updateWorkflowProgress();
|
||
this.toolbar.appendChild(this.workflowProgressEl);
|
||
|
||
this.chartCanvas = document.createElement('canvas');
|
||
Object.assign(this.chartCanvas.style, { cssText: this.styles.canvas });
|
||
this.toolbar.appendChild(this.chartCanvas);
|
||
// 画布的 CSS 尺寸由 flex 决定,会在数据行首次生成、面板拖拽缩放时变化;
|
||
// 后备缓冲必须跟着重配,否则又回到「拉伸显示」的糊法。用 ResizeObserver
|
||
// 而不是在每帧里量尺寸,布局稳定时它不会触发。
|
||
if (typeof ResizeObserver !== 'undefined') {
|
||
this.canvasObserver = new ResizeObserver(() => this.sizeCanvas());
|
||
this.canvasObserver.observe(this.chartCanvas);
|
||
}
|
||
|
||
this.dataDisplay = document.createElement('div');
|
||
Object.assign(this.dataDisplay.style, { cssText: this.styles.display });
|
||
this.toolbar.appendChild(this.dataDisplay);
|
||
|
||
this.resizeHandle = document.createElement('div');
|
||
Object.assign(this.resizeHandle.style, { cssText: `${this.styles.resizeHandle} border-color: ${this.colors.header};` });
|
||
this.toolbar.appendChild(this.resizeHandle);
|
||
|
||
document.body.appendChild(this.toolbar);
|
||
this.loadPosition();
|
||
this.loadSize();
|
||
this.onResize();
|
||
this.bindToolbarEvents();
|
||
|
||
if (this.isEnabled && this.isVisible) {
|
||
this.startAnimation();
|
||
}
|
||
},
|
||
// 资源数据改为前端按需轮询,而不是后端常驻任务往 WebSocket 里推:
|
||
// 面板关着、标签页在后台时后端就完全不用采集;也不再有一个会悄悄死掉的 asyncio 任务。
|
||
// 频率沿用原来的自适应逻辑(CPU 越忙拉得越慢),0.5–2 秒一次,和 ComfyUI 自己轮询队列同一量级。
|
||
polling: false,
|
||
pollTimer: null,
|
||
pollInterval: 1000,
|
||
|
||
shouldPoll() {
|
||
return this.isEnabled && this.isVisible && !document.hidden;
|
||
},
|
||
|
||
syncPolling() {
|
||
if (this.shouldPoll()) this.startPolling();
|
||
else this.stopPolling();
|
||
},
|
||
|
||
startPolling() {
|
||
if (this.polling) return;
|
||
this.polling = true;
|
||
const tick = async () => {
|
||
if (!this.polling) return;
|
||
try {
|
||
const res = await api.fetchApi("/kaytool/resources");
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
this.updateTarget(data);
|
||
this.updateDisplay();
|
||
this.drawCurves();
|
||
const cpu = data.cpu_percent || 0;
|
||
this.pollInterval = cpu > 80 ? 2000 : cpu > 50 ? 1000 : 500;
|
||
}
|
||
} catch (e) {
|
||
// 服务重启、断网:安静地下次再试,别刷屏
|
||
}
|
||
if (this.polling) this.pollTimer = setTimeout(tick, this.pollInterval);
|
||
};
|
||
tick();
|
||
},
|
||
|
||
stopPolling() {
|
||
this.polling = false;
|
||
if (this.pollTimer !== null) {
|
||
clearTimeout(this.pollTimer);
|
||
this.pollTimer = null;
|
||
}
|
||
},
|
||
setupWorkflowListener() {
|
||
const that = this;
|
||
const queuePrompt = api.queuePrompt;
|
||
api.queuePrompt = async function (num, prompt) {
|
||
let response;
|
||
try {
|
||
response = await queuePrompt.apply(api, [...arguments]);
|
||
} catch (e) {
|
||
const promptExecution = that.getOrMakePrompt("error");
|
||
promptExecution.error({ exception_type: "Unknown." });
|
||
throw e;
|
||
}
|
||
const promptExecution = that.getOrMakePrompt(response.prompt_id);
|
||
promptExecution.setPrompt(prompt);
|
||
that.promptsMap.set(response.prompt_id, promptExecution);
|
||
return response;
|
||
};
|
||
api.addEventListener("status", (e) => {
|
||
if (!e.detail?.exec_info) return;
|
||
this.lastQueueRemaining = e.detail.exec_info.queue_remaining;
|
||
this.dispatchProgressUpdate();
|
||
});
|
||
api.addEventListener("execution_start", (e) => {
|
||
const prompt = this.getOrMakePrompt(e.detail.prompt_id);
|
||
this.currentExecution = prompt;
|
||
this.workflowProgress.startTime = performance.now();
|
||
this.workflowProgress.totalDuration = null;
|
||
this.workflowProgress.percentage = 0;
|
||
this.workflowProgress.nodePercentage = 0;
|
||
this.workflowProgress.currentNodePercentage = 0;
|
||
this.currentWorkflow.percentage = 0;
|
||
this.workflowProgress.easeOutStartTime = null;
|
||
this.workflowProgress.isInterrupted = false;
|
||
this.workflowProgress.isProgressUnknown = false;
|
||
this.dispatchProgressUpdate();
|
||
});
|
||
api.addEventListener("executing", (e) => {
|
||
if (!this.currentExecution) {
|
||
this.currentExecution = this.getOrMakePrompt("unknown");
|
||
}
|
||
this.currentExecution.executing(e.detail);
|
||
if (e.detail == null && !this.workflowProgress.isInterrupted) {
|
||
if (this.workflowProgress.startTime) {
|
||
this.workflowProgress.totalDuration = (performance.now() - this.workflowProgress.startTime) / 1000;
|
||
}
|
||
this.workflowProgress.easeOutStartTime = performance.now();
|
||
this.currentExecution = null;
|
||
}
|
||
this.dispatchProgressUpdate();
|
||
});
|
||
api.addEventListener("progress", (e) => {
|
||
if (!this.currentExecution) {
|
||
this.currentExecution = this.getOrMakePrompt(e.detail.prompt_id);
|
||
}
|
||
this.currentExecution.executing(e.detail.node, e.detail.value, e.detail.max);
|
||
this.dispatchProgressUpdate();
|
||
});
|
||
api.addEventListener("execution_cached", (e) => {
|
||
if (!this.currentExecution) {
|
||
this.currentExecution = this.getOrMakePrompt(e.detail.prompt_id);
|
||
}
|
||
for (const cached of e.detail.nodes) {
|
||
if (!this.currentExecution.executedNodeIds.includes(cached)) {
|
||
this.currentExecution.executedNodeIds.push(cached);
|
||
}
|
||
}
|
||
this.dispatchProgressUpdate();
|
||
});
|
||
api.addEventListener("execution_error", (e) => {
|
||
if (!this.currentExecution) {
|
||
this.currentExecution = this.getOrMakePrompt(e.detail.prompt_id);
|
||
}
|
||
this.currentExecution?.error(e.detail);
|
||
this.dispatchProgressUpdate();
|
||
});
|
||
api.addEventListener("execution_interrupted", (e) => {
|
||
if (this.currentExecution) {
|
||
if (this.workflowProgress.startTime) {
|
||
this.workflowProgress.totalDuration = (performance.now() - this.workflowProgress.startTime) / 1000;
|
||
}
|
||
this.workflowProgress.easeOutStartTime = performance.now();
|
||
this.workflowProgress.isInterrupted = true;
|
||
this.currentExecution = null;
|
||
this.dispatchProgressUpdate();
|
||
}
|
||
});
|
||
},
|
||
getOrMakePrompt(id) {
|
||
let prompt = this.promptsMap.get(id);
|
||
if (!prompt) {
|
||
prompt = new KayPromptExecution(id);
|
||
this.promptsMap.set(id, prompt);
|
||
}
|
||
return prompt;
|
||
},
|
||
dispatchProgressUpdate() {
|
||
const prompt = this.currentExecution;
|
||
this.workflowProgress.queueCount = this.lastQueueRemaining;
|
||
if (prompt?.currentlyExecuting) {
|
||
if (!prompt.promptApi || prompt.totalNodes <= 0) {
|
||
this.workflowProgress.isProgressUnknown = true;
|
||
this.workflowProgress.percentage = 0;
|
||
this.workflowProgress.currentNodeLabel = prompt.currentlyExecuting.nodeLabel || "Unknown";
|
||
} else {
|
||
this.workflowProgress.isProgressUnknown = false;
|
||
const executed = prompt.executedNodeIds.length;
|
||
const total = prompt.totalNodes || 1;
|
||
const basePercentage = (executed / total) * 100;
|
||
const current = prompt.currentlyExecuting;
|
||
let nodeContribution = 0;
|
||
this.workflowProgress.currentNodeLabel = current.nodeLabel || "Unknown";
|
||
if (current.step != null && current.maxSteps) {
|
||
this.workflowProgress.nodePercentage = (current.step / current.maxSteps) * 100;
|
||
nodeContribution = (1 / total) * (current.step / current.maxSteps) * 100;
|
||
} else {
|
||
this.workflowProgress.nodePercentage = 0;
|
||
}
|
||
this.workflowProgress.percentage = Math.min(basePercentage + nodeContribution, 100);
|
||
}
|
||
} else if (this.workflowProgress.totalDuration !== null) {
|
||
this.workflowProgress.isProgressUnknown = false;
|
||
if (this.workflowProgress.isInterrupted) {
|
||
this.workflowProgress.currentNode = `${this.workflowProgress.totalDuration.toFixed(1)}s (Interrupted)`;
|
||
this.workflowProgress.currentNodeLabel = "Interrupted";
|
||
} else {
|
||
this.workflowProgress.percentage = 100;
|
||
this.workflowProgress.currentNode = `${this.workflowProgress.totalDuration.toFixed(1)}s`;
|
||
this.workflowProgress.currentNodeLabel = "Completed";
|
||
}
|
||
this.workflowProgress.nodePercentage = 0;
|
||
this.workflowProgress.completionTime = performance.now(); // 新增:设置完成时间
|
||
} else {
|
||
this.workflowProgress.isProgressUnknown = false;
|
||
this.workflowProgress.percentage = 0;
|
||
this.workflowProgress.nodePercentage = 0;
|
||
this.workflowProgress.currentNode = "Idle";
|
||
this.workflowProgress.currentNodeLabel = "Idle";
|
||
this.workflowProgress.completionTime = null; // 新增:闲置时清空完成时间
|
||
}
|
||
},
|
||
// 每帧:只动工作流进度条(和资源条形同一套做法)
|
||
updateWorkflowBar() {
|
||
const fill = this.workflowProgressEl?.querySelector('.workflow-fill');
|
||
if (!fill) return;
|
||
const wp = this.workflowProgress;
|
||
const pct = (wp.currentNode === "Idle" || (wp.isProgressUnknown && wp.resetAnimationStartTime === null))
|
||
? 0 : Math.min(Math.max(this.currentWorkflow.percentage, 0), 100);
|
||
fill.style.transform = `scaleX(${pct / 100})`;
|
||
},
|
||
|
||
updateWorkflowProgress() {
|
||
if (!this.workflowProgressEl) return;
|
||
const { percentage, currentNode, isProgressUnknown, queueCount } = this.workflowProgress;
|
||
const { styles, colors } = this;
|
||
const containerWidth = this.toolbar.clientWidth - 20;
|
||
const barWidth = Math.min(200, Math.max(50, (containerWidth - 15) * 0.18));
|
||
let displayPercentage, barPercentage, displayText;
|
||
if (currentNode === "Idle") {
|
||
displayText = "Workflow: Idle";
|
||
barPercentage = 0;
|
||
} else if (this.workflowProgress.resetAnimationStartTime !== null) {
|
||
// 重置动画期间,使用当前动画的百分比
|
||
displayPercentage = `${Math.min(Math.max(this.currentWorkflow.percentage, 0), 100).toFixed(1)}%`;
|
||
barPercentage = Math.min(Math.max(this.currentWorkflow.percentage, 0), 100);
|
||
displayText = `Workflow: ${displayPercentage} - ${currentNode}`;
|
||
} else {
|
||
displayPercentage = isProgressUnknown ? "Loading" : `${Math.min(Math.max(this.currentWorkflow.percentage, 0), 100).toFixed(1)}%`;
|
||
barPercentage = isProgressUnknown ? 0 : Math.min(Math.max(this.currentWorkflow.percentage, 0), 100);
|
||
if (queueCount > 1) {
|
||
displayPercentage += ` (${queueCount})`;
|
||
}
|
||
displayText = `Workflow: ${displayPercentage} - ${currentNode}`;
|
||
}
|
||
const dotColor = this.currentExecution?.currentlyExecuting ? 'rgba(208, 255, 0, 0.86)' : 'rgba(200, 200, 200, 0.3)';
|
||
if (!this.workflowProgressEl.querySelector('.workflow-dot')) {
|
||
this.workflowProgressEl.innerHTML = `
|
||
<span class="workflow-dot" style="${styles.dot(dotColor)}"></span>
|
||
<span class="workflow-bar" style="${styles.bar(barWidth)}">
|
||
<span class="workflow-fill" style="${styles.fill(colors.workflow, 100)} transform-origin: left center; transform: scaleX(${barPercentage / 100}); will-change: transform;"></span>
|
||
</span>
|
||
<span style="${styles.text}">${displayText}</span>
|
||
`;
|
||
} else {
|
||
const dotElement = this.workflowProgressEl.querySelector('.workflow-dot');
|
||
if (dotElement) dotElement.style.background = dotColor;
|
||
const barElement = this.workflowProgressEl.querySelector('.workflow-bar');
|
||
if (barElement) barElement.style.width = `${barWidth}px`;
|
||
// 条形本身由 updateWorkflowBar() 每帧用 transform 驱动
|
||
const textElement = this.workflowProgressEl.querySelector('span:nth-child(3)');
|
||
if (textElement) textElement.textContent = displayText;
|
||
}
|
||
},
|
||
show() {
|
||
this.isVisible = true;
|
||
if (this.toolbar && this.isEnabled) {
|
||
this.toolbar.style.display = 'flex';
|
||
this.refreshCanvas();
|
||
this.startAnimation();
|
||
}
|
||
this.syncPolling();
|
||
this.saveVisibility();
|
||
},
|
||
hide() {
|
||
this.isVisible = false;
|
||
if (this.toolbar) {
|
||
this.toolbar.style.display = 'none';
|
||
this.stopAnimation();
|
||
}
|
||
this.syncPolling();
|
||
this.saveVisibility();
|
||
},
|
||
updateEnabledState(enabled) {
|
||
this.isEnabled = enabled;
|
||
if (this.menuButton) this.menuButton.style.display = enabled ? '' : 'none';
|
||
if (this.toolbar) {
|
||
this.toolbar.style.display = enabled && this.isVisible ? 'flex' : 'none';
|
||
if (enabled && this.isVisible) {
|
||
this.refreshCanvas();
|
||
this.startAnimation();
|
||
} else {
|
||
this.stopAnimation();
|
||
}
|
||
}
|
||
this.syncPolling();
|
||
},
|
||
// 画布要同时伺候两套尺寸:显示用的 CSS 像素,和后备缓冲的物理像素。
|
||
// 只设 width/height 属性的话,在 2 倍屏上等于把一张半分辨率的图拉大显示 —— 线条就糊了。
|
||
// 画布的 CSS 尺寸由 flex 布局决定(吃掉数据行之外的剩余高度),这里只负责
|
||
// 按 devicePixelRatio 配好后备缓冲。不能反过来用 JS 算高度:之前那种
|
||
// 「面板高 − 数据行高」的算法在数据行被挤成 0 时会把画布撑满整个面板,
|
||
// 数据行就再也回不来了。
|
||
sizeCanvas() {
|
||
const canvas = this.chartCanvas;
|
||
if (!canvas) return;
|
||
const rect = canvas.getBoundingClientRect();
|
||
if (rect.width === 0 || rect.height === 0) return; // 隐藏时无尺寸,等 show 时再来
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = Math.round(rect.width * dpr);
|
||
const h = Math.round(rect.height * dpr);
|
||
if (canvas.width !== w || canvas.height !== h) {
|
||
canvas.width = w;
|
||
canvas.height = h;
|
||
}
|
||
// 绘图代码继续用 CSS 像素坐标,不必关心 dpr
|
||
canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
},
|
||
|
||
refreshCanvas() {
|
||
this.sizeCanvas();
|
||
},
|
||
updateTarget(data) {
|
||
this.target.cpu = data.cpu_percent || 0;
|
||
this.cpuCores = data.cpu_count || 4;
|
||
this.target.ram = data.ram_percent || 0;
|
||
this.target.ramTotal = data.ram_total || 0;
|
||
this.target.ramUsed = data.ram_used || 0;
|
||
this.target.cpuName = data.cpu_name || "Unknown";
|
||
this.dataHistory.cpu.push(this.target.cpu);
|
||
this.dataHistory.ram.push(this.target.ram);
|
||
if (data.gpu && typeof data.gpu !== 'string' && data.gpu.length > 0) {
|
||
this.target.gpu = data.gpu[0].load || 0;
|
||
this.target.gpuMem = data.gpu[0].memory_percent || 0;
|
||
this.target.gpuMemTotal = data.gpu[0].memory_total || 0;
|
||
this.target.gpuMemUsed = data.gpu[0].memory_used || 0;
|
||
this.target.gpuTemp = data.gpu[0].temperature || 0;
|
||
this.target.gpuName = data.gpu[0].name || "Unknown";
|
||
this.dataHistory.gpu.push(this.target.gpu);
|
||
this.dataHistory.gpuMem.push(this.target.gpuMem);
|
||
this.dataHistory.gpuTemp.push(this.target.gpuTemp);
|
||
} else {
|
||
this.target.gpu = 0;
|
||
this.target.gpuMem = 0;
|
||
this.target.gpuMemTotal = 0;
|
||
this.target.gpuMemUsed = 0;
|
||
this.target.gpuTemp = 0;
|
||
this.target.gpuName = "Unknown";
|
||
this.dataHistory.gpu.push(0);
|
||
this.dataHistory.gpuMem.push(0);
|
||
this.dataHistory.gpuTemp.push(0);
|
||
}
|
||
if (this.dataHistory.cpu.length > this.maxPoints) {
|
||
this.dataHistory.cpu.shift();
|
||
this.dataHistory.ram.shift();
|
||
this.dataHistory.gpu.shift();
|
||
this.dataHistory.gpuMem.shift();
|
||
this.dataHistory.gpuTemp.shift();
|
||
}
|
||
},
|
||
// 数据行的 DOM 只建一次,之后每帧只改条形的 transform(合成器动画,不触发布局),
|
||
// 文字每 100ms 更新一次(再快也读不清,且每次都要重排那一行)。
|
||
ensureRows() {
|
||
if (this.rowEls) return;
|
||
const { styles, colors } = this;
|
||
const defs = [['CPU', colors.cpu], ['RAM', colors.ram], ['GPU', colors.gpu], ['VRAM', colors.gpuMem], ['TEMP', colors.gpuTemp]];
|
||
this.dataDisplay.innerHTML = '';
|
||
this.rowEls = {};
|
||
for (const [label, color] of defs) {
|
||
const row = document.createElement('div'); row.style.cssText = styles.row;
|
||
const dot = document.createElement('span'); dot.style.cssText = styles.dot(color);
|
||
const bar = document.createElement('span'); bar.style.cssText = styles.bar(50);
|
||
const fill = document.createElement('span');
|
||
fill.style.cssText = styles.fill(color, 100) + ' transform-origin: left center; transform: scaleX(0); will-change: transform;';
|
||
bar.appendChild(fill);
|
||
const text = document.createElement('span'); text.style.cssText = styles.text;
|
||
row.append(dot, bar, text);
|
||
this.dataDisplay.appendChild(row);
|
||
this.rowEls[label] = { bar, fill, text, lastText: '' };
|
||
}
|
||
this.layoutRows();
|
||
},
|
||
|
||
// 面板宽度变化时才需要重算条宽和文字可用宽度
|
||
layoutRows() {
|
||
if (!this.rowEls) return;
|
||
const containerWidth = this.toolbar.clientWidth - 20;
|
||
const fixedSpacing = 13;
|
||
const barWidth = Math.min(200, Math.max(50, (containerWidth - fixedSpacing) * 0.18));
|
||
this.textAvailableWidth = containerWidth - fixedSpacing - barWidth;
|
||
for (const r of Object.values(this.rowEls)) r.bar.style.width = `${barWidth}px`;
|
||
},
|
||
|
||
// 每帧:只动条形
|
||
updateBars() {
|
||
this.ensureRows();
|
||
const c = this.current, hasGpu = !!(this.target.gpu || this.target.gpuMem);
|
||
const vals = { CPU: c.cpu, RAM: c.ram, GPU: hasGpu ? c.gpu : 0, VRAM: hasGpu ? c.gpuMem : 0, TEMP: hasGpu ? c.gpuTemp : 0 };
|
||
for (const [label, v] of Object.entries(vals)) {
|
||
this.rowEls[label].fill.style.transform = `scaleX(${Math.min(Math.max(v, 0), 100) / 100})`;
|
||
}
|
||
},
|
||
|
||
// 每 displayInterval:更新文字
|
||
updateTexts() {
|
||
this.ensureRows();
|
||
const { cpu, ram, gpu, gpuMem, gpuTemp, ramUsed, ramTotal, gpuMemUsed, gpuMemTotal } = this.current;
|
||
const hasGpu = !!(this.target.gpu || this.target.gpuMem);
|
||
const texts = {
|
||
CPU: `${cpu.toFixed(1)}% (${this.cpuCores} cores) - ${this.target.cpuName}`,
|
||
RAM: `${ramUsed.toFixed(1)}/${ramTotal.toFixed(1)}GB (${ram.toFixed(1)}%)`,
|
||
GPU: hasGpu ? `${gpu.toFixed(1)}% - ${this.target.gpuName}` : 'N/A',
|
||
VRAM: hasGpu ? `${gpuMemUsed.toFixed(1)}/${gpuMemTotal.toFixed(1)}GB (${gpuMem.toFixed(1)}%)` : 'N/A',
|
||
TEMP: hasGpu ? `${gpuTemp.toFixed(1)}°C (GPU)` : 'N/A (GPU)',
|
||
};
|
||
for (const [label, text] of Object.entries(texts)) {
|
||
const r = this.rowEls[label];
|
||
const shown = this.fitText(label, text);
|
||
if (shown !== r.lastText) { r.text.textContent = shown; r.lastText = shown; }
|
||
}
|
||
},
|
||
|
||
updateDisplay() {
|
||
this.ensureRows();
|
||
this.layoutRows();
|
||
this.updateBars();
|
||
this.updateTexts();
|
||
},
|
||
|
||
fitText(label, text) {
|
||
if (!this.measureCtx) {
|
||
this.measureCtx = document.createElement('canvas').getContext('2d');
|
||
}
|
||
const ctx = this.measureCtx;
|
||
ctx.font = '10px sans-serif';
|
||
const full = `${label}: ${text}`;
|
||
const avail = this.textAvailableWidth || 0;
|
||
if (!avail || ctx.measureText(full).width <= avail) return full;
|
||
const ellipsisWidth = ctx.measureText('...').width;
|
||
let out = `${label}: `;
|
||
let remaining = avail - ctx.measureText(out).width - ellipsisWidth;
|
||
for (const ch of text) {
|
||
const w = ctx.measureText(ch).width;
|
||
if (remaining < w) { out += '...'; break; }
|
||
out += ch; remaining -= w;
|
||
}
|
||
return out;
|
||
},
|
||
animate() {
|
||
if (!this.isVisible || !this.isEnabled || !this.toolbar) return;
|
||
const now = performance.now();
|
||
const deltaTime = (now - this.lastUpdate) / 1000;
|
||
this.lastUpdate = now;
|
||
for (const key in this.current) {
|
||
if (this.current[key] === 0 && this.target[key] !== 0) {
|
||
this.current[key] = this.target[key];
|
||
} else {
|
||
this.current[key] += (this.target[key] - this.current[key]) * Math.min(this.smoothFactor * deltaTime * 60, 1);
|
||
}
|
||
}
|
||
if (this.workflowProgress.easeOutStartTime !== null) {
|
||
const elapsed = now - this.workflowProgress.easeOutStartTime;
|
||
const t = Math.min(elapsed / this.easeOutDuration, 1);
|
||
const startPercentage = this.currentWorkflow.percentage;
|
||
const targetPercentage = this.workflowProgress.percentage;
|
||
const startNodePercentage = this.workflowProgress.currentNodePercentage;
|
||
const targetNodePercentage = this.workflowProgress.nodePercentage;
|
||
this.currentWorkflow.percentage = startPercentage + (targetPercentage - startPercentage) * this.easeOutQuad(t);
|
||
this.workflowProgress.currentNodePercentage = startNodePercentage + (targetNodePercentage - startNodePercentage) * this.easeOutQuad(t);
|
||
if (t >= 1) this.workflowProgress.easeOutStartTime = null;
|
||
} else if (!this.workflowProgress.isProgressUnknown && this.workflowProgress.currentNode !== "Idle" && this.workflowProgress.resetAnimationStartTime === null) {
|
||
this.currentWorkflow.percentage += (this.workflowProgress.percentage - this.currentWorkflow.percentage) * this.smoothFactor;
|
||
this.workflowProgress.currentNodePercentage += (this.workflowProgress.nodePercentage - this.workflowProgress.currentNodePercentage) * this.smoothFactor;
|
||
}
|
||
// 修改:处理 2 分钟后的重置动画
|
||
if (this.workflowProgress.completionTime !== null && this.workflowProgress.resetAnimationStartTime === null) {
|
||
const elapsedSinceCompletion = now - this.workflowProgress.completionTime;
|
||
if (elapsedSinceCompletion >= 120000) { // 2 分钟 = 120000 毫秒
|
||
this.workflowProgress.resetAnimationStartTime = now; // 开始重置动画
|
||
this.workflowProgress.percentage = this.currentWorkflow.percentage; // 锁定当前进度作为动画起点
|
||
this.workflowProgress.nodePercentage = this.workflowProgress.currentNodePercentage;
|
||
}
|
||
}
|
||
// 新增:执行重置动画
|
||
if (this.workflowProgress.resetAnimationStartTime !== null) {
|
||
const resetDuration = 1000; // 重置动画持续 1 秒
|
||
const elapsed = now - this.workflowProgress.resetAnimationStartTime;
|
||
const t = Math.min(elapsed / resetDuration, 1);
|
||
const easedT = this.easeOutQuad(t); // 使用现有的缓动函数
|
||
// 从当前值渐变到 0
|
||
this.currentWorkflow.percentage = this.workflowProgress.percentage * (1 - easedT);
|
||
this.workflowProgress.currentNodePercentage = this.workflowProgress.nodePercentage * (1 - easedT);
|
||
// 更新显示文本,保持平滑过渡
|
||
this.workflowProgress.currentNode = `Resetting... (${(this.currentWorkflow.percentage).toFixed(1)}%)`;
|
||
this.workflowProgress.currentNodeLabel = "Resetting";
|
||
if (t >= 1) {
|
||
// 动画结束,重置为 Idle
|
||
this.workflowProgress.percentage = 0;
|
||
this.workflowProgress.nodePercentage = 0;
|
||
this.workflowProgress.currentNodePercentage = 0;
|
||
this.workflowProgress.currentNode = "Idle";
|
||
this.workflowProgress.currentNodeLabel = "Idle";
|
||
this.workflowProgress.totalDuration = null;
|
||
this.workflowProgress.completionTime = null;
|
||
this.workflowProgress.resetAnimationStartTime = null;
|
||
this.currentWorkflow.percentage = 0;
|
||
}
|
||
}
|
||
if (this.currentExecution?.currentlyExecuting) {
|
||
if (this.workflowProgress.nodePercentage > 0) {
|
||
this.workflowProgress.currentNode = `${this.workflowProgress.currentNodeLabel} (${this.workflowProgress.currentNodePercentage.toFixed(1)}%)`;
|
||
} else {
|
||
this.workflowProgress.currentNode = this.workflowProgress.currentNodeLabel;
|
||
}
|
||
}
|
||
if (now - this.lastCurveUpdate >= 500) {
|
||
for (const key in this.displayHistory) {
|
||
this.displayHistory[key].push(this.current[key]);
|
||
if (this.displayHistory[key].length > this.maxPoints) this.displayHistory[key].shift();
|
||
}
|
||
this.lastCurveUpdate = now;
|
||
}
|
||
this.updateBars();
|
||
this.updateWorkflowBar();
|
||
if (now - this.lastDisplayUpdate >= this.displayInterval) {
|
||
this.updateTexts();
|
||
this.updateWorkflowProgress();
|
||
this.lastDisplayUpdate = now;
|
||
}
|
||
this.drawCurves();
|
||
this.animationFrameId = requestAnimationFrame(() => this.animate());
|
||
},
|
||
startAnimation() {
|
||
if (!this.animationFrameId && this.isEnabled && this.isVisible && this.toolbar) {
|
||
this.lastUpdate = performance.now();
|
||
this.lastCurveUpdate = performance.now();
|
||
this.lastDisplayUpdate = performance.now();
|
||
this.animationFrameId = requestAnimationFrame(() => this.animate());
|
||
}
|
||
},
|
||
stopAnimation() {
|
||
if (this.animationFrameId) {
|
||
cancelAnimationFrame(this.animationFrameId);
|
||
this.animationFrameId = null;
|
||
}
|
||
},
|
||
drawCurves() {
|
||
const ctx = this.chartCanvas.getContext('2d');
|
||
// setTransform 之后坐标系是 CSS 像素,所以这里不能再读后备缓冲的尺寸
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = this.chartCanvas.width / dpr;
|
||
const h = this.chartCanvas.height / dpr;
|
||
ctx.clearRect(0, 0, w, h);
|
||
ctx.strokeStyle = 'rgba(166, 166, 166, 0.5)';
|
||
ctx.lineWidth = 0.5;
|
||
ctx.beginPath();
|
||
for (let i = 0; i < 4; i++) {
|
||
const y = h - (i * h / 4);
|
||
ctx.moveTo(0, y);
|
||
ctx.lineTo(w, y);
|
||
}
|
||
ctx.stroke();
|
||
ctx.strokeStyle = 'rgba(166, 166, 166, 0.5)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(0, 0);
|
||
ctx.lineTo(w, 0);
|
||
ctx.stroke();
|
||
const step = w / (this.maxPoints - 1);
|
||
const { colors } = this;
|
||
ctx.globalCompositeOperation = 'screen';
|
||
ctx.lineWidth = 0.8;
|
||
const now = performance.now();
|
||
const timeSinceLastUpdate = Math.min((now - this.lastCurveUpdate) / 500, 1);
|
||
[
|
||
{ data: this.displayHistory.cpu, color: colors.cpu, key: 'cpu' },
|
||
{ data: this.displayHistory.ram, color: colors.ram, key: 'ram' },
|
||
{ data: this.displayHistory.gpu, color: colors.gpu, key: 'gpu' },
|
||
{ data: this.displayHistory.gpuMem, color: colors.gpuMem, key: 'gpuMem' },
|
||
{ data: this.displayHistory.gpuTemp, color: colors.gpuTemp, key: 'gpuTemp' }
|
||
].forEach(({ data, color, key }) => {
|
||
ctx.strokeStyle = color;
|
||
ctx.beginPath();
|
||
const offset = step * timeSinceLastUpdate;
|
||
let firstVisiblePointDrawn = false;
|
||
for (let i = 0; i < data.length; i++) {
|
||
const x = i * step - offset;
|
||
const y = h - (Math.min(data[i], 100) / 100) * h;
|
||
if (x > w) continue;
|
||
if (i === 0 || (x < 0 && i === data.length - 1)) {
|
||
ctx.moveTo(Math.max(x, 0), y);
|
||
firstVisiblePointDrawn = x >= 0;
|
||
} else {
|
||
const prevX = (i - 1) * step - offset;
|
||
const prevY = h - (Math.min(data[i - 1], 100) / 100) * h;
|
||
if (prevX < 0 && x >= 0) {
|
||
const edgeX = 0;
|
||
const t = (edgeX - prevX) / (x - prevX);
|
||
const edgeY = prevY + (y - prevY) * t;
|
||
ctx.moveTo(edgeX, edgeY);
|
||
const midX = (edgeX + x) / 2;
|
||
ctx.quadraticCurveTo(edgeX, edgeY, midX, (edgeY + y) / 2);
|
||
ctx.quadraticCurveTo(x, y, x, y);
|
||
firstVisiblePointDrawn = true;
|
||
} else if (prevX >= 0 && x >= 0) {
|
||
const midX = (prevX + x) / 2;
|
||
ctx.quadraticCurveTo(prevX, prevY, midX, (prevY + y) / 2);
|
||
ctx.quadraticCurveTo(x, y, x, y);
|
||
firstVisiblePointDrawn = true;
|
||
} else if (prevX < 0 && x < 0 && !firstVisiblePointDrawn) {
|
||
ctx.moveTo(Math.max(x, 0), y);
|
||
}
|
||
}
|
||
}
|
||
const lastIndex = data.length - 1;
|
||
if (lastIndex >= 0) {
|
||
const lastX = lastIndex * step - offset;
|
||
const lastValue = data[lastIndex];
|
||
const currentValue = this.current[key];
|
||
const lastY = h - (Math.min(lastValue, 100) / 100) * h;
|
||
const nextX = Math.min(w, lastX + step * timeSinceLastUpdate);
|
||
const interpolatedValue = lastValue + (currentValue - lastValue) * timeSinceLastUpdate;
|
||
const nextY = h - (Math.min(interpolatedValue, 100) / 100) * h;
|
||
if (lastX >= 0 && nextX >= 0) {
|
||
ctx.quadraticCurveTo(lastX, lastY, (lastX + nextX) / 2, (lastY + nextY) / 2);
|
||
ctx.lineTo(nextX, nextY);
|
||
} else if (lastX < 0 && nextX >= 0) {
|
||
const edgeX = 0;
|
||
const t = (edgeX - lastX) / (nextX - lastX);
|
||
const edgeY = lastY + (nextY - lastY) * t;
|
||
ctx.moveTo(edgeX, edgeY);
|
||
ctx.quadraticCurveTo(edgeX, edgeY, (edgeX + nextX) / 2, (edgeY + nextY) / 2);
|
||
ctx.lineTo(nextX, nextY);
|
||
} else if (nextX < 0) {
|
||
ctx.moveTo(0, nextY);
|
||
}
|
||
}
|
||
ctx.stroke();
|
||
});
|
||
ctx.globalCompositeOperation = 'source-over';
|
||
if (this.edgeFadeEnabled) {
|
||
const gradient = ctx.createLinearGradient(0, 0, w, 0);
|
||
gradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
|
||
gradient.addColorStop(0.1, 'rgba(255, 255, 255, 1)');
|
||
gradient.addColorStop(0.9, 'rgba(255, 255, 255, 1)');
|
||
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
|
||
ctx.save();
|
||
ctx.globalCompositeOperation = 'destination-in';
|
||
ctx.fillStyle = gradient;
|
||
ctx.fillRect(0, 0, w, h);
|
||
ctx.restore();
|
||
}
|
||
},
|
||
startMonitoring() {
|
||
for (const key in this.displayHistory) {
|
||
this.displayHistory[key] = Array(this.maxPoints).fill(0);
|
||
}
|
||
},
|
||
bindEvents() {
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (this.dragState.isDragging) this.onDrag(e);
|
||
if (this.resizeState.isResizing) this.onResizeDrag(e);
|
||
});
|
||
document.addEventListener('mouseup', (e) => {
|
||
if (this.dragState.isDragging) this.endDrag();
|
||
if (this.resizeState.isResizing) this.endResize();
|
||
});
|
||
document.addEventListener('selectstart', (e) => (this.dragState.isDragging || this.resizeState.isResizing) && e.preventDefault());
|
||
window.addEventListener('resize', () => this.onResize());
|
||
},
|
||
bindToolbarEvents() {
|
||
if (this.toolbar) {
|
||
const headerText = this.toolbar.querySelector('span');
|
||
headerText.addEventListener('mousedown', (e) => this.startDrag(e));
|
||
this.resizeHandle.addEventListener('mousedown', (e) => this.startResize(e, 'se'));
|
||
}
|
||
},
|
||
loadPosition() {
|
||
const pos = JSON.parse(localStorage.getItem('KayResourceMonitorPosition')) || {};
|
||
const leftOffset = this.menuOffsets.left + 32; // 左侧菜单 + 20px
|
||
const topOffset = window.innerHeight - this.size.height - 20; // 底部 20px
|
||
this.position.left = pos.left != null ? pos.left : leftOffset;
|
||
this.position.top = pos.top != null ? pos.top : topOffset;
|
||
this.toolbar.style.left = `${this.position.left}px`;
|
||
this.toolbar.style.top = `${this.position.top}px`;
|
||
},
|
||
loadSize() {
|
||
const size = JSON.parse(localStorage.getItem('KayResourceMonitorSize')) || {};
|
||
this.size.width = Math.max(size.width || 250, this.minWidth);
|
||
this.size.height = Math.max(size.height || 219, this.minHeight);
|
||
this.toolbar.style.width = `${this.size.width}px`;
|
||
this.toolbar.style.height = `${this.size.height}px`;
|
||
},
|
||
savePositionAndSize() {
|
||
localStorage.setItem('KayResourceMonitorPosition', JSON.stringify(this.position));
|
||
localStorage.setItem('KayResourceMonitorSize', JSON.stringify(this.size));
|
||
},
|
||
onResize() {
|
||
if (!this.toolbar) return;
|
||
const winW = window.innerWidth;
|
||
const winH = window.innerHeight;
|
||
this.position.left = Math.max(this.menuOffsets.left, Math.min(this.position.left, winW - this.size.width));
|
||
this.position.top = Math.max(this.menuOffsets.top, Math.min(this.position.top, winH - this.size.height));
|
||
this.toolbar.style.left = `${this.position.left}px`;
|
||
this.toolbar.style.top = `${this.position.top}px`;
|
||
this.refreshCanvas();
|
||
this.updateDisplay();
|
||
},
|
||
startDrag(e) {
|
||
e.preventDefault();
|
||
const rect = this.toolbar.getBoundingClientRect();
|
||
this.dragState.isDragging = true;
|
||
this.dragState.offsetX = e.clientX - rect.left;
|
||
this.dragState.offsetY = e.clientY - rect.top;
|
||
this.toolbar.querySelector('span').style.cursor = 'grabbing';
|
||
},
|
||
onDrag(e) {
|
||
if (!this.dragState.isDragging) return;
|
||
const winW = window.innerWidth;
|
||
const winH = window.innerHeight;
|
||
this.position.left = Math.max(this.menuOffsets.left, Math.min(e.clientX - this.dragState.offsetX, winW - this.size.width));
|
||
this.position.top = Math.max(this.menuOffsets.top, Math.min(e.clientY - this.dragState.offsetY, winH - this.size.height));
|
||
this.toolbar.style.left = `${this.position.left}px`;
|
||
this.toolbar.style.top = `${this.position.top}px`;
|
||
},
|
||
endDrag() {
|
||
if (!this.dragState.isDragging) return;
|
||
this.dragState.isDragging = false;
|
||
this.toolbar.querySelector('span').style.cursor = 'grab';
|
||
this.savePositionAndSize();
|
||
},
|
||
startResize(e, direction) {
|
||
e.preventDefault();
|
||
this.resizeState.isResizing = true;
|
||
this.resizeState.startX = e.clientX;
|
||
this.resizeState.startY = e.clientY;
|
||
this.resizeState.startWidth = this.toolbar.clientWidth;
|
||
this.resizeState.startHeight = this.toolbar.clientHeight;
|
||
this.resizeHandle.style.cursor = 'se-resize';
|
||
},
|
||
onResizeDrag(e) {
|
||
if (!this.resizeState.isResizing) return;
|
||
const dx = e.clientX - this.resizeState.startX;
|
||
const dy = e.clientY - this.resizeState.startY;
|
||
this.size.width = Math.max(this.minWidth, this.resizeState.startWidth + dx);
|
||
this.size.height = Math.min(this.maxHeight, Math.max(this.minHeight, this.resizeState.startHeight + dy));
|
||
this.toolbar.style.width = `${this.size.width}px`;
|
||
this.toolbar.style.height = `${this.size.height}px`;
|
||
this.onResize();
|
||
},
|
||
endResize() {
|
||
if (!this.resizeState.isResizing) return;
|
||
this.resizeState.isResizing = false;
|
||
this.resizeHandle.style.cursor = 'se-resize';
|
||
this.savePositionAndSize();
|
||
}
|
||
};
|
||
|
||
app.registerExtension({
|
||
name: "KayTool.ResourceMonitor",
|
||
async setup() {
|
||
await KayResourceMonitor.init();
|
||
const showMenuButton = new (await import("/scripts/ui/components/button.js")).ComfyButton({
|
||
content: "𝙆 Monitor",
|
||
action: () => {
|
||
if (KayResourceMonitor.isVisible) {
|
||
KayResourceMonitor.hide();
|
||
showMenuButton.tooltip = "Show Monitor";
|
||
} else {
|
||
KayResourceMonitor.show();
|
||
showMenuButton.tooltip = "Hide Monitor";
|
||
}
|
||
},
|
||
tooltip: KayResourceMonitor.isVisible ? "Hide Monitor" : "Show Monitor"
|
||
});
|
||
KayResourceMonitor.menuButton = showMenuButton.element;
|
||
// 设置项的 onChange 只在开关被拨动时触发,页面刷新走的是这里,
|
||
// 所以初始显隐必须自己按设置来一次,否则关掉的按钮一刷新就又冒出来。
|
||
showMenuButton.element.style.display = KayResourceMonitor.isEnabled ? '' : 'none';
|
||
if (app.menu?.settingsGroup) {
|
||
app.menu.settingsGroup.append(showMenuButton);
|
||
}
|
||
window.KayResourceMonitor = KayResourceMonitor;
|
||
}
|
||
}); |