feat(mobile): implement mobile stack drag-and-drop functionality with improved viewport handling
This commit is contained in:
+202
-101
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user