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 [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',
|
||||||
|
|||||||
Reference in New Issue
Block a user