feat(mobile): enhance mobile interaction with new touch controls and layout adjustments
This commit is contained in:
+552
-106
@@ -31,6 +31,11 @@ declare global {
|
||||
cornerstone3dTools: typeof cornerstoneTools;
|
||||
cornerstone3d: typeof cornerstone3d;
|
||||
cornerstoneDICOMImageLoader: typeof cornerstoneDICOMImageLoader;
|
||||
importAnnotations?: (json: string) => void;
|
||||
importViewerState?: (json: string) => void;
|
||||
loadDicomStackFromFiles?: (files: FileList | File[]) => void;
|
||||
loadDicomStackFromWadouriList?: (wadouriImageIds: string[]) => Promise<void>;
|
||||
[key: string]: any;
|
||||
|
||||
}
|
||||
}
|
||||
@@ -42,47 +47,21 @@ function pixelToWorld(imageId: string, point: { x: number, y: number }) {
|
||||
// Default to 1 if spacing is missing
|
||||
const dx = columnPixelSpacing || 1;
|
||||
const dy = rowPixelSpacing || 1;
|
||||
// Convert cosines to vectors
|
||||
const rc = rowCosines;
|
||||
const cc = columnCosines;
|
||||
const ipp = imagePositionPatient;
|
||||
// World = IPP + x*col*cos*spacing + y*row*cos*spacing
|
||||
return [
|
||||
ipp[0] + point.x * cc[0] * dx + point.y * rc[0] * dy,
|
||||
ipp[1] + point.x * cc[1] * dx + point.y * rc[1] * dy,
|
||||
ipp[2] + point.x * cc[2] * dx + point.y * rc[2] * dy,
|
||||
];
|
||||
return {
|
||||
x: imagePositionPatient[0] + rowCosines[0] * point.y * dy + columnCosines[0] * point.x * dx,
|
||||
y: imagePositionPatient[1] + rowCosines[1] * point.y * dy + columnCosines[1] * point.x * dx,
|
||||
z: imagePositionPatient[2] + rowCosines[2] * point.y * dy + columnCosines[2] * point.x * dx,
|
||||
};
|
||||
}
|
||||
|
||||
// Add to global types:
|
||||
declare global {
|
||||
interface Window {
|
||||
exportViewerState?: () => string;
|
||||
importViewerState?: (json: string) => void;
|
||||
exportAnnotations?: () => string;
|
||||
importAnnotations?: (json: string) => void;
|
||||
importLegacyAnnotations?: (json: string) => void;
|
||||
importLegacyViewport?: (json: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
type StackEntry = {
|
||||
imageIds: string[];
|
||||
studyInstanceUID?: string;
|
||||
name?: string;
|
||||
caseId?: string;
|
||||
studyId?: string;
|
||||
seriesGroups?: StackSeriesGroup[];
|
||||
type ViewportDiv = HTMLDivElement & {
|
||||
_cornerstoneEnabled?: boolean;
|
||||
_lastViewportType?: Enums.ViewportType;
|
||||
_resizeObserver?: ResizeObserver;
|
||||
_overlayListener?: EventListener;
|
||||
};
|
||||
|
||||
type StackSeriesGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
imageIds: string[];
|
||||
seriesInstanceUID?: string;
|
||||
bValue?: number | null;
|
||||
groupValues?: Partial<Record<GroupingRuleId, string>>;
|
||||
};
|
||||
type ViewportDropZone = "left" | "right" | "top" | "bottom" | "center";
|
||||
|
||||
type GroupingRuleId =
|
||||
| "dwiBValue"
|
||||
@@ -100,32 +79,22 @@ type GroupingRuleDefinition = {
|
||||
defaultEnabled: boolean;
|
||||
};
|
||||
|
||||
type ViewportDropZone = "center" | "left" | "right" | "top" | "bottom";
|
||||
type StackSeriesGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
imageIds: string[];
|
||||
seriesInstanceUID?: string;
|
||||
bValue?: number | null;
|
||||
groupValues?: Partial<Record<GroupingRuleId, string>>;
|
||||
};
|
||||
|
||||
|
||||
// Add this type for clarity
|
||||
declare global {
|
||||
interface Window {
|
||||
loadDicomStackFromFiles?: (files: FileList | File[]) => void;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
loadDicomStackFromWadouriList?: (wadouriImageIds: string[]) => void;
|
||||
}
|
||||
}
|
||||
|
||||
window.cornerstone3dTools = cornerstoneTools;
|
||||
window.cornerstone3d = cornerstone3d
|
||||
window.cornerstoneDICOMImageLoader = cornerstoneDICOMImageLoader;
|
||||
|
||||
|
||||
type ViewportDiv = HTMLDivElement & {
|
||||
_cornerstoneEnabled?: boolean;
|
||||
_lastViewportType?: any;
|
||||
_resizeObserver?: ResizeObserver;
|
||||
_overlayListener?: EventListener;
|
||||
type StackEntry = {
|
||||
imageIds: string[];
|
||||
name?: string;
|
||||
studyId?: string;
|
||||
studyInstanceUID?: string;
|
||||
caseId?: string | number;
|
||||
seriesGroups?: StackSeriesGroup[];
|
||||
};
|
||||
|
||||
const { MouseBindings, KeyboardBindings } = csToolsEnums;
|
||||
@@ -499,6 +468,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
const [fullscreenHoverIdx, setFullscreenHoverIdx] = useState<number | null>(null);
|
||||
const [openDropdownIdx, setOpenDropdownIdx] = useState<number | null>(null);
|
||||
const [topControlsPeek, setTopControlsPeek] = useState(false);
|
||||
const [isPhoneLayout, setIsPhoneLayout] = useState(false);
|
||||
const [mobileInteractionActive, setMobileInteractionActive] = useState(false);
|
||||
const [mobileFocusedViewport, setMobileFocusedViewport] = useState<number | null>(null);
|
||||
const [mobileSingleViewportMode, setMobileSingleViewportMode] = useState(true);
|
||||
const [mobileArmedStackIdx, setMobileArmedStackIdx] = useState<number | null>(null);
|
||||
const [mobileArmedDropZone, setMobileArmedDropZone] = useState<"center" | "right" | "bottom">("center");
|
||||
|
||||
const MAX_VIEWPORTS = 9;
|
||||
const renderingEngineId = "renderingEngine_" + container_id;
|
||||
@@ -1644,12 +1619,30 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
fallbackBByBase.set(base, values);
|
||||
}));
|
||||
|
||||
const getSliceKey = (id: string): string => {
|
||||
const planeMeta = (metaData.get("imagePlaneModule", id) || {}) as any;
|
||||
const ipp = Array.isArray(planeMeta?.imagePositionPatient) ? planeMeta.imagePositionPatient : null;
|
||||
if (ipp && ipp.length >= 3) {
|
||||
const nums = ipp.slice(0, 3).map((v: any) => Number(v));
|
||||
if (nums.every((v: number) => Number.isFinite(v))) {
|
||||
return `IPP:${nums.map((v: number) => v.toFixed(3)).join(",")}`;
|
||||
}
|
||||
}
|
||||
const sliceLocation = Number(planeMeta?.sliceLocation);
|
||||
if (Number.isFinite(sliceLocation)) {
|
||||
return `SL:${sliceLocation.toFixed(3)}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
type GroupCandidate = {
|
||||
id: string;
|
||||
seriesInstanceUID?: string;
|
||||
seriesDescription?: string;
|
||||
seriesNumber?: number;
|
||||
instanceNumber?: number;
|
||||
orientationKey: string;
|
||||
sliceKey: string;
|
||||
bValue: number | null;
|
||||
ruleValues: Partial<Record<GroupingRuleId, string>>;
|
||||
};
|
||||
@@ -1662,7 +1655,9 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
const seriesInstanceUID = (seriesMeta.seriesInstanceUID || "").trim() || undefined;
|
||||
const seriesDescription = (seriesMeta.seriesDescription || "").trim() || undefined;
|
||||
const seriesNumber = Number(seriesMeta.seriesNumber);
|
||||
const instanceNumber = Number(imageMeta.instanceNumber);
|
||||
const orientationKey = getOrientationKey(id);
|
||||
const sliceKey = getSliceKey(id);
|
||||
|
||||
let bValueRaw = Number(mrMeta.diffusionBValue ?? mrMeta.diffusionbValue ?? mrMeta.bValue);
|
||||
let bValue = Number.isFinite(bValueRaw) ? bValueRaw : null;
|
||||
@@ -1717,7 +1712,9 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
seriesInstanceUID,
|
||||
seriesDescription,
|
||||
seriesNumber: Number.isFinite(seriesNumber) ? seriesNumber : undefined,
|
||||
instanceNumber: Number.isFinite(instanceNumber) ? instanceNumber : undefined,
|
||||
orientationKey,
|
||||
sliceKey,
|
||||
bValue,
|
||||
ruleValues,
|
||||
};
|
||||
@@ -1790,6 +1787,56 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
});
|
||||
|
||||
if (groups.size <= 1) {
|
||||
// Fallback for missing diffusion/temporal tags:
|
||||
// If slices repeat uniformly (e.g. 56 images / 28 unique positions),
|
||||
// split by the occurrence index per slice position.
|
||||
if (activeRuleIds.length === 0 && candidates.length >= 4) {
|
||||
const sliceCounts = new Map<string, number>();
|
||||
candidates.forEach((c) => {
|
||||
if (!c.sliceKey) return;
|
||||
sliceCounts.set(c.sliceKey, (sliceCounts.get(c.sliceKey) || 0) + 1);
|
||||
});
|
||||
|
||||
const uniqueSliceCount = sliceCounts.size;
|
||||
if (uniqueSliceCount >= 2 && uniqueSliceCount < candidates.length) {
|
||||
const repeats = candidates.length / uniqueSliceCount;
|
||||
const repeatsRounded = Math.round(repeats);
|
||||
const hasCleanRepeats = Math.abs(repeats - repeatsRounded) < 1e-6 && repeatsRounded >= 2 && repeatsRounded <= 8;
|
||||
const uniformPerSlice = hasCleanRepeats
|
||||
&& Array.from(sliceCounts.values()).every((count) => count === repeatsRounded);
|
||||
|
||||
if (uniformPerSlice) {
|
||||
const groupsByCycle = Array.from({ length: repeatsRounded }, () => [] as string[]);
|
||||
const seenPerSlice = new Map<string, number>();
|
||||
const orderedCandidates = [...candidates]
|
||||
.map((c, idx) => ({ c, idx }))
|
||||
.sort((a, b) => {
|
||||
const ia = a.c.instanceNumber;
|
||||
const ib = b.c.instanceNumber;
|
||||
if (Number.isFinite(ia) && Number.isFinite(ib)) return (ia as number) - (ib as number);
|
||||
return a.idx - b.idx;
|
||||
});
|
||||
|
||||
orderedCandidates.forEach(({ c }) => {
|
||||
if (!c.sliceKey) return;
|
||||
const seen = seenPerSlice.get(c.sliceKey) || 0;
|
||||
const cycle = Math.min(repeatsRounded - 1, seen);
|
||||
groupsByCycle[cycle].push(c.id);
|
||||
seenPerSlice.set(c.sliceKey, seen + 1);
|
||||
});
|
||||
|
||||
const validGroups = groupsByCycle.filter((ids) => ids.length > 0);
|
||||
if (validGroups.length > 1) {
|
||||
return validGroups.map((ids, idx) => ({
|
||||
key: `SERIES_FALLBACK_${idx}`,
|
||||
label: `Series ${idx + 1}`,
|
||||
imageIds: dedupeImageIds(ids),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [{ key: "SERIES_0", label: "Series 1", imageIds: dedupeImageIds(imageIds) }];
|
||||
}
|
||||
|
||||
@@ -1911,13 +1958,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
})
|
||||
);
|
||||
|
||||
const [viewedIndicesByViewport, setViewedIndicesByViewport] = useState<number[][]>(
|
||||
() => Array(MAX_VIEWPORTS).fill(null).map(() => [])
|
||||
);
|
||||
const [viewedImageIds, setViewedImageIds] = useState<Record<string, true>>({});
|
||||
const viewportStackSignaturesRef = useRef<string[]>(Array(MAX_VIEWPORTS).fill(""));
|
||||
const defaultVoiRangeByViewportRef = useRef<Array<{ lower: number; upper: number } | null>>(Array(MAX_VIEWPORTS).fill(null));
|
||||
const activePresetByViewportRef = useRef<Array<PresetOption | null>>(Array(MAX_VIEWPORTS).fill(null));
|
||||
const lastRenderedImageIdByViewportRef = useRef<Array<string | null>>(Array(MAX_VIEWPORTS).fill(null));
|
||||
const pendingSliceIndexByViewportRef = useRef<number[]>(Array(MAX_VIEWPORTS).fill(0));
|
||||
|
||||
const [viewportModes, setViewportModes] = useState<Array<'stack' | 'volume'>>(
|
||||
() => Array(MAX_VIEWPORTS).fill('stack')
|
||||
@@ -1960,6 +2006,53 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(max-width: 768px), (pointer: coarse)");
|
||||
const update = () => {
|
||||
const nextIsPhone = media.matches;
|
||||
setIsPhoneLayout(nextIsPhone);
|
||||
if (!nextIsPhone) {
|
||||
setMobileInteractionActive(false);
|
||||
setMobileFocusedViewport(null);
|
||||
}
|
||||
};
|
||||
update();
|
||||
if (typeof media.addEventListener === "function") {
|
||||
media.addEventListener("change", update);
|
||||
return () => media.removeEventListener("change", update);
|
||||
}
|
||||
media.addListener(update);
|
||||
return () => media.removeListener(update);
|
||||
}, []);
|
||||
|
||||
const activateMobileInteractionForViewport = useCallback((viewportIdx: number) => {
|
||||
if (!isPhoneLayout) return;
|
||||
setActiveViewport(viewportIdx);
|
||||
setMobileFocusedViewport(viewportIdx);
|
||||
setMobileInteractionActive(true);
|
||||
setMobileSingleViewportMode(true);
|
||||
setStackPanelOpen(false);
|
||||
|
||||
const root = appRootRef.current;
|
||||
if (root && document.fullscreenElement !== root) {
|
||||
const request = (root.requestFullscreen
|
||||
|| (root as any).webkitRequestFullscreen
|
||||
|| (root as any).msRequestFullscreen) as (() => Promise<void> | void) | undefined;
|
||||
if (request) {
|
||||
try {
|
||||
const result = request.call(root);
|
||||
if (result && typeof (result as Promise<void>).catch === "function") {
|
||||
(result as Promise<void>).catch(() => {
|
||||
// Ignore fullscreen rejections (browser policy or user settings).
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore fullscreen failures.
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isPhoneLayout]);
|
||||
|
||||
|
||||
|
||||
const [metaModalOpen, setMetaModalOpen] = useState(false);
|
||||
@@ -2370,6 +2463,21 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
toolGroup.setToolActive(tool, { bindings });
|
||||
});
|
||||
|
||||
// Add touch bindings so tools are usable on phones/tablets.
|
||||
const primaryTool = mouseToolBindings.Primary;
|
||||
if (primaryTool) {
|
||||
const primaryBindings = [
|
||||
...(toolBindings[primaryTool] || []),
|
||||
{ numTouchPoints: 1 },
|
||||
];
|
||||
toolGroup.setToolActive(primaryTool, { bindings: primaryBindings });
|
||||
}
|
||||
const panBindings = [
|
||||
...(toolBindings[PanTool.toolName] || []),
|
||||
{ numTouchPoints: 2 },
|
||||
];
|
||||
toolGroup.setToolActive(PanTool.toolName, { bindings: panBindings });
|
||||
|
||||
// Also update external image handlers (pan/zoom) to respect selected tools
|
||||
try {
|
||||
const primaryTool = mouseToolBindings.Primary;
|
||||
@@ -2800,16 +2908,10 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
}
|
||||
}
|
||||
|
||||
if (currentIndex >= 0) {
|
||||
setViewedIndicesByViewport((prev) => {
|
||||
const next = [...prev];
|
||||
const existing = new Set(next[viewportIdx] || []);
|
||||
if (!existing.has(currentIndex)) {
|
||||
existing.add(currentIndex);
|
||||
next[viewportIdx] = Array.from(existing).sort((a, b) => a - b);
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
if (currentImageId) {
|
||||
setViewedImageIds((prev) => {
|
||||
if (prev[currentImageId]) return prev;
|
||||
return { ...prev, [currentImageId]: true };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2908,24 +3010,15 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
}, [viewportModes, viewportImageIds]);
|
||||
|
||||
useEffect(() => {
|
||||
setViewedIndicesByViewport((prev) => {
|
||||
let changed = false;
|
||||
const next = [...prev];
|
||||
|
||||
for (let i = 0; i < MAX_VIEWPORTS; i++) {
|
||||
const ids = viewportImageIds[i] || [];
|
||||
const signature = `${ids.length}:${ids[0] || ""}:${ids[ids.length - 1] || ""}`;
|
||||
if (viewportStackSignaturesRef.current[i] !== signature) {
|
||||
viewportStackSignaturesRef.current[i] = signature;
|
||||
defaultVoiRangeByViewportRef.current[i] = null;
|
||||
lastRenderedImageIdByViewportRef.current[i] = null;
|
||||
next[i] = [];
|
||||
changed = true;
|
||||
}
|
||||
for (let i = 0; i < MAX_VIEWPORTS; i++) {
|
||||
const ids = viewportImageIds[i] || [];
|
||||
const signature = `${ids.length}:${ids[0] || ""}:${ids[ids.length - 1] || ""}`;
|
||||
if (viewportStackSignaturesRef.current[i] !== signature) {
|
||||
viewportStackSignaturesRef.current[i] = signature;
|
||||
defaultVoiRangeByViewportRef.current[i] = null;
|
||||
lastRenderedImageIdByViewportRef.current[i] = null;
|
||||
}
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}
|
||||
}, [viewportImageIds]);
|
||||
|
||||
function saveVisibleViewportState() {
|
||||
@@ -3420,6 +3513,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: imageIds });
|
||||
volume.load();
|
||||
viewport.setVolumes([{ volumeId }]);
|
||||
if (typeof viewport.getNumberOfSlices === "function") {
|
||||
const totalSlices = viewport.getNumberOfSlices();
|
||||
const pending = pendingSliceIndexByViewportRef.current[i] || 0;
|
||||
const targetSlice = Math.max(0, Math.min(Math.max(0, totalSlices - 1), pending));
|
||||
csUtilities.jumpToSlice(viewport.element, { imageIndex: targetSlice });
|
||||
}
|
||||
viewport.render();
|
||||
|
||||
// Always (re-)attach overlay update for each viewport
|
||||
@@ -3835,6 +3934,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
|
||||
const renderingEngine = renderingEngineRef.current;
|
||||
const viewport = renderingEngine?.getViewport(`CT_${viewportIdx}`);
|
||||
const isVolumeMode = viewportModes[viewportIdx] === "volume";
|
||||
|
||||
const sliceScalarForImageId = (imageId?: string): number | null => {
|
||||
if (!imageId) return null;
|
||||
@@ -3883,8 +3983,15 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
const ratio = oldTotal > 1 ? currentIdx / (oldTotal - 1) : 0;
|
||||
mappedIndex = Math.max(0, Math.min(selectedGroup.imageIds.length - 1, Math.round(ratio * Math.max(0, selectedGroup.imageIds.length - 1))));
|
||||
}
|
||||
} else if (isVolumeMode && viewport && typeof viewport.getSliceIndex === "function" && typeof viewport.getNumberOfSlices === "function") {
|
||||
const oldTotal = viewport.getNumberOfSlices();
|
||||
const currentIdx = viewport.getSliceIndex();
|
||||
const ratio = oldTotal > 1 ? currentIdx / (oldTotal - 1) : 0;
|
||||
mappedIndex = Math.max(0, Math.min(selectedGroup.imageIds.length - 1, Math.round(ratio * Math.max(0, selectedGroup.imageIds.length - 1))));
|
||||
}
|
||||
|
||||
pendingSliceIndexByViewportRef.current[viewportIdx] = mappedIndex;
|
||||
|
||||
setSelectedSeriesIdxByViewport(prev => {
|
||||
const next = [...prev];
|
||||
next[viewportIdx] = boundedIdx;
|
||||
@@ -3899,12 +4006,22 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
void setLoadedImageIdsAndCacheMeta(selectedGroup.imageIds, { force: true });
|
||||
window.setTimeout(() => {
|
||||
const vp = renderingEngineRef.current?.getViewport(`CT_${viewportIdx}`);
|
||||
if (!vp || typeof vp.setImageIdIndex !== "function") return;
|
||||
csUtilities.jumpToSlice(vp.element, { imageIndex: mappedIndex });
|
||||
vp.render();
|
||||
updateOverlay(viewportIdx, vp);
|
||||
if (!vp) return;
|
||||
if (viewportModes[viewportIdx] === "stack" && typeof vp.setImageIdIndex === "function") {
|
||||
csUtilities.jumpToSlice(vp.element, { imageIndex: mappedIndex });
|
||||
vp.render();
|
||||
updateOverlay(viewportIdx, vp);
|
||||
return;
|
||||
}
|
||||
if (viewportModes[viewportIdx] === "volume" && typeof vp.getNumberOfSlices === "function") {
|
||||
const total = vp.getNumberOfSlices();
|
||||
const target = Math.max(0, Math.min(Math.max(0, total - 1), mappedIndex));
|
||||
csUtilities.jumpToSlice(vp.element, { imageIndex: target });
|
||||
vp.render();
|
||||
updateOverlay(viewportIdx, vp);
|
||||
}
|
||||
}, 25);
|
||||
}, [seriesGroupsByViewport, setLoadedImageIdsAndCacheMeta, updateOverlay, viewportImageIds]);
|
||||
}, [seriesGroupsByViewport, setLoadedImageIdsAndCacheMeta, updateOverlay, viewportImageIds, viewportModes]);
|
||||
|
||||
useEffect(() => {
|
||||
const timers: number[] = [];
|
||||
@@ -5012,6 +5129,10 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
for (let i = 0; i < numVisible; i++) {
|
||||
// Determine if this viewport should be visible in the current grid
|
||||
const isVisible = i < viewportGrid.rows * viewportGrid.cols;
|
||||
const isFocusedMobileViewport = !isPhoneLayout
|
||||
|| !mobileInteractionActive
|
||||
|| !mobileSingleViewportMode
|
||||
|| mobileFocusedViewport === i;
|
||||
|
||||
const currentPreviewZone = dropPreview?.viewportIdx === i ? dropPreview.zone : null;
|
||||
const resolvedPreviewZone = currentPreviewZone ? resolveDropZone(currentPreviewZone) : null;
|
||||
@@ -5022,6 +5143,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
ref={el => (viewportRefs.current[i] = el)}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: isVisible && isFocusedMobileViewport ? "flex" : "none",
|
||||
border: isVisible ? "1px solid #222" : "none", // Only show border if visible
|
||||
background: "#000",
|
||||
minWidth: 0,
|
||||
@@ -5032,12 +5154,28 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
gridRow: Math.floor(i / viewportGrid.cols) + 1,
|
||||
gridColumn: (i % viewportGrid.cols) + 1,
|
||||
height: "100%",
|
||||
touchAction: (isPhoneLayout && mobileInteractionActive) ? "none" : "pan-y",
|
||||
boxShadow: activeViewport === i
|
||||
? "0 0 0 4px #1976d2, 0 0 16px 4px #1976d2cc"
|
||||
: undefined,
|
||||
zIndex: activeViewport === i ? 10 : 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isPhoneLayout && !mobileInteractionActive) {
|
||||
activateMobileInteractionForViewport(i);
|
||||
setViewportMenuOpen(false);
|
||||
setTopControlsPeek(false);
|
||||
setSideMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
if (isPhoneLayout && mobileInteractionActive && mobileArmedStackIdx !== null) {
|
||||
applyStackDrop(i, mobileArmedStackIdx, mobileArmedDropZone).catch((err) => {
|
||||
appLogger.error("Failed to apply mobile stack drop:", err);
|
||||
});
|
||||
setMobileArmedStackIdx(null);
|
||||
setStackPanelOpen(false);
|
||||
return;
|
||||
}
|
||||
setActiveViewport(i);
|
||||
setViewportMenuOpen(false);
|
||||
}}
|
||||
@@ -5100,6 +5238,47 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
setExternalDragActive(false);
|
||||
}}
|
||||
>
|
||||
{isPhoneLayout && !mobileInteractionActive && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
pointerEvents: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "linear-gradient(to top, rgba(0,0,0,0.55), rgba(0,0,0,0.2))",
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.2,
|
||||
textAlign: "center",
|
||||
padding: "18px",
|
||||
}}
|
||||
>
|
||||
Tap to activate fullscreen interaction
|
||||
</div>
|
||||
)}
|
||||
{isPhoneLayout && mobileInteractionActive && mobileArmedStackIdx !== null && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 62,
|
||||
background: "rgba(25, 118, 210, 0.92)",
|
||||
color: "#fff",
|
||||
borderRadius: 6,
|
||||
padding: "6px 8px",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
Tap to drop stack ({mobileArmedDropZone})
|
||||
</div>
|
||||
)}
|
||||
{(draggedStackIdx !== null || externalDragActive) && currentPreviewZone && (
|
||||
<div
|
||||
style={{
|
||||
@@ -5850,7 +6029,11 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
? viewport.getCurrentImageIdIndex()
|
||||
: -1;
|
||||
|
||||
const viewedSet = new Set(viewedIndicesByViewport[i] || []);
|
||||
const viewedSet = new Set(
|
||||
imageIds
|
||||
.map((id, idx) => (viewedImageIds[id] ? idx : -1))
|
||||
.filter((idx) => idx >= 0)
|
||||
);
|
||||
const loadedFlags = imageIds.map((id) => {
|
||||
if (id.startsWith("external:")) return true;
|
||||
return !!metaData.get("imagePlaneModule", id) || !!metaData.get("generalSeriesModule", id);
|
||||
@@ -6237,6 +6420,19 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
|
||||
|
||||
|
||||
const mobileQuickTools = [
|
||||
StackScrollTool.toolName,
|
||||
WindowLevelTool.toolName,
|
||||
PanTool.toolName,
|
||||
ZoomTool.toolName,
|
||||
];
|
||||
const mobileGridLayouts: Array<{ rows: number; cols: number }> = [
|
||||
{ rows: 1, cols: 1 },
|
||||
{ rows: 1, cols: 2 },
|
||||
{ rows: 2, cols: 2 },
|
||||
];
|
||||
const hideChromeForMobileInteraction = isPhoneLayout && mobileInteractionActive;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={appRootRef}
|
||||
@@ -6246,8 +6442,8 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
boxSizing: "border-box",
|
||||
background: "#222",
|
||||
padding: "12px",
|
||||
background: hideChromeForMobileInteraction ? "#000" : "#222",
|
||||
padding: hideChromeForMobileInteraction ? "0px" : (isPhoneLayout ? "6px" : "12px"),
|
||||
overflow: "hidden",
|
||||
}}>
|
||||
{/* Loading overlay */}
|
||||
@@ -6314,7 +6510,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
{/* Right-side stack panel is implemented in the main content flex container below */}
|
||||
|
||||
{/* Open stacks button: compact and lower z-index so it does not cover modals/controls */}
|
||||
{!stackPanelOpen && (
|
||||
{!stackPanelOpen && !hideChromeForMobileInteraction && (
|
||||
<button
|
||||
onClick={() => setStackPanelOpen(true)}
|
||||
aria-pressed={false}
|
||||
@@ -6342,6 +6538,109 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hideChromeForMobileInteraction && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setMobileInteractionActive(false);
|
||||
setMobileFocusedViewport(null);
|
||||
setMobileSingleViewportMode(false);
|
||||
setStackPanelOpen(false);
|
||||
setMobileArmedStackIdx(null);
|
||||
const root = appRootRef.current;
|
||||
if (root && document.fullscreenElement === root && document.exitFullscreen) {
|
||||
document.exitFullscreen().catch(() => {
|
||||
// Ignore fullscreen exit failures.
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
left: 10,
|
||||
zIndex: 3500,
|
||||
background: "rgba(0,0,0,0.78)",
|
||||
color: "#fff",
|
||||
border: "1px solid rgba(255,255,255,0.25)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 10px",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Exit Viewer
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hideChromeForMobileInteraction && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 10,
|
||||
zIndex: 3500,
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setStackPanelOpen((open) => !open)}
|
||||
style={{
|
||||
background: "rgba(0,0,0,0.78)",
|
||||
color: "#fff",
|
||||
border: "1px solid rgba(255,255,255,0.25)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 10px",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Stacks
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (mobileSingleViewportMode) {
|
||||
setMobileSingleViewportMode(false);
|
||||
} else {
|
||||
setMobileSingleViewportMode(true);
|
||||
setMobileFocusedViewport(activeViewport ?? 0);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: "rgba(0,0,0,0.78)",
|
||||
color: "#fff",
|
||||
border: "1px solid rgba(255,255,255,0.25)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 10px",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{mobileSingleViewportMode ? "Grid" : "Focus"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewportGrid((prev) => {
|
||||
const idx = mobileGridLayouts.findIndex((g) => g.rows === prev.rows && g.cols === prev.cols);
|
||||
const next = mobileGridLayouts[(idx + 1 + mobileGridLayouts.length) % mobileGridLayouts.length];
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
background: "rgba(0,0,0,0.78)",
|
||||
color: "#fff",
|
||||
border: "1px solid rgba(255,255,255,0.25)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 10px",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Layout
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hideChromeForMobileInteraction && (
|
||||
<div>
|
||||
{/* Side menu toggle button */}
|
||||
<button
|
||||
@@ -6589,9 +6888,11 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Viewport grid menu at top center */}
|
||||
|
||||
|
||||
{!hideChromeForMobileInteraction && (
|
||||
<div
|
||||
ref={topControlsRef}
|
||||
className="grid-menu-hover-container"
|
||||
@@ -6771,16 +7072,17 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Main content area: viewports and right-side panel in a row so panel pushes content */}
|
||||
<div style={{ display: 'flex', flexDirection: 'row', height: 'calc(100% - 48px)', gap: 8 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', height: hideChromeForMobileInteraction ? '100%' : 'calc(100% - 48px)', gap: hideChromeForMobileInteraction ? 0 : 8 }}>
|
||||
{/* Viewport grid container — flex:1 so it shrinks when panel takes space */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'grid',
|
||||
margin: 3,
|
||||
margin: hideChromeForMobileInteraction ? 0 : 3,
|
||||
gridTemplateRows: `repeat(${viewportGrid.rows}, 1fr)`,
|
||||
gridTemplateColumns: `repeat(${viewportGrid.cols}, 1fr)`,
|
||||
gap: '5px',
|
||||
@@ -6792,6 +7094,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
>
|
||||
{viewports}
|
||||
{/* Settings button positioned relative to the viewport area so it doesn't shift when the right panel opens */}
|
||||
{!hideChromeForMobileInteraction && (
|
||||
<button
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -6811,11 +7114,93 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hideChromeForMobileInteraction && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 10,
|
||||
zIndex: 3200,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
background: "rgba(0,0,0,0.74)",
|
||||
border: "1px solid rgba(255,255,255,0.2)",
|
||||
borderRadius: 10,
|
||||
padding: "8px 8px",
|
||||
maxWidth: "95%",
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
{mobileQuickTools.map((toolName) => {
|
||||
const isActive = mouseToolBindings.Primary === toolName;
|
||||
const label = TOOL_OPTIONS.find((opt) => opt.value === toolName)?.label || toolName;
|
||||
return (
|
||||
<button
|
||||
key={toolName}
|
||||
title={label}
|
||||
onClick={() => handleToolChange("Primary", toolName)}
|
||||
style={{
|
||||
minWidth: 46,
|
||||
height: 42,
|
||||
borderRadius: 8,
|
||||
border: isActive ? "1px solid #90caf9" : "1px solid rgba(255,255,255,0.22)",
|
||||
background: isActive ? "#1565c0" : "#1f1f1f",
|
||||
color: "#fff",
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
padding: "0 8px",
|
||||
}}
|
||||
>
|
||||
{getToolIcon(toolName)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right-side panel occupies layout space when visible; when closed width is 0 and it doesn't catch pointer events */}
|
||||
<div style={{ width: stackPanelOpen ? stackPanelWidth : 0, transition: 'width 220ms ease', display: 'flex', flexDirection: 'column', pointerEvents: stackPanelOpen ? 'auto' : 'none', overflow: 'hidden', position: 'relative' }}>
|
||||
{stackPanelOpen && (
|
||||
<div style={hideChromeForMobileInteraction
|
||||
? {
|
||||
position: 'absolute',
|
||||
left: 8,
|
||||
right: 8,
|
||||
bottom: stackPanelOpen ? 62 : -420,
|
||||
height: 'min(48vh, 420px)',
|
||||
transition: 'bottom 220ms ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
pointerEvents: stackPanelOpen ? 'auto' : 'none',
|
||||
overflow: 'hidden',
|
||||
zIndex: 3450,
|
||||
borderRadius: 12,
|
||||
border: '1px solid #2f3336',
|
||||
boxShadow: '0 6px 24px rgba(0,0,0,0.45)',
|
||||
background: '#1f2428',
|
||||
}
|
||||
: {
|
||||
width: stackPanelOpen ? stackPanelWidth : 0,
|
||||
transition: 'width 220ms ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
pointerEvents: stackPanelOpen ? 'auto' : 'none',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{stackPanelOpen && !hideChromeForMobileInteraction && (
|
||||
<div
|
||||
onMouseDown={beginStackPanelResize}
|
||||
title="Resize stacks panel"
|
||||
@@ -6856,6 +7241,21 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
Active VP: {activeViewport !== null ? activeViewport + 1 : '-'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{hideChromeForMobileInteraction && (
|
||||
<button
|
||||
title="Toggle stack drop mode"
|
||||
onClick={() => {
|
||||
setMobileArmedDropZone((prev) => {
|
||||
if (prev === "center") return "right";
|
||||
if (prev === "right") return "bottom";
|
||||
return "center";
|
||||
});
|
||||
}}
|
||||
style={{ background: 'transparent', color: '#90caf9', border: '1px solid #335', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 12 }}
|
||||
>
|
||||
Drop: {mobileArmedDropZone}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
title="Close"
|
||||
onClick={() => { setStackPanelOpen(false); }}
|
||||
@@ -6960,6 +7360,46 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
{isActiveViewportStack && (
|
||||
<div style={{ fontSize: 11, color: '#bbdefb', fontWeight: 700 }}>ACTIVE</div>
|
||||
)}
|
||||
{hideChromeForMobileInteraction && !invalid && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const targetViewport = activeViewport ?? 0;
|
||||
handleLoadStack(targetViewport, idx);
|
||||
setStackPanelOpen(false);
|
||||
}}
|
||||
style={{
|
||||
background: '#1565c0',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '3px 7px',
|
||||
fontSize: 11,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMobileArmedStackIdx(idx);
|
||||
}}
|
||||
style={{
|
||||
background: mobileArmedStackIdx === idx ? '#2e7d32' : '#444',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '3px 7px',
|
||||
fontSize: 11,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{mobileArmedStackIdx === idx ? 'Armed' : 'Drag'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -6988,8 +7428,10 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
background: "rgba(0,0,0,0.4)",
|
||||
zIndex: 1000,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
overflowY: "auto",
|
||||
padding: "16px 12px",
|
||||
}}
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
>
|
||||
@@ -7000,9 +7442,13 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
||||
borderRadius: "8px",
|
||||
minWidth: "320px",
|
||||
minHeight: "180px",
|
||||
maxWidth: "min(960px, 96vw)",
|
||||
maxHeight: "calc(100vh - 32px)",
|
||||
padding: "24px",
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.4)",
|
||||
position: "relative",
|
||||
overflowY: "auto",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user