mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
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>
525 lines
18 KiB
TypeScript
525 lines
18 KiB
TypeScript
import {
|
|
DndContext,
|
|
type DragStartEvent,
|
|
PointerSensor,
|
|
useDraggable,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import type { FlowSortKey } from "@yaakapp-internal/proxy-lib";
|
|
import {
|
|
ContextMenu,
|
|
Dropdown,
|
|
type DropdownItem,
|
|
Icon,
|
|
IconButton,
|
|
ResizeHandle,
|
|
} from "@yaakapp-internal/ui";
|
|
import classNames from "classnames";
|
|
import { useAtomValue, useSetAtom } from "jotai";
|
|
import {
|
|
memo,
|
|
type MouseEvent,
|
|
type PointerEvent as ReactPointerEvent,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
addSortKey,
|
|
allColumns,
|
|
type Column,
|
|
type ColumnDef,
|
|
type ColumnId,
|
|
type ColumnPrefs,
|
|
type CustomColumnId,
|
|
canHide,
|
|
dropBoundary,
|
|
hideOtherColumns,
|
|
isCustomColumn,
|
|
moveColumn,
|
|
moveColumnBy,
|
|
removeCustomColumn,
|
|
resetOrder,
|
|
resetWidth,
|
|
resetWidths,
|
|
resizeColumn,
|
|
toggleColumn,
|
|
unavailableColumns,
|
|
} from "../lib/columns";
|
|
import { columnPrefsAtom, hasAppsAtom } from "../lib/store";
|
|
import { addColumnDialogAtom } from "./AddColumnDialog";
|
|
|
|
/** Pixels a press on a label moves before it drags the column rather than sorting by it */
|
|
const DRAG_DISTANCE = 4;
|
|
/** How near the list's side edge, in pixels, a dragged column scrolls the list that way */
|
|
const EDGE = 40;
|
|
/** The most the list scrolls sideways a frame, at the very edge */
|
|
const EDGE_SPEED = 18;
|
|
|
|
interface Props {
|
|
columns: Column[];
|
|
template: string;
|
|
/** The rows' width, as CSS, so the header scrolls sideways with them */
|
|
width: string;
|
|
prefs: ColumnPrefs;
|
|
sort: FlowSortKey[];
|
|
/** A click on a label, or with `add`, an Option-click */
|
|
onSort: (column: ColumnId, add: boolean) => void;
|
|
/** Sets the sort from the menus; empty goes back to arrival order */
|
|
onSetSort: (sort: FlowSortKey[]) => void;
|
|
onResetSort: () => void;
|
|
/** The rows' scrolling element, which a column dragged to its edge scrolls sideways */
|
|
scrollElement: () => HTMLElement | null;
|
|
/** 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: 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: "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
|
|
* and a third goes back to arrival order; Option-click adds it to the sort as a further key.
|
|
* Dragging a label moves its column, and Escape puts it back. Dragging the edge after a label
|
|
* resizes its column and a double-click on it resets it. A right-click on a label opens the
|
|
* column's menu; the picker sits in the empty corner on hover.
|
|
*/
|
|
export const FlowListHeader = memo(function FlowListHeader({
|
|
columns,
|
|
template,
|
|
width,
|
|
prefs,
|
|
sort,
|
|
onSort,
|
|
onSetSort: setSort,
|
|
onResetSort,
|
|
scrollElement,
|
|
onColumnDrag,
|
|
}: Props) {
|
|
const setPrefs = useSetAtom(columnPrefsAtom);
|
|
const unavailable = unavailableColumns(useAtomValue(hasAppsAtom));
|
|
const openAddColumn = useSetAtom(addColumnDialogAtom);
|
|
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 { sensors, dndHandlers, gridRef, onPointerDownCapture } = useColumnDrag({
|
|
columns,
|
|
scrollElement,
|
|
onColumnDrag,
|
|
onDrop: (id, to) => setPrefs((p) => moveColumn(p, id, to, unavailable)),
|
|
});
|
|
|
|
// Taking out the sort's only key goes back to arrival order, as Reset Sort does
|
|
const removeColumn = (id: CustomColumnId) => {
|
|
if (sort.length > 0 && sort.every((k) => k.column === id)) onResetSort();
|
|
setPrefs((p) => removeCustomColumn(p, id));
|
|
};
|
|
|
|
const pickerItems: DropdownItem[] = [
|
|
{
|
|
type: "content",
|
|
label: <div className="px-1.5 pt-1 text-xs text-text-subtlest">Columns</div>,
|
|
},
|
|
...allColumns(prefs).map((c): DropdownItem => {
|
|
const shown = prefs.visible[c.id] ?? c.defaultVisible;
|
|
return {
|
|
label: c.label,
|
|
keepOpenOnSelect: true,
|
|
disabled: shown && !canHide(prefs, c.id, unavailable),
|
|
leftSlot: shown ? (
|
|
<Icon icon="check" size="sm" />
|
|
) : (
|
|
<span aria-hidden className="inline-block w-4 h-4" />
|
|
),
|
|
onSelect: () => setPrefs((p) => toggleColumn(p, c.id, unavailable)),
|
|
...(isCustomColumn(c.id)
|
|
? {
|
|
submenuTrigger: "button" as const,
|
|
submenu: [
|
|
{
|
|
label: "Remove Column",
|
|
icon: "trash" as const,
|
|
color: "danger" as const,
|
|
onSelect: () => removeColumn(c.id as CustomColumnId),
|
|
},
|
|
],
|
|
}
|
|
: {}),
|
|
};
|
|
}),
|
|
{ label: "Add Column…", icon: "plus", onSelect: () => openAddColumn(true) },
|
|
{ type: "separator" },
|
|
{ label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
|
|
{ label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) },
|
|
{
|
|
label: "Reset Column Order",
|
|
disabled: prefs.order.length === 0,
|
|
onSelect: () => setPrefs((p) => resetOrder(p)),
|
|
},
|
|
];
|
|
|
|
const columnItems = (c: ColumnDef): DropdownItem[] => {
|
|
const sortBy = (descending: boolean) => setSort([{ column: c.id, descending }]);
|
|
const addTo = (descending: boolean) => setSort(addSortKey(sort, c.id, descending));
|
|
const at = columns.findIndex((col) => col.id === c.id);
|
|
return [
|
|
{ label: "Sort Ascending", onSelect: () => sortBy(false) },
|
|
{ label: "Sort Descending", onSelect: () => sortBy(true) },
|
|
{ label: "Add to Sort Ascending", onSelect: () => addTo(false) },
|
|
{ label: "Add to Sort Descending", onSelect: () => addTo(true) },
|
|
{ label: "Clear Sorting", disabled: sort.length === 0, onSelect: onResetSort },
|
|
{ type: "separator" },
|
|
{
|
|
label: "Move Left",
|
|
disabled: at <= 0,
|
|
onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, -1, unavailable)),
|
|
},
|
|
{
|
|
label: "Move Right",
|
|
disabled: at === -1 || at >= columns.length - 1,
|
|
onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, 1, unavailable)),
|
|
},
|
|
{ type: "separator" },
|
|
{
|
|
label: "Hide Column",
|
|
disabled: !canHide(prefs, c.id, unavailable),
|
|
onSelect: () => setPrefs((p) => toggleColumn(p, c.id, unavailable)),
|
|
},
|
|
{
|
|
label: "Hide Other Columns",
|
|
disabled: columns.length <= 1,
|
|
onSelect: () => setPrefs((p) => hideOtherColumns(p, c.id)),
|
|
},
|
|
...(isCustomColumn(c.id)
|
|
? [
|
|
{
|
|
label: "Remove Column",
|
|
color: "danger" as const,
|
|
onSelect: () => removeColumn(c.id as CustomColumnId),
|
|
},
|
|
]
|
|
: []),
|
|
{ type: "separator" },
|
|
{ label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) },
|
|
];
|
|
};
|
|
|
|
// A right-click on a label is for its column; anywhere else in the header, the picker.
|
|
const openMenu = useCallback(
|
|
(e: MouseEvent) => {
|
|
e.preventDefault();
|
|
const el = (e.target as HTMLElement).closest<HTMLElement>("[data-column]");
|
|
const id = el?.dataset.column as ColumnId | undefined;
|
|
const column = columns.find((c) => c.id === id) ?? null;
|
|
setMenu({ x: e.clientX, y: e.clientY, column });
|
|
},
|
|
[columns],
|
|
);
|
|
|
|
return (
|
|
// The same margins and padding as the rows, so each label sits over its cells.
|
|
<div style={{ width, minWidth: "100%" }} className="px-1.5">
|
|
{/* 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 }}
|
|
className={classNames(
|
|
"group/header grid items-center h-xs px-1.5 select-none",
|
|
"text-xs text-text-subtlest border-b border-border-subtle",
|
|
)}
|
|
>
|
|
<span className="flex items-center justify-center">
|
|
<Dropdown items={pickerItems}>
|
|
<IconButton
|
|
size="xs"
|
|
icon="settings_2"
|
|
title="Columns"
|
|
tooltip
|
|
iconColor="secondary"
|
|
className="opacity-0 group-hover/header:opacity-100 focus-visible:opacity-100 !h-5 !w-5"
|
|
/>
|
|
</Dropdown>
|
|
</span>
|
|
<DndContext sensors={sensors} autoScroll={false} {...dndHandlers}>
|
|
{columns.map((c) => (
|
|
<HeaderCell
|
|
key={c.id}
|
|
column={c}
|
|
sort={sort}
|
|
onSort={onSort}
|
|
onResizeStart={() => {
|
|
startWidth.current = c.current;
|
|
}}
|
|
onResizeMove={(dx) => setPrefs((p) => resizeColumn(p, c.id, startWidth.current + dx))}
|
|
onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))}
|
|
/>
|
|
))}
|
|
</DndContext>
|
|
<ContextMenu
|
|
triggerPosition={menu}
|
|
items={menu == null ? [] : menu.column == null ? pickerItems : columnItems(menu.column)}
|
|
onClose={() => setMenu(null)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
interface HeaderCellProps {
|
|
column: Column;
|
|
sort: FlowSortKey[];
|
|
onSort: (column: ColumnId, add: boolean) => void;
|
|
onResizeStart: () => void;
|
|
/** How far the edge moved since the resize began */
|
|
onResizeMove: (dx: number) => void;
|
|
onResetWidth: () => void;
|
|
}
|
|
|
|
function HeaderCell({
|
|
column: c,
|
|
sort,
|
|
onSort,
|
|
onResizeStart,
|
|
onResizeMove,
|
|
onResetWidth,
|
|
}: HeaderCellProps) {
|
|
// The label drags the column; the resize handle beside it is outside it, so it still resizes.
|
|
const { listeners, setNodeRef } = useDraggable({ id: c.id });
|
|
const sorted = sort.some((k) => k.column === c.id);
|
|
return (
|
|
<span data-column={c.id} className="relative h-full flex items-center min-w-0 px-1.5">
|
|
<button
|
|
ref={setNodeRef}
|
|
type="button"
|
|
{...listeners}
|
|
// A press that moves drags instead; dnd-kit swallows the click that ends a drag.
|
|
onClick={(e) => onSort(c.id, e.altKey)}
|
|
// No tooltip: the header sits under the search field, and a tooltip here covered it
|
|
aria-label={`Sort by ${c.label}`}
|
|
className={classNames(
|
|
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap",
|
|
"hover:text-text-subtle",
|
|
c.align === "right" && "justify-end",
|
|
sorted && "text-text-subtle",
|
|
)}
|
|
>
|
|
<ColumnLabel column={c} sort={sort} />
|
|
</button>
|
|
{/* On the column's right edge, the boundary with the next, resizing this one */}
|
|
<ResizeHandle
|
|
side="right"
|
|
justify="center"
|
|
className="absolute top-0 translate-x-1/2"
|
|
onResizeStart={onResizeStart}
|
|
onResizeMove={(e) => onResizeMove(e.x - e.xStart)}
|
|
onReset={onResetWidth}
|
|
/>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** A column's name, after its sort direction and, sorted by several, its place in the sort */
|
|
function ColumnLabel({ column: c, sort }: { column: Column; sort: FlowSortKey[] }) {
|
|
const at = sort.findIndex((k) => k.column === c.id);
|
|
const key = sort[at];
|
|
return (
|
|
<>
|
|
{key != null && (
|
|
<Icon
|
|
icon="chevron_down"
|
|
size="xs"
|
|
className={classNames("shrink-0", !key.descending && "rotate-180")}
|
|
/>
|
|
)}
|
|
{key != null && sort.length > 1 && (
|
|
<span className="shrink-0 -ml-0.5 text-2xs tabular-nums">{at + 1}</span>
|
|
)}
|
|
<span className="truncate">{c.id === "app" ? "" : c.label}</span>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Dragging a column's label: where it would drop, the boundary between the labels nearest the
|
|
* 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,
|
|
onColumnDrag,
|
|
onDrop,
|
|
}: {
|
|
columns: Column[];
|
|
scrollElement: () => HTMLElement | null;
|
|
onColumnDrag: (event: ColumnDragEvent) => void;
|
|
/** Moves the column to the boundary `to` between those shown */
|
|
onDrop: (id: ColumnId, to: number) => void;
|
|
}) {
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: DRAG_DISTANCE } }),
|
|
);
|
|
const gridRef = useRef<HTMLDivElement>(null);
|
|
// Live while dragging, read each frame
|
|
const state = useRef<{
|
|
/** 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 */
|
|
to: number | null;
|
|
/** The pointer the ghost was last moved to */
|
|
ghostX: number;
|
|
frame: number;
|
|
} | null>(null);
|
|
const latest = useRef({ columns, scrollElement, onColumnDrag, onDrop });
|
|
useLayoutEffect(() => {
|
|
latest.current = { columns, scrollElement, onColumnDrag, onDrop };
|
|
});
|
|
|
|
/** Ends the drag, dropped or put back */
|
|
const stop = useCallback((dropped: boolean) => {
|
|
const s = state.current;
|
|
state.current = null;
|
|
if (s == null) return null;
|
|
cancelAnimationFrame(s.frame);
|
|
latest.current.onColumnDrag({ type: "end", dropped });
|
|
return s;
|
|
}, []);
|
|
|
|
const onDragStart = useCallback((e: DragStartEvent) => {
|
|
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, from, cells, x, to: null, ghostX: x, frame: 0 };
|
|
latest.current.onColumnDrag({ type: "start", id, x });
|
|
target(state.current);
|
|
|
|
const tick = () => {
|
|
const s = state.current;
|
|
if (s == null) return;
|
|
// Near an edge, the list scrolls that way, faster the nearer; the header follows it.
|
|
const list = latest.current.scrollElement();
|
|
if (list != null) {
|
|
const { left, right } = list.getBoundingClientRect();
|
|
const near =
|
|
s.x < left + EDGE ? s.x - left - EDGE : s.x > right - EDGE ? s.x - right + EDGE : 0;
|
|
if (near !== 0) {
|
|
const speed = Math.min(1, Math.abs(near) / EDGE) * EDGE_SPEED;
|
|
list.scrollLeft += Math.sign(near) * Math.max(1, speed);
|
|
}
|
|
}
|
|
target(s);
|
|
if (s.x !== s.ghostX) {
|
|
s.ghostX = s.x;
|
|
latest.current.onColumnDrag({ type: "move", x: s.x });
|
|
}
|
|
s.frame = requestAnimationFrame(tick);
|
|
};
|
|
state.current.frame = requestAnimationFrame(tick);
|
|
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
|
|
}, []);
|
|
|
|
/** Drops the column where the pointer is, once; a second call finds no drag */
|
|
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);
|
|
// 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
|
|
// release only once it has rendered the drag, so a release right after a move would drop
|
|
// where the pointer was a frame before, or not at all. The release drops it here instead.
|
|
const pointer = useRef(0);
|
|
const track = useCallback((e: PointerEvent) => {
|
|
pointer.current = e.clientX;
|
|
if (state.current != null) state.current.x = e.clientX;
|
|
}, []);
|
|
const unlisten = useRef<(() => void) | null>(null);
|
|
const onPointerDownCapture = useCallback(
|
|
(e: ReactPointerEvent) => {
|
|
unlisten.current?.();
|
|
pointer.current = e.clientX;
|
|
const up = (e: PointerEvent) => {
|
|
unlisten.current?.();
|
|
track(e);
|
|
drop();
|
|
};
|
|
const lost = () => {
|
|
unlisten.current?.();
|
|
cancel();
|
|
};
|
|
window.addEventListener("pointermove", track, true);
|
|
window.addEventListener("pointerup", up, true);
|
|
window.addEventListener("pointercancel", lost, true);
|
|
unlisten.current = () => {
|
|
unlisten.current = null;
|
|
window.removeEventListener("pointermove", track, true);
|
|
window.removeEventListener("pointerup", up, true);
|
|
window.removeEventListener("pointercancel", lost, true);
|
|
};
|
|
},
|
|
[track, drop, cancel],
|
|
);
|
|
|
|
// A drag never outlives the header
|
|
useEffect(
|
|
() => () => {
|
|
unlisten.current?.();
|
|
cancel();
|
|
},
|
|
[cancel],
|
|
);
|
|
|
|
/** Sets where the drag would drop, telling the list when that changes */
|
|
function target(s: NonNullable<typeof state.current>) {
|
|
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;
|
|
latest.current.onColumnDrag({ type: "target", to });
|
|
}
|
|
|
|
return {
|
|
sensors,
|
|
gridRef,
|
|
onPointerDownCapture,
|
|
dndHandlers: { onDragStart, onDragEnd: drop, onDragCancel: cancel },
|
|
};
|
|
}
|