15 Commits
v1.0 ... v1.5

Author SHA1 Message Date
f0d9974a31 enlarge empty-column drop area when collapsed: min-height 100px -> 250px, still under max; v1.5 2026-07-06 21:11:39 -03:00
1743f9b868 fix top scrollbar freezing after card drag: re-resolve #board-container on rebuild, sync on parent; v1.4.2 2026-07-06 07:46:45 -03:00
045bae1e4c empty columns need 100px for drop 2026-07-04 09:50:45 -03:00
8ae373b4e7 bar from scratch VERSION v1.4 2026-07-01 10:06:22 -03:00
174b6ad31a bar from scratch Template 2026-07-01 10:06:10 -03:00
4ab761c03e bar from scratch plugin 2026-07-01 10:06:01 -03:00
b8308782f8 bar from scratch Assets 2026-07-01 10:05:52 -03:00
5cccbef7d7 title fixed in config 2026-07-01 10:00:33 -03:00
9b78882835 plugin focused on the vertical size and horizontal bars v1.3 2026-07-01 09:45:30 -03:00
51eba0ad5e growth until hit maximum feature bloat, now decoupling into TweakDrag new plugin 2026-07-01 09:42:51 -03:00
54b9fa93fb starting v1.2 with new tweaks 2026-07-01 07:31:52 -03:00
65b4771e51 gitignore PLAN-* files 2026-07-01 07:31:14 -03:00
dbfe98b3c7 drag-scroll checkbox VERSION 1.1 2026-06-28 17:19:42 -03:00
3beaa88fe9 drag-scroll checkbox 2026-06-28 17:19:04 -03:00
dcb672777d drag-scroll behavior new feature 2026-06-28 17:18:53 -03:00
9 changed files with 247 additions and 61 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
PLAN-*

View File

@@ -26,25 +26,56 @@
* (BoardDragAndDrop.js: css("min-height", parent().height())). In CSS, min-height * (BoardDragAndDrop.js: css("min-height", parent().height())). In CSS, min-height
* beats max-height, so that inline value keeps tall columns from shrinking and our * beats max-height, so that inline value keeps tall columns from shrinking and our
* max-height alone does nothing. A stylesheet !important overrides the inline * max-height alone does nothing. A stylesheet !important overrides the inline
* (non-important) min-height, so we reset it to 0 and let max-height win. * (non-important) min-height so max-height can win.
*
* We override it to a NON-ZERO value, not 0: Kanboard relies on that same inline
* min-height to keep EMPTY columns tall enough to be a drop target, so zeroing it makes
* you unable to drop a card into an empty collapsed column. 250px gives a comfortable drop
* target (well above Kanboard's 70px .draggable-placeholder) while staying below the
* collapsed max-height, so the board never overflows the viewport (which would re-hide the
* horizontal scrollbar). Non-empty columns are unaffected -- their content is taller -- and
* short columns simply get this min-height, which is only cosmetic (no scrollbar).
*/ */
#board td .board-task-list.board-task-list-compact { #board td .board-task-list.board-task-list-compact {
max-height: calc(100vh - var(--sv-shrink-offset, 240px)) !important; max-height: calc(100vh - var(--sv-shrink-offset, 240px)) !important;
min-height: 0 !important; min-height: 250px !important;
} }
/* /*
* Optional top horizontal scrollbar (added by Asset/js/top-scrollbar.js when the * Optional custom top horizontal scrollbar (built by Asset/js/top-scrollbar.js when the
* "Add a top horizontal scrollbar" setting is enabled). The inner element is given a * "Add a top horizontal scrollbar" setting is enabled). It is a track + draggable thumb,
* width equal to the board's scrollable width by the script; overflow-x: auto then * not a native scrollbar, so it renders and can be sized identically in every browser
* produces a scrollbar whose range mirrors the board's own bottom scrollbar. The 1px * (Chrome, Firefox, Safari) and on touch. Thickness follows the --sv-bar-size variable
* inner height keeps the bar to just the scrollbar itself. * (Settings -> Shrink Vertically), injected in the page head; falls back to 25px.
* touch-action: none lets a finger drag the thumb without scrolling the page.
*/ */
.sv-top-scroll { .sv-top-scroll {
overflow-x: auto; position: relative;
overflow-y: hidden; width: 100%;
height: var(--sv-bar-size, 25px);
background: rgba(0, 0, 0, 0.06);
border-radius: 4px;
overflow: hidden;
touch-action: none;
} }
.sv-top-scroll-inner { .sv-top-scroll-thumb {
height: 1px; position: absolute;
top: 0;
left: 0;
width: 0;
height: 100%;
background: rgba(0, 0, 0, 0.35);
border-radius: 4px;
cursor: grab;
touch-action: none;
}
.sv-top-scroll.sv-dragging .sv-top-scroll-thumb {
cursor: grabbing;
background: rgba(0, 0, 0, 0.5);
}
.sv-dragging {
user-select: none;
} }

