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 [mobileSingleViewportMode, setMobileSingleViewportMode] = useState(true);
const [mobileArmedStackIdx, setMobileArmedStackIdx] = useState<number | null>(null); const [mobileArmedStackIdx, setMobileArmedStackIdx] = useState<number | null>(null);
const [mobileArmedDropZone, setMobileArmedDropZone] = useState<"center" | "right" | "bottom">("center"); 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 MAX_VIEWPORTS = 9;
const renderingEngineId = "renderingEngine_" + container_id; 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); const [dropPreview, setDropPreview] = useState<{ viewportIdx: number; zone: ViewportDropZone } | null>(null);
// After your useState for viewportImageIds: // After your useState for viewportImageIds:
const [viewportImageIds, _setViewportImageIds] = useState<string[][]>( 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, studyInstanceUID: (d as any).studyInstanceUID,
}; };
// If the descriptor has pre-declared series groups, respect them // Avoid expensive startup preprocessing across all stacks.
// (expand multiframe flat within each series, no further splitting) // Keep descriptor-declared groups if provided, otherwise defer split/grouping until load.
if (!isRawArray && Array.isArray((d as any).series)) { if (!isRawArray && Array.isArray((d as any).series)) {
const seriesExpanded = await Promise.all( const declaredGroups: StackSeriesGroup[] = (d as any).series
(d as any).series.map(async (seriesEntry: any, seriesIdx: number) => { .map((seriesEntry: any, seriesIdx: number) => {
const rawIds = Array.isArray(seriesEntry?.imageIds) const rawIds = Array.isArray(seriesEntry?.imageIds)
? seriesEntry.imageIds ? seriesEntry.imageIds
: (Array.isArray(seriesEntry?.i) ? seriesEntry.i : []); : (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 rawLabel = typeof seriesEntry?.name === "string" ? seriesEntry.name : "";
const label = rawLabel.trim() || `Series ${seriesIdx + 1}`; const label = rawLabel.trim() || `Series ${seriesIdx + 1}`;
return { return {
key: String(seriesEntry?.seriesInstanceUID || seriesEntry?.seriesId || `SERIES_${seriesIdx}`), key: String(seriesEntry?.seriesInstanceUID || seriesEntry?.seriesId || `SERIES_${seriesIdx}`),
label, label,
imageIds: expandedIds, imageIds: normalizedIds,
seriesInstanceUID: seriesEntry?.seriesInstanceUID, seriesInstanceUID: seriesEntry?.seriesInstanceUID,
bValue: Number.isFinite(Number(seriesEntry?.bValue)) ? Number(seriesEntry.bValue) : null, bValue: Number.isFinite(Number(seriesEntry?.bValue)) ? Number(seriesEntry.bValue) : null,
} as StackSeriesGroup; } as StackSeriesGroup;
}) })
); .filter((group: StackSeriesGroup) => group.imageIds.length > 0);
const filtered = seriesExpanded.filter((group) => group.imageIds.length > 0);
if (filtered.length > 0) { if (declaredGroups.length > 0) {
const allImageIds = filtered.flatMap((g) => g.imageIds); allStacks.push({
allStacks.push({ imageIds: allImageIds, seriesGroups: filtered, ...meta }); imageIds: dedupeImageIds(declaredGroups.flatMap((g) => g.imageIds)),
seriesGroups: declaredGroups,
...meta,
});
continue; continue;
} }
} }
// Split multiframe files into separate stacks; single-frame IDs stay together const normalizedImageIds = dedupeImageIds(imageIdsRaw.map(normalizeToWadouriOrExternal));
const { singleFrameIds, multiframeGroups } = await splitMultiframeToStacks(imageIdsRaw); if (normalizedImageIds.length > 0) {
allStacks.push({ imageIds: normalizedImageIds, seriesGroups: [], ...meta });
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,
});
} }
} }
@@ -980,19 +974,21 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
if (allStacks[0]) { if (allStacks[0]) {
const firstStack = allStacks[0]; const firstStack = allStacks[0];
let firstGroups = firstStack.seriesGroups || []; let firstGroups = firstStack.seriesGroups || [];
let firstImageIds = firstStack.imageIds;
if (firstGroups.length === 0) { if (firstGroups.length === 0) {
await setLoadedImageIdsAndCacheMeta(firstStack.imageIds, { force: true }); firstImageIds = await expandMultiframeImageIds(firstStack.imageIds);
firstGroups = await deriveSeriesGroupsFromImageIds(firstStack.imageIds); await setLoadedImageIdsAndCacheMeta(firstImageIds, { force: true });
allStacks[0] = { ...firstStack, seriesGroups: firstGroups }; firstGroups = await deriveSeriesGroupsFromImageIds(firstImageIds);
allStacks[0] = { ...firstStack, imageIds: firstImageIds, seriesGroups: firstGroups };
setAvailableStacks([...allStacks]); setAvailableStacks([...allStacks]);
} }
const firstSeries = firstGroups[0]?.imageIds || firstStack.imageIds; const firstSeries = firstGroups[0]?.imageIds || firstImageIds;
await setLoadedImageIdsAndCacheMeta(firstSeries); 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)); setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => firstGroups));
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0)); setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
} else { } else {
setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map(() => []));
setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => [])); setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => []));
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0)); setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
} }
@@ -1000,7 +996,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
.catch(err => { .catch(err => {
appLogger.error("Failed to load imageStacks:", err); appLogger.error("Failed to load imageStacks:", err);
setAvailableStacks([]); setAvailableStacks([]);
setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map(() => []));
}); });
return () => { mounted = false; }; return () => { mounted = false; };
@@ -1009,9 +1005,8 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
const handleLoadStack = async (viewportIdx: number, stackIdx: number) => { const handleLoadStack = async (viewportIdx: number, stackIdx: number) => {
const stackObj = availableStacks[stackIdx]; const stackObj = availableStacks[stackIdx];
if (!stackObj) return; if (!stackObj) return;
// Stack imageIds are already correctly expanded at load time (single-frame IDs or // Expand multiframe IDs lazily when the stack is actually loaded.
// per-frame query IDs for multiframe stacks). No expansion needed here. const stack = await expandMultiframeImageIds(stackObj.imageIds || []);
const stack = stackObj.imageIds;
appLogger.debug("[handleLoadStack] Loading stack:", stack.length, "ids. First:", stack[0]?.slice(0, 80)); appLogger.debug("[handleLoadStack] Loading stack:", stack.length, "ids. First:", stack[0]?.slice(0, 80));
await setLoadedImageIdsAndCacheMeta(stack, { force: true }); await setLoadedImageIdsAndCacheMeta(stack, { force: true });
@@ -1273,12 +1268,13 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
} }
}, [container_id, stackThumbnails, setStackThumbnail]); }, [container_id, stackThumbnails, setStackThumbnail]);
// Kick off thumbnail generation when stacks change // Generate thumbnails lazily when the stack panel is open.
useEffect(() => { useEffect(() => {
if (!stackPanelOpen) return;
if (!availableStacks || availableStacks.length === 0) return; if (!availableStacks || availableStacks.length === 0) return;
const renderingEngine = renderingEngineRef.current; const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return; // wait until rendering engine exists if (!renderingEngine) return;
availableStacks.forEach((stack, idx) => { availableStacks.slice(0, 12).forEach((stack, idx) => {
const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : []; const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : [];
const midIndex = Math.floor(imageIds.length / 2); const midIndex = Math.floor(imageIds.length / 2);
const chosen = imageIds[midIndex] || imageIds[0] || null; 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); generateThumbnail(idx, chosen as string | null);
} }
}); });
}, [availableStacks, generateThumbnail, stackThumbnails]); }, [availableStacks, generateThumbnail, stackPanelOpen, stackThumbnails]);
// Wrap setLoadedImageIds to also cache DICOM metadata // Wrap setLoadedImageIds to also cache DICOM metadata
const setLoadedImageIdsAndCacheMeta = async (imageIds: string[], options?: { force?: boolean }) => { const setLoadedImageIdsAndCacheMeta = async (imageIds: string[], options?: { force?: boolean }) => {
@@ -1389,33 +1385,21 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
} }
} catch (_) {} } 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 { 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) { if (rangeResponse.ok || rangeResponse.status === 206) {
const buffer = await rangeResponse.arrayBuffer(); const buffer = await rangeResponse.arrayBuffer();
const count = tryParseBytes(new Uint8Array(buffer)); const count = tryParseBytes(new Uint8Array(buffer));
// If server returned full content (200) or we found frames, cache and return // Treat range parse as authoritative to avoid N full-file fetches on single-frame studies.
if (rangeResponse.status === 200 || count > 1) { multiframeCountCacheRef.current[cacheKey] = count;
multiframeCountCacheRef.current[cacheKey] = count; return count;
return count;
}
} }
} catch (_) {} } catch (_) {}
// 4. Full fetch fallback (only if range request not sufficient) // 4. Conservative fallback: if detection failed, assume single-frame.
try { multiframeCountCacheRef.current[cacheKey] = 1;
appLogger.debug("[detectMultiframeFrameCount] Falling back to full fetch for:", url.slice(-60)); return 1;
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;
}
}, []); }, []);
/** @deprecated Use detectMultiframeFrameCount instead. */ /** @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"; 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 => { const resolveDropZone = useCallback((zone: ViewportDropZone): ViewportDropZone => {
if (zone === "left" || zone === "right") { if (zone === "left" || zone === "right") {
return viewportGrid.cols < 3 ? zone : "center"; return viewportGrid.cols < 3 ? zone : "center";
@@ -2053,7 +2054,11 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
} }
}, [isPhoneLayout]); }, [isPhoneLayout]);
useEffect(() => {
if (!isPhoneLayout || !mobileInteractionActive || !mobileSingleViewportMode) return;
if (activeViewport === null) return;
setMobileFocusedViewport(activeViewport);
}, [activeViewport, isPhoneLayout, mobileInteractionActive, mobileSingleViewportMode]);
const [metaModalOpen, setMetaModalOpen] = useState(false); const [metaModalOpen, setMetaModalOpen] = useState(false);
const [metaModalContent, setMetaModalContent] = useState<any>(null); 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(() => { useEffect(() => {
const setup = async () => { const setup = async () => {
appLogger.debug("setup") appLogger.debug("setup")
@@ -3355,23 +3442,6 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
if (!renderingEngine) { if (!renderingEngine) {
renderingEngine = new RenderingEngine(renderingEngineId); renderingEngine = new RenderingEngine(renderingEngineId);
renderingEngineRef.current = renderingEngine; 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 --- // --- 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([]); setAvailableStacks([]);
setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map(() => []));
setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0)); setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0));
setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => [])); setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => []));
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0)); setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
@@ -5161,6 +5231,9 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
zIndex: activeViewport === i ? 10 : 1, zIndex: activeViewport === i ? 10 : 1,
}} }}
onClick={() => { onClick={() => {
if (mobilePointerDrag) {
return;
}
if (isPhoneLayout && !mobileInteractionActive) { if (isPhoneLayout && !mobileInteractionActive) {
activateMobileInteractionForViewport(i); activateMobileInteractionForViewport(i);
setViewportMenuOpen(false); setViewportMenuOpen(false);
@@ -6640,6 +6713,28 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
</div> </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 && ( {!hideChromeForMobileInteraction && (
<div> <div>
{/* Side menu toggle button */} {/* 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 // Prefer the middle image for a representative thumbnail, fallback to first
const midIndex = Math.floor(imageIds.length / 2); const midIndex = Math.floor(imageIds.length / 2);
const chosenImage = imageIds[midIndex] || imageIds[0]; 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 ( return (
<div <div
@@ -7300,10 +7393,25 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
setDropPreview(null); setDropPreview(null);
}} }}
onClick={() => { onClick={() => {
if (Date.now() < suppressStackRowClickUntilRef.current) return;
if (invalid) return; if (invalid) return;
const targetViewport = activeViewport ?? 0; const targetViewport = activeViewport ?? 0;
handleLoadStack(targetViewport, idx); 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={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -7321,7 +7429,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
const generated = stackThumbnails[idx]; const generated = stackThumbnails[idx];
// If generation explicitly failed, treat as missing so placeholder is shown // If generation explicitly failed, treat as missing so placeholder is shown
const generatedValid = generated && generated !== 'BROKEN' ? generated : null; const generatedValid = generated && generated !== 'BROKEN' ? generated : null;
const src = generatedValid || thumbnailUrl || null; const src = generatedValid || null;
if (!src) { if (!src) {
return (<div style={{ width: 56, height: 56, borderRadius: 4, background: '#111', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#777' }}>No Img</div>); 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' }} style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 4, background: '#111' }}
onError={() => { onError={() => {
appLogger.warn('Thumbnail <img> failed to load for stack', idx, src); appLogger.warn('Thumbnail <img> failed to load for stack', idx, src);
const usedGeneratedImage = !!generatedValid; // Generated image failed: mark as broken to avoid endless reload attempts.
setStackThumbnail(idx, 'BROKEN');
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);
}
}} }}
/> />
); );
@@ -7384,8 +7481,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (Date.now() < suppressStackRowClickUntilRef.current) return;
setMobileArmedStackIdx(idx); setMobileArmedStackIdx(idx);
}} }}
onPointerDown={(e) => {
beginMobilePointerStackDrag(idx, e);
}}
style={{ style={{
background: mobileArmedStackIdx === idx ? '#2e7d32' : '#444', background: mobileArmedStackIdx === idx ? '#2e7d32' : '#444',
color: '#fff', color: '#fff',