[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
345
components/vault/vaultReorderDrag.ts
Normal file
345
components/vault/vaultReorderDrag.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user