diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx index fc196d318..df6a8af35 100644 --- a/apps/yaak-proxy/components/FlowList.tsx +++ b/apps/yaak-proxy/components/FlowList.tsx @@ -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(null); - const [dropLine, setDropLine] = useState(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} /> - {/* A dragged column's cells dim where they are, in one rule rather than a render a row */} - {ghostColumn != null && ( - - )} - {/* Where a dragged column would drop, down the header and the rows in view, over its ghost */} - {dropLine != null && ( -
-
-
- )}
{ }, [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, headerRef: RefObject, listRef: RefObject, -): { ghostColumn: ColumnId | null; onColumnDrag: (event: ColumnDragEvent) => void } { - const [ghostColumn, setGhostColumn] = useState(null); - const ghost = useRef(null); +): (event: ColumnDragEvent) => void { + const drag = useRef(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("[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 { diff --git a/apps/yaak-proxy/components/FlowListHeader.tsx b/apps/yaak-proxy/components/FlowListHeader.tsx index 952ca6cc6..837eec069 100644 --- a/apps/yaak-proxy/components/FlowListHeader.tsx +++ b/apps/yaak-proxy/components/FlowListHeader.tsx @@ -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 */}
{ 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 ( - +