2 Commits
v2.1.2 ... v2.2

7 changed files with 229 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
* (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:<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 () {
"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"] });
}
}

View File

@@ -27,6 +27,7 @@ class ConfigController extends BaseController
'organon_tweaks_persistent_sort' => (int) $this->configModel->get('organon_tweaks_persistent_sort', 1),
'organon_tweaks_done_badge' => (int) $this->configModel->get('organon_tweaks_done_badge', 0),
'organon_tweaks_done_closes_task' => (int) $this->configModel->get('organon_tweaks_done_closes_task', 0),
'organon_tweaks_done_autosubtasks' => (int) $this->configModel->get('organon_tweaks_done_autosubtasks', 0),
),
'errors' => array(),
)));
@@ -49,6 +50,7 @@ class ConfigController extends BaseController
$persistentSort = isset($values['organon_tweaks_persistent_sort']) ? 1 : 0;
$doneBadge = isset($values['organon_tweaks_done_badge']) ? 1 : 0;
$doneClosesTask = isset($values['organon_tweaks_done_closes_task']) ? 1 : 0;
$doneAutoSubtasks = isset($values['organon_tweaks_done_autosubtasks']) ? 1 : 0;
if ($this->configModel->save(array(
'organon_tweaks_always_comment_icon' => $alwaysCommentIcon,
@@ -64,6 +66,7 @@ class ConfigController extends BaseController
'organon_tweaks_persistent_sort' => $persistentSort,
'organon_tweaks_done_badge' => $doneBadge,
'organon_tweaks_done_closes_task' => $doneClosesTask,
'organon_tweaks_done_autosubtasks' => $doneAutoSubtasks,
))) {
$this->flash->success(t('Settings saved successfully.'));
} else {

View File

@@ -3,6 +3,7 @@
namespace Kanboard\Plugin\OrganonTweaks\Helper;
use Kanboard\Core\Base;
use Kanboard\Model\SubtaskModel;
/**
* Done/Todo badge helper.
@@ -22,6 +23,8 @@ class OrganonDoneHelper extends Base
{
const DONE_KEY = 'organon_done';
const CLOSES_KEY = 'organon_tweaks_done_closes_task';
const AUTOSUB_KEY = 'organon_tweaks_done_autosubtasks';
const ALLDONE_KEY = 'organon_subtasks_alldone';
/**
* Is this task Done? (mode-aware: closed status in close-mode, else the metadata marker)
@@ -47,4 +50,63 @@ class OrganonDoneHelper extends Base
{
return (int) $this->configModel->get(self::CLOSES_KEY, 0) === 1;
}
/**
* Mark a task Done in the current mode. Idempotent -- a no-op if it is already Done. Close-mode
* closes the task; marker-mode sets the DONE_KEY metadata.
*
* @param int $task_id
*/
public function markDone($task_id)
{
$task = $this->taskFinderModel->getById((int) $task_id);
if (empty($task) || $this->isDone($task)) {
return;
}
if ($this->closesTask()) {
$this->taskStatusModel->close((int) $task_id);
} else {
$this->taskMetadataModel->save((int) $task_id, array(self::DONE_KEY => 'on'));
}
}
/**
* Recompute whether the task's subtasks are ALL done and, only on the up-transition
* (was-not-all-done -> now-all-done), auto-mark the task Done. The last-seen all-done state is kept
* in ALLDONE_KEY ('on'/'off') to detect that edge -- so a manual unmark is honored (nothing
* re-fires) and title edits / refreshes never re-mark. Never auto-unmarks.
*
* @param int $task_id
* @param int $exclude_id a subtask id to exclude from the counts (the row being deleted --
* EVENT_DELETE fires BEFORE the row is removed, so it still counts)
*/
public function syncSubtasksDone($task_id, $exclude_id = 0)
{
$task_id = (int) $task_id;
$exclude_id = (int) $exclude_id;
$totalQuery = $this->db->table(SubtaskModel::TABLE)->eq('task_id', $task_id);
$doneQuery = $this->db->table(SubtaskModel::TABLE)->eq('task_id', $task_id)->eq('status', SubtaskModel::STATUS_DONE);
if ($exclude_id > 0) {
$totalQuery->neq('id', $exclude_id);
$doneQuery->neq('id', $exclude_id);
}
$total = $totalQuery->count();
$done = $doneQuery->count();
$allDone = $total > 0 && $done === $total;
$prev = $this->taskMetadataModel->get($task_id, self::ALLDONE_KEY, 'off') === 'on';
// Save the new state BEFORE marking: in close-mode markDone() -> close() -> closeAll() re-fires
// subtask events into this method; with the marker already 'on', that re-entry sees no
// transition and is a no-op (no loop).
$this->taskMetadataModel->save($task_id, array(self::ALLDONE_KEY => $allDone ? 'on' : 'off'));
if ($allDone && ! $prev) {
$this->markDone($task_id);
}
}
}

