feat(tasks): Kanban columns keep one height across rows, wheel moves row by row

On the desktop every column, in every row, is as high as the room from
the board to the bottom of the scroll area (at least about three cards),
so the first row fits on opening and further rows fill one screen; the
first row no longer grows when a column wraps into a second row. With
several rows one wheel step moves to the next row or back (own logic:
the browser's snapping returned to the nearest point and never got past
the first row). Card lists that can scroll go first; Ctrl + wheel over
the board scrolls freely without zooming.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-02 20:45:10 +01:00
1 parent e2d5140834
commit 044d858231
1 file changed
+75 -11
+75 -11
View File
@@ -41,6 +41,7 @@ import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './Ma
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge';
import { ListSwitcher } from '../../components/ListSwitcher';
import { isPhoneWidth } from '../../lib/breakpoints';
import { LivePill } from '../../components/LivePill';
import { FormSheet } from '../../components/FormSheet';
import { EmojiIconButton } from '../../components/EmojiIconButton';
@@ -74,6 +75,8 @@ interface TasksViewProps {
}
const KANBAN_WIDE_KEY = 'mindsift:kanban_wide';
// Desktop Kanban columns never get lower than this: room for the header and about three cards
const KANBAN_MIN_COL_HEIGHT = 340;
// Wide Kanban: a column grows to at most this width, the board stays centred
const KANBAN_COL_MAX = 380;
const KANBAN_GAP = 12; // gap-3
@@ -538,38 +541,99 @@ export const TasksView: React.FC<TasksViewProps> = ({
[ownColumnOf, boardIdOf]
);
// Desktop Kanban in one row: the columns fill the screen height and scroll on their own, so the page
// itself does not scroll as well (like the workspace). Several rows of columns keep the page scroll.
// Desktop Kanban: every column, in every row, is as high as the room from the board's top to the bottom of
// the scroll area, so the first row fits completely on opening and each further row fills one screen;
// more cards scroll inside the column. At least room for about three cards (the page scrolls below that).
// One row: the page itself does not scroll (like the workspace). Several rows: the wheel moves row by row
// (below).
const kanbanBoardRef = useRef<HTMLDivElement>(null);
const [kanbanColHeight, setKanbanColHeight] = useState<number | null>(null);
const kanbanOneRow = viewMode === 'kanban' && (isKanbanWide || boardColumns.length <= 4);
const [kanbanMultiRow, setKanbanMultiRow] = useState(false);
const isKanban = viewMode === 'kanban';
useEffect(() => {
if (!kanbanOneRow) {
if (!isKanban) {
setKanbanColHeight(null);
setKanbanMultiRow(false);
return;
}
const fit = () => {
const el = kanbanBoardRef.current;
if (!el || window.innerWidth < 1024) {
if (!el || isPhoneWidth()) {
setKanbanColHeight(null);
setKanbanMultiRow(false);
return;
}
// From the board's top to the bottom of the app's scroll area (the desktop window itself does not
// scroll); then take back what still overflows (paddings)
const area = el.closest<HTMLElement>('[data-app-scroll]');
const areaTop = area ? area.getBoundingClientRect().top - area.scrollTop : -window.scrollY;
const areaHeight = area ? area.clientHeight : window.innerHeight;
const top = el.getBoundingClientRect().top - areaTop;
setKanbanColHeight(Math.max(320, areaHeight - top - 16));
setKanbanColHeight(Math.max(KANBAN_MIN_COL_HEIGHT, areaHeight - top - 16));
requestAnimationFrame(() => {
const tops = new Set(
Array.from(el.querySelectorAll<HTMLElement>('[data-kanban-col]')).map((c) => c.offsetTop)
);
const multi = tops.size > 1;
setKanbanMultiRow(multi);
if (multi) return;
// One row: take back what still overflows (paddings), so the page does not scroll
const over = area ? area.scrollHeight - area.clientHeight : document.documentElement.scrollHeight - window.innerHeight;
if (over > 0) setKanbanColHeight((h) => (h ? Math.max(320, h - over) : h));
if (over > 0) setKanbanColHeight((h) => (h ? Math.max(KANBAN_MIN_COL_HEIGHT, h - over) : h));
});
};
fit();
window.addEventListener('resize', fit);
return () => window.removeEventListener('resize', fit);
}, [kanbanOneRow, selectedListId, loading]);
}, [isKanban, isKanbanWide, boardColumns.length, selectedListId, loading]);
// Several rows: one wheel step moves to the next row (or back up to the previous row / the page top),
// smoothly. The browser's own snapping goes back to the nearest point, so a wheel step shorter than a row
// never got past the first one. A column whose cards can still scroll that way scrolls first; Ctrl +
// wheel over the board scrolls freely (and does not zoom there); scrollbar and keys scroll freely.
useEffect(() => {
const board = kanbanBoardRef.current;
const area = board?.closest<HTMLElement>('[data-app-scroll]');
if (!kanbanMultiRow || !board || !area) return;
let pending = 0; // trackpads send many small steps: move only once they add up
let lockedUntil = 0;
const snapPoints = () => {
const areaTop = area.getBoundingClientRect().top - area.scrollTop;
const rows = new Set<number>([0]);
board.querySelectorAll<HTMLElement>('[data-kanban-col]').forEach((c) => {
rows.add(Math.max(0, Math.round(c.getBoundingClientRect().top - areaTop - 12)));
});
return Array.from(rows).sort((a, b) => a - b);
};
const onWheel = (e: WheelEvent) => {
const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY;
if (e.ctrlKey) {
if (!board.contains(e.target as Node)) return; // zooming elsewhere stays as it is
e.preventDefault();
area.scrollBy({ top: dy });
return;
}
if (dy === 0 || e.shiftKey) return;
const list = (e.target as HTMLElement).closest<HTMLElement>('[data-kanban-list]');
if (list && (dy > 0 ? list.scrollTop + list.clientHeight < list.scrollHeight - 1 : list.scrollTop > 0)) return;
e.preventDefault();
if (Date.now() < lockedUntil) return;
pending += dy;
if (Math.abs(pending) < 40) return;
const down = pending > 0;
pending = 0;
const cur = area.scrollTop;
const points = snapPoints();
const next = down ? points.find((p) => p > cur + 2) : [...points].reverse().find((p) => p < cur - 2);
if (next === undefined) {
// Past the last row (e.g. a very low window): plain scrolling
area.scrollBy({ top: dy });
return;
}
lockedUntil = Date.now() + 450;
area.scrollTo({ top: next, behavior: 'smooth' });
};
area.addEventListener('wheel', onWheel, { passive: false });
return () => area.removeEventListener('wheel', onWheel);
}, [kanbanMultiRow]);
const safeTasks = useMemo(() => {
if (!Array.isArray(tasks)) return [];
@@ -2782,7 +2846,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div>
{/* Tasks List in Column */}
<div className={`p-2 space-y-2 flex-1 min-h-0 overflow-y-auto ${kanbanColHeight ? '' : 'max-h-[70vh]'}`}>
<div data-kanban-list className={`p-2 space-y-2 flex-1 min-h-0 overflow-y-auto ${kanbanColHeight ? '' : 'max-h-[70vh]'}`}>
{colTasks.map((task) => {
const isDragging = draggingTaskId === task.id;
const readOnly = isTaskReadOnly(task);