feat(mobile): enhance mobile interaction with new touch controls and layout adjustments
This commit is contained in:
+541
-95
@@ -31,6 +31,11 @@ declare global {
|
|||||||
cornerstone3dTools: typeof cornerstoneTools;
|
cornerstone3dTools: typeof cornerstoneTools;
|
||||||
cornerstone3d: typeof cornerstone3d;
|
cornerstone3d: typeof cornerstone3d;
|
||||||
cornerstoneDICOMImageLoader: typeof cornerstoneDICOMImageLoader;
|
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
|
// Default to 1 if spacing is missing
|
||||||
const dx = columnPixelSpacing || 1;
|
const dx = columnPixelSpacing || 1;
|
||||||
const dy = rowPixelSpacing || 1;
|
const dy = rowPixelSpacing || 1;
|
||||||
// Convert cosines to vectors
|
return {
|
||||||
const rc = rowCosines;
|
x: imagePositionPatient[0] + rowCosines[0] * point.y * dy + columnCosines[0] * point.x * dx,
|
||||||
const cc = columnCosines;
|
y: imagePositionPatient[1] + rowCosines[1] * point.y * dy + columnCosines[1] * point.x * dx,
|
||||||
const ipp = imagePositionPatient;
|
z: imagePositionPatient[2] + rowCosines[2] * point.y * dy + columnCosines[2] * point.x * dx,
|
||||||
// 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,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to global types:
|
type ViewportDiv = HTMLDivElement & {
|
||||||
declare global {
|
_cornerstoneEnabled?: boolean;
|
||||||
interface Window {
|
_lastViewportType?: Enums.ViewportType;
|
||||||
exportViewerState?: () => string;
|
_resizeObserver?: ResizeObserver;
|
||||||
importViewerState?: (json: string) => void;
|
_overlayListener?: EventListener;
|
||||||
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 StackSeriesGroup = {
|
type ViewportDropZone = "left" | "right" | "top" | "bottom" | "center";
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
imageIds: string[];
|
|
||||||
seriesInstanceUID?: string;
|
|
||||||
bValue?: number | null;
|
|
||||||
groupValues?: Partial<Record<GroupingRuleId, string>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type GroupingRuleId =
|
type GroupingRuleId =
|
||||||
| "dwiBValue"
|
| "dwiBValue"
|
||||||
@@ -100,32 +79,22 @@ type GroupingRuleDefinition = {
|
|||||||
defaultEnabled: boolean;
|
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>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StackEntry = {
|
||||||
// Add this type for clarity
|
imageIds: string[];
|
||||||
declare global {
|
name?: string;
|
||||||
interface Window {
|
studyId?: string;
|
||||||
loadDicomStackFromFiles?: (files: FileList | File[]) => void;
|
studyInstanceUID?: string;
|
||||||
}
|
caseId?: string | number;
|
||||||
}
|
seriesGroups?: StackSeriesGroup[];
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { MouseBindings, KeyboardBindings } = csToolsEnums;
|
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 [fullscreenHoverIdx, setFullscreenHoverIdx] = useState<number | null>(null);
|
||||||
const [openDropdownIdx, setOpenDropdownIdx] = useState<number | null>(null);
|
const [openDropdownIdx, setOpenDropdownIdx] = useState<number | null>(null);
|
||||||
const [topControlsPeek, setTopControlsPeek] = useState(false);
|
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 MAX_VIEWPORTS = 9;
|
||||||
const renderingEngineId = "renderingEngine_" + container_id;
|
const renderingEngineId = "renderingEngine_" + container_id;
|
||||||
@@ -1644,12 +1619,30 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
fallbackBByBase.set(base, values);
|
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 = {
|
type GroupCandidate = {
|
||||||
id: string;
|
id: string;
|
||||||
seriesInstanceUID?: string;
|
seriesInstanceUID?: string;
|
||||||
seriesDescription?: string;
|
seriesDescription?: string;
|
||||||
seriesNumber?: number;
|
seriesNumber?: number;
|
||||||
|
instanceNumber?: number;
|
||||||
orientationKey: string;
|
orientationKey: string;
|
||||||
|
sliceKey: string;
|
||||||
bValue: number | null;
|
bValue: number | null;
|
||||||
ruleValues: Partial<Record<GroupingRuleId, string>>;
|
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 seriesInstanceUID = (seriesMeta.seriesInstanceUID || "").trim() || undefined;
|
||||||
const seriesDescription = (seriesMeta.seriesDescription || "").trim() || undefined;
|
const seriesDescription = (seriesMeta.seriesDescription || "").trim() || undefined;
|
||||||
const seriesNumber = Number(seriesMeta.seriesNumber);
|
const seriesNumber = Number(seriesMeta.seriesNumber);
|
||||||
|
const instanceNumber = Number(imageMeta.instanceNumber);
|
||||||
const orientationKey = getOrientationKey(id);
|
const orientationKey = getOrientationKey(id);
|
||||||
|
const sliceKey = getSliceKey(id);
|
||||||
|
|
||||||
let bValueRaw = Number(mrMeta.diffusionBValue ?? mrMeta.diffusionbValue ?? mrMeta.bValue);
|
let bValueRaw = Number(mrMeta.diffusionBValue ?? mrMeta.diffusionbValue ?? mrMeta.bValue);
|
||||||
let bValue = Number.isFinite(bValueRaw) ? bValueRaw : null;
|
let bValue = Number.isFinite(bValueRaw) ? bValueRaw : null;
|
||||||
@@ -1717,7 +1712,9 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
seriesInstanceUID,
|
seriesInstanceUID,
|
||||||
seriesDescription,
|
seriesDescription,
|
||||||
seriesNumber: Number.isFinite(seriesNumber) ? seriesNumber : undefined,
|
seriesNumber: Number.isFinite(seriesNumber) ? seriesNumber : undefined,
|
||||||
|
instanceNumber: Number.isFinite(instanceNumber) ? instanceNumber : undefined,
|
||||||
orientationKey,
|
orientationKey,
|
||||||
|
sliceKey,
|
||||||
bValue,
|
bValue,
|
||||||
ruleValues,
|
ruleValues,
|
||||||
};
|
};
|
||||||
@@ -1790,6 +1787,56 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (groups.size <= 1) {
|
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) }];
|
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[][]>(
|
const [viewedImageIds, setViewedImageIds] = useState<Record<string, true>>({});
|
||||||
() => Array(MAX_VIEWPORTS).fill(null).map(() => [])
|
|
||||||
);
|
|
||||||
const viewportStackSignaturesRef = useRef<string[]>(Array(MAX_VIEWPORTS).fill(""));
|
const viewportStackSignaturesRef = useRef<string[]>(Array(MAX_VIEWPORTS).fill(""));
|
||||||
const defaultVoiRangeByViewportRef = useRef<Array<{ lower: number; upper: number } | null>>(Array(MAX_VIEWPORTS).fill(null));
|
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 activePresetByViewportRef = useRef<Array<PresetOption | null>>(Array(MAX_VIEWPORTS).fill(null));
|
||||||
const lastRenderedImageIdByViewportRef = useRef<Array<string | 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'>>(
|
const [viewportModes, setViewportModes] = useState<Array<'stack' | 'volume'>>(
|
||||||
() => Array(MAX_VIEWPORTS).fill('stack')
|
() => 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);
|
const [metaModalOpen, setMetaModalOpen] = useState(false);
|
||||||
@@ -2370,6 +2463,21 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
toolGroup.setToolActive(tool, { bindings });
|
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
|
// Also update external image handlers (pan/zoom) to respect selected tools
|
||||||
try {
|
try {
|
||||||
const primaryTool = mouseToolBindings.Primary;
|
const primaryTool = mouseToolBindings.Primary;
|
||||||
@@ -2800,16 +2908,10 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentIndex >= 0) {
|
if (currentImageId) {
|
||||||
setViewedIndicesByViewport((prev) => {
|
setViewedImageIds((prev) => {
|
||||||
const next = [...prev];
|
if (prev[currentImageId]) return prev;
|
||||||
const existing = new Set(next[viewportIdx] || []);
|
return { ...prev, [currentImageId]: true };
|
||||||
if (!existing.has(currentIndex)) {
|
|
||||||
existing.add(currentIndex);
|
|
||||||
next[viewportIdx] = Array.from(existing).sort((a, b) => a - b);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2908,10 +3010,6 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
}, [viewportModes, viewportImageIds]);
|
}, [viewportModes, viewportImageIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setViewedIndicesByViewport((prev) => {
|
|
||||||
let changed = false;
|
|
||||||
const next = [...prev];
|
|
||||||
|
|
||||||
for (let i = 0; i < MAX_VIEWPORTS; i++) {
|
for (let i = 0; i < MAX_VIEWPORTS; i++) {
|
||||||
const ids = viewportImageIds[i] || [];
|
const ids = viewportImageIds[i] || [];
|
||||||
const signature = `${ids.length}:${ids[0] || ""}:${ids[ids.length - 1] || ""}`;
|
const signature = `${ids.length}:${ids[0] || ""}:${ids[ids.length - 1] || ""}`;
|
||||||
@@ -2919,13 +3017,8 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
viewportStackSignaturesRef.current[i] = signature;
|
viewportStackSignaturesRef.current[i] = signature;
|
||||||
defaultVoiRangeByViewportRef.current[i] = null;
|
defaultVoiRangeByViewportRef.current[i] = null;
|
||||||
lastRenderedImageIdByViewportRef.current[i] = null;
|
lastRenderedImageIdByViewportRef.current[i] = null;
|
||||||
next[i] = [];
|
|
||||||
changed = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return changed ? next : prev;
|
|
||||||
});
|
|
||||||
}, [viewportImageIds]);
|
}, [viewportImageIds]);
|
||||||
|
|
||||||
function saveVisibleViewportState() {
|
function saveVisibleViewportState() {
|
||||||
@@ -3420,6 +3513,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: imageIds });
|
const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: imageIds });
|
||||||
volume.load();
|
volume.load();
|
||||||
viewport.setVolumes([{ volumeId }]);
|
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();
|
viewport.render();
|
||||||
|
|
||||||
// Always (re-)attach overlay update for each viewport
|
// 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 renderingEngine = renderingEngineRef.current;
|
||||||
const viewport = renderingEngine?.getViewport(`CT_${viewportIdx}`);
|
const viewport = renderingEngine?.getViewport(`CT_${viewportIdx}`);
|
||||||
|
const isVolumeMode = viewportModes[viewportIdx] === "volume";
|
||||||
|
|
||||||
const sliceScalarForImageId = (imageId?: string): number | null => {
|
const sliceScalarForImageId = (imageId?: string): number | null => {
|
||||||
if (!imageId) return 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;
|
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))));
|
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 => {
|
setSelectedSeriesIdxByViewport(prev => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next[viewportIdx] = boundedIdx;
|
next[viewportIdx] = boundedIdx;
|
||||||
@@ -3899,12 +4006,22 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
void setLoadedImageIdsAndCacheMeta(selectedGroup.imageIds, { force: true });
|
void setLoadedImageIdsAndCacheMeta(selectedGroup.imageIds, { force: true });
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const vp = renderingEngineRef.current?.getViewport(`CT_${viewportIdx}`);
|
const vp = renderingEngineRef.current?.getViewport(`CT_${viewportIdx}`);
|
||||||
if (!vp || typeof vp.setImageIdIndex !== "function") return;
|
if (!vp) return;
|
||||||
|
if (viewportModes[viewportIdx] === "stack" && typeof vp.setImageIdIndex === "function") {
|
||||||
csUtilities.jumpToSlice(vp.element, { imageIndex: mappedIndex });
|
csUtilities.jumpToSlice(vp.element, { imageIndex: mappedIndex });
|
||||||
vp.render();
|
vp.render();
|
||||||
updateOverlay(viewportIdx, vp);
|
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);
|
}, 25);
|
||||||
}, [seriesGroupsByViewport, setLoadedImageIdsAndCacheMeta, updateOverlay, viewportImageIds]);
|
}, [seriesGroupsByViewport, setLoadedImageIdsAndCacheMeta, updateOverlay, viewportImageIds, viewportModes]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timers: number[] = [];
|
const timers: number[] = [];
|
||||||
@@ -5012,6 +5129,10 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
for (let i = 0; i < numVisible; i++) {
|
for (let i = 0; i < numVisible; i++) {
|
||||||
// Determine if this viewport should be visible in the current grid
|
// Determine if this viewport should be visible in the current grid
|
||||||
const isVisible = i < viewportGrid.rows * viewportGrid.cols;
|
const isVisible = i < viewportGrid.rows * viewportGrid.cols;
|
||||||
|
const isFocusedMobileViewport = !isPhoneLayout
|
||||||
|
|| !mobileInteractionActive
|
||||||
|
|| !mobileSingleViewportMode
|
||||||
|
|| mobileFocusedViewport === i;
|
||||||
|
|
||||||
const currentPreviewZone = dropPreview?.viewportIdx === i ? dropPreview.zone : null;
|
const currentPreviewZone = dropPreview?.viewportIdx === i ? dropPreview.zone : null;
|
||||||
const resolvedPreviewZone = currentPreviewZone ? resolveDropZone(currentPreviewZone) : 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)}
|
ref={el => (viewportRefs.current[i] = el)}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
display: isVisible && isFocusedMobileViewport ? "flex" : "none",
|
||||||
border: isVisible ? "1px solid #222" : "none", // Only show border if visible
|
border: isVisible ? "1px solid #222" : "none", // Only show border if visible
|
||||||
background: "#000",
|
background: "#000",
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
@@ -5032,12 +5154,28 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
gridRow: Math.floor(i / viewportGrid.cols) + 1,
|
gridRow: Math.floor(i / viewportGrid.cols) + 1,
|
||||||
gridColumn: (i % viewportGrid.cols) + 1,
|
gridColumn: (i % viewportGrid.cols) + 1,
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
touchAction: (isPhoneLayout && mobileInteractionActive) ? "none" : "pan-y",
|
||||||
boxShadow: activeViewport === i
|
boxShadow: activeViewport === i
|
||||||
? "0 0 0 4px #1976d2, 0 0 16px 4px #1976d2cc"
|
? "0 0 0 4px #1976d2, 0 0 16px 4px #1976d2cc"
|
||||||
: undefined,
|
: undefined,
|
||||||
zIndex: activeViewport === i ? 10 : 1,
|
zIndex: activeViewport === i ? 10 : 1,
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
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);
|
setActiveViewport(i);
|
||||||
setViewportMenuOpen(false);
|
setViewportMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
@@ -5100,6 +5238,47 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
setExternalDragActive(false);
|
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 && (
|
{(draggedStackIdx !== null || externalDragActive) && currentPreviewZone && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -5850,7 +6029,11 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
? viewport.getCurrentImageIdIndex()
|
? viewport.getCurrentImageIdIndex()
|
||||||
: -1;
|
: -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) => {
|
const loadedFlags = imageIds.map((id) => {
|
||||||
if (id.startsWith("external:")) return true;
|
if (id.startsWith("external:")) return true;
|
||||||
return !!metaData.get("imagePlaneModule", id) || !!metaData.get("generalSeriesModule", id);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={appRootRef}
|
ref={appRootRef}
|
||||||
@@ -6246,8 +6442,8 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
background: "#222",
|
background: hideChromeForMobileInteraction ? "#000" : "#222",
|
||||||
padding: "12px",
|
padding: hideChromeForMobileInteraction ? "0px" : (isPhoneLayout ? "6px" : "12px"),
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}>
|
}}>
|
||||||
{/* Loading overlay */}
|
{/* 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 */}
|
{/* 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 */}
|
{/* Open stacks button: compact and lower z-index so it does not cover modals/controls */}
|
||||||
{!stackPanelOpen && (
|
{!stackPanelOpen && !hideChromeForMobileInteraction && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setStackPanelOpen(true)}
|
onClick={() => setStackPanelOpen(true)}
|
||||||
aria-pressed={false}
|
aria-pressed={false}
|
||||||
@@ -6342,6 +6538,109 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
</button>
|
</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>
|
<div>
|
||||||
{/* Side menu toggle button */}
|
{/* Side menu toggle button */}
|
||||||
<button
|
<button
|
||||||
@@ -6589,9 +6888,11 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Viewport grid menu at top center */}
|
{/* Viewport grid menu at top center */}
|
||||||
|
|
||||||
|
|
||||||
|
{!hideChromeForMobileInteraction && (
|
||||||
<div
|
<div
|
||||||
ref={topControlsRef}
|
ref={topControlsRef}
|
||||||
className="grid-menu-hover-container"
|
className="grid-menu-hover-container"
|
||||||
@@ -6771,16 +7072,17 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Main content area: viewports and right-side panel in a row so panel pushes content */}
|
{/* 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 */}
|
{/* Viewport grid container — flex:1 so it shrinks when panel takes space */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
margin: 3,
|
margin: hideChromeForMobileInteraction ? 0 : 3,
|
||||||
gridTemplateRows: `repeat(${viewportGrid.rows}, 1fr)`,
|
gridTemplateRows: `repeat(${viewportGrid.rows}, 1fr)`,
|
||||||
gridTemplateColumns: `repeat(${viewportGrid.cols}, 1fr)`,
|
gridTemplateColumns: `repeat(${viewportGrid.cols}, 1fr)`,
|
||||||
gap: '5px',
|
gap: '5px',
|
||||||
@@ -6792,6 +7094,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
>
|
>
|
||||||
{viewports}
|
{viewports}
|
||||||
{/* Settings button positioned relative to the viewport area so it doesn't shift when the right panel opens */}
|
{/* Settings button positioned relative to the viewport area so it doesn't shift when the right panel opens */}
|
||||||
|
{!hideChromeForMobileInteraction && (
|
||||||
<button
|
<button
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -6811,11 +7114,93 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
>
|
>
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Right-side panel occupies layout space when visible; when closed width is 0 and it doesn't catch pointer events */}
|
{/* 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' }}>
|
<div style={hideChromeForMobileInteraction
|
||||||
{stackPanelOpen && (
|
? {
|
||||||
|
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
|
<div
|
||||||
onMouseDown={beginStackPanelResize}
|
onMouseDown={beginStackPanelResize}
|
||||||
title="Resize stacks panel"
|
title="Resize stacks panel"
|
||||||
@@ -6856,6 +7241,21 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
Active VP: {activeViewport !== null ? activeViewport + 1 : '-'}
|
Active VP: {activeViewport !== null ? activeViewport + 1 : '-'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<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
|
<button
|
||||||
title="Close"
|
title="Close"
|
||||||
onClick={() => { setStackPanelOpen(false); }}
|
onClick={() => { setStackPanelOpen(false); }}
|
||||||
@@ -6960,6 +7360,46 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
{isActiveViewportStack && (
|
{isActiveViewportStack && (
|
||||||
<div style={{ fontSize: 11, color: '#bbdefb', fontWeight: 700 }}>ACTIVE</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -6988,8 +7428,10 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
background: "rgba(0,0,0,0.4)",
|
background: "rgba(0,0,0,0.4)",
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "flex-start",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "16px 12px",
|
||||||
}}
|
}}
|
||||||
onClick={() => setSettingsOpen(false)}
|
onClick={() => setSettingsOpen(false)}
|
||||||
>
|
>
|
||||||
@@ -7000,9 +7442,13 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat
|
|||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
minWidth: "320px",
|
minWidth: "320px",
|
||||||
minHeight: "180px",
|
minHeight: "180px",
|
||||||
|
maxWidth: "min(960px, 96vw)",
|
||||||
|
maxHeight: "calc(100vh - 32px)",
|
||||||
padding: "24px",
|
padding: "24px",
|
||||||
boxShadow: "0 4px 24px rgba(0,0,0,0.4)",
|
boxShadow: "0 4px 24px rgba(0,0,0,0.4)",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
overflowY: "auto",
|
||||||
|
margin: "0 auto",
|
||||||
}}
|
}}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user