From 185a4e30dfbc69da79904a044b199ea09c5c42dc Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Tue, 10 Nov 2026 09:22:17 -0700 Subject: [PATCH] Drag a whole column's ghost, and keep default places until reordered The ghost of a dragged column is now its header and the cells of the rows in view, copied once when the drag starts and moved sideways by a transform, kept within the list; the column dims in place, its cells by one style rule so no row renders. A built-in column turned on before any reordering takes its default place again; once the person has an order, a newly shown column goes at the end. Co-Authored-By: Claude Opus 5.5 --- apps/yaak-proxy/components/FlowList.tsx | 151 +++++++++++++++++- apps/yaak-proxy/components/FlowListHeader.tsx | 65 ++++---- apps/yaak-proxy/lib/columns.test.ts | 27 +++- apps/yaak-proxy/lib/columns.ts | 5 +- 4 files changed, 198 insertions(+), 50 deletions(-) diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx index 75b4ae566..fc196d318 100644 --- a/apps/yaak-proxy/components/FlowList.tsx +++ b/apps/yaak-proxy/components/FlowList.tsx @@ -14,7 +14,14 @@ import { } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai"; -import type { CSSProperties, KeyboardEvent, MouseEvent, ReactElement, WheelEvent } from "react"; +import type { + CSSProperties, + KeyboardEvent, + MouseEvent, + ReactElement, + RefObject, + WheelEvent, +} from "react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { selectedFlowAtom, selectedFlowIdAtom } from "../lib/atoms"; import { shortContentType } from "../lib/body"; @@ -61,7 +68,7 @@ import { import { useShowToast } from "../lib/toast"; import { AppIcon } from "./AppIcon"; import { FlowListEmpty, PausedRow } from "./FlowListEmpty"; -import { FlowListHeader } from "./FlowListHeader"; +import { type ColumnDragEvent, FlowListHeader } from "./FlowListHeader"; const BOTTOM_PADDING = 6; /** Rows from the top at which older flows start loading, or from the bottom, sorted, more */ @@ -305,6 +312,7 @@ export function FlowList({ style, className }: Props) { 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); @@ -442,14 +450,19 @@ export function FlowList({ style, className }: Props) { onResetSort={follow} scrollElement={getScrollElement} onDropLine={handleDropLine} + onColumnDrag={onColumnDrag} /> - {/* Where a dragged column would drop, down the header and the rows in view */} + {/* 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]); } +/** + * 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. + */ +function useColumnGhost( + rootRef: RefObject, + headerRef: RefObject, + listRef: RefObject, +): { ghostColumn: ColumnId | null; onColumnDrag: (event: ColumnDragEvent) => void } { + const [ghostColumn, setGhostColumn] = useState(null); + const ghost = useRef(null); + + const onColumnDrag = useCallback( + (event: ColumnDragEvent) => { + if (event.type === "move") { + ghost.current?.move(event.x); + return; + } + 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], + ); + + useEffect(() => () => ghost.current?.remove(), []); + return { ghostColumn, onColumnDrag }; +} + +interface ColumnGhost { + /** Follows the pointer, across the window */ + move: (x: number) => void; + remove: () => void; +} + +function snapshotColumn( + id: ColumnId, + startX: number, + root: HTMLElement, + header: HTMLElement, + list: HTMLElement, +): ColumnGhost | null { + const cell = header.querySelector(`[data-column="${id}"]`); + if (cell == null) return null; + const rootBox = root.getBoundingClientRect(); + const cellBox = cell.getBoundingClientRect(); + const listBox = list.getBoundingClientRect(); + const listHeight = list.clientHeight; + + const el = document.createElement("div"); + el.setAttribute("aria-hidden", "true"); + el.className = classNames( + "pointer-events-none absolute top-0 z-20 overflow-hidden rounded-sm", + // Lifted: a shadow, and just enough opacity to read as held, not enough to show text through + "bg-surface border border-border-subtle shadow-lg opacity-95", + ); + Object.assign(el.style, { + left: `${cellBox.left - rootBox.left}px`, + width: `${cellBox.width}px`, + height: `${rootBox.height}px`, + willChange: "transform", + }); + + // The header's label, without its resize handle + const head = place(cellBox.top - rootBox.top, cellBox.height, "text-xs text-text-subtlest"); + const label = cell.cloneNode(true) as HTMLElement; + for (const handle of label.querySelectorAll(".cursor-col-resize")) handle.remove(); + label.style.width = "100%"; + head.append(label); + el.append(head); + + // The rows in view, clipped to the list as it shows them + const rows = document.createElement("div"); + rows.className = "absolute left-0 right-0 overflow-hidden"; + Object.assign(rows.style, { top: `${listBox.top - rootBox.top}px`, height: `${listHeight}px` }); + for (const row of list.querySelectorAll('[role="option"]')) { + const rowBox = row.getBoundingClientRect(); + if (rowBox.bottom <= listBox.top || rowBox.top >= listBox.top + listHeight) continue; + const source = row.querySelector(`[data-column="${id}"]`); + if (source == null) continue; + const copy = place( + rowBox.top - listBox.top, + rowBox.height, + classNames( + "whitespace-nowrap tabular-nums", + row.getAttribute("aria-selected") === "true" && "text-text bg-surface-highlight", + row.classList.contains("text-text-subtlest") && "text-text-subtlest", + ), + ); + for (const child of source.children) { + const clone = child.cloneNode(true) as HTMLElement; + clone.style.width = "100%"; + copy.append(clone); + } + rows.append(copy); + } + el.append(rows); + root.append(el); + + // Kept within the list's visible width + const min = listBox.left; + const max = Math.max(min, listBox.left + list.clientWidth - cellBox.width); + const move = (x: number) => { + const left = Math.min(max, Math.max(min, cellBox.left + x - startX)); + el.style.transform = `translateX(${left - cellBox.left}px)`; + }; + move(startX); + return { move, remove: () => el.remove() }; +} + +/** A line of the ghost, `top` pixels down its box */ +function place(top: number, height: number, className: string): HTMLElement { + const el = document.createElement("div"); + el.className = classNames("absolute left-0 right-0 flex items-center", className); + Object.assign(el.style, { top: `${top}px`, height: `${height}px` }); + return el; +} + interface RowProps { flow: Flow; index: number; diff --git a/apps/yaak-proxy/components/FlowListHeader.tsx b/apps/yaak-proxy/components/FlowListHeader.tsx index a232080f8..952ca6cc6 100644 --- a/apps/yaak-proxy/components/FlowListHeader.tsx +++ b/apps/yaak-proxy/components/FlowListHeader.tsx @@ -1,6 +1,5 @@ import { DndContext, - DragOverlay, type DragStartEvent, PointerSensor, useDraggable, @@ -28,7 +27,6 @@ import { useRef, useState, } from "react"; -import { createPortal } from "react-dom"; import { addSortKey, allColumns, @@ -77,8 +75,16 @@ interface Props { 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 */ + onColumnDrag: (event: ColumnDragEvent) => void; } +/** A column's drag, for the ghost the list draws of it; `x` is the pointer, across the window */ +export type ColumnDragEvent = + | { type: "start"; id: ColumnId; x: number } + | { type: "move"; x: number } + | { type: "end" }; + /** * 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. @@ -97,6 +103,7 @@ export const FlowListHeader = memo(function FlowListHeader({ onResetSort, scrollElement, onDropLine, + onColumnDrag, }: Props) { const setPrefs = useSetAtom(columnPrefsAtom); const unavailable = unavailableColumns(useAtomValue(hasAppsAtom)); @@ -108,6 +115,7 @@ export const FlowListHeader = memo(function FlowListHeader({ columns, scrollElement, onDropLine, + onColumnDrag, onDrop: (id, to) => setPrefs((p) => moveColumn(p, id, to, unavailable)), }); @@ -259,24 +267,6 @@ export const FlowListHeader = memo(function FlowListHeader({ onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))} /> ))} - {/* Over everything, as the header clips what overflows it */} - {createPortal( - - {dragging != null && ( -
- -
- )} -
, - document.body, - )} k.column === c.id); const key = sort[at]; return ( @@ -377,7 +356,7 @@ function ColumnLabel({ {key != null && sort.length > 1 && ( {at + 1} )} - {c.id === "app" && !ghost ? "" : c.label} + {c.id === "app" ? "" : c.label} ); } @@ -391,11 +370,13 @@ 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; }) { @@ -413,11 +394,13 @@ function useColumnDrag({ /** The boundary it would move to, null where it would stay, and the line drawn on it */ 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, onDrop }); + const latest = useRef({ columns, scrollElement, onDropLine, onColumnDrag, onDrop }); useLayoutEffect(() => { - latest.current = { columns, scrollElement, onDropLine, onDrop }; + latest.current = { columns, scrollElement, onDropLine, onColumnDrag, onDrop }; }); const stop = useCallback(() => { @@ -426,13 +409,17 @@ function useColumnDrag({ if (s != null) cancelAnimationFrame(s.frame); setDragging(null); latest.current.onDropLine(null); + if (s != null) latest.current.onColumnDrag({ type: "end" }); 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 }; + 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 target(state.current); @@ -452,6 +439,10 @@ function useColumnDrag({ } } 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); diff --git a/apps/yaak-proxy/lib/columns.test.ts b/apps/yaak-proxy/lib/columns.test.ts index cafcb4b3c..6e01d5a2d 100644 --- a/apps/yaak-proxy/lib/columns.test.ts +++ b/apps/yaak-proxy/lib/columns.test.ts @@ -269,17 +269,30 @@ describe("column order", () => { ]); }); - test("a column turned on goes at the end, unless it had a place", () => { + test("never reordered, a built-in column turned on takes its default place", () => { // Request Size's default place is before Size - let prefs = toggleColumn(defaultColumnPrefs, "requestSize"); + const prefs = toggleColumn(defaultColumnPrefs, "requestSize"); + expect(prefs.order).toEqual([]); + expect(ids(prefs).slice(-4)).toEqual(["type", "requestSize", "size", "time"]); + // Among the built-in ones, so a custom column stays at the end + const custom = addCustomColumn(defaultColumnPrefs, "header.x-cache"); + expect(ids(custom).at(-1)).toBe("field:header.x-cache"); + const both = toggleColumn(custom, "requestSize"); + expect(both.order).toEqual([]); + expect(ids(both).slice(-4)).toEqual(["requestSize", "size", "time", "field:header.x-cache"]); + }); + + test("once reordered, a column turned on goes at the end, unless it had a place", () => { + let prefs = moveColumn(defaultColumnPrefs, "time", 0); + prefs = toggleColumn(prefs, "requestSize"); expect(ids(prefs).at(-1)).toBe("requestSize"); - expect(ids(prefs).slice(0, -1)).toEqual(defaults); // Moved, hidden and shown again, it goes back where it was moved to - prefs = moveColumn(prefs, "requestSize", defaults.indexOf("size")); + const before = ids(prefs).indexOf("size"); + prefs = moveColumn(prefs, "requestSize", before); prefs = toggleColumn(toggleColumn(prefs, "requestSize"), "requestSize"); - expect(ids(prefs).slice(-3)).toEqual(["requestSize", "size", "time"]); - // Already last of those shown, nothing needs placing - expect(toggleColumn(defaultColumnPrefs, "remote").order).toEqual([]); + expect(ids(prefs).slice(-3)).toEqual(["type", "requestSize", "size"]); + // A custom column added goes at the end too + expect(ids(addCustomColumn(prefs, "query.page")).at(-1)).toBe("field:query.page"); }); test("a custom column is added at the end and leaves the order when removed", () => { diff --git a/apps/yaak-proxy/lib/columns.ts b/apps/yaak-proxy/lib/columns.ts index 693cd9ee4..7c2cc339f 100644 --- a/apps/yaak-proxy/lib/columns.ts +++ b/apps/yaak-proxy/lib/columns.ts @@ -243,10 +243,11 @@ export function toggleColumn( /** * Puts a column just shown after the others shown, unless the person placed it before, so it * goes back to that place. The columns shown before it are placed too, where they are, so the - * ones not listed don't jump ahead of it. + * ones not listed don't jump ahead of it. Until the person has ordered the columns, each keeps + * its default place: a built-in one among the others, an added one at the end. */ function placeAtEnd(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs { - if (prefs.order.includes(id)) return prefs; + if (prefs.order.length === 0 || prefs.order.includes(id)) return prefs; const all = allColumns(prefs); if (all.filter((c) => isVisible(prefs, c)).at(-1)?.id === id) return prefs; const placed = all.filter(