mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
The line was hidden while a drop would leave the column where it is, so a drag showed nothing until it crossed a neighbour's middle. It now shows from the first movement, on the nearer edge of the column's own slot when the drop would change nothing, and at the first boundary over the mark column. The choice of boundary moves to a tested function in the columns module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
538 lines
18 KiB
TypeScript
538 lines
18 KiB
TypeScript
import {
|
|
DndContext,
|
|
DragOverlay,
|
|
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 { createPortal } from "react-dom";
|
|
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;
|
|
/** Where a dragged column would drop, in the window's coordinates, or null for nowhere */
|
|
onDropLine: (x: number | null) => void;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
onDropLine,
|
|
}: 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 { dragging, sensors, dndHandlers, gridRef, onPointerDownCapture } = useColumnDrag({
|
|
columns,
|
|
scrollElement,
|
|
onDropLine,
|
|
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}
|
|
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}
|
|
dragging={dragging?.id === c.id}
|
|
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))}
|
|
/>
|
|
))}
|
|
{/* Over everything, as the header clips what overflows it */}
|
|
{createPortal(
|
|
<DragOverlay dropAnimation={null}>
|
|
{dragging != null && (
|
|
<div
|
|
className={classNames(
|
|
// As wide as its name, however narrow the column
|
|
"flex items-center gap-0.5 h-full w-max px-1.5 rounded-sm",
|
|
"cursor-grabbing whitespace-nowrap",
|
|
"text-xs text-text bg-surface-highlight border border-border shadow-lg",
|
|
)}
|
|
>
|
|
<ColumnLabel column={dragging} sort={sort} ghost />
|
|
</div>
|
|
)}
|
|
</DragOverlay>,
|
|
document.body,
|
|
)}
|
|
</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[];
|
|
/** 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 */
|
|
onResizeMove: (dx: number) => void;
|
|
onResetWidth: () => void;
|
|
}
|
|
|
|
function HeaderCell({
|
|
column: c,
|
|
sort,
|
|
dragging,
|
|
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={classNames(
|
|
"relative h-full flex items-center min-w-0 px-1.5",
|
|
dragging && "opacity-40",
|
|
)}
|
|
>
|
|
<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. App's
|
|
* column is as narrow as its icons, so only its dragged ghost names it.
|
|
*/
|
|
function ColumnLabel({
|
|
column: c,
|
|
sort,
|
|
ghost = false,
|
|
}: {
|
|
column: Column;
|
|
sort: FlowSortKey[];
|
|
ghost?: boolean;
|
|
}) {
|
|
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" && !ghost ? "" : c.label}</span>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
function useColumnDrag({
|
|
columns,
|
|
scrollElement,
|
|
onDropLine,
|
|
onDrop,
|
|
}: {
|
|
columns: Column[];
|
|
scrollElement: () => HTMLElement | null;
|
|
onDropLine: (x: number | null) => 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);
|
|
const [dragging, setDragging] = useState<Column | null>(null);
|
|
// Live while dragging, read each frame
|
|
const state = useRef<{
|
|
/** The column dragged */
|
|
id: ColumnId;
|
|
/** The pointer, across the window */
|
|
x: number;
|
|
/** The boundary it would move to, null where it would stay, and the line drawn on it */
|
|
to: number | null;
|
|
line: number | null;
|
|
frame: number;
|
|
} | null>(null);
|
|
const latest = useRef({ columns, scrollElement, onDropLine, onDrop });
|
|
useLayoutEffect(() => {
|
|
latest.current = { columns, scrollElement, onDropLine, onDrop };
|
|
});
|
|
|
|
const stop = useCallback(() => {
|
|
const s = state.current;
|
|
state.current = null;
|
|
if (s != null) cancelAnimationFrame(s.frame);
|
|
setDragging(null);
|
|
latest.current.onDropLine(null);
|
|
return s;
|
|
}, []);
|
|
|
|
const onDragStart = useCallback((e: DragStartEvent) => {
|
|
const column = latest.current.columns.find((c) => c.id === e.active.id);
|
|
if (column == null) return;
|
|
state.current = { id: column.id, x: pointer.current, to: null, line: null, frame: 0 };
|
|
setDragging(column);
|
|
// The line shows from the drag's first movement, not a frame later
|
|
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);
|
|
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);
|
|
const s = stop();
|
|
if (s?.to != null) latest.current.onDrop(s.id, s.to);
|
|
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
|
|
}, [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 cancel = () => {
|
|
unlisten.current?.();
|
|
stop();
|
|
};
|
|
window.addEventListener("pointermove", track, true);
|
|
window.addEventListener("pointerup", up, true);
|
|
window.addEventListener("pointercancel", cancel, true);
|
|
unlisten.current = () => {
|
|
unlisten.current = null;
|
|
window.removeEventListener("pointermove", track, true);
|
|
window.removeEventListener("pointerup", up, true);
|
|
window.removeEventListener("pointercancel", cancel, true);
|
|
};
|
|
},
|
|
[track, drop, stop],
|
|
);
|
|
|
|
// A drag never outlives the header
|
|
useEffect(
|
|
() => () => {
|
|
unlisten.current?.();
|
|
stop();
|
|
},
|
|
[stop],
|
|
);
|
|
|
|
/** Sets where the drag would drop, from the pointer and the labels where they are now */
|
|
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);
|
|
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;
|
|
}
|
|
|
|
return {
|
|
dragging,
|
|
sensors,
|
|
gridRef,
|
|
onPointerDownCapture,
|
|
dndHandlers: { onDragStart, onDragEnd: drop, onDragCancel: stop },
|
|
};
|
|
}
|