/* * OrganonTweaks -- keep the board's scroll position (horizontal AND vertical) across board AJAX * rebuilds and full page reloads. * * Two events reset the scroll and jump the board around: * 1. AJAX rebuild -- dropping a card / periodic polling replaces #board-container * (BoardDragAndDrop.refresh); the new element starts at scroll 0. * 2. Full page reload -- e.g. clicking the Todo/Done badge or the "Mark all" action, which * navigate and redirect back to the board; a fresh page starts at scroll 0. * * Axes restored (all persisted per board in sessionStorage, so they survive a full reload): * - horizontal: #board-container.scrollLeft (key :hx) * - page vertical (expanded mode): window.scrollY (key :vy) * - per-column vertical (compact mode): each native .board-task-list-compact scrollTop, keyed by * its data-swimlane-id + data-column-id (key :vcol::) * * The per-column container is native (core board.css: .board-task-list-compact { overflow-y:auto }); * ShrinkVertically only re-tunes its max-height, so this works with or without that plugin. Doing all * axes every time is safe: in expanded mode there are no compact lists (that loop is a no-op) and the * page owns the vertical scroll; in compact mode the columns own it and the page barely moves. */ (function () { "use strict"; function baseKey() { var m = location.href.match(/board\/(\d+)/) || location.href.match(/project_id=(\d+)/); return "organon-board-scroll-" + (m ? m[1] : location.pathname); } function readNum(suffix) { try { var v = window.sessionStorage.getItem(baseKey() + suffix); return v !== null ? parseInt(v, 10) : null; } catch (e) { return null; } } function writeNum(suffix, value) { try { window.sessionStorage.setItem(baseKey() + suffix, value); } catch (e) { // sessionStorage unavailable (private mode / disabled) -- degrade to AJAX-only restore. } } // While we restore programmatically, the browser fires scroll events; suppress the save briefly so // a not-yet-scrollable column (whose scrollTop stays 0) does not overwrite the stored position. var suppress = 0; function suppressBriefly() { suppress++; window.setTimeout(function () { if (suppress > 0) { suppress--; } }, 150); } function colSuffix(el) { return ":vcol:" + (el.getAttribute("data-swimlane-id") || "0") + ":" + (el.getAttribute("data-column-id") || "0"); } // Restore the vertical axes. Called on load, again on the next frame (after ShrinkVertically has // settled the column heights), and on every board rebuild. function restoreVertical() { var y = readNum(":vy"); if (y !== null && Math.round(window.scrollY) !== y) { suppressBriefly(); window.scrollTo(window.scrollX, y); } var lists = document.querySelectorAll("#board .board-task-list-compact"); for (var i = 0; i < lists.length; i++) { var el = lists[i]; var s = readNum(colSuffix(el)); if (s !== null && el.scrollTop !== s) { suppressBriefly(); el.scrollTop = s; } } } // Core adds the .board-task-list-compact class via JS AFTER load (BoardVerticalScrolling.render), // and re-adds it on every board render -- so the per-column scroll containers often do not exist // yet when we first restore (this is why compact restore worked on F5, where the browser restores // element scroll natively, but not after a badge/redirect navigation). Re-run the vertical restore // whenever the board mutates or a class changes, rAF-coalesced. Idempotent (only sets scrollTop on // a mismatch), so it lands once the compact lists appear and never fights the user afterwards. var restoreScheduled = false; function scheduleRestore() { if (restoreScheduled) { return; } restoreScheduled = true; var run = function () { restoreScheduled = false; restoreVertical(); }; if (window.requestAnimationFrame) { window.requestAnimationFrame(run); } else { window.setTimeout(run, 16); } } function init() { var container = document.getElementById("board-container"); if (!container) { return; } var parent = container.parentNode; // --- horizontal --- var storedX = readNum(":hx"); var lastScrollLeft = storedX !== null ? storedX : container.scrollLeft; var lastContainer = container; if (storedX !== null && container.scrollLeft !== storedX) { suppressBriefly(); container.scrollLeft = storedX; } // --- vertical (page + per-column), now and again after layout settles --- restoreVertical(); if (window.requestAnimationFrame) { window.requestAnimationFrame(restoreVertical); } // Save on scroll. Capture phase: scroll does not bubble, but a capture listener on document // still receives it from #board-container and from any per-column list. document.addEventListener("scroll", function (e) { if (suppress > 0) { return; } var t = e.target; var c = document.getElementById("board-container"); if (t === c) { lastScrollLeft = c.scrollLeft; writeNum(":hx", c.scrollLeft); } else if (t && t.nodeType === 1 && t.classList && t.classList.contains("board-task-list-compact")) { writeNum(colSuffix(t), t.scrollTop); } }, true); // Page vertical scroll targets the document, so listen on window. window.addEventListener("scroll", function () { if (suppress === 0) { writeNum(":vy", Math.round(window.scrollY)); } }); // Observe the STABLE parent for: (a) #board-container being replaced on an AJAX rebuild // (childList) -> restore horizontal; and (b) class changes anywhere below (attributes) -> the // moment core adds .board-task-list-compact, restore the per-column verticals. subtree covers // the deep .board-task-list elements. Both funnel into the rAF-coalesced scheduleRestore. if (window.MutationObserver) { new MutationObserver(function () { var c = document.getElementById("board-container"); if (c && c !== lastContainer) { lastContainer = c; if (c.scrollLeft !== lastScrollLeft) { suppressBriefly(); c.scrollLeft = lastScrollLeft; } } scheduleRestore(); }).observe(parent, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] }); } } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();