Implement drag-and-drop functionality for viewport stack management with visual drop zone previews
This commit is contained in:
+185
-3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user