Files
iblogs/web/public/js/log.js
indifferentketchup 415324f340 feat: apply smart fold automatically on page load
Logs with errors now open already smart-folded — every entry within
±25 of an error stays visible, gaps collapse into draggable bars.
Folds can only be expanded individually via per-bar click or
drag; the previous "toggle to unfold everything" path is gone. The
header error-count chip becomes informational only (cursor and
pointer-events stripped so it no longer reads as interactive).

Removed the dead toggleErrors / uncollapseAllErrors / "toggled"
state plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:31:31 +00:00

372 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* line numbers */
updateLineNumber(location.hash);
for (let line of document.querySelectorAll('.line-number')) {
line.addEventListener("click", () =>
updateLineNumber(line.attributes.getNamedItem("id").value));
}
function updateLineNumber(id) {
if (id && id.startsWith('#')) {
id = id.substring(1);
}
if (!id) {
return;
}
let element = document.getElementById(id);
if (element.classList.contains("line-number")) {
for (const line of document.querySelectorAll(".line-active")) {
line.classList.remove("line-active");
}
element.closest('.entry').classList.add('line-active');
}
}
/* Scroll to top/bottom buttons */
const downButton = document.getElementById("down-button");
if (downButton) {
downButton.addEventListener("click", () => scrollToHeight(document.body.scrollHeight));
}
const upButton = document.getElementById("up-button");
if (upButton) {
upButton.addEventListener("click", () => scrollToHeight(0));
}
/**
* Scroll to a specific height
* Disable smooth scrolling for large pages
* @param {number} top height to scroll to
* @param {number} [smoothScrollLimit] only use smooth scrolling if the distance is less than this value
*/
function scrollToHeight(top, smoothScrollLimit = 10000) {
const distance = Math.abs(document.documentElement.scrollTop - top);
const behavior = (distance < smoothScrollLimit) ? "smooth" : "instant";
window.scrollTo({left: 0, top, behavior});
}
/*
* Smart fold around errors.
*
* Every entry within ±ERROR_WINDOW_SIZE of an error stays visible; runs of
* non-error entries with no nearby errors collapse into a draggable fold
* bar. Vertical drag on the bar progressively reveals or re-hides lines;
* a click without drag reveals the next DEFAULT_CLICK_REVEAL lines.
*
* The fold is applied automatically on page load when the log contains
* errors. Folds can only be expanded individually — there is no
* "unfold everything" path. The error-count chip in the header is purely
* informational; it is not interactive.
*/
const ERROR_WINDOW_SIZE = 25;
const PIXELS_PER_LINE_DRAG = 6;
const DEFAULT_CLICK_REVEAL = 25;
const DRAG_PIXEL_THRESHOLD = 3;
applySmartFold();
function applySmartFold() {
const lines = Array.from(document.querySelectorAll('.log-inner > .entry'));
const total = lines.length;
if (!total) return;
// Pass 1: mark entries within ±ERROR_WINDOW_SIZE of any error. If the
// log has no errors, every entry stays visible (we never produce one
// giant fold spanning the whole log).
const mustShow = new Array(total).fill(false);
let sawError = false;
for (let i = 0; i < total; i++) {
if (lines[i].classList.contains("entry-error")) {
sawError = true;
const lo = Math.max(0, i - ERROR_WINDOW_SIZE);
const hi = Math.min(total - 1, i + ERROR_WINDOW_SIZE);
for (let j = lo; j <= hi; j++) mustShow[j] = true;
}
}
if (!sawError) return;
// Pass 2: hide unmarked entries; emit a fold bar at the START of each run.
let runEntries = [];
const flushRun = () => {
if (!runEntries.length) return;
const bar = createFoldBar(runEntries);
runEntries[0].insertAdjacentElement("beforebegin", bar);
runEntries = [];
};
for (let i = 0; i < total; i++) {
if (!mustShow[i]) {
lines[i].style.display = "none";
runEntries.push(lines[i]);
} else {
flushRun();
}
}
flushRun();
}
function createFoldBar(entries) {
const bar = document.createElement("div");
bar.classList.add("collapsed-lines", "collapsed-lines-foldable");
bar.appendChild(document.createElement("div")); // line-number column slot
const count = document.createElement("div");
count.classList.add("collapsed-lines-count");
bar.appendChild(count);
bar._hiddenEntries = entries;
bar._revealedCount = 0;
renderFoldLabel(bar);
attachFoldDragHandler(bar);
return bar;
}
function renderFoldLabel(bar) {
const entries = bar._hiddenEntries;
const total = entries.length;
const revealed = bar._revealedCount;
const remaining = total - revealed;
const count = bar.querySelector(".collapsed-lines-count");
count.replaceChildren();
if (remaining <= 0) return;
const firstHiddenLine = parseInt(entries[revealed].querySelector(".line-number").innerHTML, 10);
const lastHiddenLine = parseInt(entries[total - 1].querySelector(".line-number").innerHTML, 10);
const word = remaining === 1 ? "line" : "lines";
const grip = document.createElement("i");
grip.className = "fa-solid fa-grip-lines";
count.appendChild(grip);
count.append(` ${remaining} ${word} hidden · ${firstHiddenLine}${lastHiddenLine} · drag to reveal `);
count.appendChild(grip.cloneNode());
}
function applyFoldReveal(bar, n) {
const entries = bar._hiddenEntries;
n = Math.max(0, Math.min(entries.length, n));
if (n === bar._revealedCount) return;
for (let i = 0; i < entries.length; i++) {
if (i < n) entries[i].style.removeProperty("display");
else entries[i].style.display = "none";
}
bar._revealedCount = n;
renderFoldLabel(bar);
}
function attachFoldDragHandler(bar) {
let dragging = false;
let dragStartY = 0;
let dragStartReveal = 0;
let didDrag = false;
bar.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
dragging = true;
didDrag = false;
dragStartY = e.clientY;
dragStartReveal = bar._revealedCount;
bar.setPointerCapture(e.pointerId);
bar.classList.add("collapsed-lines-dragging");
e.preventDefault();
});
bar.addEventListener("pointermove", (e) => {
if (!dragging) return;
const dy = e.clientY - dragStartY;
if (Math.abs(dy) >= DRAG_PIXEL_THRESHOLD) didDrag = true;
const target = dragStartReveal + Math.round(dy / PIXELS_PER_LINE_DRAG);
applyFoldReveal(bar, target);
});
const finish = (e) => {
if (!dragging) return;
dragging = false;
try { bar.releasePointerCapture(e.pointerId); } catch {}
bar.classList.remove("collapsed-lines-dragging");
if (!didDrag) {
// Plain click: reveal the next chunk of lines.
applyFoldReveal(bar, bar._revealedCount + DEFAULT_CLICK_REVEAL);
}
if (bar._revealedCount >= bar._hiddenEntries.length) {
bar.remove();
}
};
bar.addEventListener("pointerup", finish);
bar.addEventListener("pointercancel", finish);
}
/* convert timestamps */
let timeElements = document.querySelectorAll('[data-time]');
for (const element of timeElements) {
const timestamp = parseInt(element.dataset.time);
if (isNaN(timestamp)) {
continue;
}
const date = new Date(timestamp * 1000);
element.innerHTML = date.toLocaleString();
}
/* settings */
const settingCheckboxes = document.querySelectorAll(".setting-checkbox");
settingCheckboxes.forEach(checkbox => checkbox.addEventListener("change", handleSettingChange));
let settingsChannel = null;
if (typeof BroadcastChannel !== "undefined") {
settingsChannel = new BroadcastChannel("mc-logs-settings");
settingsChannel.onmessage = (e) => {
if (e.data.type === "settings-updated") {
for (const checkbox of settingCheckboxes) {
checkbox.checked = !!e.data.settings[checkbox.dataset.key];
applySetting(checkbox);
}
}
};
}
function handleSettingChange(e) {
let checkbox = e.target;
applySetting(checkbox);
saveSettings();
if (settingsChannel) {
settingsChannel.postMessage({
type: "settings-updated",
settings: getCurrentSettings()
});
}
}
function applySetting(checkbox) {
let bodyClass = checkbox.dataset.bodyClass;
if (checkbox.checked) {
document.body.classList.add(bodyClass);
} else {
document.body.classList.remove(bodyClass);
}
switch (checkbox.dataset.key) {
case "floatingScrollbar":
initFloatingScrollbar();
break;
}
}
function getCurrentSettings() {
const data = {};
for (const checkbox of settingCheckboxes) {
data[checkbox.dataset.key] = checkbox.checked;
}
return data;
}
function saveSettings() {
const data = {};
for (const checkbox of settingCheckboxes) {
data[checkbox.dataset.key] = checkbox.checked;
}
document.cookie = "IBLOGS_SETTINGS=" + encodeURIComponent(JSON.stringify(data)) + ";path=/;expires=" + new Date(new Date().getTime() + 100 * 365 * 24 * 60 * 60 * 1000).toUTCString();
}
/* copy to clipboard */
const copyButtons = document.querySelectorAll("[data-clipboard]");
copyButtons.forEach(button => button.addEventListener("click", handleCopyButtonClick));
const doneClassName = "fa-solid fa-check";
async function handleCopyButtonClick(e) {
const button = e.currentTarget;
const data = button.dataset.clipboard;
await navigator.clipboard.writeText(data);
const iconElement = button.querySelector("i");
if (!iconElement) {
return;
}
const originalClassName = iconElement.className;
if (originalClassName === doneClassName) {
return;
}
iconElement.className = doneClassName;
setTimeout(() => {
iconElement.className = originalClassName;
}, 2000);
}
/* delete button */
const deleteButton = document.querySelector(".delete-log-button");
const deleteErrorElement = document.querySelector(".delete-overlay .popover-error");
if (deleteButton) {
deleteButton.addEventListener("click", handleDeleteButtonClick);
}
async function handleDeleteButtonClick() {
deleteErrorElement.style.display = "none";
const response = await fetch(window.location.href, {
method: "DELETE",
credentials: "include"
});
if (!response.ok) {
deleteErrorElement.style.display = "block";
deleteErrorElement.textContent = `${response.status} (${response.statusText})`;
return;
}
window.location.href = "/";
}
/* floating scroll bar */
const browser = getComputedStyle(document.body)
.getPropertyValue("--browser")
.replaceAll(/['"]/g, '')
.trim()
.toLowerCase();
const floatingScrollbar = document.querySelector(".floating-scrollbar");
let logContainer = null;
if (browser === "firefox") {
logContainer = document.querySelector(".log");
} else {
logContainer = document.querySelector(".log-inner");
}
if (floatingScrollbar && logContainer) {
updateFloatingScrollbarWidths();
floatingScrollbar.addEventListener("scroll", () => {
syncScroll(floatingScrollbar, logContainer);
});
logContainer.addEventListener("scroll", () => {
syncScroll(logContainer, floatingScrollbar);
});
const observer = new ResizeObserver(() => {
updateFloatingScrollbarWidths();
});
observer.observe(logContainer);
}
function syncScroll(source, target) {
if (Math.abs(source.scrollLeft - target.scrollLeft) > 1) {
target.scrollLeft = source.scrollLeft;
}
}
function initFloatingScrollbar() {
if (!floatingScrollbar || !logContainer) {
return;
}
updateFloatingScrollbarWidths();
syncScroll(logContainer, floatingScrollbar);
}
function updateFloatingScrollbarWidths() {
floatingScrollbar.style.setProperty(
"--floating-scrollbar-width",
`${logContainer.clientWidth}px`
);
floatingScrollbar.style.setProperty(
"--floating-scrollbar-content-width",
`${logContainer.scrollWidth}px`
);
}