Implement drag-and-drop functionality for viewport stack management with visual drop zone previews

This commit is contained in:
Ross
2026-05-18 11:37:34 +01:00
parent 19702b6052
commit f0e0fe0ef6
+185 -3
View File
@@ -73,6 +73,8 @@ type StackEntry = {
studyId?: string;
};
type ViewportDropZone = "center" | "left" | "right" | "top" | "bottom";
// Add this type for clarity
declare global {
@@ -237,6 +239,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]);
const [activeViewport, setActiveViewport] = useState<number | null>(0);
const [draggedStackIdx, setDraggedStackIdx] = useState<number | null>(null);
const [dropPreview, setDropPreview] = useState<{ viewportIdx: number; zone: ViewportDropZone } | null>(null);
// After your useState for viewportImageIds:
const [viewportImageIds, _setViewportImageIds] = useState<string[][]>(
() => Array(MAX_VIEWPORTS).fill([])
@@ -943,6 +947,35 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const [altEnablesReferenceCursors, setAltEnablesReferenceCursors] = useState(true);
const getDropZoneFromPointer = useCallback((e: React.DragEvent<HTMLDivElement>): ViewportDropZone => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const w = rect.width;
const h = rect.height;
const edgeThreshold = Math.min(w, h) * 0.24;
const distances = [
{ zone: "left" as const, distance: x },
{ zone: "right" as const, distance: w - x },
{ zone: "top" as const, distance: y },
{ zone: "bottom" as const, distance: h - y },
];
distances.sort((a, b) => a.distance - b.distance);
return distances[0].distance <= edgeThreshold ? distances[0].zone : "center";
}, []);
const resolveDropZone = useCallback((zone: ViewportDropZone): ViewportDropZone => {
if (zone === "left" || zone === "right") {
return viewportGrid.cols < 3 ? zone : "center";
}
if (zone === "top" || zone === "bottom") {
return viewportGrid.rows < 3 ? zone : "center";
}
return "center";
}, [viewportGrid]);
// Helper to generate grid options
const gridOptions = []
for (let rows = 1; rows <= 3; rows++) {
@@ -1739,6 +1772,88 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
}
}
const applyStackDrop = async (viewportIdx: number, stackIdx: number, requestedZone: ViewportDropZone) => {
const stackObj = availableStacks[stackIdx];
if (!stackObj) return;
const resolvedZone = resolveDropZone(requestedZone);
if (resolvedZone === "center") {
await handleLoadStack(viewportIdx, stackIdx);
setActiveViewport(viewportIdx);
return;
}
const oldRows = viewportGrid.rows;
const oldCols = viewportGrid.cols;
const row = Math.floor(viewportIdx / oldCols);
const col = viewportIdx % oldCols;
const newRows = (resolvedZone === "top" || resolvedZone === "bottom") ? oldRows + 1 : oldRows;
const newCols = (resolvedZone === "left" || resolvedZone === "right") ? oldCols + 1 : oldCols;
if (newRows > 3 || newCols > 3 || newRows * newCols > MAX_VIEWPORTS) {
await handleLoadStack(viewportIdx, stackIdx);
setActiveViewport(viewportIdx);
return;
}
const nextImageIds = Array(MAX_VIEWPORTS).fill([] as string[]);
const nextModes = Array(MAX_VIEWPORTS).fill("stack") as Array<'stack' | 'volume'>;
const nextSelected = Array(MAX_VIEWPORTS).fill(0);
const targetNewRow = resolvedZone === "top" ? row : resolvedZone === "bottom" ? row + 1 : row;
const targetNewCol = resolvedZone === "left" ? col : resolvedZone === "right" ? col + 1 : col;
const insertedViewportIndex = targetNewRow * newCols + targetNewCol;
for (let oldIdx = 0; oldIdx < oldRows * oldCols; oldIdx++) {
const oldRow = Math.floor(oldIdx / oldCols);
const oldCol = oldIdx % oldCols;
let mappedRow = oldRow;
let mappedCol = oldCol;
if (resolvedZone === "left" || resolvedZone === "right") {
if (oldRow === row) {
if (oldCol < col) {
mappedCol = oldCol;
} else if (oldCol > col) {
mappedCol = oldCol + 1;
} else {
mappedCol = resolvedZone === "left" ? oldCol + 1 : oldCol;
}
}
}
if (resolvedZone === "top" || resolvedZone === "bottom") {
if (oldCol === col) {
if (oldRow < row) {
mappedRow = oldRow;
} else if (oldRow > row) {
mappedRow = oldRow + 1;
} else {
mappedRow = resolvedZone === "top" ? oldRow + 1 : oldRow;
}
}
}
const mappedIdx = mappedRow * newCols + mappedCol;
nextImageIds[mappedIdx] = viewportImageIds[oldIdx] || [];
nextModes[mappedIdx] = viewportModes[oldIdx] || "stack";
nextSelected[mappedIdx] = selectedStackIdx[oldIdx] ?? 0;
}
nextImageIds[insertedViewportIndex] = stackObj.imageIds;
nextModes[insertedViewportIndex] = "stack";
nextSelected[insertedViewportIndex] = stackIdx;
saveVisibleViewportState();
setViewportGrid({ rows: newRows, cols: newCols });
setViewportImageIds(nextImageIds);
setViewportModes(nextModes);
setSelectedStackIdx(nextSelected);
setActiveViewport(insertedViewportIndex);
await setLoadedImageIdsAndCacheMeta(stackObj.imageIds);
};
useEffect(() => {
const setup = async () => {
console.log("setup")
@@ -3142,6 +3257,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
// Determine if this viewport should be visible in the current grid
const isVisible = i < viewportGrid.rows * viewportGrid.cols;
const currentPreviewZone = dropPreview?.viewportIdx === i ? dropPreview.zone : null;
const resolvedPreviewZone = currentPreviewZone ? resolveDropZone(currentPreviewZone) : null;
viewports.push(
<div
key={i}
@@ -3168,19 +3286,77 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
setViewportMenuOpen(false);
}}
onDoubleClick={() => handleDoubleClick(i)}
onDragOver={e => {
onDragEnter={e => {
const idxStr = e.dataTransfer.getData('text/stack-idx');
if (!idxStr) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
const zone = getDropZoneFromPointer(e);
setDropPreview({ viewportIdx: i, zone });
}}
onDragOver={e => {
const idxStr = e.dataTransfer.getData('text/stack-idx');
if (!idxStr) return;
e.preventDefault();
const zone = getDropZoneFromPointer(e);
const resolved = resolveDropZone(zone);
setDropPreview({ viewportIdx: i, zone });
e.dataTransfer.dropEffect = resolved === zone ? 'copy' : 'move';
}}
onDragLeave={e => {
if (dropPreview?.viewportIdx !== i) return;
const nextTarget = e.relatedTarget as Node | null;
if (nextTarget && e.currentTarget.contains(nextTarget)) return;
setDropPreview(null);
}}
onDrop={e => {
e.preventDefault();
const idxStr = e.dataTransfer.getData('text/stack-idx');
const stackIdx = parseInt(idxStr, 10);
if (!isNaN(stackIdx)) {
handleLoadStack(i, stackIdx);
const zone = getDropZoneFromPointer(e);
applyStackDrop(i, stackIdx, zone).catch((err) => {
console.error("Failed to apply dropped stack:", err);
});
}
setDropPreview(null);
setDraggedStackIdx(null);
}}
>
{draggedStackIdx !== null && currentPreviewZone && (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 19,
pointerEvents: "none",
background: "rgba(0,0,0,0.08)",
}}
>
<div
style={{
position: "absolute",
left: resolvedPreviewZone === "left" ? 0 : resolvedPreviewZone === "right" ? "50%" : "20%",
top: resolvedPreviewZone === "top" ? 0 : resolvedPreviewZone === "bottom" ? "50%" : "20%",
width: resolvedPreviewZone === "left" || resolvedPreviewZone === "right" ? "50%" : "60%",
height: resolvedPreviewZone === "top" || resolvedPreviewZone === "bottom" ? "50%" : "60%",
border: "2px solid #90caf9",
boxShadow: "0 0 0 9999px rgba(17, 29, 45, 0.22)",
background: "rgba(25, 118, 210, 0.28)",
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 12,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: 0.4,
}}
>
{resolvedPreviewZone === "center" ? "Load Here" : `Split ${resolvedPreviewZone}`}
</div>
</div>
)}
<button
style={{
position: "absolute",
@@ -4618,9 +4794,15 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
draggable={!invalid}
onDragStart={e => {
if (invalid) return;
setDraggedStackIdx(idx);
setDropPreview(null);
e.dataTransfer.setData('text/stack-idx', String(idx));
e.dataTransfer.effectAllowed = 'copy';
}}
onDragEnd={() => {
setDraggedStackIdx(null);
setDropPreview(null);
}}
onClick={() => {
if (invalid) return;
const targetViewport = activeViewport ?? 0;