fix: the log panel stops eating your selection mid-copy

Every 1500 ms the poll did `bodyEl.textContent = lines.join("\n")`, and
assigning textContent destroys and rebuilds the text node — which drops
whatever the user had selected. Copying three lines out of a running log was
a race against the next tick.

Now the write is withheld while a non-collapsed selection sits inside the log
body (a small "paused — selection held" note says so), and lands as soon as the
selection is released or moves out. Unchanged text no longer touches the DOM at
all, so an idle log is completely still. Adds an explicit ⏸ Pause toggle for
deliberate freezing, and Copy now copies just the selection when there is one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-04 22:33:15 +03:00
co-authored by Claude Opus 5
parent 4372988bc2
commit 8e8a4a5471
2 changed files with 126 additions and 11 deletions
+124 -10
View File
@@ -1,5 +1,5 @@
// Menubar "Log" button + a collapsible panel showing ComfyUI's real backend log
// (tee'd stdout/stderr from the server). Polls only while open.
// (tee'd stdout/stderr from the server). Polls only while open. Freezes during selection to preserve user interaction.
(function () {
const API = window.MovieEditorAPI;
const right = document.querySelector(".menubar-right");
@@ -19,27 +19,141 @@
panel.append(head, bodyEl);
document.body.append(panel);
let timer = null, autoscroll = true;
let timer = null, autoscroll = true, lastText = "", isFrozen = false, isManuallyPaused = false, pendingText = null;
// Helper to check if a node is inside bodyEl (handles text nodes too).
function nodeInBody(node) {
return node === bodyEl || bodyEl.contains(node);
}
// Helper to check if user has a non-collapsed selection inside bodyEl.
function selectionInBody() {
const sel = window.getSelection();
if (sel.isCollapsed) return false;
return nodeInBody(sel.anchorNode) || nodeInBody(sel.focusNode);
}
async function refresh() {
if (isManuallyPaused) return;
try {
const r = await API.log(800);
bodyEl.textContent = (r.lines || []).join("\n");
const newText = (r.lines || []).join("\n");
// Skip DOM write if text hasn't changed.
if (newText === lastText) return;
// If user is selecting, freeze updates and keep polling. textContent assignment
// destroys the text node tree, which nukes the selection mid-copy, so we defer the
// write until the selection is released or moves out of the log.
if (selectionInBody()) {
isFrozen = true;
pendingText = newText;
updateFreezeIndicator();
return;
}
// Update is safe to apply.
lastText = newText;
bodyEl.textContent = newText;
if (autoscroll) bodyEl.scrollTop = bodyEl.scrollHeight;
if (isFrozen) {
isFrozen = false;
pendingText = null;
updateFreezeIndicator();
}
} catch (e) {
bodyEl.textContent = "(log unavailable: " + e.message + ")";
const errMsg = "(log unavailable: " + e.message + ")";
lastText = errMsg;
bodyEl.textContent = errMsg;
}
}
bodyEl.addEventListener("scroll", () => {
autoscroll = bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight < 40;
});
function open() { panel.hidden = false; btn.classList.add("active"); autoscroll = true; refresh(); timer = setInterval(refresh, 1500); }
function close() { panel.hidden = true; btn.classList.remove("active"); clearInterval(timer); timer = null; }
function updateFreezeIndicator() {
const existingIndicator = actions.querySelector(".log-freeze-indicator");
if (isFrozen) {
if (!existingIndicator) {
const indicator = document.createElement("span");
indicator.className = "log-freeze-indicator";
indicator.textContent = "paused — selection held";
actions.insertBefore(indicator, actions.firstChild);
}
} else {
if (existingIndicator) existingIndicator.remove();
}
}
function open() {
panel.hidden = false;
btn.classList.add("active");
autoscroll = true;
isFrozen = false;
isManuallyPaused = false;
pendingText = null;
updatePauseButton();
updateFreezeIndicator();
refresh();
timer = setInterval(refresh, 1500);
}
function close() {
panel.hidden = true;
btn.classList.remove("active");
clearInterval(timer);
timer = null;
isFrozen = false;
isManuallyPaused = false;
pendingText = null;
updateFreezeIndicator();
}
btn.onclick = () => (panel.hidden ? open() : close());
const copy = document.createElement("button"); copy.className = "btn ghost tiny"; copy.textContent = "Copy";
copy.onclick = () => { try { navigator.clipboard.writeText(bodyEl.textContent); copy.textContent = "Copied"; setTimeout(() => (copy.textContent = "Copy"), 1200); } catch (_) {} };
const x = document.createElement("button"); x.className = "btn ghost tiny"; x.textContent = "✕"; x.onclick = close;
actions.append(copy, x);
// Helper to get the currently selected text within bodyEl.
function getSelectedText() {
const sel = window.getSelection();
if (sel.isCollapsed) return null;
if (!nodeInBody(sel.anchorNode) && !nodeInBody(sel.focusNode)) return null;
return sel.toString();
}
const copy = document.createElement("button");
copy.className = "btn ghost tiny";
copy.textContent = "Copy";
copy.onclick = () => {
try {
const selectedText = getSelectedText();
const textToCopy = selectedText || lastText;
navigator.clipboard.writeText(textToCopy);
copy.textContent = selectedText ? "Copied selection" : "Copied";
setTimeout(() => (copy.textContent = "Copy"), 1200);
} catch (_) {}
};
const pauseBtn = document.createElement("button");
pauseBtn.className = "btn ghost tiny";
pauseBtn.textContent = "⏸ Pause";
pauseBtn.onclick = () => {
isManuallyPaused = !isManuallyPaused;
updatePauseButton();
// Refetch rather than flushing pendingText: nothing was polled while paused, so the
// only text held is whatever a selection froze earlier and it may be minutes stale.
if (!isManuallyPaused) { pendingText = null; refresh(); }
};
function updatePauseButton() {
pauseBtn.textContent = isManuallyPaused ? "▶ Resume" : "⏸ Pause";
pauseBtn.classList.toggle("on", isManuallyPaused);
}
const x = document.createElement("button");
x.className = "btn ghost tiny";
x.textContent = "✕";
x.onclick = close;
actions.append(copy, pauseBtn, x);
})();
+2 -1
View File
@@ -121,7 +121,8 @@ button, input, select, textarea { font-family: inherit; color: var(--text); }
.log-panel[hidden] { display: none; } /* explicit display:flex above would otherwise beat the hidden attribute */
.log-head { display: flex; align-items: center; justify-content: space-between; padding: 7px 10px; border-bottom: 1px solid var(--line);
font-family: var(--font-mono); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: var(--amber); }
.log-actions { display: flex; gap: 6px; }
.log-actions { display: flex; gap: 6px; align-items: center; }
.log-freeze-indicator { font-family: var(--font-mono); font-size: 10px; color: var(--faint); white-space: nowrap; }
.log-body { flex: 1; margin: 0; padding: 8px 10px; overflow: auto; white-space: pre-wrap; word-break: break-word;
font-family: var(--font-mono); font-size: 11px; line-height: 1.45; color: var(--muted); background: #060606; }