diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index 7b15d06..e4aeb8e 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -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([]); const [activeViewport, setActiveViewport] = useState(0); + const [draggedStackIdx, setDraggedStackIdx] = useState(null); + const [dropPreview, setDropPreview] = useState<{ viewportIdx: number; zone: ViewportDropZone } | null>(null); // After your useState for viewportImageIds: const [viewportImageIds, _setViewportImageIds] = useState( () => 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): 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(
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 && ( +
+
+ {resolvedPreviewZone === "center" ? "Load Here" : `Split ${resolvedPreviewZone}`} +
+
+ )}