MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m5s
- Kanban on phones/tablets: hold a card, drag it onto a card or column, release; the page scrolls at the edges
- Admin settings: database backups can be deleted (DELETE /api/v1/admin/backups/{name})
- Share message: large Liste/Aufgabe buttons, close button, tap outside closes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
6.7 KiB
TypeScript
170 lines
6.7 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
|
|
// Drag & drop on the Kanban board with a finger (browsers offer HTML5 drag & drop only for mouse and
|
|
// pen): hold a card for a moment, it lifts, drag it onto another card or column, let go. A short tap
|
|
// still opens the task and a swipe still scrolls, because the drag starts only after the hold.
|
|
// Cards carry data-kanban-task="<id>", columns data-kanban-col="<id>".
|
|
|
|
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
|
|
|
|
export type KanbanDropTarget = { taskId: string; position: 'before' | 'after' } | null;
|
|
|
|
interface Options {
|
|
enabled: boolean;
|
|
onStart: (taskId: string) => void;
|
|
onHover: (colId: string | null, target: KanbanDropTarget) => void;
|
|
onDrop: (taskId: string, colId: string | null, target: KanbanDropTarget) => void;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
interface Drag {
|
|
taskId: string;
|
|
label: string;
|
|
x: number;
|
|
y: number;
|
|
offsetX: number;
|
|
offsetY: number;
|
|
width: number;
|
|
}
|
|
|
|
export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel }: 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 cancelHold = () => {
|
|
if (holdRef.current) window.clearTimeout(holdRef.current.timer);
|
|
holdRef.current = null;
|
|
};
|
|
|
|
const hoverAt = (x: number, y: number) => {
|
|
const el = document.elementFromPoint(x, y) as HTMLElement | null;
|
|
const card = el?.closest('[data-kanban-task]') as HTMLElement | null;
|
|
const col = (el?.closest('[data-kanban-col]') as HTMLElement | null)?.dataset.kanbanCol ?? null;
|
|
let target: KanbanDropTarget = null;
|
|
const cardId = card?.dataset.kanbanTask;
|
|
if (card && cardId && cardId !== dragRef.current?.taskId) {
|
|
const r = card.getBoundingClientRect();
|
|
target = { taskId: cardId, position: y - r.top < r.height / 2 ? 'before' : 'after' };
|
|
}
|
|
const prev = hoverRef.current;
|
|
if (prev.col !== col || prev.target?.taskId !== target?.taskId || prev.target?.position !== target?.position) {
|
|
hoverRef.current = { col, target };
|
|
callbacks.current.onHover(col, target);
|
|
}
|
|
};
|
|
|
|
// While dragging: the page must not scroll under the finger (non-passive listener), it scrolls
|
|
// only near the edges
|
|
useEffect(() => {
|
|
if (!drag) return;
|
|
let frame = 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));
|
|
hoverAt(d.x, d.y);
|
|
}
|
|
frame = requestAnimationFrame(scroll);
|
|
};
|
|
frame = requestAnimationFrame(scroll);
|
|
|
|
const move = (e: TouchEvent) => {
|
|
e.preventDefault();
|
|
const t = e.touches[0];
|
|
if (!t || !dragRef.current) return;
|
|
dragRef.current = { ...dragRef.current, x: t.clientX, y: t.clientY };
|
|
setDrag(dragRef.current);
|
|
};
|
|
const end = (e: TouchEvent) => {
|
|
const d = dragRef.current;
|
|
dragRef.current = null;
|
|
setDrag(null);
|
|
suppressClickRef.current = true;
|
|
window.setTimeout(() => (suppressClickRef.current = false), 400);
|
|
if (!d) return;
|
|
if (e.type === 'touchcancel') callbacks.current.onCancel();
|
|
else callbacks.current.onDrop(d.taskId, hoverRef.current.col, hoverRef.current.target);
|
|
hoverRef.current = { col: null, target: null };
|
|
};
|
|
document.addEventListener('touchmove', move, { passive: false });
|
|
document.addEventListener('touchend', end);
|
|
document.addEventListener('touchcancel', end);
|
|
return () => {
|
|
cancelAnimationFrame(frame);
|
|
document.removeEventListener('touchmove', move);
|
|
document.removeEventListener('touchend', end);
|
|
document.removeEventListener('touchcancel', end);
|
|
};
|
|
// Listeners live as long as one drag; positions are read from refs
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [Boolean(drag)]);
|
|
|
|
/** Props for a card; label is what the lifted card shows */
|
|
const cardProps = useCallback(
|
|
(taskId: string, label: string, draggable: boolean) => {
|
|
if (!enabled || !draggable) return {};
|
|
return {
|
|
onTouchStart: (e: React.TouchEvent<HTMLElement>) => {
|
|
if (e.touches.length !== 1) return;
|
|
const t = e.touches[0];
|
|
const el = e.currentTarget;
|
|
cancelHold();
|
|
holdRef.current = {
|
|
x: t.clientX,
|
|
y: t.clientY,
|
|
timer: window.setTimeout(() => {
|
|
const hold = holdRef.current;
|
|
holdRef.current = null;
|
|
if (!hold) return;
|
|
const r = el.getBoundingClientRect();
|
|
navigator.vibrate?.(15);
|
|
const d: Drag = { taskId, label, x: hold.x, y: hold.y, offsetX: hold.x - r.left, offsetY: hold.y - r.top, width: r.width };
|
|
dragRef.current = d;
|
|
hoverRef.current = { col: null, target: null };
|
|
setDrag(d);
|
|
callbacks.current.onStart(taskId);
|
|
}, HOLD_MS),
|
|
};
|
|
},
|
|
onTouchMove: (e: React.TouchEvent<HTMLElement>) => {
|
|
const h = holdRef.current;
|
|
const t = e.touches[0];
|
|
if (h && t && Math.hypot(t.clientX - h.x, t.clientY - h.y) > MOVE_TOLERANCE) cancelHold(); // scrolling
|
|
},
|
|
onTouchEnd: cancelHold,
|
|
onTouchCancel: cancelHold,
|
|
// The long press must not open the browser's context menu or select text
|
|
onContextMenu: (e: React.MouseEvent) => e.preventDefault(),
|
|
style: { WebkitTouchCallout: 'none' } as React.CSSProperties,
|
|
};
|
|
},
|
|
[enabled]
|
|
);
|
|
|
|
/** A tap right after a drag must not open the task */
|
|
const consumeClick = () => suppressClickRef.current;
|
|
|
|
const ghost = drag
|
|
? createPortal(
|
|
<div
|
|
className="fixed z-[80] pointer-events-none panel-surface shadow-panel px-3 py-2.5 text-xs font-medium text-main rotate-1 opacity-95 border-accent"
|
|
style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY, width: drag.width }}
|
|
>
|
|
{drag.label}
|
|
</div>,
|
|
document.body
|
|
)
|
|
: null;
|
|
|
|
return { cardProps, ghost, consumeClick, dragging: Boolean(drag) };
|
|
}
|