feat(mobile): implement mobile stack drag-and-drop functionality with improved viewport handling

This commit is contained in:
Ross
2026-05-19 14:47:41 +01:00
parent a5c530a907
commit 6a0552c843
+202 -101
View File
@@ -474,6 +474,14 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
const [mobileSingleViewportMode, setMobileSingleViewportMode] = useState(true);
const [mobileArmedStackIdx, setMobileArmedStackIdx] = useState<number | null>(null);
const [mobileArmedDropZone, setMobileArmedDropZone] = useState<"center" | "right" | "bottom">("center");
const [mobilePointerDrag, setMobilePointerDrag] = useState<{
stackIdx: number;
x: number;
y: number;
targetViewportIdx: number | null;
zone: ViewportDropZone;
} | null>(null);
const suppressStackRowClickUntilRef = useRef(0);
const MAX_VIEWPORTS = 9;
const renderingEngineId = "renderingEngine_" + container_id;
@@ -492,7 +500,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
const [dropPreview, setDropPreview] = useState<{ viewportIdx: number; zone: ViewportDropZone } | null>(null);
// After your useState for viewportImageIds:
const [viewportImageIds, _setViewportImageIds] = useState<string[][]>(
() => Array(MAX_VIEWPORTS).fill([])
() => Array(MAX_VIEWPORTS).fill(null).map(() => [])
);
@@ -923,54 +931,40 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
studyInstanceUID: (d as any).studyInstanceUID,
};
// If the descriptor has pre-declared series groups, respect them
// (expand multiframe flat within each series, no further splitting)
// Avoid expensive startup preprocessing across all stacks.
// Keep descriptor-declared groups if provided, otherwise defer split/grouping until load.
if (!isRawArray && Array.isArray((d as any).series)) {
const seriesExpanded = await Promise.all(
(d as any).series.map(async (seriesEntry: any, seriesIdx: number) => {
const declaredGroups: StackSeriesGroup[] = (d as any).series
.map((seriesEntry: any, seriesIdx: number) => {
const rawIds = Array.isArray(seriesEntry?.imageIds)
? seriesEntry.imageIds
: (Array.isArray(seriesEntry?.i) ? seriesEntry.i : []);
const expandedIds = await expandMultiframeImageIds(rawIds);
const normalizedIds = dedupeImageIds(rawIds.map(normalizeToWadouriOrExternal));
const rawLabel = typeof seriesEntry?.name === "string" ? seriesEntry.name : "";
const label = rawLabel.trim() || `Series ${seriesIdx + 1}`;
return {
key: String(seriesEntry?.seriesInstanceUID || seriesEntry?.seriesId || `SERIES_${seriesIdx}`),
label,
imageIds: expandedIds,
imageIds: normalizedIds,
seriesInstanceUID: seriesEntry?.seriesInstanceUID,
bValue: Number.isFinite(Number(seriesEntry?.bValue)) ? Number(seriesEntry.bValue) : null,
} as StackSeriesGroup;
})
);
const filtered = seriesExpanded.filter((group) => group.imageIds.length > 0);
if (filtered.length > 0) {
const allImageIds = filtered.flatMap((g) => g.imageIds);
allStacks.push({ imageIds: allImageIds, seriesGroups: filtered, ...meta });
.filter((group: StackSeriesGroup) => group.imageIds.length > 0);
if (declaredGroups.length > 0) {
allStacks.push({
imageIds: dedupeImageIds(declaredGroups.flatMap((g) => g.imageIds)),
seriesGroups: declaredGroups,
...meta,
});
continue;
}
}
// Split multiframe files into separate stacks; single-frame IDs stay together
const { singleFrameIds, multiframeGroups } = await splitMultiframeToStacks(imageIdsRaw);
if (singleFrameIds.length > 0) {
const seriesGroups = await deriveSeriesGroupsFromImageIds(singleFrameIds);
allStacks.push({ imageIds: singleFrameIds, seriesGroups, ...meta });
}
for (const mf of multiframeGroups) {
const baseName = stripWadouriPrefix(mf.baseImageId).split("/").pop()?.split("?")[0] ?? "multiframe";
const mfName = meta.name ? `${meta.name} \u2014 ${baseName}` : baseName;
const seriesGroups = await deriveSeriesGroupsFromImageIds(mf.frameIds);
allStacks.push({
imageIds: mf.frameIds,
seriesGroups,
name: mfName,
studyId: meta.studyId,
studyInstanceUID: meta.studyInstanceUID,
caseId: meta.caseId,
});
const normalizedImageIds = dedupeImageIds(imageIdsRaw.map(normalizeToWadouriOrExternal));
if (normalizedImageIds.length > 0) {
allStacks.push({ imageIds: normalizedImageIds, seriesGroups: [], ...meta });
}
}
@@ -980,19 +974,21 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
if (allStacks[0]) {
const firstStack = allStacks[0];
let firstGroups = firstStack.seriesGroups || [];
let firstImageIds = firstStack.imageIds;
if (firstGroups.length === 0) {
await setLoadedImageIdsAndCacheMeta(firstStack.imageIds, { force: true });
firstGroups = await deriveSeriesGroupsFromImageIds(firstStack.imageIds);
allStacks[0] = { ...firstStack, seriesGroups: firstGroups };
firstImageIds = await expandMultiframeImageIds(firstStack.imageIds);
await setLoadedImageIdsAndCacheMeta(firstImageIds, { force: true });
firstGroups = await deriveSeriesGroupsFromImageIds(firstImageIds);
allStacks[0] = { ...firstStack, imageIds: firstImageIds, seriesGroups: firstGroups };
setAvailableStacks([...allStacks]);
}
const firstSeries = firstGroups[0]?.imageIds || firstStack.imageIds;
const firstSeries = firstGroups[0]?.imageIds || firstImageIds;
await setLoadedImageIdsAndCacheMeta(firstSeries);
setViewportImageIds(Array(MAX_VIEWPORTS).fill(firstSeries || []));
setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map((_, idx) => (idx === 0 ? (firstSeries || []) : [])));
setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => firstGroups));
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
} else {
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map(() => []));
setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => []));
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
}
@@ -1000,7 +996,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
.catch(err => {
appLogger.error("Failed to load imageStacks:", err);
setAvailableStacks([]);
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map(() => []));
});
return () => { mounted = false; };
@@ -1009,9 +1005,8 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
const handleLoadStack = async (viewportIdx: number, stackIdx: number) => {
const stackObj = availableStacks[stackIdx];
if (!stackObj) return;
// Stack imageIds are already correctly expanded at load time (single-frame IDs or
// per-frame query IDs for multiframe stacks). No expansion needed here.
const stack = stackObj.imageIds;
// Expand multiframe IDs lazily when the stack is actually loaded.
const stack = await expandMultiframeImageIds(stackObj.imageIds || []);
appLogger.debug("[handleLoadStack] Loading stack:", stack.length, "ids. First:", stack[0]?.slice(0, 80));
await setLoadedImageIdsAndCacheMeta(stack, { force: true });
@@ -1273,12 +1268,13 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
}
}, [container_id, stackThumbnails, setStackThumbnail]);
// Kick off thumbnail generation when stacks change
// Generate thumbnails lazily when the stack panel is open.
useEffect(() => {
if (!stackPanelOpen) return;
if (!availableStacks || availableStacks.length === 0) return;
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return; // wait until rendering engine exists
availableStacks.forEach((stack, idx) => {
if (!renderingEngine) return;
availableStacks.slice(0, 12).forEach((stack, idx) => {
const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : [];
const midIndex = Math.floor(imageIds.length / 2);
const chosen = imageIds[midIndex] || imageIds[0] || null;
@@ -1286,7 +1282,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
generateThumbnail(idx, chosen as string | null);
}
});
}, [availableStacks, generateThumbnail, stackThumbnails]);
}, [availableStacks, generateThumbnail, stackPanelOpen, stackThumbnails]);
// Wrap setLoadedImageIds to also cache DICOM metadata
const setLoadedImageIdsAndCacheMeta = async (imageIds: string[], options?: { force?: boolean }) => {
@@ -1389,33 +1385,21 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
}
} catch (_) {}
// 3. Range request — first 64 KB is typically enough to find tag (0028,0008)
// 3. Range request — first 256 KB should include top-level NumberOfFrames if present.
try {
const rangeResponse = await fetch(url, { headers: { Range: "bytes=0-65535" } });
const rangeResponse = await fetch(url, { headers: { Range: "bytes=0-262143" } });
if (rangeResponse.ok || rangeResponse.status === 206) {
const buffer = await rangeResponse.arrayBuffer();
const count = tryParseBytes(new Uint8Array(buffer));
// If server returned full content (200) or we found frames, cache and return
if (rangeResponse.status === 200 || count > 1) {
multiframeCountCacheRef.current[cacheKey] = count;
return count;
}
// Treat range parse as authoritative to avoid N full-file fetches on single-frame studies.
multiframeCountCacheRef.current[cacheKey] = count;
return count;
}
} catch (_) {}
// 4. Full fetch fallback (only if range request not sufficient)
try {
appLogger.debug("[detectMultiframeFrameCount] Falling back to full fetch for:", url.slice(-60));
const response = await fetch(url);
const buffer = await response.arrayBuffer();
const count = tryParseBytes(new Uint8Array(buffer));
multiframeCountCacheRef.current[cacheKey] = count;
return count;
} catch (e) {
appLogger.warn("[detectMultiframeFrameCount] Failed to detect frame count for:", url.slice(-60), e);
multiframeCountCacheRef.current[cacheKey] = 1;
return 1;
}
// 4. Conservative fallback: if detection failed, assume single-frame.
multiframeCountCacheRef.current[cacheKey] = 1;
return 1;
}, []);
/** @deprecated Use detectMultiframeFrameCount instead. */
@@ -1911,6 +1895,23 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
return distances[0].distance <= edgeThreshold ? distances[0].zone : "center";
}, []);
const getDropZoneFromClientPoint = useCallback((rect: DOMRect, clientX: number, clientY: number): ViewportDropZone => {
const x = clientX - rect.left;
const y = 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";
@@ -2053,7 +2054,11 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
}
}, [isPhoneLayout]);
useEffect(() => {
if (!isPhoneLayout || !mobileInteractionActive || !mobileSingleViewportMode) return;
if (activeViewport === null) return;
setMobileFocusedViewport(activeViewport);
}, [activeViewport, isPhoneLayout, mobileInteractionActive, mobileSingleViewportMode]);
const [metaModalOpen, setMetaModalOpen] = useState(false);
const [metaModalContent, setMetaModalContent] = useState<any>(null);
@@ -3285,6 +3290,88 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
}
};
const beginMobilePointerStackDrag = useCallback((stackIdx: number, startEvent: React.PointerEvent) => {
if (!(isPhoneLayout && mobileInteractionActive)) return;
if ((startEvent.pointerType || "").toLowerCase() === "mouse") return;
startEvent.preventDefault();
startEvent.stopPropagation();
suppressStackRowClickUntilRef.current = Date.now() + 400;
const visibleCount = viewportGrid.rows * viewportGrid.cols;
let latestTargetViewport: number | null = null;
let latestZone: ViewportDropZone = "center";
setMobilePointerDrag({
stackIdx,
x: startEvent.clientX,
y: startEvent.clientY,
targetViewportIdx: null,
zone: "center",
});
const onMove = (ev: PointerEvent) => {
let targetViewport: number | null = null;
let zone: ViewportDropZone = "center";
for (let i = 0; i < visibleCount; i++) {
const el = viewportRefs.current[i];
if (!el) continue;
const rect = el.getBoundingClientRect();
if (rect.width <= 1 || rect.height <= 1) continue;
if (ev.clientX < rect.left || ev.clientX > rect.right || ev.clientY < rect.top || ev.clientY > rect.bottom) continue;
targetViewport = i;
zone = resolveDropZone(getDropZoneFromClientPoint(rect, ev.clientX, ev.clientY));
break;
}
latestTargetViewport = targetViewport;
latestZone = zone;
setMobilePointerDrag((prev) => prev ? {
...prev,
x: ev.clientX,
y: ev.clientY,
targetViewportIdx: targetViewport,
zone,
} : prev);
setDropPreview(targetViewport !== null ? { viewportIdx: targetViewport, zone } : null);
};
const finish = async () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onCancel);
setDropPreview(null);
setMobilePointerDrag(null);
if (latestTargetViewport !== null) {
try {
await applyStackDrop(latestTargetViewport, stackIdx, latestZone);
setStackPanelOpen(false);
setActiveViewport(latestTargetViewport);
setMobileFocusedViewport(latestTargetViewport);
} catch (err) {
appLogger.error("Failed mobile pointer stack drop:", err);
}
}
suppressStackRowClickUntilRef.current = Date.now() + 400;
};
const onUp = () => {
void finish();
};
const onCancel = () => {
void finish();
};
window.addEventListener("pointermove", onMove, { passive: true });
window.addEventListener("pointerup", onUp, { passive: true });
window.addEventListener("pointercancel", onCancel, { passive: true });
}, [applyStackDrop, getDropZoneFromClientPoint, isPhoneLayout, mobileInteractionActive, resolveDropZone, viewportGrid]);
useEffect(() => {
const setup = async () => {
appLogger.debug("setup")
@@ -3355,23 +3442,6 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
if (!renderingEngine) {
renderingEngine = new RenderingEngine(renderingEngineId);
renderingEngineRef.current = renderingEngine;
// If stacks were already available before the engine was ready, kick off thumbnails now
try {
if (availableStacks && availableStacks.length > 0) {
availableStacks.forEach((stack, idx) => {
const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : [];
const midIndex = Math.floor(imageIds.length / 2);
const chosen = imageIds[midIndex] || imageIds[0] || null;
if (chosen && !stackThumbnails[idx] && !thumbRequestedRef.current[idx]) {
// fire-and-forget
// eslint-disable-next-line @typescript-eslint/no-floating-promises
generateThumbnail(idx, chosen as string | null);
}
});
}
} catch (e) {
// ignore
}
}
// --- In your setup function, use the correct tool group per viewport ---
@@ -4342,7 +4412,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
}
setAvailableStacks([]);
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map(() => []));
setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0));
setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => []));
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
@@ -5161,6 +5231,9 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
zIndex: activeViewport === i ? 10 : 1,
}}
onClick={() => {
if (mobilePointerDrag) {
return;
}
if (isPhoneLayout && !mobileInteractionActive) {
activateMobileInteractionForViewport(i);
setViewportMenuOpen(false);
@@ -6640,6 +6713,28 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
</div>
)}
{mobilePointerDrag && (
<div
style={{
position: "fixed",
left: mobilePointerDrag.x + 14,
top: mobilePointerDrag.y - 18,
zIndex: 5000,
background: "rgba(21,101,192,0.92)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.35)",
borderRadius: 8,
padding: "5px 8px",
fontSize: 11,
fontWeight: 700,
pointerEvents: "none",
whiteSpace: "nowrap",
}}
>
Drop on viewport {mobilePointerDrag.targetViewportIdx !== null ? mobilePointerDrag.targetViewportIdx + 1 : "-"} ({mobilePointerDrag.zone})
</div>
)}
{!hideChromeForMobileInteraction && (
<div>
{/* Side menu toggle button */}
@@ -7281,8 +7376,6 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
// Prefer the middle image for a representative thumbnail, fallback to first
const midIndex = Math.floor(imageIds.length / 2);
const chosenImage = imageIds[midIndex] || imageIds[0];
// Remove the wadouri: scheme if present. Use replace to avoid off-by-one slice errors.
const thumbnailUrl = chosenImage ? stripWadouriPrefix(chosenImage) : null;
return (
<div
@@ -7300,10 +7393,25 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
setDropPreview(null);
}}
onClick={() => {
if (Date.now() < suppressStackRowClickUntilRef.current) return;
if (invalid) return;
const targetViewport = activeViewport ?? 0;
handleLoadStack(targetViewport, idx);
}}
onTouchStart={() => {
if (invalid) return;
if (stackThumbnails[idx] || thumbRequestedRef.current[idx]) return;
const mid = Math.floor(imageIds.length / 2);
const chosen = imageIds[mid] || imageIds[0] || null;
if (chosen) generateThumbnail(idx, chosen as string | null);
}}
onMouseEnter={() => {
if (invalid) return;
if (stackThumbnails[idx] || thumbRequestedRef.current[idx]) return;
const mid = Math.floor(imageIds.length / 2);
const chosen = imageIds[mid] || imageIds[0] || null;
if (chosen) generateThumbnail(idx, chosen as string | null);
}}
style={{
display: 'flex',
alignItems: 'center',
@@ -7321,7 +7429,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
const generated = stackThumbnails[idx];
// If generation explicitly failed, treat as missing so placeholder is shown
const generatedValid = generated && generated !== 'BROKEN' ? generated : null;
const src = generatedValid || thumbnailUrl || null;
const src = generatedValid || null;
if (!src) {
return (<div style={{ width: 56, height: 56, borderRadius: 4, background: '#111', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#777' }}>No Img</div>);
}
@@ -7333,19 +7441,8 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 4, background: '#111' }}
onError={() => {
appLogger.warn('Thumbnail <img> failed to load for stack', idx, src);
const usedGeneratedImage = !!generatedValid;
if (usedGeneratedImage) {
// Generated image failed: mark as broken to avoid endless reload attempts.
setStackThumbnail(idx, 'BROKEN');
return;
}
// Fallback URL failed (often raw DICOM). Do not mark BROKEN yet;
// request generation and keep the thumbnail pipeline alive.
if (!thumbRequestedRef.current[idx]) {
generateThumbnail(idx, chosenImage as string | null);
}
// Generated image failed: mark as broken to avoid endless reload attempts.
setStackThumbnail(idx, 'BROKEN');
}}
/>
);
@@ -7384,8 +7481,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
<button
onClick={(e) => {
e.stopPropagation();
if (Date.now() < suppressStackRowClickUntilRef.current) return;
setMobileArmedStackIdx(idx);
}}
onPointerDown={(e) => {
beginMobilePointerStackDrag(idx, e);
}}
style={{
background: mobileArmedStackIdx === idx ? '#2e7d32' : '#444',
color: '#fff',