diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 82f1022..b47f2a4 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -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 = ({ [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(null); const [kanbanColHeight, setKanbanColHeight] = useState(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('[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('[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('[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([0]); + board.querySelectorAll('[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('[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 = ({ {/* Tasks List in Column */} -
+
{colTasks.map((task) => { const isDragging = draggingTaskId === task.id; const readOnly = isTaskReadOnly(task);