feat(mobile): enhance mobile interaction with new touch controls and layout adjustments

This commit is contained in:
Ross
2026-05-19 14:26:28 +01:00
parent 41f13ade5e
commit a5c530a907
+552 -106
View File
@@ -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()}
>