Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
374 changes: 374 additions & 0 deletions extensions/default/src/Toolbar/ToolbarModeSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,374 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { Link, useLocation } from 'react-router-dom';

import {
Button,
cn,
Icons,
Popover,
PopoverContent,
PopoverTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
useImageViewer,
} from '@ohif/ui-next';
import { CommandsManager } from '@ohif/core';
import { useAppConfig } from '@state';
import { useTranslation } from 'react-i18next';

import {
evaluateModeValidity,
fetchStudyEnvelope,
getDataSourcePathSegment,
modeIsValidForOrdering,
type StudyEnvelope,
usePreservedViewerSearch,
type LoadedModeRouteHint,
} from '../utils/modeSelectorUtils';

const HIDDEN_MODE_IDS = new Set(['ohif-gcp-mode']);

type ToolbarMenuRow = {
mode: LoadedModeRouteHint & { displayName?: string; hide?: boolean; isValidMode?: unknown };
validity: Exclude<ReturnType<typeof evaluateModeValidity>, undefined>;
modeHref: { pathname: string; search: string };
isCurrentRoute: boolean;
isDisabled: boolean;
label: string;
};

function ToolbarModeSelector({ commandsManager: _commandsManager, servicesManager }) {
const extensionManager = servicesManager.services.customizationService.extensionManager;
const { t } = useTranslation('ToolbarModeSelector');

const labels = useMemo(
() => ({
browseModes: t('Browse modes'),
modes: t('Modes'),
loadingMetadata: t('Loading study metadata for modes…'),
unableToEvaluate: t('Unable to evaluate this mode'),
currentMode: t('Current mode'),
noModesAvailable: t('No modes available'),
}),
[t]
);

const location = useLocation();
const preservedSearch = usePreservedViewerSearch(location.search);

const [appConfig] = useAppConfig();
const loadedModes = appConfig?.loadedModes || [];
const groupEnabledModesFirst = appConfig?.groupEnabledModesFirst === true;

const imageViewer = useImageViewer();
const StudyInstanceUIDs = imageViewer?.StudyInstanceUIDs;
const primaryUid = Array.isArray(StudyInstanceUIDs) ? StudyInstanceUIDs[0] : undefined;

const [studyEnvelope, setStudyEnvelope] = useState<StudyEnvelope | null>(null);
const [metadataLoadFinished, setMetadataLoadFinished] = useState(false);
const [open, setOpen] = useState(false);

const dataSource = useMemo(
() =>
extensionManager.getActiveDataSourceOrNull?.() ?? extensionManager.getActiveDataSource?.()?.[0],
[extensionManager]
);

useEffect(() => {
let cancelled = false;

setMetadataLoadFinished(false);

async function load() {
if (!primaryUid || !dataSource) {
setStudyEnvelope(null);
setMetadataLoadFinished(true);
return;
}

const env = await fetchStudyEnvelope(primaryUid, dataSource);

if (!cancelled) {
setStudyEnvelope(env);
setMetadataLoadFinished(true);
}
}

load();

return () => {
cancelled = true;
};
}, [primaryUid, dataSource]);

const modesForToolbar = useMemo(
() => loadedModes.filter(m => !m.hide && !HIDDEN_MODE_IDS.has(m.id)),
[loadedModes]
);

const comparableModesList = useMemo(() => {
if (!studyEnvelope || !modesForToolbar?.length) {
return [...modesForToolbar];
}

const list = [...modesForToolbar];

if (groupEnabledModesFirst && studyEnvelope) {
list.sort((a, b) => {
const validA = modeIsValidForOrdering(a, studyEnvelope);
const validB = modeIsValidForOrdering(b, studyEnvelope);
return Number(validB) - Number(validA);
});
}

return list;
}, [groupEnabledModesFirst, modesForToolbar, studyEnvelope]);

const buildHrefForMode = useCallback(
(routeName: string) => {
const dsSuffix = getDataSourcePathSegment(location.pathname, loadedModes, extensionManager);
const pathname = `/${routeName}${dsSuffix ? `/${dsSuffix}` : ''}`;
return { pathname, search: preservedSearch };
},
[extensionManager, loadedModes, location.pathname, preservedSearch]
);

const closePopover = useCallback(() => {
setOpen(false);
}, []);

const modeMenuRows = useMemo((): ToolbarMenuRow[] => {
if (!studyEnvelope) {
return [];
}
const rows: ToolbarMenuRow[] = [];
for (const mode of comparableModesList) {
const validity = evaluateModeValidity(mode, studyEnvelope, labels.unableToEvaluate);
if (!validity || validity.valid === null) {
continue;
}
const routeName = mode.routeName;
if (!routeName) {
continue;
}
const modeHref = buildHrefForMode(routeName);
const isCurrentRoute = location.pathname === modeHref.pathname;
const isDisabled = validity.valid !== true || isCurrentRoute;
const label = mode.displayName || routeName;
rows.push({ mode, validity, modeHref, isCurrentRoute, isDisabled, label });
}
return rows;
}, [buildHrefForMode, comparableModesList, labels.unableToEvaluate, location.pathname, studyEnvelope]);

if (modesForToolbar.length <= 1) {
return null;
}

if (!primaryUid) {
return null;
}

if (!studyEnvelope) {
if (!metadataLoadFinished) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled
className={cn(
'inline-flex h-10 w-10 cursor-not-allowed items-center justify-center !rounded-lg',
'opacity-40 text-foreground/80 hover:bg-muted hover:text-highlight'
)}
aria-label={labels.browseModes}
data-cy="mode-selector-trigger"
>
<Icons.ByName
name="icon-list-view"
className="text-muted-foreground h-7 w-7"
/>
</Button>
</TooltipTrigger>
<TooltipContent
side="bottom"
className="max-w-[260px]"
>
<p className="text-xs leading-snug">{labels.loadingMetadata}</p>
</TooltipContent>
</Tooltip>
);
}

