1 Commits

Author SHA1 Message Date
cc397e9982 restore vertical axes of scroll bars 2026-07-20 10:13:06 -03:00
4 changed files with 132 additions and 37 deletions

View File

@@ -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 * 1. AJAX rebuild -- dropping a card / periodic polling replaces #board-container
* (BoardDragAndDrop.refresh), and the new element starts at scrollLeft 0. * (BoardDragAndDrop.refresh); the new element starts at scroll 0.
* 2. Full page reload -- e.g. clicking the Done/Todo badge, which navigates and redirects back to * 2. Full page reload -- e.g. clicking the Todo/Done badge or the "Mark all" action, which
* the board; a fresh page starts at scrollLeft 0. * 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 * Axes restored (all persisted per board in sessionStorage, so they survive a full reload):
* parent, since the container itself is replaced), and in sessionStorage (keyed per board) so it * - horizontal: #board-container.scrollLeft (key :hx)
* survives a full reload as well. * - 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:<sw>:<col>)
*
* 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 () { (function () {
"use strict"; "use strict";
function boardKey() { function baseKey() {
var m = location.href.match(/board\/(\d+)/) || location.href.match(/project_id=(\d+)/); var m = location.href.match(/board\/(\d+)/) || location.href.match(/project_id=(\d+)/);
return "organon-board-scroll-" + (m ? m[1] : location.pathname); return "organon-board-scroll-" + (m ? m[1] : location.pathname);
} }
function readStored() { function readNum(suffix) {
try { try {
var v = window.sessionStorage.getItem(boardKey()); var v = window.sessionStorage.getItem(baseKey() + suffix);
return v ? parseInt(v, 10) : 0; return v !== null ? parseInt(v, 10) : null;
} catch (e) { } catch (e) {
return 0; return null;
} }
} }
function writeStored(value) { function writeNum(suffix, value) {
try { try {
window.sessionStorage.setItem(boardKey(), value); window.sessionStorage.setItem(baseKey() + suffix, value);
} catch (e) { } catch (e) {
// sessionStorage unavailable (private mode / disabled) -- degrade to AJAX-only restore. // 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() { function init() {
var container = document.getElementById("board-container"); var container = document.getElementById("board-container");
if (!container) { if (!container) {
@@ -43,37 +110,61 @@
} }
var parent = container.parentNode; 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. // --- horizontal ---
if (stored && container.scrollLeft !== stored) { var storedX = readNum(":hx");
container.scrollLeft = stored; 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 // --- vertical (page + per-column), now and again after layout settles ---
// this survives the container being replaced). 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) { document.addEventListener("scroll", function (e) {
if (suppress > 0) {
return;
}
var t = e.target;
var c = document.getElementById("board-container"); var c = document.getElementById("board-container");
if (c && e.target === c) { if (t === c) {
lastScrollLeft = c.scrollLeft; 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); }, true);
// When the board is rebuilt, #board-container becomes a new element at scrollLeft 0; restore // Page vertical scroll targets the document, so listen on window.
// the remembered position before the browser paints it. 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) { if (window.MutationObserver) {
new MutationObserver(function () { new MutationObserver(function () {
var c = document.getElementById("board-container"); var c = document.getElementById("board-container");
if (c && c !== lastContainer) { if (c && c !== lastContainer) {
lastContainer = c; lastContainer = c;
if (c.scrollLeft !== lastScrollLeft) { if (c.scrollLeft !== lastScrollLeft) {
suppressBriefly();
c.scrollLeft = lastScrollLeft; c.scrollLeft = lastScrollLeft;
} }
} }
}).observe(parent, { childList: true }); scheduleRestore();
}).observe(parent, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
} }
} }

View File

@@ -175,7 +175,7 @@ class Plugin extends Base
public function getPluginVersion() public function getPluginVersion()
{ {
return '2.1.2'; return '2.1.3';
} }
public function getPluginHomepage() public function getPluginHomepage()

View File

@@ -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 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 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 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 example clicking the Todo/Done badge or the "Mark all" action, which navigate and redirect back to
the same. This tweak remembers the horizontal scroll position and restores it in both cases, the board) does the same. This tweak remembers the scroll position -- **horizontal and vertical** --
so the board stays where you were. 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 - Implemented in `Asset/js/keep-scroll.js`: it observes the stable parent (the container itself is
itself is replaced) and restores `scrollLeft` on whichever `#board-container` is current replaced) and restores, on whichever `#board-container` is current after an AJAX rebuild and again
after an AJAX rebuild, and persists the position per board in `sessionStorage` so it also after a full reload (persisted per board in `sessionStorage`):
survives a full page reload. - 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". - **On by default.** Toggle it under "Settings -> Organon Tweaks".
### Open a card only on a quick click ### Open a card only on a quick click

View File

@@ -1 +1 @@
OrganonTweaks v2.1.2 OrganonTweaks v2.1.3