feat(tasks): Kanban fills the screen row by row, on phones too; pages keep their scroll position
Desktop, several rows: the first row grows while the bar above the board scrolls away (CSS variables follow the scroll position), so every row fills the screen once snapped. Phones: each column is as high as the room between header and bottom bar; the page snaps column by column (the first column is lower by the bar above the board and snaps at the page top). While a card is dragged snapping is off; holding it at the top/bottom edge moves the page to the previous/next column (useKanbanTouchDrag snapPoints). Each app page (Triage, Workspace, Aufgaben, Listen) keeps its scroll position when switching pages; it comes back once the page has loaded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0b60ae6298
commit
18709353cf
3 files changed
+153
-19
No files matched your search
@@ -77,6 +77,10 @@ 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;
|
||||
const KANBAN_MIN_PHONE_COL_HEIGHT = 280;
|
||||
// First row of a Kanban board with several rows: the full height minus what is still visible of the bar
|
||||
// above the board (--kb-top at the start, shrinking with the scroll position --kb-scroll)
|
||||
const KANBAN_FIRST_ROW_HEIGHT = `max(${KANBAN_MIN_COL_HEIGHT}px, calc(var(--kb-full) - max(0px, calc(var(--kb-top) - var(--kb-scroll, 0px)))))`;
|
||||
// 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
|
||||
@@ -544,36 +548,63 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
// 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
|
||||
// One row: the page itself does not scroll (like the workspace). Several rows: the first row grows while
|
||||
// the bar above the board scrolls away, so a row always fills the screen; the wheel moves row by row
|
||||
// (below).
|
||||
const kanbanBoardRef = useRef<HTMLDivElement>(null);
|
||||
const [kanbanColHeight, setKanbanColHeight] = useState<number | null>(null);
|
||||
const [kanbanMultiRow, setKanbanMultiRow] = useState(false);
|
||||
const [kanbanPerRow, setKanbanPerRow] = useState(0);
|
||||
// Phones: one column per screen (between header and bottom bar) and the page snaps column by column;
|
||||
// kanbanPhoneTop is where a snapped column starts (below the header)
|
||||
const [kanbanPhoneSnap, setKanbanPhoneSnap] = useState(false);
|
||||
const [kanbanPhoneTop, setKanbanPhoneTop] = useState(0);
|
||||
// The first column is lower by the bar above the board, so it shows completely at the page top (its
|
||||
// snap point); a swipe then goes on to the second one
|
||||
const [kanbanPhoneFirstHeight, setKanbanPhoneFirstHeight] = useState(0);
|
||||
const isKanban = viewMode === 'kanban';
|
||||
useEffect(() => {
|
||||
if (!isKanban) {
|
||||
setKanbanColHeight(null);
|
||||
setKanbanMultiRow(false);
|
||||
setKanbanPhoneSnap(false);
|
||||
return;
|
||||
}
|
||||
const fit = () => {
|
||||
const el = kanbanBoardRef.current;
|
||||
if (!el || isPhoneWidth()) {
|
||||
if (!el) {
|
||||
setKanbanColHeight(null);
|
||||
setKanbanMultiRow(false);
|
||||
setKanbanPhoneSnap(false);
|
||||
return;
|
||||
}
|
||||
if (isPhoneWidth()) {
|
||||
const headerBottom = document.querySelector('header')?.getBoundingClientRect().bottom ?? 0;
|
||||
const navTop = document.querySelector('[data-mobile-nav]')?.getBoundingClientRect().top ?? window.innerHeight;
|
||||
setKanbanColHeight(Math.max(KANBAN_MIN_PHONE_COL_HEIGHT, navTop - headerBottom - 24));
|
||||
setKanbanPhoneTop(Math.round(headerBottom + 12));
|
||||
const boardTop = el.getBoundingClientRect().top + window.scrollY;
|
||||
setKanbanPhoneFirstHeight(Math.max(KANBAN_MIN_PHONE_COL_HEIGHT, Math.round(navTop - boardTop - 12)));
|
||||
setKanbanMultiRow(false);
|
||||
setKanbanPhoneSnap(true);
|
||||
return;
|
||||
}
|
||||
setKanbanPhoneSnap(false);
|
||||
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(KANBAN_MIN_COL_HEIGHT, areaHeight - top - 16));
|
||||
// Several rows: a row fills the whole area (--kb-full); the first one grows into the room the bar
|
||||
// above the board frees while scrolling (--kb-top - --kb-scroll, see the scroll effect)
|
||||
el.style.setProperty('--kb-full', `${Math.max(KANBAN_MIN_COL_HEIGHT, areaHeight - 24)}px`);
|
||||
el.style.setProperty('--kb-top', `${Math.max(0, top - 12)}px`);
|
||||
requestAnimationFrame(() => {
|
||||
const tops = new Set(
|
||||
Array.from(el.querySelectorAll<HTMLElement>('[data-kanban-col]')).map((c) => c.offsetTop)
|
||||
);
|
||||
const colTops = Array.from(el.querySelectorAll<HTMLElement>('[data-kanban-col]')).map((c) => c.offsetTop);
|
||||
const tops = new Set(colTops);
|
||||
const multi = tops.size > 1;
|
||||
setKanbanMultiRow(multi);
|
||||
setKanbanPerRow(colTops.filter((t) => t === Math.min(...colTops)).length);
|
||||
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;
|
||||
@@ -631,8 +662,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
lockedUntil = Date.now() + 450;
|
||||
area.scrollTo({ top: next, behavior: 'smooth' });
|
||||
};
|
||||
// The first row grows while the bar above the board scrolls away
|
||||
const onScroll = () => board.style.setProperty('--kb-scroll', `${area.scrollTop}px`);
|
||||
onScroll();
|
||||
area.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => area.removeEventListener('wheel', onWheel);
|
||||
area.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
area.removeEventListener('wheel', onWheel);
|
||||
area.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
}, [kanbanMultiRow]);
|
||||
|
||||
const safeTasks = useMemo(() => {
|
||||
@@ -1421,6 +1459,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setDragOverTaskTarget(null);
|
||||
setDragOverKanbanCol(null);
|
||||
};
|
||||
// Page positions of the phone board's snap points: the page top (bar and first column) and the start of
|
||||
// every further column
|
||||
const phoneSnapPoints = useCallback(() => {
|
||||
const points = new Set<number>([0]); // page top: bar and first column
|
||||
kanbanBoardRef.current?.querySelectorAll<HTMLElement>('[data-kanban-col]').forEach((c, i) => {
|
||||
if (i > 0) points.add(Math.max(0, Math.round(c.getBoundingClientRect().top + window.scrollY - kanbanPhoneTop)));
|
||||
});
|
||||
return Array.from(points).sort((a, b) => a - b);
|
||||
}, [kanbanPhoneTop]);
|
||||
const kanbanTouch = useKanbanTouchDrag({
|
||||
enabled: !canDragTasks && viewMode === 'kanban',
|
||||
onStart: (taskId) => setDraggingTaskId(taskId),
|
||||
@@ -1438,8 +1485,22 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
else void moveTaskToColumn(taskId, ownColId);
|
||||
},
|
||||
onCancel: clearDragState,
|
||||
snapPoints: kanbanPhoneSnap ? phoneSnapPoints : undefined,
|
||||
});
|
||||
|
||||
// Phones: the page snaps to its top and to each column; while a card is dragged it does not (the drag
|
||||
// moves the page from column to column itself, see useKanbanTouchDrag)
|
||||
useEffect(() => {
|
||||
if (!kanbanPhoneSnap) return;
|
||||
const root = document.documentElement;
|
||||
root.style.scrollPaddingTop = `${kanbanPhoneTop}px`;
|
||||
root.style.scrollSnapType = kanbanTouch.dragging ? 'none' : 'y mandatory';
|
||||
return () => {
|
||||
root.style.scrollSnapType = '';
|
||||
root.style.scrollPaddingTop = '';
|
||||
};
|
||||
}, [kanbanPhoneSnap, kanbanPhoneTop, kanbanTouch.dragging]);
|
||||
|
||||
const handleCreateTaskList = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const title = newListTitle.trim();
|
||||
@@ -1970,7 +2031,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
// Phone Kanban: the page top is the first snap point (bar and first column)
|
||||
<div className={`w-full space-y-3 ${kanbanPhoneSnap ? 'snap-start' : ''}`}>
|
||||
{/* Top Header: Task Lists Tabs & View Mode Switcher (Desktop) */}
|
||||
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
|
||||
{/* Task Lists selector tabs */}
|
||||
@@ -2813,7 +2875,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}`}
|
||||
style={kanbanBoardStyle(isKanbanWide, boardColumns.length)}
|
||||
>
|
||||
{boardColumns.map((col) => {
|
||||
{boardColumns.map((col, colIndex) => {
|
||||
const colTasks = kanbanTasks.filter((t) => inBoardColumn(t, col));
|
||||
// While dragging: a column the card's list does not have is greyed out and takes nothing
|
||||
const blockedForDrag =
|
||||
@@ -2830,8 +2892,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onDragLeave={(e) => handleKanbanColDragLeave(e, col.id)}
|
||||
onDrop={(e) => handleKanbanDrop(e, col.id, colTasks)}
|
||||
data-kanban-col={col.id}
|
||||
style={kanbanColHeight ? { height: kanbanColHeight } : undefined}
|
||||
className={`bg-canvas border border-border flex flex-col overflow-hidden transition-all ${
|
||||
style={
|
||||
!kanbanColHeight
|
||||
? undefined
|
||||
: kanbanPhoneSnap && colIndex === 0
|
||||
? { height: kanbanPhoneFirstHeight }
|
||||
: !kanbanMultiRow
|
||||
? { height: kanbanColHeight }
|
||||
: colIndex < kanbanPerRow
|
||||
? { height: KANBAN_FIRST_ROW_HEIGHT }
|
||||
: { height: 'var(--kb-full)' }
|
||||
}
|
||||
className={`bg-canvas border border-border flex flex-col overflow-hidden transition-[opacity,background-color,box-shadow] ${
|
||||
kanbanPhoneSnap && colIndex > 0 ? 'snap-start snap-always' : ''
|
||||
} ${
|
||||
kanbanColHeight ? '' : 'min-h-[420px]'
|
||||
} ${dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''} ${blockedForDrag ? 'opacity-40' : ''}`}
|
||||
title={blockedForDrag ? 'Diese Spalte gibt es in der Liste der Aufgabe nicht' : undefined}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createPortal } from 'react-dom';
|
||||
const HOLD_MS = 350;
|
||||
const MOVE_TOLERANCE = 8; // px a finger may wander during the hold before it counts as scrolling
|
||||
const EDGE = 90; // px near the top/bottom edge that scroll the page while dragging
|
||||
const EDGE_DWELL_MS = 450; // with snap points: how long a card waits at the edge before the page moves on
|
||||
|
||||
export type KanbanDropTarget = { taskId: string; position: 'before' | 'after' } | null;
|
||||
|
||||
@@ -18,6 +19,9 @@ interface Options {
|
||||
onHover: (colId: string | null, target: KanbanDropTarget) => void;
|
||||
onDrop: (taskId: string, colId: string | null, target: KanbanDropTarget) => void;
|
||||
onCancel: () => void;
|
||||
// Page positions the board snaps to (one column per screen on phones). With them, holding a card at
|
||||
// the top/bottom edge moves the page to the previous/next one instead of scrolling slowly.
|
||||
snapPoints?: () => number[];
|
||||
}
|
||||
|
||||
interface Drag {
|
||||
@@ -30,14 +34,14 @@ interface Drag {
|
||||
width: number;
|
||||
}
|
||||
|
||||
export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel }: Options) {
|
||||
export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel, snapPoints }: Options) {
|
||||
const [drag, setDrag] = useState<Drag | null>(null);
|
||||
const holdRef = useRef<{ timer: number; x: number; y: number } | null>(null);
|
||||
const dragRef = useRef<Drag | null>(null);
|
||||
const hoverRef = useRef<{ col: string | null; target: KanbanDropTarget }>({ col: null, target: null });
|
||||
const suppressClickRef = useRef(false);
|
||||
const callbacks = useRef({ onStart, onHover, onDrop, onCancel });
|
||||
callbacks.current = { onStart, onHover, onDrop, onCancel };
|
||||
const callbacks = useRef({ onStart, onHover, onDrop, onCancel, snapPoints });
|
||||
callbacks.current = { onStart, onHover, onDrop, onCancel, snapPoints };
|
||||
|
||||
const cancelHold = () => {
|
||||
if (holdRef.current) window.clearTimeout(holdRef.current.timer);
|
||||
@@ -66,11 +70,30 @@ export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel
|
||||
useEffect(() => {
|
||||
if (!drag) return;
|
||||
let frame = 0;
|
||||
let edgeSince = 0;
|
||||
let movingUntil = 0;
|
||||
const scroll = () => {
|
||||
const d = dragRef.current;
|
||||
if (d) {
|
||||
if (d.y < EDGE) window.scrollBy(0, -Math.ceil((EDGE - d.y) / 6));
|
||||
else if (d.y > window.innerHeight - EDGE) window.scrollBy(0, Math.ceil((d.y - (window.innerHeight - EDGE)) / 6));
|
||||
const up = d.y < EDGE;
|
||||
const down = d.y > window.innerHeight - EDGE;
|
||||
const points = callbacks.current.snapPoints?.();
|
||||
if (points && (up || down)) {
|
||||
// Snapping board: after a short wait at the edge, on to the previous/next column (again after
|
||||
// another wait while the card stays there)
|
||||
const now = Date.now();
|
||||
if (!edgeSince) edgeSince = now;
|
||||
if (now - edgeSince > EDGE_DWELL_MS && now > movingUntil) {
|
||||
const cur = window.scrollY;
|
||||
const next = down ? points.find((p) => p > cur + 2) : [...points].reverse().find((p) => p < cur - 2);
|
||||
if (next !== undefined) window.scrollTo({ top: next, behavior: 'smooth' });
|
||||
movingUntil = now + 1000;
|
||||
edgeSince = now;
|
||||
}
|
||||
} else if (points) {
|
||||
edgeSince = 0;
|
||||
} else if (up) window.scrollBy(0, -Math.ceil((EDGE - d.y) / 6));
|
||||
else if (down) window.scrollBy(0, Math.ceil((d.y - (window.innerHeight - EDGE)) / 6));
|
||||
hoverAt(d.x, d.y);
|
||||
}
|
||||
frame = requestAnimationFrame(scroll);
|
||||
|
||||
Reference in new issue
Block a user