return null;
}

return (
<Popover
open={open}
onOpenChange={setOpen}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
'inline-flex h-10 w-10 items-center justify-center !rounded-lg',
open
? 'bg-background text-foreground/80'
: 'bg-transparent text-foreground/80 hover:bg-background hover:text-highlight'
)}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={labels.browseModes}
data-cy="mode-selector-trigger"
>
<Icons.ByName
name="icon-list-view"
className="h-7 w-7"
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent
side="bottom"
sideOffset={6}
>
<p className="text-xs">{labels.browseModes}</p>
</TooltipContent>
</Tooltip>
<PopoverContent
align="center"
sideOffset={10}
className={cn(
'bg-popover/98 text-popover-foreground z-[100] flex w-[min(100vw-1.75rem,17.5rem)] max-w-none flex-col overflow-visible rounded-lg border border-border/80 p-0 shadow-xl shadow-black/10 backdrop-blur-md dark:border-border/55 dark:bg-popover dark:shadow-black/35',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95'
)}
>
<header className="border-b border-border/40 px-2.5 py-1.5">
<p className="text-muted-foreground text-xs">{labels.modes}</p>
</header>

<ul
role="menu"
className="space-y-0.5 px-1.5 py-1.5"
>
{modeMenuRows.length === 0 ?
<li className="list-none">
<p className="text-muted-foreground px-2 py-2 text-xs leading-snug">{labels.noModesAvailable}</p>
</li>
: modeMenuRows.map(
({
mode: { routeName },
validity,
modeHref,
isCurrentRoute,
isDisabled,
label,
}) => (
<li
key={routeName}
className="list-none"
>
{!isDisabled ?
<Link
role="menuitem"
tabIndex={0}
data-cy={`mode-selector-${routeName}`}
to={modeHref}
className={cn(
'group flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5',
'text-foreground outline-none ring-offset-background transition-colors duration-150',
'hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-primary/35 focus-visible:ring-offset-1 active:bg-accent/90'
)}
onClick={closePopover}
>
<span className="min-w-0 flex-1 truncate text-left text-sm leading-snug">{label}</span>
<Icons.ChevronRight
aria-hidden
className={cn(
'h-3.5 w-3.5 shrink-0 opacity-35 transition-opacity duration-150',
'text-muted-foreground group-hover:translate-x-px group-hover:opacity-100'
)}
/>
</Link>
: isCurrentRoute ?
<div
aria-current="true"
aria-label={`${label} — ${labels.currentMode}`}
className={cn(
'relative flex w-full cursor-default items-center justify-between gap-2 overflow-hidden rounded-lg px-2 py-1.5',
'border border-primary/18 bg-gradient-to-br from-accent/85 via-accent/50 to-accent/30 text-foreground',
'shadow-[inset_0_1px_0_rgb(255_255_255/0.12)] ring-1 ring-inset ring-border/50',
'dark:from-accent/35 dark:via-accent/25 dark:to-muted/55 dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.04)] dark:ring-border/35'
)}
data-cy={`mode-selector-current-${routeName}`}
>
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium leading-snug tracking-tight text-foreground">
{label}
</span>
<span
aria-hidden
className="h-3.5 w-3.5 shrink-0 self-center select-none opacity-0"
/>
</div>
: validity.description ?
<Tooltip>
<TooltipTrigger asChild>
<div
role="presentation"
className={cn(
'flex w-full cursor-not-allowed rounded-lg px-2 py-1.5 text-muted-foreground opacity-[0.88]'
)}
data-cy={`mode-selector-disabled-${routeName}`}
>
<span className="min-w-0 flex-1 truncate text-left text-sm leading-snug">{label}</span>
</div>
</TooltipTrigger>
<TooltipContent
align="start"
side="top"
sideOffset={6}
className="z-[220] max-w-[236px]"
>
<p className="text-xs leading-snug">{validity.description}</p>
</TooltipContent>
</Tooltip>
: (
<div
role="presentation"
className={cn(
'flex w-full cursor-not-allowed rounded-lg px-2 py-1.5 text-muted-foreground opacity-[0.88]'
)}
data-cy={`mode-selector-disabled-${routeName}`}
>
<span className="min-w-0 flex-1 truncate text-left text-sm leading-snug">{label}</span>
</div>
)
}
</li>
)
)
}
</ul>
</PopoverContent>
</Popover>
);
}

