Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca57a4044e | |||
| cc397e9982 |
@@ -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"] });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
Plugin.php
25
Plugin.php
@@ -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()
|
||||
|
||||
24
README.md
24
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
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user