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
+41
-4
@@ -208,10 +208,47 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
// Desktop: the window does not scroll; this area under the header does (phones keep the window scroll)
|
// Desktop: the window does not scroll; this area under the header does (phones keep the window scroll)
|
||||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
// A new page starts at its top (the desktop scroll area would otherwise keep the old page's position)
|
// Each page keeps its scroll position when switching pages. The new page is often still loading and
|
||||||
|
// shorter than before: the position comes back as soon as the content is high enough (or the user
|
||||||
|
// scrolls first, or after 3 s). Scroll events meanwhile do not count as the page's position.
|
||||||
|
const scrollPositionsRef = useRef<Partial<Record<ActiveTab, number>>>({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollAreaRef.current?.scrollTo?.({ top: 0 });
|
const area = scrollAreaRef.current;
|
||||||
}, [activeTab]);
|
if (!area) return;
|
||||||
|
const target = scrollPositionsRef.current[activeTab] ?? 0;
|
||||||
|
let restoring = true;
|
||||||
|
let observer: ResizeObserver | null = null;
|
||||||
|
const finish = () => {
|
||||||
|
if (!restoring) return;
|
||||||
|
restoring = false;
|
||||||
|
observer?.disconnect();
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
area.removeEventListener('wheel', finish);
|
||||||
|
area.removeEventListener('pointerdown', finish);
|
||||||
|
area.removeEventListener('keydown', finish);
|
||||||
|
};
|
||||||
|
const tryRestore = () => {
|
||||||
|
area.scrollTop = target;
|
||||||
|
if (Math.abs(area.scrollTop - target) < 2) finish();
|
||||||
|
};
|
||||||
|
const timeout = window.setTimeout(finish, 3000);
|
||||||
|
area.addEventListener('wheel', finish, { passive: true });
|
||||||
|
area.addEventListener('pointerdown', finish);
|
||||||
|
area.addEventListener('keydown', finish);
|
||||||
|
tryRestore();
|
||||||
|
if (restoring && typeof ResizeObserver !== 'undefined' && area.firstElementChild) {
|
||||||
|
observer = new ResizeObserver(tryRestore);
|
||||||
|
observer.observe(area.firstElementChild);
|
||||||
|
}
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!restoring) scrollPositionsRef.current[activeTab] = area.scrollTop;
|
||||||
|
};
|
||||||
|
area.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
finish();
|
||||||
|
area.removeEventListener('scroll', onScroll);
|
||||||
|
};
|
||||||
|
}, [activeTab, user?.id]);
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
const [showTriageTrash, setShowTriageTrash] = useState(false);
|
const [showTriageTrash, setShowTriageTrash] = useState(false);
|
||||||
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
|
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
|
||||||
@@ -1413,7 +1450,7 @@ export const App: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
|
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
|
||||||
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
|
<nav data-mobile-nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
|
||||||
{/* Col 1: Triage */}
|
{/* Col 1: Triage */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ interface TasksViewProps {
|
|||||||
const KANBAN_WIDE_KEY = 'mindsift:kanban_wide';
|
const KANBAN_WIDE_KEY = 'mindsift:kanban_wide';
|
||||||
// Desktop Kanban columns never get lower than this: room for the header and about three cards
|
// 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_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
|
// Wide Kanban: a column grows to at most this width, the board stays centred
|
||||||
const KANBAN_COL_MAX = 380;
|
const KANBAN_COL_MAX = 380;
|
||||||
const KANBAN_GAP = 12; // gap-3
|
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
|
// 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;
|
// 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).
|
// 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).
|
// (below).
|
||||||
const kanbanBoardRef = useRef<HTMLDivElement>(null);
|
const kanbanBoardRef = useRef<HTMLDivElement>(null);
|
||||||
const [kanbanColHeight, setKanbanColHeight] = useState<number | null>(null);
|
const [kanbanColHeight, setKanbanColHeight] = useState<number | null>(null);
|
||||||
const [kanbanMultiRow, setKanbanMultiRow] = useState(false);
|
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';
|
const isKanban = viewMode === 'kanban';
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isKanban) {
|
if (!isKanban) {
|
||||||
setKanbanColHeight(null);
|
setKanbanColHeight(null);
|
||||||
setKanbanMultiRow(false);
|
setKanbanMultiRow(false);
|
||||||
|
setKanbanPhoneSnap(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fit = () => {
|
const fit = () => {
|
||||||
const el = kanbanBoardRef.current;
|
const el = kanbanBoardRef.current;
|
||||||
if (!el || isPhoneWidth()) {
|
if (!el) {
|
||||||
setKanbanColHeight(null);
|
setKanbanColHeight(null);
|
||||||
setKanbanMultiRow(false);
|
setKanbanMultiRow(false);
|
||||||
|
setKanbanPhoneSnap(false);
|
||||||
return;
|
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 area = el.closest<HTMLElement>('[data-app-scroll]');
|
||||||
const areaTop = area ? area.getBoundingClientRect().top - area.scrollTop : -window.scrollY;
|
const areaTop = area ? area.getBoundingClientRect().top - area.scrollTop : -window.scrollY;
|
||||||
const areaHeight = area ? area.clientHeight : window.innerHeight;
|
const areaHeight = area ? area.clientHeight : window.innerHeight;
|
||||||
const top = el.getBoundingClientRect().top - areaTop;
|
const top = el.getBoundingClientRect().top - areaTop;
|
||||||
setKanbanColHeight(Math.max(KANBAN_MIN_COL_HEIGHT, areaHeight - top - 16));
|
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(() => {
|
requestAnimationFrame(() => {
|
||||||
const tops = new Set(
|
const colTops = Array.from(el.querySelectorAll<HTMLElement>('[data-kanban-col]')).map((c) => c.offsetTop);
|
||||||
Array.from(el.querySelectorAll<HTMLElement>('[data-kanban-col]')).map((c) => c.offsetTop)
|
const tops = new Set(colTops);
|
||||||
);
|
|
||||||
const multi = tops.size > 1;
|
const multi = tops.size > 1;
|
||||||
setKanbanMultiRow(multi);
|
setKanbanMultiRow(multi);
|
||||||
|
setKanbanPerRow(colTops.filter((t) => t === Math.min(...colTops)).length);
|
||||||
if (multi) return;
|
if (multi) return;
|
||||||
// One row: take back what still overflows (paddings), so the page does not scroll
|
// 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;
|
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;
|
lockedUntil = Date.now() + 450;
|
||||||
area.scrollTo({ top: next, behavior: 'smooth' });
|
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 });
|
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]);
|
}, [kanbanMultiRow]);
|
||||||
|
|
||||||
const safeTasks = useMemo(() => {
|
const safeTasks = useMemo(() => {
|
||||||
@@ -1421,6 +1459,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
setDragOverTaskTarget(null);
|
setDragOverTaskTarget(null);
|
||||||
setDragOverKanbanCol(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({
|
const kanbanTouch = useKanbanTouchDrag({
|
||||||
enabled: !canDragTasks && viewMode === 'kanban',
|
enabled: !canDragTasks && viewMode === 'kanban',
|
||||||
onStart: (taskId) => setDraggingTaskId(taskId),
|
onStart: (taskId) => setDraggingTaskId(taskId),
|
||||||
@@ -1438,8 +1485,22 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
else void moveTaskToColumn(taskId, ownColId);
|
else void moveTaskToColumn(taskId, ownColId);
|
||||||
},
|
},
|
||||||
onCancel: clearDragState,
|
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) => {
|
const handleCreateTaskList = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const title = newListTitle.trim();
|
const title = newListTitle.trim();
|
||||||
@@ -1970,7 +2031,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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) */}
|
{/* 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">
|
<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 */}
|
{/* Task Lists selector tabs */}
|
||||||
@@ -2813,7 +2875,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
style={kanbanBoardStyle(isKanbanWide, boardColumns.length)}
|
style={kanbanBoardStyle(isKanbanWide, boardColumns.length)}
|
||||||
>
|
>
|
||||||
{boardColumns.map((col) => {
|
{boardColumns.map((col, colIndex) => {
|
||||||
const colTasks = kanbanTasks.filter((t) => inBoardColumn(t, col));
|
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
|
// While dragging: a column the card's list does not have is greyed out and takes nothing
|
||||||
const blockedForDrag =
|
const blockedForDrag =
|
||||||
@@ -2830,8 +2892,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
onDragLeave={(e) => handleKanbanColDragLeave(e, col.id)}
|
onDragLeave={(e) => handleKanbanColDragLeave(e, col.id)}
|
||||||
onDrop={(e) => handleKanbanDrop(e, col.id, colTasks)}
|
onDrop={(e) => handleKanbanDrop(e, col.id, colTasks)}
|
||||||
data-kanban-col={col.id}
|
data-kanban-col={col.id}
|
||||||
style={kanbanColHeight ? { height: kanbanColHeight } : undefined}
|
style={
|
||||||
className={`bg-canvas border border-border flex flex-col overflow-hidden transition-all ${
|
!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]'
|
kanbanColHeight ? '' : 'min-h-[420px]'
|
||||||
} ${dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''} ${blockedForDrag ? 'opacity-40' : ''}`}
|
} ${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}
|
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 HOLD_MS = 350;
|
||||||
const MOVE_TOLERANCE = 8; // px a finger may wander during the hold before it counts as scrolling
|
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 = 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;
|
export type KanbanDropTarget = { taskId: string; position: 'before' | 'after' } | null;
|
||||||
|
|
||||||
@@ -18,6 +19,9 @@ interface Options {
|
|||||||
onHover: (colId: string | null, target: KanbanDropTarget) => void;
|
onHover: (colId: string | null, target: KanbanDropTarget) => void;
|
||||||
onDrop: (taskId: string, colId: string | null, target: KanbanDropTarget) => void;
|
onDrop: (taskId: string, colId: string | null, target: KanbanDropTarget) => void;
|
||||||
onCancel: () => 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 {
|
interface Drag {
|
||||||
@@ -30,14 +34,14 @@ interface Drag {
|
|||||||
width: number;
|
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 [drag, setDrag] = useState<Drag | null>(null);
|
||||||
const holdRef = useRef<{ timer: number; x: number; y: number } | null>(null);
|
const holdRef = useRef<{ timer: number; x: number; y: number } | null>(null);
|
||||||
const dragRef = useRef<Drag | null>(null);
|
const dragRef = useRef<Drag | null>(null);
|
||||||
const hoverRef = useRef<{ col: string | null; target: KanbanDropTarget }>({ col: null, target: null });
|
const hoverRef = useRef<{ col: string | null; target: KanbanDropTarget }>({ col: null, target: null });
|
||||||
const suppressClickRef = useRef(false);
|
const suppressClickRef = useRef(false);
|
||||||
const callbacks = useRef({ onStart, onHover, onDrop, onCancel });
|
const callbacks = useRef({ onStart, onHover, onDrop, onCancel, snapPoints });
|
||||||
callbacks.current = { onStart, onHover, onDrop, onCancel };
|
callbacks.current = { onStart, onHover, onDrop, onCancel, snapPoints };
|
||||||
|
|
||||||
const cancelHold = () => {
|
const cancelHold = () => {
|
||||||
if (holdRef.current) window.clearTimeout(holdRef.current.timer);
|
if (holdRef.current) window.clearTimeout(holdRef.current.timer);
|
||||||
@@ -66,11 +70,30 @@ export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
|
let edgeSince = 0;
|
||||||
|
let movingUntil = 0;
|
||||||
const scroll = () => {
|
const scroll = () => {
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (d) {
|
if (d) {
|
||||||
if (d.y < EDGE) window.scrollBy(0, -Math.ceil((EDGE - d.y) / 6));
|
const up = d.y < EDGE;
|
||||||
else if (d.y > window.innerHeight - EDGE) window.scrollBy(0, Math.ceil((d.y - (window.innerHeight - EDGE)) / 6));
|
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);
|
hoverAt(d.x, d.y);
|
||||||
}
|
}
|
||||||
frame = requestAnimationFrame(scroll);
|
frame = requestAnimationFrame(scroll);
|
||||||
|
|||||||
Reference in new issue
Block a user