Files
iblogs/web/public/js/log.js
indifferentketchup 4d0d98c604 feat: smart fold around errors with drag-to-reveal handle
Replaces the all-or-nothing "Errors only" collapse with a contextual
fold: ±25 entries around every error stay visible, and runs of
non-error entries with no nearby error collapse into a fold bar.

Each fold bar is draggable. Vertical pointer drag on the bar reveals
or re-hides lines from the top of the hidden range, ~6 px per line.
A click without drag reveals the next 25 lines. When the run is
fully revealed, the bar removes itself. Buttons / scroll behaviour
are unchanged; the existing "X errors" toggle in the header is the
entry point and still un-collapses everything on second click.

Visual: foldable bars get a faint horizontal hatch (suggesting
compressed content), an ns-resize cursor, and a hover/dragging
state that intensifies the hatch toward --accent. The grip-lines
icon flanks the line-count to call out the affordance.

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

381 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});
}
/*
* Error collapse toggle.
*
* Smart fold: 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.
*/
const ERROR_WINDOW_SIZE = 25;
const PIXELS_PER_LINE_DRAG = 6;
const DEFAULT_CLICK_REVEAL = 25;
const DRAG_PIXEL_THRESHOLD = 3;
const toggleErrorsButton = document.getElementById("error-toggle");
if (toggleErrorsButton) {
toggleErrorsButton.addEventListener("click", toggleErrors);
}
function toggleErrors() {
if (toggleErrorsButton.classList.contains("toggled")) {
toggleErrorsButton.classList.remove("toggled");
uncollapseAllErrors();
} else {
toggleErrorsButton.classList.add("toggled");
collapseAllErrors();
}
}
function collapseAllErrors() {
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.
const mustShow = new Array(total).fill(false);
for (let i = 0; i < total; i++) {
if (lines[i].classList.contains("entry-error")) {
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;
}
}
// 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 uncollapseAllErrors() {
document.querySelectorAll(".log-inner > .entry").forEach(line => line.style.removeProperty("display"));
document.querySelectorAll(".collapsed-lines").forEach(bar => bar.remove());
}
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`
);
}