mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
9ee37e4015
commit
185a4e30df
4 files changed
+198
-50
No files matched your search
@@ -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<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);
|
||||
@@ -442,14 +450,19 @@ export function FlowList({ style, className }: Props) {
|
||||
onResetSort={follow}
|
||||
scrollElement={getScrollElement}
|
||||
onDropLine={handleDropLine}
|
||||
onColumnDrag={onColumnDrag}
|
||||
/>
|
||||
</div>
|
||||
{/* 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 && (
|
||||
<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 overflow-hidden"
|
||||
className="pointer-events-none absolute inset-y-0 left-0 z-30 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
style={{ left: dropLine }}
|
||||
@@ -460,6 +473,7 @@ export function FlowList({ style, className }: Props) {
|
||||
<div className="relative min-h-0">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-flow-rows
|
||||
role="listbox"
|
||||
aria-label="Flows"
|
||||
tabIndex={0}
|
||||
@@ -569,6 +583,135 @@ function useArrivals(flows: Flow[], following: boolean): Map<string, number> {
|
||||
}, [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<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);
|
||||
|
||||
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<HTMLElement>(`[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<HTMLElement>('[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;
|
||||
|
||||
@@ -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(
|
||||
<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}
|
||||
@@ -350,19 +340,8 @@ function HeaderCell({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}) {
|
||||
/** 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 (
|
||||
@@ -377,7 +356,7 @@ function ColumnLabel({
|
||||
{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>
|
||||
<span className="truncate">{c.id === "app" ? "" : c.label}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user