ToolbarModeSelector.propTypes = {
commandsManager: PropTypes.instanceOf(CommandsManager),
servicesManager: PropTypes.shape({
services: PropTypes.shape({
customizationService: PropTypes.object.isRequired,
}).isRequired,
}).isRequired,
};

export default ToolbarModeSelector;
1 change: 1 addition & 0 deletions extensions/default/src/Toolbar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './ToolRowWrapper';
export * from './ToolBoxWrapper';
export * from './ToolbarDivider';
export * from './ToolbarLayoutSelector';
export { default as ToolbarModeSelector } from './ToolbarModeSelector';
6 changes: 6 additions & 0 deletions extensions/default/src/getToolbarModule.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { utils } from '@ohif/ui-next';

import ToolbarLayoutSelectorWithServices from './Toolbar/ToolbarLayoutSelector';
import ToolbarModeSelectorWithServices from './Toolbar/ToolbarModeSelector';

// legacy
import { ProgressDropdownWithService } from './Components/ProgressDropdownWithService';
Expand Down Expand Up @@ -42,6 +43,11 @@ export default function getToolbarModule({ commandsManager, servicesManager }: w
defaultComponent: props =>
ToolbarLayoutSelectorWithServices({ ...props, commandsManager, servicesManager }),
},
{
name: 'ohif.modeSelector',
defaultComponent: props =>
ToolbarModeSelectorWithServices({ ...props, commandsManager, servicesManager }),
},
{
name: 'ohif.progressDropdown',
defaultComponent: ProgressDropdownWithService,
Expand Down
Loading
Loading