View File

@@ -3,6 +3,7 @@
namespace Kanboard\Plugin\OrganonTweaks;
use Kanboard\Core\Plugin\Base;
use Kanboard\Model\SubtaskModel;
use Kanboard\Model\TaskModel;
class Plugin extends Base
@@ -122,6 +123,28 @@ class Plugin extends Base
// Bulk "Mark all as Done/Todo" entry in the column header dropdown (moved into the native
// menu by relocate.js). Opens a two-button modal that confirms + picks the direction.
$this->template->hook->attach('template:board:column:dropdown', 'organonTweaks:board/mark_all_item');
// Sub-tweak (opt-in): auto-mark a card Done when its LAST subtask is completed. On every
// subtask change, recompute all-done and mark Done only on the up-transition
// (state-comparison in the helper), so a manual unmark is honored -- nothing re-fires.
// Dispatcher direct (the plugin on() wrapper drops the event). DELETE fires before the row
// is removed, so its id is excluded from the recount.
if ((int) $this->configModel->get('organon_tweaks_done_autosubtasks', 0) === 1) {
$container = $this->container;
$sync = function ($task_id, $exclude_id = 0) use ($container) {
$helper = new \Kanboard\Plugin\OrganonTweaks\Helper\OrganonDoneHelper($container);
$helper->syncSubtasksDone((int) $task_id, (int) $exclude_id);
};
$this->dispatcher->addListener(SubtaskModel::EVENT_UPDATE, function ($event) use ($sync) {
$sync($event['subtask']['task_id']);
});
$this->dispatcher->addListener(SubtaskModel::EVENT_CREATE, function ($event) use ($sync) {
$sync($event['subtask']['task_id']);
});
$this->dispatcher->addListener(SubtaskModel::EVENT_DELETE, function ($event) use ($sync) {
$sync($event['subtask']['task_id'], $event['subtask']['id']);
});
}
}
// Auto-managed shared custom filters (v1.5 "Show all tasks", v1.6 month filters, v1.7
@@ -175,7 +198,7 @@ class Plugin extends Base
public function getPluginVersion()
{
return '2.1.2';
return '2.2.0';
}
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
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
@@ -138,6 +142,12 @@ recolored -- only the badge.
open-only filter, but reappears if you clear the filter (or use the "Board: show all tasks" filter).
- With that option **off**, the badge is an independent marker stored in task metadata
(`organon_done`) that never touches the open/closed status.
- **Auto-mark on subtasks (opt-in).** When every subtask on a card is completed, the badge flips to
Done automatically (honoring the mode above -- marker or close). It only nudges at the moment the
last subtask completes: recomputing all-done on each subtask change and acting only on the
not-all-done -> all-done transition, so if you then set the badge back to Todo it is respected (the
subtasks stay done -- the description may hold unfinished business). It never auto-reverts to Todo.
Off by default; enable it in the Done badge settings.
- **Bulk per-column action.** The board column header dropdown gains a **Mark all as Done/Todo** entry
(`Template/board/mark_all_item.php`, moved into the menu by `relocate.js`). It opens a single modal
that both confirms and picks the direction -- **Mark all Done** or **Mark all Todo** -- then sets

View File

@@ -70,6 +70,9 @@
<?= $this->form->checkbox('organon_tweaks_done_closes_task', t('Marking Done also closes the task'), 1, isset($values['organon_tweaks_done_closes_task']) && $values['organon_tweaks_done_closes_task'] == 1) ?>
<p class="form-help"><?= t('When on, "Done" means the task is closed (so it leaves the board unless you clear the status filter), and clicking Todo reopens it -- native Close/Open stay in sync. When off, the badge is an independent marker that never changes the open/closed status.') ?></p>
<?= $this->form->checkbox('organon_tweaks_done_autosubtasks', t('Auto-mark Done when the last subtask is completed'), 1, isset($values['organon_tweaks_done_autosubtasks']) && $values['organon_tweaks_done_autosubtasks'] == 1) ?>
<p class="form-help"><?= t('When every subtask on a card is done, flip the badge to Done automatically (honoring the mode above). It only nudges at that moment -- if you then set it back to Todo, that is respected (the subtasks stay done).') ?></p>
</fieldset>
<div class="form-actions">

View File

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