From cc397e99827bf46c3aa26f38191e62a7a0e4c561 Mon Sep 17 00:00:00 2001 From: Ruben Carlo Benante Date: Mon, 20 Jul 2026 10:13:06 -0300 Subject: [PATCH] restore vertical axes of scroll bars --- Asset/js/keep-scroll.js | 147 ++++++++++++++++++++++++++++++++-------- Plugin.php | 2 +- README.md | 18 +++-- VERSION | 2 +- 4 files changed, 132 insertions(+), 37 deletions(-) diff --git a/Asset/js/keep-scroll.js b/Asset/js/keep-scroll.js index f389348..b14a9c1 100644 --- a/Asset/js/keep-scroll.js +++ b/Asset/js/keep-scroll.js @@ -1,41 +1,108 @@ /* - * OrganonTweaks -- keep the board's horizontal scroll position across refreshes AND full reloads. + * OrganonTweaks -- keep the board's scroll position (horizontal AND vertical) across board AJAX + * rebuilds and full page reloads. * - * Two cases lose the horizontal scroll and snap the board back to the first column: + * Two events reset the scroll and jump the board around: * 1. AJAX rebuild -- dropping a card / periodic polling replaces #board-container - * (BoardDragAndDrop.refresh), and the new element starts at scrollLeft 0. - * 2. Full page reload -- e.g. clicking the Done/Todo badge, which navigates and redirects back to - * the board; a fresh page starts at scrollLeft 0. + * (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. * - * We remember the last position and restore it: in-memory for the AJAX rebuild (observing the STABLE - * parent, since the container itself is replaced), and in sessionStorage (keyed per board) so it - * survives a full reload as well. + * 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 boardKey() { + 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 readStored() { + function readNum(suffix) { try { - var v = window.sessionStorage.getItem(boardKey()); - return v ? parseInt(v, 10) : 0; + var v = window.sessionStorage.getItem(baseKey() + suffix); + return v !== null ? parseInt(v, 10) : null; } catch (e) { - return 0; + return null; } } - function writeStored(value) { + function writeNum(suffix, value) { try { - window.sessionStorage.setItem(boardKey(), value); + 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) { @@ -43,37 +110,61 @@ } var parent = container.parentNode; - var stored = readStored(); - var lastScrollLeft = stored || container.scrollLeft; - var lastContainer = container; - // Restore across a full page reload (badge click, etc.), not only AJAX rebuilds. - if (stored && container.scrollLeft !== stored) { - container.scrollLeft = stored; + // --- 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; } - // Remember the position whenever the board is scrolled (capture: scroll does not bubble, and - // this survives the container being replaced). + // --- 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 (c && e.target === c) { + if (t === c) { lastScrollLeft = c.scrollLeft; - writeStored(lastScrollLeft); + 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); - // When the board is rebuilt, #board-container becomes a new element at scrollLeft 0; restore - // the remembered position before the browser paints it. + // 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; } } - }).observe(parent, { childList: true }); + scheduleRestore(); + }).observe(parent, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] }); } } diff --git a/Plugin.php b/Plugin.php index 17816df..be70e14 100644 --- a/Plugin.php +++ b/Plugin.php @@ -175,7 +175,7 @@ class Plugin extends Base public function getPluginVersion() { - return '2.1.2'; + return '2.1.3'; } public function getPluginHomepage() diff --git a/README.md b/README.md index 2949d35..164abc7 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,18 @@ have comments keep showing the count as before. When you drop a card (or Kanboard auto-refreshes the board via polling), it rebuilds the board with `$("#board-container").replaceWith(...)`, and the new element starts scrolled to the far left -- so the view jumps back to the first column. A **full page reload** (for -example clicking the Done/Todo badge, which navigates and redirects back to the board) does -the same. This tweak remembers the horizontal scroll position and restores it in both cases, -so the board stays where you were. +example clicking the Todo/Done badge or the "Mark all" action, which navigate and redirect back to +the board) does the same. This tweak remembers the scroll position -- **horizontal and vertical** -- +and restores it in both cases, so the board stays where you were. -- Implemented in `Asset/js/keep-scroll.js`: it observes the stable parent (the container - itself is replaced) and restores `scrollLeft` on whichever `#board-container` is current - after an AJAX rebuild, and persists the position per board in `sessionStorage` so it also - survives a full page reload. +- Implemented in `Asset/js/keep-scroll.js`: it observes the stable parent (the container itself is + replaced) and restores, on whichever `#board-container` is current after an AJAX rebuild and again + after a full reload (persisted per board in `sessionStorage`): + - the **horizontal** position (`#board-container.scrollLeft`); + - the **page vertical** position in expanded mode (`window.scrollY`); + - the **per-column vertical** position in compact/collapsed mode -- each native + `.board-task-list-compact` list, keyed by its `data-swimlane-id` + `data-column-id`, so every + column keeps its own place independently (works with or without ShrinkVertically). - **On by default.** Toggle it under "Settings -> Organon Tweaks". ### Open a card only on a quick click diff --git a/VERSION b/VERSION index 8ee9858..07db151 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -OrganonTweaks v2.1.2 +OrganonTweaks v2.1.3