mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Slide columns aside for a dragged one instead of drawing a drop line
While a column is dragged it hides where it is and the columns between it and where it would land slide by its width, header and cells together, so the gap shows the drop. One style element carries a rule a column and changes only when the target does; the rows don't render. On drop the rules go as the order changes, in the same task, so nothing animates; on Escape the columns slide home and the column shows again. The drop line and its code are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
185a4e30df
commit
80b587f507
4 files changed
+223
-133
No files matched your search
@@ -30,6 +30,7 @@ import {
|
||||
type Column,
|
||||
type ColumnId,
|
||||
cellFilterTerm,
|
||||
columnShifts,
|
||||
customValue,
|
||||
gridTemplate,
|
||||
gridWidth,
|
||||
@@ -308,15 +309,10 @@ export function FlowList({ style, className }: Props) {
|
||||
el.scrollLeft += e.deltaX;
|
||||
}, []);
|
||||
|
||||
// A dragged column's drop line, from the window's coordinates to the list's
|
||||
// A dragged column: its ghost, and the others sliding aside to make room for it
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [dropLine, setDropLine] = useState<number | null>(null);
|
||||
const getScrollElement = useCallback(() => scrollRef.current, []);
|
||||
const { ghostColumn, onColumnDrag } = useColumnGhost(rootRef, headerScrollRef, scrollRef);
|
||||
const handleDropLine = useCallback((x: number | null) => {
|
||||
const root = rootRef.current;
|
||||
setDropLine(x == null || root == null ? null : x - root.getBoundingClientRect().left);
|
||||
}, []);
|
||||
const onColumnDrag = useColumnDragView(rootRef, headerScrollRef, scrollRef);
|
||||
|
||||
// A list too short to scroll still loads older flows on scrolling up.
|
||||
const handleWheel = useCallback(
|
||||
@@ -449,27 +445,9 @@ export function FlowList({ style, className }: Props) {
|
||||
onSetSort={handleSetSort}
|
||||
onResetSort={follow}
|
||||
scrollElement={getScrollElement}
|
||||
onDropLine={handleDropLine}
|
||||
onColumnDrag={onColumnDrag}
|
||||
/>
|
||||
</div>
|
||||
{/* A dragged column's cells dim where they are, in one rule rather than a render a row */}
|
||||
{ghostColumn != null && (
|
||||
<style>{`[data-flow-rows] [data-column="${ghostColumn}"] > * { opacity: 0.4; }`}</style>
|
||||
)}
|
||||
{/* Where a dragged column would drop, down the header and the rows in view, over its ghost */}
|
||||
{dropLine != null && (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{ right: scrollbarWidth }}
|
||||
className="pointer-events-none absolute inset-y-0 left-0 z-30 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
style={{ left: dropLine }}
|
||||
className="absolute inset-y-0 w-0.5 -translate-x-1/2 bg-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative min-h-0">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
@@ -583,44 +561,129 @@ function useArrivals(flows: Flow[], following: boolean): Map<string, number> {
|
||||
}, [flows, following]);
|
||||
}
|
||||
|
||||
/** How long the columns take to slide aside for a dragged column, or back */
|
||||
const SLIDE_MS = 150;
|
||||
|
||||
/**
|
||||
* The ghost of a dragged column: its header and the cells of the rows in view, copied once when
|
||||
* the drag starts, at the column's width and the list's height. It follows the pointer sideways,
|
||||
* kept within the list, by a transform, so the rows don't render while it moves; flows arriving
|
||||
* or the list scrolling sideways meanwhile leave the copy as it was.
|
||||
* Shows a column's drag without rendering a row. Its ghost is its header and the cells of the
|
||||
* rows in view, copied once when the drag starts and following the pointer sideways. The column
|
||||
* hides where it is, and the columns between it and where it would drop slide aside by its width
|
||||
* to leave a gap there, all by one style element: a rule a column, header and cells together,
|
||||
* which rows arriving meanwhile follow too. Dropped, the rules go as the order changes, in the
|
||||
* same task, so nothing animates or jumps; put back, the columns slide home first.
|
||||
*/
|
||||
function useColumnGhost(
|
||||
function useColumnDragView(
|
||||
rootRef: RefObject<HTMLDivElement | null>,
|
||||
headerRef: RefObject<HTMLDivElement | null>,
|
||||
listRef: RefObject<HTMLDivElement | null>,
|
||||
): { ghostColumn: ColumnId | null; onColumnDrag: (event: ColumnDragEvent) => void } {
|
||||
const [ghostColumn, setGhostColumn] = useState<ColumnId | null>(null);
|
||||
const ghost = useRef<ColumnGhost | null>(null);
|
||||
): (event: ColumnDragEvent) => void {
|
||||
const drag = useRef<ColumnDragView | null>(null);
|
||||
// Columns sliding home after a drag was put back, until they are there
|
||||
const settling = useRef<{ style: HTMLStyleElement; timer: number } | null>(null);
|
||||
|
||||
const settle = useCallback(() => {
|
||||
const s = settling.current;
|
||||
settling.current = null;
|
||||
if (s == null) return;
|
||||
clearTimeout(s.timer);
|
||||
s.style.remove();
|
||||
}, []);
|
||||
|
||||
const onColumnDrag = useCallback(
|
||||
(event: ColumnDragEvent) => {
|
||||
if (event.type === "move") {
|
||||
ghost.current?.move(event.x);
|
||||
return;
|
||||
const d = drag.current;
|
||||
switch (event.type) {
|
||||
case "move":
|
||||
d?.ghost?.move(event.x);
|
||||
return;
|
||||
case "target":
|
||||
if (d == null) return;
|
||||
d.to = event.to;
|
||||
d.style.textContent = columnDragCss(d);
|
||||
return;
|
||||
case "end": {
|
||||
drag.current = null;
|
||||
if (d == null) return;
|
||||
d.ghost?.remove();
|
||||
if (event.dropped || d.to == null) {
|
||||
d.style.remove();
|
||||
return;
|
||||
}
|
||||
d.to = null;
|
||||
d.style.textContent = columnDragCss(d);
|
||||
settling.current = { style: d.style, timer: window.setTimeout(settle, SLIDE_MS) };
|
||||
return;
|
||||
}
|
||||
case "start": {
|
||||
settle();
|
||||
d?.ghost?.remove();
|
||||
d?.style.remove();
|
||||
drag.current = null;
|
||||
const root = rootRef.current;
|
||||
const header = headerRef.current;
|
||||
const list = listRef.current;
|
||||
if (root == null || header == null || list == null) return;
|
||||
const cells = Array.from(
|
||||
header.querySelectorAll<HTMLElement>("[data-flow-header] > [data-column]"),
|
||||
);
|
||||
const ids = cells.map((c) => c.dataset.column as ColumnId);
|
||||
const from = ids.indexOf(event.id);
|
||||
if (from === -1) return;
|
||||
// Copied before it hides
|
||||
const ghost = snapshotColumn(event.id, event.x, root, header, list);
|
||||
const style = document.createElement("style");
|
||||
document.head.append(style);
|
||||
const width = cells[from]?.getBoundingClientRect().width ?? 0;
|
||||
drag.current = { ghost, ids, from, width, to: null, style };
|
||||
style.textContent = columnDragCss(drag.current);
|
||||
}
|
||||
}
|
||||
ghost.current?.remove();
|
||||
ghost.current = null;
|
||||
if (event.type === "end") {
|
||||
setGhostColumn(null);
|
||||
return;
|
||||
}
|
||||
const root = rootRef.current;
|
||||
const header = headerRef.current;
|
||||
const list = listRef.current;
|
||||
if (root == null || header == null || list == null) return;
|
||||
ghost.current = snapshotColumn(event.id, event.x, root, header, list);
|
||||
setGhostColumn(event.id);
|
||||
},
|
||||
[rootRef, headerRef, listRef],
|
||||
[rootRef, headerRef, listRef, settle],
|
||||
);
|
||||
|
||||
useEffect(() => () => ghost.current?.remove(), []);
|
||||
return { ghostColumn, onColumnDrag };
|
||||
useEffect(
|
||||
() => () => {
|
||||
settle();
|
||||
drag.current?.ghost?.remove();
|
||||
drag.current?.style.remove();
|
||||
drag.current = null;
|
||||
},
|
||||
[settle],
|
||||
);
|
||||
return onColumnDrag;
|
||||
}
|
||||
|
||||
interface ColumnDragView {
|
||||
ghost: ColumnGhost | null;
|
||||
/** The columns shown, the dragged one's place among them and its width, and its target */
|
||||
ids: ColumnId[];
|
||||
from: number;
|
||||
width: number;
|
||||
to: number | null;
|
||||
style: HTMLStyleElement;
|
||||
}
|
||||
|
||||
/** The rules for a drag: the column hidden, and those it passes slid aside */
|
||||
function columnDragCss({ ids, from, width, to }: ColumnDragView): string {
|
||||
// A row's cell is the box in its grid: the column's child, or inside the `contents` span a
|
||||
// tooltip wraps it in, as a box that is only its contents can't move
|
||||
const cells = (is: string) =>
|
||||
`[data-flow-rows] ${is} > :not(.contents), [data-flow-rows] ${is} > .contents > *`;
|
||||
const column = (id: ColumnId) => {
|
||||
const is = `[data-column="${CSS.escape(id)}"]`;
|
||||
return `[data-flow-header] > ${is}, ${cells(is)}`;
|
||||
};
|
||||
const rules = [
|
||||
`[data-flow-header] > [data-column], ${cells("[data-column]")} { transition: transform ${SLIDE_MS}ms ease; }`,
|
||||
];
|
||||
const dragged = ids[from];
|
||||
if (dragged != null) rules.push(`${column(dragged)} { visibility: hidden; }`);
|
||||
columnShifts(ids.length, from, width, to).forEach((dx, i) => {
|
||||
const id = ids[i];
|
||||
if (dx !== 0 && id != null) rules.push(`${column(id)} { transform: translateX(${dx}px); }`);
|
||||
});
|
||||
return rules.join("\n");
|
||||
}
|
||||
|
||||
interface ColumnGhost {
|
||||
|
||||
@@ -73,17 +73,20 @@ interface Props {
|
||||
onResetSort: () => void;
|
||||
/** The rows' scrolling element, which a column dragged to its edge scrolls sideways */
|
||||
scrollElement: () => HTMLElement | null;
|
||||
/** Where a dragged column would drop, in the window's coordinates, or null for nowhere */
|
||||
onDropLine: (x: number | null) => void;
|
||||
/** A column's drag: its start, the pointer's moves, and its end, for the list's ghost */
|
||||
/** A column's drag, for the list to show: its ghost, and the columns making room for it */
|
||||
onColumnDrag: (event: ColumnDragEvent) => void;
|
||||
}
|
||||
|
||||
/** A column's drag, for the ghost the list draws of it; `x` is the pointer, across the window */
|
||||
/**
|
||||
* A column's drag: its start, the pointer's moves (`x`, across the window), the boundary it
|
||||
* would drop at when that changes (`to`, null where it would stay), and its end, dropped there
|
||||
* or put back
|
||||
*/
|
||||
export type ColumnDragEvent =
|
||||
| { type: "start"; id: ColumnId; x: number }
|
||||
| { type: "move"; x: number }
|
||||
| { type: "end" };
|
||||
| { type: "target"; to: number | null }
|
||||
| { type: "end"; dropped: boolean };
|
||||
|
||||
/**
|
||||
* The list's only control surface: a click on a label sorts by it alone, a second reverses it
|
||||
@@ -102,7 +105,6 @@ export const FlowListHeader = memo(function FlowListHeader({
|
||||
onSetSort: setSort,
|
||||
onResetSort,
|
||||
scrollElement,
|
||||
onDropLine,
|
||||
onColumnDrag,
|
||||
}: Props) {
|
||||
const setPrefs = useSetAtom(columnPrefsAtom);
|
||||
@@ -111,10 +113,9 @@ export const FlowListHeader = memo(function FlowListHeader({
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; column: ColumnDef | null } | null>(null);
|
||||
// Widths when a drag began, so each move sets the width from where it started.
|
||||
const startWidth = useRef(0);
|
||||
const { dragging, sensors, dndHandlers, gridRef, onPointerDownCapture } = useColumnDrag({
|
||||
const { sensors, dndHandlers, gridRef, onPointerDownCapture } = useColumnDrag({
|
||||
columns,
|
||||
scrollElement,
|
||||
onDropLine,
|
||||
onColumnDrag,
|
||||
onDrop: (id, to) => setPrefs((p) => moveColumn(p, id, to, unavailable)),
|
||||
});
|
||||
@@ -232,6 +233,7 @@ export const FlowListHeader = memo(function FlowListHeader({
|
||||
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
|
||||
<div
|
||||
ref={gridRef}
|
||||
data-flow-header
|
||||
onPointerDownCapture={onPointerDownCapture}
|
||||
onContextMenu={openMenu}
|
||||
style={{ gridTemplateColumns: template }}
|
||||
@@ -258,7 +260,6 @@ export const FlowListHeader = memo(function FlowListHeader({
|
||||
key={c.id}
|
||||
column={c}
|
||||
sort={sort}
|
||||
dragging={dragging?.id === c.id}
|
||||
onSort={onSort}
|
||||
onResizeStart={() => {
|
||||
startWidth.current = c.current;
|
||||
@@ -281,8 +282,6 @@ export const FlowListHeader = memo(function FlowListHeader({
|
||||
interface HeaderCellProps {
|
||||
column: Column;
|
||||
sort: FlowSortKey[];
|
||||
/** Being dragged, so it dims where it was */
|
||||
dragging: boolean;
|
||||
onSort: (column: ColumnId, add: boolean) => void;
|
||||
onResizeStart: () => void;
|
||||
/** How far the edge moved since the resize began */
|
||||
@@ -293,7 +292,6 @@ interface HeaderCellProps {
|
||||
function HeaderCell({
|
||||
column: c,
|
||||
sort,
|
||||
dragging,
|
||||
onSort,
|
||||
onResizeStart,
|
||||
onResizeMove,
|
||||
@@ -303,13 +301,7 @@ function HeaderCell({
|
||||
const { listeners, setNodeRef } = useDraggable({ id: c.id });
|
||||
const sorted = sort.some((k) => k.column === c.id);
|
||||
return (
|
||||
<span
|
||||
data-column={c.id}
|
||||
className={classNames(
|
||||
"relative h-full flex items-center min-w-0 px-1.5",
|
||||
dragging && "opacity-40",
|
||||
)}
|
||||
>
|
||||
<span data-column={c.id} className="relative h-full flex items-center min-w-0 px-1.5">
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
type="button"
|
||||
@@ -363,19 +355,18 @@ function ColumnLabel({ column: c, sort }: { column: Column; sort: FlowSortKey[]
|
||||
|
||||
/**
|
||||
* Dragging a column's label: where it would drop, the boundary between the labels nearest the
|
||||
* pointer, and the list scrolling sideways while the pointer is near its edge. The columns only
|
||||
* move on the drop, so the rows lay out once.
|
||||
* pointer as they were laid out when the drag began, and the list scrolling sideways while the
|
||||
* pointer is near its edge. The list shows the drag; the columns only change order on the drop,
|
||||
* so the rows lay out once.
|
||||
*/
|
||||
function useColumnDrag({
|
||||
columns,
|
||||
scrollElement,
|
||||
onDropLine,
|
||||
onColumnDrag,
|
||||
onDrop,
|
||||
}: {
|
||||
columns: Column[];
|
||||
scrollElement: () => HTMLElement | null;
|
||||
onDropLine: (x: number | null) => void;
|
||||
onColumnDrag: (event: ColumnDragEvent) => void;
|
||||
/** Moves the column to the boundary `to` between those shown */
|
||||
onDrop: (id: ColumnId, to: number) => void;
|
||||
@@ -384,44 +375,51 @@ function useColumnDrag({
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: DRAG_DISTANCE } }),
|
||||
);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [dragging, setDragging] = useState<Column | null>(null);
|
||||
// Live while dragging, read each frame
|
||||
const state = useRef<{
|
||||
/** The column dragged */
|
||||
/** The column dragged, and its place among those shown */
|
||||
id: ColumnId;
|
||||
from: number;
|
||||
/** The labels' edges when the drag began, from the header's left, as the columns slide */
|
||||
cells: { left: number; right: number }[];
|
||||
/** The pointer, across the window */
|
||||
x: number;
|
||||
/** The boundary it would move to, null where it would stay, and the line drawn on it */
|
||||
/** The boundary it would move to, null where it would stay */
|
||||
to: number | null;
|
||||
line: number | null;
|
||||
/** The pointer the ghost was last moved to */
|
||||
ghostX: number;
|
||||
frame: number;
|
||||
} | null>(null);
|
||||
const latest = useRef({ columns, scrollElement, onDropLine, onColumnDrag, onDrop });
|
||||
const latest = useRef({ columns, scrollElement, onColumnDrag, onDrop });
|
||||
useLayoutEffect(() => {
|
||||
latest.current = { columns, scrollElement, onDropLine, onColumnDrag, onDrop };
|
||||
latest.current = { columns, scrollElement, onColumnDrag, onDrop };
|
||||
});
|
||||
|
||||
const stop = useCallback(() => {
|
||||
/** Ends the drag, dropped or put back */
|
||||
const stop = useCallback((dropped: boolean) => {
|
||||
const s = state.current;
|
||||
state.current = null;
|
||||
if (s != null) cancelAnimationFrame(s.frame);
|
||||
setDragging(null);
|
||||
latest.current.onDropLine(null);
|
||||
if (s != null) latest.current.onColumnDrag({ type: "end" });
|
||||
if (s == null) return null;
|
||||
cancelAnimationFrame(s.frame);
|
||||
latest.current.onColumnDrag({ type: "end", dropped });
|
||||
return s;
|
||||
}, []);
|
||||
|
||||
const onDragStart = useCallback((e: DragStartEvent) => {
|
||||
const column = latest.current.columns.find((c) => c.id === e.active.id);
|
||||
if (column == null) return;
|
||||
const grid = gridRef.current;
|
||||
const from = latest.current.columns.findIndex((c) => c.id === e.active.id);
|
||||
if (grid == null || from === -1) return;
|
||||
const gridLeft = grid.getBoundingClientRect().left;
|
||||
const cells = Array.from(grid.querySelectorAll<HTMLElement>(":scope > [data-column]")).map(
|
||||
(el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return { left: box.left - gridLeft, right: box.right - gridLeft };
|
||||
},
|
||||
);
|
||||
const id = e.active.id as ColumnId;
|
||||
const x = pointer.current;
|
||||
state.current = { id: column.id, x, to: null, line: null, ghostX: x, frame: 0 };
|
||||
// Before the column dims, so the ghost is of it as it shows
|
||||
latest.current.onColumnDrag({ type: "start", id: column.id, x });
|
||||
setDragging(column);
|
||||
// The line shows from the drag's first movement, not a frame later
|
||||
state.current = { id, from, cells, x, to: null, ghostX: x, frame: 0 };
|
||||
latest.current.onColumnDrag({ type: "start", id, x });
|
||||
target(state.current);
|
||||
|
||||
const tick = () => {
|
||||
@@ -453,10 +451,14 @@ function useColumnDrag({
|
||||
const drop = useCallback(() => {
|
||||
// Where the pointer is now, as a release can come before the next frame reads it
|
||||
if (state.current != null) target(state.current);
|
||||
const s = stop();
|
||||
// The list lets go of the drag and the order changes in the same task, so nothing jumps.
|
||||
const s = stop(true);
|
||||
if (s?.to != null) latest.current.onDrop(s.id, s.to);
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
|
||||
}, [stop]);
|
||||
const cancel = useCallback(() => {
|
||||
stop(false);
|
||||
}, [stop]);
|
||||
|
||||
// The pointer from a press on the header until its release, read before dnd-kit sees it.
|
||||
// dnd-kit starts a drag on the move that crosses `DRAG_DISTANCE` and reports moves and the
|
||||
@@ -477,52 +479,46 @@ function useColumnDrag({
|
||||
track(e);
|
||||
drop();
|
||||
};
|
||||
const cancel = () => {
|
||||
const lost = () => {
|
||||
unlisten.current?.();
|
||||
stop();
|
||||
cancel();
|
||||
};
|
||||
window.addEventListener("pointermove", track, true);
|
||||
window.addEventListener("pointerup", up, true);
|
||||
window.addEventListener("pointercancel", cancel, true);
|
||||
window.addEventListener("pointercancel", lost, true);
|
||||
unlisten.current = () => {
|
||||
unlisten.current = null;
|
||||
window.removeEventListener("pointermove", track, true);
|
||||
window.removeEventListener("pointerup", up, true);
|
||||
window.removeEventListener("pointercancel", cancel, true);
|
||||
window.removeEventListener("pointercancel", lost, true);
|
||||
};
|
||||
},
|
||||
[track, drop, stop],
|
||||
[track, drop, cancel],
|
||||
);
|
||||
|
||||
// A drag never outlives the header
|
||||
useEffect(
|
||||
() => () => {
|
||||
unlisten.current?.();
|
||||
stop();
|
||||
cancel();
|
||||
},
|
||||
[stop],
|
||||
[cancel],
|
||||
);
|
||||
|
||||
/** Sets where the drag would drop, from the pointer and the labels where they are now */
|
||||
/** Sets where the drag would drop, telling the list when that changes */
|
||||
function target(s: NonNullable<typeof state.current>) {
|
||||
const cells = Array.from(
|
||||
gridRef.current?.querySelectorAll<HTMLElement>(":scope > [data-column]") ?? [],
|
||||
).map((el) => el.getBoundingClientRect());
|
||||
// Its place is read each time, as the columns can change under a drag: Operation shows
|
||||
// itself when a flow has one.
|
||||
const from = latest.current.columns.findIndex((c) => c.id === s.id);
|
||||
const { to, line } = dropBoundary(s.x, cells, from);
|
||||
const gridLeft = gridRef.current?.getBoundingClientRect().left ?? 0;
|
||||
const cells = s.cells.map((c) => ({ left: gridLeft + c.left, right: gridLeft + c.right }));
|
||||
const to = dropBoundary(s.x, cells, s.from);
|
||||
if (to === s.to) return;
|
||||
s.to = to;
|
||||
// Told again only when it changes: another boundary, or the same one scrolled
|
||||
if (line !== s.line) latest.current.onDropLine(line);
|
||||
s.line = line;
|
||||
latest.current.onColumnDrag({ type: "target", to });
|
||||
}
|
||||
|
||||
return {
|
||||
dragging,
|
||||
sensors,
|
||||
gridRef,
|
||||
onPointerDownCapture,
|
||||
dndHandlers: { onDragStart, onDragEnd: drop, onDragCancel: stop },
|
||||
dndHandlers: { onDragStart, onDragEnd: drop, onDragCancel: cancel },
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
addSortKey,
|
||||
allColumns,
|
||||
cellFilterTerm,
|
||||
columnShifts,
|
||||
type ColumnPrefs,
|
||||
customPath,
|
||||
customPathProblem,
|
||||
@@ -336,25 +337,37 @@ describe("column order", () => {
|
||||
expect(parseColumnPrefs({ order: "time" }).order).toEqual([]);
|
||||
});
|
||||
|
||||
test("the drop line shows at every boundary, its own slot's edges included", () => {
|
||||
test("a drag's target is the boundary nearest the pointer, null in its own slot", () => {
|
||||
// Four columns 100px wide from x 24, after the mark column; the third is dragged
|
||||
const cells = [24, 124, 224, 324].map((left) => ({ left, right: left + 100 }));
|
||||
const at = (x: number) => dropBoundary(x, cells, 2);
|
||||
// Over the mark column or the first's left half: the first boundary
|
||||
expect(at(5)).toEqual({ to: 0, line: 24 });
|
||||
expect(at(60)).toEqual({ to: 0, line: 24 });
|
||||
expect(at(80)).toEqual({ to: 1, line: 124 });
|
||||
// The left neighbour's near half and its own left half: its left edge, staying
|
||||
expect(at(190)).toEqual({ to: null, line: 224 });
|
||||
expect(at(250)).toEqual({ to: null, line: 224 });
|
||||
// Its own right half and the right neighbour's near half: its right edge, staying
|
||||
expect(at(290)).toEqual({ to: null, line: 324 });
|
||||
expect(at(350)).toEqual({ to: null, line: 324 });
|
||||
expect(at(5)).toBe(0);
|
||||
expect(at(60)).toBe(0);
|
||||
expect(at(80)).toBe(1);
|
||||
// The left neighbour's near half, its own column, the right neighbour's near half: staying
|
||||
expect(at(190)).toBeNull();
|
||||
expect(at(250)).toBeNull();
|
||||
expect(at(290)).toBeNull();
|
||||
expect(at(350)).toBeNull();
|
||||
// Past the right neighbour's middle, and past the end
|
||||
expect(at(380)).toEqual({ to: 4, line: 424 });
|
||||
expect(at(900)).toEqual({ to: 4, line: 424 });
|
||||
expect(at(380)).toBe(4);
|
||||
expect(at(900)).toBe(4);
|
||||
// The last column dragged to the end stays
|
||||
expect(dropBoundary(900, cells, 3)).toEqual({ to: null, line: 424 });
|
||||
expect(dropBoundary(900, cells, 3)).toBeNull();
|
||||
});
|
||||
|
||||
test("the columns a drag passes slide by its width to make room", () => {
|
||||
// Five columns; the second, 80px wide, is dragged
|
||||
expect(columnShifts(5, 1, 80, null)).toEqual([0, 0, 0, 0, 0]);
|
||||
// Right, to before the fifth: the third and fourth slide left
|
||||
expect(columnShifts(5, 1, 80, 4)).toEqual([0, 0, -80, -80, 0]);
|
||||
// Right, to the end: all after it slide left
|
||||
expect(columnShifts(5, 1, 80, 5)).toEqual([0, 0, -80, -80, -80]);
|
||||
// Left, to the start: the first slides right
|
||||
expect(columnShifts(5, 1, 80, 0)).toEqual([80, 0, 0, 0, 0]);
|
||||
// The fourth, dragged left to before the second: the second and third slide right
|
||||
expect(columnShifts(5, 3, 60, 1)).toEqual([0, 60, 60, 0, 0]);
|
||||
});
|
||||
|
||||
test("Reset Column Order puts every column back", () => {
|
||||
|
||||
@@ -301,21 +301,39 @@ export function resetOrder(prefs: ColumnPrefs): ColumnPrefs {
|
||||
|
||||
/**
|
||||
* Where a dragged column would drop, from the pointer and the shown columns' edges, left to
|
||||
* right: the boundary before the first column whose middle is right of the pointer, and the
|
||||
* line's place on it. Over its own column or the near half of a neighbour, that is an edge of
|
||||
* its own slot, where it would stay, so `to` is null; the line still shows there. Left of the
|
||||
* first column, over the fixed mark column, it is the first boundary.
|
||||
* right, as they are laid out before the drag: the boundary before the first column whose middle
|
||||
* is right of the pointer. Over its own column or the near half of a neighbour, that is an edge
|
||||
* of its own slot, where it would stay, so null. Left of the first column, over the fixed mark
|
||||
* column, it is the first boundary.
|
||||
*/
|
||||
export function dropBoundary(
|
||||
x: number,
|
||||
cells: { left: number; right: number }[],
|
||||
from: number,
|
||||
): { to: number | null; line: number | null } {
|
||||
): number | null {
|
||||
let at = cells.findIndex((r) => x < (r.left + r.right) / 2);
|
||||
if (at === -1) at = cells.length;
|
||||
const line = cells[at]?.left ?? cells.at(-1)?.right ?? null;
|
||||
const stays = from === -1 || at === from || at === from + 1;
|
||||
return { to: stays ? null : at, line };
|
||||
return stays ? null : at;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far, in pixels, each of `count` shown columns slides while the one at `from`, `width`
|
||||
* wide, is dragged to the boundary `to`: those it would pass make room for it, leftwards when it
|
||||
* goes right and rightwards when it goes left. The dragged column itself, and the rest, stay.
|
||||
*/
|
||||
export function columnShifts(
|
||||
count: number,
|
||||
from: number,
|
||||
width: number,
|
||||
to: number | null,
|
||||
): number[] {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
if (to == null || i === from) return 0;
|
||||
if (to > from && i > from && i < to) return -width;
|
||||
if (to < from && i >= to && i < from) return width;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
/** Shows `id` alone */
|
||||
|
||||
Reference in new issue
Block a user