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>
381 lines
12 KiB
JavaScript
381 lines
12 KiB
JavaScript
/* 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`
|
||
);
|
||
}
|