View File

@@ -1,63 +1,186 @@
/* /*
* ShrinkVertically -- optional horizontal scrollbar above the board. * ShrinkVertically -- optional custom horizontal scrollbar above the board.
* *
* Kanboard's board scrolls horizontally inside #board-container (overflow-x: auto), * Kanboard's board scrolls horizontally inside #board-container (overflow-x: auto), so the
* so the only horizontal scrollbar sits at the very bottom of the board. On a wide * only native horizontal scrollbar sits at the very bottom of the board. On a wide board
* board you have to scroll all the way down to reach it. This adds a second, mirrored * you have to scroll all the way down to reach it. This adds a second scrollbar just above
* scrollbar just above the columns and keeps the two scroll positions in sync. * the columns and keeps it in sync with the board.
*
* Unlike a native scrollbar (whose thickness cannot be sized in Firefox), this is a custom
* track + thumb widget: it renders and behaves the same in every browser (Chrome, Firefox,
* Safari) and on touch, and its thickness follows the --sv-bar-size setting.
*
* Resilience note: after a card drag Kanboard rebuilds the board with
* `$("#board-container").replaceWith(data)` (BoardDragAndDrop.refresh), so the element we
* started with is destroyed and replaced. We therefore never cache the container: we
* re-resolve it on every use, listen for its scroll on `document` in the capture phase (so
* the listener survives the swap), and observe the STABLE parent (which is not replaced) so
* the widget keeps working -- previously the observer and scroll listener died with the old
* element, which froze the bar and lost sync after each move.
* *
* Loaded only when the "Add a top horizontal scrollbar" setting is enabled. * Loaded only when the "Add a top horizontal scrollbar" setting is enabled.
*/ */
(function () { (function () {
"use strict"; "use strict";
// Keep the thumb wide enough to grab, even on a very wide board.
var MIN_THUMB = 24;
function getContainer() {
return document.getElementById("board-container");
}
function init() { function init() {
var container = document.getElementById("board-container"); var container = getContainer();
// Only on the board, and never add it twice. // Only on the board, and never add it twice.
if (!container || document.querySelector(".sv-top-scroll")) { if (!container || document.querySelector(".sv-top-scroll")) {
return; return;
} }
var bar = document.createElement("div"); // The parent survives the board refresh (only #board-container is replaced).
bar.className = "sv-top-scroll"; var boardParent = container.parentNode;
var inner = document.createElement("div"); var track = document.createElement("div");
inner.className = "sv-top-scroll-inner"; track.className = "sv-top-scroll";
bar.appendChild(inner);
container.parentNode.insertBefore(bar, container); var thumb = document.createElement("div");
thumb.className = "sv-top-scroll-thumb";
track.appendChild(thumb);
function sync() { boardParent.insertBefore(track, container);
// Match the dummy bar's content width to the board's scrollable width
// so its scrollbar has the same range as the board's own scrollbar. var dragging = false;
inner.style.width = container.scrollWidth + "px"; var dragStartX = 0;
var dragStartScrollLeft = 0;
function metrics() {
var c = getContainer();
if (!c) {
return null;
}
return {
c: c,
viewport: c.clientWidth,
content: c.scrollWidth,
trackW: track.clientWidth
};
} }
var lock = false; function positionThumb(m, thumbW) {
var maxScroll = m.content - m.viewport;
var maxThumb = m.trackW - thumbW;
var left = maxScroll > 0 ? (m.c.scrollLeft / maxScroll) * maxThumb : 0;
thumb.style.transform = "translateX(" + Math.round(left) + "px)";
}
bar.addEventListener("scroll", function () { // Recompute thumb width/position; hide the whole bar when nothing to scroll.
if (lock) { return; } function layout() {
lock = true; var m = metrics();
container.scrollLeft = bar.scrollLeft;
lock = false; if (!m || m.content <= m.viewport || m.trackW <= 0) {
track.style.display = "none";
return;
}
track.style.display = "";
var thumbW = Math.round(m.trackW * m.viewport / m.content);
if (thumbW < MIN_THUMB) { thumbW = MIN_THUMB; }
if (thumbW > m.trackW) { thumbW = m.trackW; }
thumb.style.width = thumbW + "px";
positionThumb(m, thumbW);
}
// Keep the track directly before the (current) container after a board refresh.
function reattachTrack() {
var c = getContainer();
if (c && c.parentNode && track.nextSibling !== c) {
c.parentNode.insertBefore(track, c);
}
}
// Board scrolled by any other means -> keep the thumb in sync. Capture-phase on
// document catches #board-container's (non-bubbling) scroll and survives the swap.
document.addEventListener("scroll", function (e) {
if (dragging) { return; }
if (e.target !== getContainer()) { return; }
var m = metrics();
if (!m || m.content <= m.viewport) { return; }
positionThumb(m, thumb.offsetWidth);
}, true);
// Drag the thumb (mouse + touch via Pointer Events).
thumb.addEventListener("pointerdown", function (e) {
var c = getContainer();
if (!c) { return; }
e.preventDefault();
e.stopPropagation();
dragging = true;
dragStartX = e.clientX;
dragStartScrollLeft = c.scrollLeft;
track.classList.add("sv-dragging");
if (thumb.setPointerCapture) {
thumb.setPointerCapture(e.pointerId);
}
}); });
container.addEventListener("scroll", function () { thumb.addEventListener("pointermove", function (e) {
if (lock) { return; } if (!dragging) { return; }
lock = true; var m = metrics();
bar.scrollLeft = container.scrollLeft; if (!m) { return; }
lock = false; var maxThumb = m.trackW - thumb.offsetWidth;
var maxScroll = m.content - m.viewport;
if (maxThumb <= 0) { return; }
m.c.scrollLeft = dragStartScrollLeft + (e.clientX - dragStartX) * (maxScroll / maxThumb);
positionThumb(m, thumb.offsetWidth);
}); });
window.addEventListener("resize", sync); function endDrag(e) {
if (!dragging) { return; }
dragging = false;
track.classList.remove("sv-dragging");
if (thumb.releasePointerCapture && e && e.pointerId != null) {
try { thumb.releasePointerCapture(e.pointerId); } catch (err) { /* ignore */ }
}
}
// The board can be redrawn by the AJAX polling/refresh; keep the width current. thumb.addEventListener("pointerup", endDrag);
thumb.addEventListener("pointercancel", endDrag);
// Click on the empty track -> jump so the thumb centers under the pointer.
track.addEventListener("pointerdown", function (e) {
if (e.target === thumb || dragging) { return; }
var m = metrics();
if (!m || m.content <= m.viewport) { return; }
var thumbW = thumb.offsetWidth;
var maxThumb = m.trackW - thumbW;
var maxScroll = m.content - m.viewport;
if (maxThumb <= 0) { return; }
var clickX = e.clientX - track.getBoundingClientRect().left;
var targetLeft = clickX - thumbW / 2;
if (targetLeft < 0) { targetLeft = 0; }
if (targetLeft > maxThumb) { targetLeft = maxThumb; }
m.c.scrollLeft = (targetLeft / maxThumb) * maxScroll;
positionThumb(m, thumbW);
});
window.addEventListener("resize", layout);
// Observe the STABLE parent so we keep working after #board-container is replaced by
// a card move / AJAX polling; re-seat the track and recompute sizes on any change.
if (window.MutationObserver) { if (window.MutationObserver) {
new MutationObserver(sync).observe(container, { childList: true, subtree: true }); new MutationObserver(function () {
reattachTrack();
layout();
}).observe(boardParent, { childList: true, subtree: true });
} }
sync(); layout();
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {

View File

@@ -14,15 +14,21 @@ class ConfigController extends BaseController
const MIN_OFFSET = 0; const MIN_OFFSET = 0;
const MAX_OFFSET = 2000; const MAX_OFFSET = 2000;
const DEFAULT_BAR_SIZE = 25;
const MIN_BAR_SIZE = 0;
const MAX_BAR_SIZE = 200;
public function show() public function show()
{ {
$offset = (int) $this->configModel->get('shrink_vertically_offset', self::DEFAULT_OFFSET); $offset = (int) $this->configModel->get('shrink_vertically_offset', self::DEFAULT_OFFSET);
$topScrollbar = (int) $this->configModel->get('shrink_vertically_top_scrollbar', 0); $barSize = (int) $this->configModel->get('shrink_vertically_bar_size', self::DEFAULT_BAR_SIZE);
$topScrollbar = (int) $this->configModel->get('shrink_vertically_top_scrollbar', 1);
$this->response->html($this->helper->layout->config('shrinkVertically:config/show', array( $this->response->html($this->helper->layout->config('shrinkVertically:config/show', array(
'title' => t('Settings').' &gt; '.t('Shrink Vertically'), 'title' => t('Settings').' &gt; '.t('Shrink Vertically'),
'values' => array( 'values' => array(
'shrink_vertically_offset' => $offset, 'shrink_vertically_offset' => $offset,
'shrink_vertically_bar_size' => $barSize,
'shrink_vertically_top_scrollbar' => $topScrollbar, 'shrink_vertically_top_scrollbar' => $topScrollbar,
), ),
'errors' => array(), 'errors' => array(),
@@ -36,10 +42,14 @@ class ConfigController extends BaseController
$offset = isset($values['shrink_vertically_offset']) ? (int) $values['shrink_vertically_offset'] : self::DEFAULT_OFFSET; $offset = isset($values['shrink_vertically_offset']) ? (int) $values['shrink_vertically_offset'] : self::DEFAULT_OFFSET;
$offset = max(self::MIN_OFFSET, min(self::MAX_OFFSET, $offset)); $offset = max(self::MIN_OFFSET, min(self::MAX_OFFSET, $offset));
$barSize = isset($values['shrink_vertically_bar_size']) ? (int) $values['shrink_vertically_bar_size'] : self::DEFAULT_BAR_SIZE;
$barSize = max(self::MIN_BAR_SIZE, min(self::MAX_BAR_SIZE, $barSize));
$topScrollbar = isset($values['shrink_vertically_top_scrollbar']) ? 1 : 0; $topScrollbar = isset($values['shrink_vertically_top_scrollbar']) ? 1 : 0;
if ($this->configModel->save(array( if ($this->configModel->save(array(
'shrink_vertically_offset' => $offset, 'shrink_vertically_offset' => $offset,
'shrink_vertically_bar_size' => $barSize,
'shrink_vertically_top_scrollbar' => $topScrollbar, 'shrink_vertically_top_scrollbar' => $topScrollbar,
))) { ))) {
$this->flash->success(t('Settings saved successfully.')); $this->flash->success(t('Settings saved successfully.'));

View File

@@ -27,7 +27,7 @@ class Plugin extends Base
$this->template->hook->attach('template:config:sidebar', 'shrinkVertically:config/sidebar'); $this->template->hook->attach('template:config:sidebar', 'shrinkVertically:config/sidebar');
// Optional: a second, mirrored horizontal scrollbar above the board columns. // Optional: a second, mirrored horizontal scrollbar above the board columns.
if ((int) $this->configModel->get('shrink_vertically_top_scrollbar', 0) === 1) { if ((int) $this->configModel->get('shrink_vertically_top_scrollbar', 1) === 1) {
$this->hook->on('template:layout:js', array( $this->hook->on('template:layout:js', array(
'template' => 'plugins/ShrinkVertically/Asset/js/top-scrollbar.js', 'template' => 'plugins/ShrinkVertically/Asset/js/top-scrollbar.js',
)); ));
@@ -51,7 +51,7 @@ class Plugin extends Base
public function getPluginVersion() public function getPluginVersion()
{ {
return '1.0.0'; return '1.5.0';
} }
public function getPluginHomepage() public function getPluginHomepage()

View File

@@ -1,9 +1,12 @@
# ShrinkVertically -- keep the board horizontal scrollbar visible # ShrinkVertically -- keep the board horizontal scrollbar visible
A small Kanboard plugin focused on the board's horizontal scrollbar. It fixes the A small Kanboard plugin focused on the vertically-collapsed board view. It fixes the
vertically-collapsed board view so the bottom scrollbar stays on screen, and can collapse so the bottom horizontal scrollbar stays on screen, and can optionally add a
optionally add a second, mirrored horizontal scrollbar above the columns. Both are second mirrored scrollbar above the columns. Configurable under
configurable under "Settings -> Shrink Vertically". "Settings -> Shrink Vertically".
Board drag/touch and column-gap options that used to live here have moved to the separate
`TweakDrag` plugin.
## The problem ## The problem
@@ -66,21 +69,27 @@ from the folder name). Reload the board; no build step, no configuration.
Kanboard's board scrolls horizontally inside `#board-container`, so its only horizontal Kanboard's board scrolls horizontally inside `#board-container`, so its only horizontal
scrollbar is at the very bottom of the board -- on a wide board you must scroll all the scrollbar is at the very bottom of the board -- on a wide board you must scroll all the
way down to move sideways. Enable "Add a top horizontal scrollbar" in the settings to get way down to move sideways. Enable "Add a top horizontal scrollbar" in the settings to get
a second, mirrored scrollbar just above the columns. A small script a second scrollbar just above the columns.
(`Asset/js/top-scrollbar.js`, loaded only when the option is on) inserts a dummy scroll
strip above `#board-container` and keeps its scroll position in sync with the board both It is a custom track + draggable thumb (`Asset/js/top-scrollbar.js`, loaded only when the
ways. option is on), not a native scrollbar, so it renders and can be sized the same in every
browser (Chrome, Firefox, Safari) and on touch. Drag the thumb or click the track to move
the board; the thumb tracks the board when you scroll it by other means. Its thickness
follows the "Top Horizontal Scrollbar thickness" setting.
## Settings ## Settings
Go to "Settings -> Shrink Vertically": These settings are global (per Kanboard instance) and admin-only. Go to
"Settings -> Shrink Vertically":
- **Vertical offset in pixels** -- pixels reserved around the collapsed columns. Stored in - **Vertical offset in pixels** -- pixels reserved around the collapsed columns. Injected
the Kanboard configuration and injected in the page head as the `--sv-shrink-offset` CSS in the page head as the `--sv-shrink-offset` CSS variable. Increase it if the scrollbar
variable. Increase it if the scrollbar is still clipped (for example with a taller theme is still clipped (for example with a taller theme header); decrease it to make the
header); decrease it to make the collapsed columns taller. Default: 240. collapsed columns taller. Default: 240.
- **Top Horizontal Scrollbar thickness in pixels** -- thickness of the custom top
scrollbar (all browsers). Injected as the `--sv-bar-size` CSS variable. Default: 25.
- **Add a top horizontal scrollbar to the board** -- toggles the mirrored top scrollbar - **Add a top horizontal scrollbar to the board** -- toggles the mirrored top scrollbar
described above. described above. Default: on.
## License ## License

View File

@@ -13,6 +13,14 @@
</p> </p>
</fieldset> </fieldset>
<fieldset>
<?= $this->form->label(t('Top Horizontal Scrollbar thickness in pixels'), 'shrink_vertically_bar_size') ?>
<?= $this->form->number('shrink_vertically_bar_size', $values, $errors) ?>
<p class="form-help">
<?= t('Thickness of the top horizontal scrollbar. Default: 25.') ?>
</p>
</fieldset>
<fieldset> <fieldset>
<?= $this->form->checkbox('shrink_vertically_top_scrollbar', t('Add a top horizontal scrollbar to the board'), 1, isset($values['shrink_vertically_top_scrollbar']) && $values['shrink_vertically_top_scrollbar'] == 1) ?> <?= $this->form->checkbox('shrink_vertically_top_scrollbar', t('Add a top horizontal scrollbar to the board'), 1, isset($values['shrink_vertically_top_scrollbar']) && $values['shrink_vertically_top_scrollbar'] == 1) ?>
<p class="form-help"> <p class="form-help">

View File

@@ -1,3 +1,6 @@
<style type="text/css"> <style type="text/css">
:root { --sv-shrink-offset: <?= (int) $this->app->config('shrink_vertically_offset', 240) ?>px; } :root {
--sv-shrink-offset: <?= (int) $this->app->config('shrink_vertically_offset', 240) ?>px;
--sv-bar-size: <?= (int) $this->app->config('shrink_vertically_bar_size', 25) ?>px;
}
</style> </style>

View File

@@ -1 +1 @@
ShrinkVertically v1.0 ShrinkVertically v1.5