Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
346 lines
11 KiB
TypeScript
346 lines
11 KiB
TypeScript
import React from "react";
|
|
|
|
export type VaultDropPosition = "before" | "after";
|
|
export type VaultDropIntent = VaultDropPosition | "inside";
|
|
export type VaultDropAxis = "x" | "y";
|
|
|
|
const useIsomorphicLayoutEffect =
|
|
typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
|
|
|
|
export const getVaultDropPosition = (
|
|
element: HTMLElement,
|
|
clientX: number,
|
|
clientY: number,
|
|
isGrid = false,
|
|
): VaultDropPosition => {
|
|
const rect = element.getBoundingClientRect();
|
|
if (isGrid) return clientX < rect.left + rect.width / 2 ? "before" : "after";
|
|
return clientY < rect.top + rect.height / 2 ? "before" : "after";
|
|
};
|
|
|
|
export const getVaultDropIntent = (
|
|
element: HTMLElement,
|
|
clientX: number,
|
|
clientY: number,
|
|
isGrid: boolean,
|
|
): VaultDropIntent => {
|
|
const rect = element.getBoundingClientRect();
|
|
if (isGrid) {
|
|
const edgeSize = Math.max(18, Math.min(36, rect.width * 0.22));
|
|
if (clientX <= rect.left + edgeSize) return "before";
|
|
if (clientX >= rect.right - edgeSize) return "after";
|
|
return "inside";
|
|
}
|
|
|
|
const edgeSize = Math.max(8, Math.min(14, rect.height * 0.28));
|
|
if (clientY <= rect.top + edgeSize) return "before";
|
|
if (clientY >= rect.bottom - edgeSize) return "after";
|
|
return "inside";
|
|
};
|
|
|
|
export const hasVaultDragType = (dataTransfer: DataTransfer, type: string) =>
|
|
Array.from(dataTransfer.types).includes(type);
|
|
|
|
export const handleVaultRootDrop = ({
|
|
dataTransfer,
|
|
preventDefault,
|
|
setDragOverDropTarget,
|
|
moveGroup,
|
|
moveHostToGroup,
|
|
resetHostDragState,
|
|
}: {
|
|
dataTransfer: Pick<DataTransfer, "getData">;
|
|
preventDefault: () => void;
|
|
setDragOverDropTarget: (target: null) => void;
|
|
moveGroup: (groupPath: string, targetParentPath: string | null) => void;
|
|
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
|
|
resetHostDragState: () => void;
|
|
}) => {
|
|
preventDefault();
|
|
setDragOverDropTarget(null);
|
|
const groupPath = dataTransfer.getData("group-path");
|
|
const hostId = dataTransfer.getData("host-id");
|
|
if (groupPath) moveGroup(groupPath, null);
|
|
if (hostId) {
|
|
moveHostToGroup(hostId, null);
|
|
resetHostDragState();
|
|
}
|
|
};
|
|
|
|
export const handleVaultHostDropToGroup = ({
|
|
dataTransfer,
|
|
groupPath,
|
|
moveHostToGroup,
|
|
resetHostDragState,
|
|
}: {
|
|
dataTransfer: Pick<DataTransfer, "getData">;
|
|
groupPath: string | null;
|
|
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
|
|
resetHostDragState: () => void;
|
|
}) => {
|
|
const hostId = dataTransfer.getData("host-id");
|
|
if (!hostId) return false;
|
|
|
|
moveHostToGroup(hostId, groupPath);
|
|
resetHostDragState();
|
|
return true;
|
|
};
|
|
|
|
let activeVaultDropIndicator: HTMLElement | null = null;
|
|
|
|
export const clearVaultDropIndicator = () => {
|
|
activeVaultDropIndicator?.removeAttribute("data-vault-drop-position");
|
|
activeVaultDropIndicator?.removeAttribute("data-vault-drop-axis");
|
|
activeVaultDropIndicator = null;
|
|
};
|
|
|
|
export const markVaultDropIndicator = (
|
|
target: HTMLElement,
|
|
position: VaultDropPosition,
|
|
axis: VaultDropAxis = "y",
|
|
) => {
|
|
if (target.dataset.vaultDropPosition === position && target.dataset.vaultDropAxis === axis) return;
|
|
clearVaultDropIndicator();
|
|
target.dataset.vaultDropPosition = position;
|
|
target.dataset.vaultDropAxis = axis;
|
|
activeVaultDropIndicator = target;
|
|
};
|
|
|
|
export const markVaultInsideDropIndicator = (target: HTMLElement) => {
|
|
if (target.dataset.vaultDropPosition === "inside") return;
|
|
clearVaultDropIndicator();
|
|
target.dataset.vaultDropPosition = "inside";
|
|
activeVaultDropIndicator = target;
|
|
};
|
|
|
|
export const useVaultGridLayoutAnimation = (
|
|
containerRef: React.RefObject<HTMLElement | null>,
|
|
) => {
|
|
const previousRectsRef = React.useRef<Map<string, DOMRect> | null>(null);
|
|
|
|
const prepare = React.useCallback(() => {
|
|
const container = containerRef.current;
|
|
if (!container || typeof window === "undefined") return;
|
|
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
|
|
|
|
const rects = new Map<string, DOMRect>();
|
|
container.querySelectorAll<HTMLElement>("[data-vault-grid-item]").forEach((element) => {
|
|
const key = element.dataset.vaultGridItem;
|
|
if (!key) return;
|
|
rects.set(key, element.getBoundingClientRect());
|
|
});
|
|
previousRectsRef.current = rects;
|
|
}, [containerRef]);
|
|
|
|
useIsomorphicLayoutEffect(() => {
|
|
const previousRects = previousRectsRef.current;
|
|
if (!previousRects || previousRects.size === 0) return;
|
|
previousRectsRef.current = null;
|
|
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
container.querySelectorAll<HTMLElement>("[data-vault-grid-item]").forEach((element) => {
|
|
const key = element.dataset.vaultGridItem;
|
|
if (!key || typeof element.animate !== "function") return;
|
|
const previous = previousRects.get(key);
|
|
if (!previous) return;
|
|
|
|
const next = element.getBoundingClientRect();
|
|
const deltaX = previous.left - next.left;
|
|
const deltaY = previous.top - next.top;
|
|
if (Math.abs(deltaX) < 1 && Math.abs(deltaY) < 1) return;
|
|
|
|
element.animate(
|
|
[
|
|
{ transform: `translate(${deltaX}px, ${deltaY}px)` },
|
|
{ transform: "translate(0, 0)" },
|
|
],
|
|
{
|
|
duration: 180,
|
|
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
return prepare;
|
|
};
|
|
|
|
export const useVaultItemReorder = ({
|
|
containerRef,
|
|
viewMode,
|
|
dragType,
|
|
targetAttribute,
|
|
onReorder,
|
|
disabled = false,
|
|
}: {
|
|
containerRef: React.RefObject<HTMLElement | null>;
|
|
viewMode: "grid" | "list" | string;
|
|
dragType: string;
|
|
targetAttribute: string;
|
|
onReorder: (
|
|
sourceId: string,
|
|
targetId: string,
|
|
position: VaultDropPosition,
|
|
) => void;
|
|
disabled?: boolean;
|
|
}) => {
|
|
const lastPreviewReorderRef = React.useRef<string | null>(null);
|
|
const draggingIdRef = React.useRef<string | null>(null);
|
|
const [draggingId, setDraggingId] = React.useState<string | null>(null);
|
|
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(containerRef);
|
|
const selector = `[${targetAttribute}]`;
|
|
|
|
const reset = React.useCallback(() => {
|
|
lastPreviewReorderRef.current = null;
|
|
draggingIdRef.current = null;
|
|
setDraggingId(null);
|
|
clearVaultDropIndicator();
|
|
}, []);
|
|
|
|
const handleDragOverCapture = React.useCallback((event: React.DragEvent<HTMLElement>) => {
|
|
if (disabled) return;
|
|
const target = (event.target as Element | null)?.closest(selector);
|
|
if (!(target instanceof HTMLElement)) return;
|
|
if (!draggingIdRef.current && !hasVaultDragType(event.dataTransfer, dragType)) return;
|
|
|
|
const sourceId = draggingIdRef.current || event.dataTransfer.getData(dragType);
|
|
const targetId = target.getAttribute(targetAttribute);
|
|
if (!sourceId || !targetId) return;
|
|
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
if (sourceId === targetId) return;
|
|
|
|
const isGrid = viewMode === "grid";
|
|
const position = getVaultDropPosition(target, event.clientX, event.clientY, isGrid);
|
|
|
|
if (!isGrid) {
|
|
markVaultDropIndicator(target, position);
|
|
return;
|
|
}
|
|
|
|
const previewKey = `${sourceId}:${targetId}:${position}`;
|
|
if (lastPreviewReorderRef.current === previewKey) return;
|
|
|
|
prepareGridLayoutAnimation();
|
|
lastPreviewReorderRef.current = previewKey;
|
|
onReorder(sourceId, targetId, position);
|
|
}, [
|
|
disabled,
|
|
dragType,
|
|
onReorder,
|
|
prepareGridLayoutAnimation,
|
|
selector,
|
|
targetAttribute,
|
|
viewMode,
|
|
]);
|
|
|
|
const handleDragOver = React.useCallback((event: React.DragEvent<HTMLElement>) => {
|
|
if (disabled || viewMode === "grid") return;
|
|
const target = (event.target as Element | null)?.closest(selector);
|
|
if (!(target instanceof HTMLElement)) return;
|
|
if (!draggingIdRef.current && !hasVaultDragType(event.dataTransfer, dragType)) return;
|
|
|
|
const sourceId = draggingIdRef.current || event.dataTransfer.getData(dragType);
|
|
const targetId = target.getAttribute(targetAttribute);
|
|
if (!sourceId || !targetId) return;
|
|
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
if (sourceId === targetId) return;
|
|
markVaultDropIndicator(
|
|
target,
|
|
getVaultDropPosition(target, event.clientX, event.clientY),
|
|
);
|
|
}, [disabled, dragType, selector, targetAttribute, viewMode]);
|
|
|
|
const handleDropCapture = React.useCallback((event: React.DragEvent<HTMLElement>) => {
|
|
if (disabled) return;
|
|
const target = (event.target as Element | null)?.closest(selector);
|
|
clearVaultDropIndicator();
|
|
if (!(target instanceof HTMLElement)) {
|
|
reset();
|
|
return;
|
|
}
|
|
|
|
const sourceId = draggingIdRef.current || event.dataTransfer.getData(dragType);
|
|
const targetId = target.getAttribute(targetAttribute);
|
|
if (!sourceId || !targetId) {
|
|
lastPreviewReorderRef.current = null;
|
|
draggingIdRef.current = null;
|
|
setDraggingId(null);
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (sourceId === targetId) {
|
|
lastPreviewReorderRef.current = null;
|
|
draggingIdRef.current = null;
|
|
setDraggingId(null);
|
|
return;
|
|
}
|
|
|
|
const position = getVaultDropPosition(
|
|
target,
|
|
event.clientX,
|
|
event.clientY,
|
|
viewMode === "grid",
|
|
);
|
|
const previewKey = `${sourceId}:${targetId}:${position}`;
|
|
if (viewMode !== "grid" || lastPreviewReorderRef.current !== previewKey) {
|
|
prepareGridLayoutAnimation();
|
|
onReorder(sourceId, targetId, position);
|
|
}
|
|
lastPreviewReorderRef.current = null;
|
|
draggingIdRef.current = null;
|
|
setDraggingId(null);
|
|
}, [
|
|
disabled,
|
|
dragType,
|
|
onReorder,
|
|
prepareGridLayoutAnimation,
|
|
reset,
|
|
selector,
|
|
targetAttribute,
|
|
viewMode,
|
|
]);
|
|
|
|
const getItemReorderProps = React.useCallback((id: string, gridItemKey = id) => ({
|
|
[targetAttribute]: id,
|
|
"data-vault-grid-item": gridItemKey,
|
|
"data-vault-reorder-grid": viewMode === "grid" ? "true" : undefined,
|
|
"data-vault-reorder-dragging": draggingId === id ? "true" : undefined,
|
|
draggable: !disabled,
|
|
onDragStart: (event: React.DragEvent<HTMLElement>) => {
|
|
if (disabled) return;
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData(dragType, id);
|
|
draggingIdRef.current = id;
|
|
setDraggingId(id);
|
|
|
|
// The live grid preview can re-parent the dragged node (virtualised rows,
|
|
// section changes), and React then unmounts it. dragend still fires on the
|
|
// detached node but no longer reaches handleDragEndCapture on the
|
|
// container, so bind the cleanup natively on the source node itself.
|
|
const sourceNode = event.currentTarget as HTMLElement;
|
|
const handleNativeDragEnd = () => {
|
|
sourceNode.removeEventListener("dragend", handleNativeDragEnd);
|
|
reset();
|
|
};
|
|
sourceNode.addEventListener("dragend", handleNativeDragEnd);
|
|
},
|
|
}), [disabled, dragType, draggingId, reset, targetAttribute, viewMode]);
|
|
|
|
return {
|
|
handleDragOverCapture,
|
|
handleDragOver,
|
|
handleDropCapture,
|
|
handleDragEndCapture: reset,
|
|
getItemReorderProps,
|
|
};
|
|
};
|