diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index e116c6134..fb069b4e5 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -289,10 +289,16 @@ export default async function init({ eventTarget.addEventListenerDebounced( EVENTS.ERROR_EVENT, ({ detail }) => { + // Create a stable ID for deduplication based on error type and message + const errorId = `cornerstone-error-${detail.type}-${detail.message.substring(0, 50)}`; + uiNotificationService.show({ title: detail.type, message: detail.message, type: 'error', + id: errorId, + allowDuplicates: false, // Prevent duplicate error notifications + deduplicationInterval: 30000, // 30 seconds deduplication window }); }, 100 @@ -310,37 +316,73 @@ export default async function init({ } function initializeWebWorkerProgressHandler(uiNotificationService) { - const activeToasts = new Map(); + // Use a single map to track all active worker tasks + const activeWorkerTasks = new Map(); - // eventTarget.addEventListener(EVENTS.WEB_WORKER_PROGRESS, ({ detail }) => { - // const { progress, type, id } = detail; + // Create a normalized task key that doesn't include the random ID + // This helps us identify and deduplicate the same type of task + const getNormalizedTaskKey = type => { + return `worker-task-${type.toLowerCase().replace(/\s+/g, '-')}`; + }; - // const cacheKey = `${type}-${id}`; - // if (progress === 0 && !activeToasts.has(cacheKey)) { - // const progressPromise = new Promise((resolve, reject) => { - // activeToasts.set(cacheKey, { resolve, reject }); - // }); + eventTarget.addEventListener(EVENTS.WEB_WORKER_PROGRESS, ({ detail }) => { + const { progress, type, id } = detail; - // uiNotificationService.show({ - // id: cacheKey, - // title: `${type}`, - // message: `${type}: ${progress}%`, - // autoClose: false, - // promise: progressPromise, - // promiseMessages: { - // loading: `Computing...`, - // success: `Completed successfully`, - // error: 'Web Worker failed', - // }, - // }); - // } else { - // if (progress === 100) { - // const { resolve } = activeToasts.get(cacheKey); - // resolve({ progress, type }); - // activeToasts.delete(cacheKey); - // } - // } - // }); + // Skip notifications for compute statistics + if (type === cornerstoneTools.Enums.WorkerTypes.COMPUTE_STATISTICS) { + return; + } + + const normalizedKey = getNormalizedTaskKey(type); + + if (progress === 0) { + // Check if we're already tracking a task of this type + if (!activeWorkerTasks.has(normalizedKey)) { + const progressPromise = new Promise((resolve, reject) => { + activeWorkerTasks.set(normalizedKey, { + resolve, + reject, + originalId: id, + type, + }); + }); + + uiNotificationService.show({ + id: normalizedKey, // Use the normalized key as ID for better deduplication + title: `${type}`, + message: `Computing...`, + autoClose: false, + allowDuplicates: false, + deduplicationInterval: 60000, // 60 seconds - prevent frequent notifications of same type + promise: progressPromise, + promiseMessages: { + loading: `Computing...`, + success: `Completed successfully`, + error: 'Web Worker failed', + }, + }); + } else { + // Already tracking this type of task, just let it continue + console.debug(`Already tracking a "${type}" task, skipping duplicate notification`); + } + } + // Task completed + else if (progress === 100) { + // Check if we have this task type in our tracking map + const taskData = activeWorkerTasks.get(normalizedKey); + + if (taskData) { + // Resolve the promise to update the notification + const { resolve } = taskData; + resolve({ progress, type }); + + // Remove from tracking + activeWorkerTasks.delete(normalizedKey); + + console.debug(`Worker task "${type}" completed successfully`); + } + } + }); } /** diff --git a/extensions/cornerstone/src/initCornerstoneTools.js b/extensions/cornerstone/src/initCornerstoneTools.js index d6627408b..2837496bb 100644 --- a/extensions/cornerstone/src/initCornerstoneTools.js +++ b/extensions/cornerstone/src/initCornerstoneTools.js @@ -48,6 +48,8 @@ import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool'; export default function initCornerstoneTools(configuration = {}) { CrosshairsTool.isAnnotation = false; + LabelmapSlicePropagationTool.isAnnotation = false; + MarkerLabelmapTool.isAnnotation = false; ReferenceLinesTool.isAnnotation = false; AdvancedMagnifyTool.isAnnotation = false; PlanarFreehandContourSegmentationTool.isAnnotation = false; diff --git a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx index 2bbbda1dc..19c00feb4 100644 --- a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx +++ b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx @@ -180,15 +180,12 @@ const CornerstoneViewportDownloadForm = ({ const toolGroup = ToolGroupManager.getToolGroupForViewport(activeViewportId, renderingEngineId); toolGroup.addViewport(downloadViewportId, renderingEngineId); - Object.keys(toolGroup.getToolInstances()).forEach(toolName => { - if (show && toolName !== 'Crosshairs') { - try { - toolGroup.setToolEnabled(toolName); - } catch (error) { - console.debug('Error enabling tool:', error); - } - } else { - toolGroup.setToolDisabled(toolName); + const toolInstances = toolGroup.getToolInstances(); + const toolInstancesArray = Object.values(toolInstances); + + toolInstancesArray.forEach(toolInstance => { + if (toolInstance.constructor.isAnnotation !== false) { + toolGroup.setToolEnabled(toolInstance.toolName); } }); }; diff --git a/extensions/default/src/ViewerLayout/constants/panels.ts b/extensions/default/src/ViewerLayout/constants/panels.ts index 2c6684032..3219e6c51 100644 --- a/extensions/default/src/ViewerLayout/constants/panels.ts +++ b/extensions/default/src/ViewerLayout/constants/panels.ts @@ -4,7 +4,7 @@ const collapsedOutsideBorderSize = 4; const collapsedWidth = 25; const rightPanelInitialExpandedWidth = 280; -const leftPanelInitialExpandedWidth = 292; +const leftPanelInitialExpandedWidth = 282; const panelGroupDefinition = { groupId: 'viewerLayoutResizablePanelGroup', diff --git a/modes/longitudinal/src/toolbarButtons.ts b/modes/longitudinal/src/toolbarButtons.ts index 48f38a2d3..14c1e9c10 100644 --- a/modes/longitudinal/src/toolbarButtons.ts +++ b/modes/longitudinal/src/toolbarButtons.ts @@ -120,8 +120,8 @@ const toolbarButtons: Button[] = [ tooltip: 'Show Reference Lines', commands: 'toggleEnabledDisabledToolbar', listeners: { - [ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: callbacks('ReferenceLinesTool'), - [ViewportGridService.EVENTS.VIEWPORTS_READY]: callbacks('ReferenceLinesTool'), + [ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: callbacks('ReferenceLines'), + [ViewportGridService.EVENTS.VIEWPORTS_READY]: callbacks('ReferenceLines'), }, evaluate: [ 'evaluate.cornerstoneTool.toggle', diff --git a/platform/core/src/services/UINotificationService/index.ts b/platform/core/src/services/UINotificationService/index.ts index f1f565d81..eada24005 100644 --- a/platform/core/src/services/UINotificationService/index.ts +++ b/platform/core/src/services/UINotificationService/index.ts @@ -60,17 +60,24 @@ class UINotificationService { * @param {string} [notification.promiseMessages.loading] - Message to show while promise is pending * @param {string | function} [notification.promiseMessages.success] - Message to show when promise resolves * @param {string | function} [notification.promiseMessages.error] - Message to show when promise rejects + * @param {object} [notification.action] - Action button configuration + * @param {string} notification.action.label - The label for the action button + * @param {function} notification.action.onClick - The function to call when the action button is clicked * @returns {string} id - The ID of the created notification */ show({ title, message, - duration = 5000, + duration = 2000, position = 'bottom-right', type = 'info', autoClose = true, promise, promiseMessages, + id, + allowDuplicates = false, + deduplicationInterval = 30000, + action, }: { title: string; message: string | ((data?: any) => string); @@ -90,6 +97,13 @@ class UINotificationService { success?: string | ((data: any) => string); error?: string | ((error: any) => string); }; + id?: string; + allowDuplicates?: boolean; + deduplicationInterval?: number; + action?: { + label: string; + onClick: () => void; + }; }): string { if (promise && promiseMessages) { const loadingId = serviceImplementation._show({ @@ -98,6 +112,9 @@ class UINotificationService { type: 'loading', autoClose: false, position, + id: id ? `${id}-loading` : undefined, + allowDuplicates, + deduplicationInterval, }); promise.then( @@ -114,6 +131,10 @@ class UINotificationService { duration, position, autoClose, + id: id ? `${id}-success` : undefined, + allowDuplicates, + deduplicationInterval, + action, }); this.hide(loadingId); }, @@ -130,6 +151,10 @@ class UINotificationService { duration, position, autoClose, + id: id ? `${id}-error` : undefined, + allowDuplicates, + deduplicationInterval, + action, }); this.hide(loadingId); } @@ -145,6 +170,10 @@ class UINotificationService { position, type, autoClose, + id, + allowDuplicates, + deduplicationInterval, + action, }); } } diff --git a/platform/ui-next/src/components/Errorboundary/ErrorBoundary.tsx b/platform/ui-next/src/components/Errorboundary/ErrorBoundary.tsx index 0ad5c2bfd..4fc51cf7c 100644 --- a/platform/ui-next/src/components/Errorboundary/ErrorBoundary.tsx +++ b/platform/ui-next/src/components/Errorboundary/ErrorBoundary.tsx @@ -1,10 +1,10 @@ import React, { useState, useEffect } from 'react'; -import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary'; +import { ErrorBoundary as ReactErrorBoundary, FallbackProps } from 'react-error-boundary'; import { useTranslation } from 'react-i18next'; -import { toast } from 'sonner'; import { Dialog, DialogContent } from '../Dialog/Dialog'; import { ScrollArea } from '../ScrollArea/ScrollArea'; import { Button } from '../Button/Button'; +import { useNotification } from '../../contextProviders'; const isProduction = process.env.NODE_ENV === 'production'; @@ -112,7 +112,7 @@ interface ErrorBoundaryError extends Error { stack?: string; } -interface DefaultFallbackProps { +interface DefaultFallbackProps extends FallbackProps { error: ErrorBoundaryError; context: string; resetErrorBoundary: () => void; @@ -135,6 +135,8 @@ const DefaultFallback = ({ }: DefaultFallbackProps) => { const { t } = useTranslation('ErrorBoundary'); const [showDetails, setShowDetails] = useState(false); + const { show } = useNotification(); + const title = `${t('Something went wrong')}${!isProduction && ` ${t('in')} ${context}`}.`; const subtitle = t('Sorry, something went wrong there. Try again.'); @@ -143,20 +145,32 @@ const DefaultFallback = ({ const copyErrorToClipboard = () => { if (code) { navigator.clipboard.writeText(code); - toast.success(t('Error copied to clipboard')); + show({ + title: t('Success'), + message: t('Error copied to clipboard'), + type: 'success', + duration: 3000, + }); } }; useEffect(() => { - toast.error(title, { - description: subtitle, + // Use a stable ID based on error message to support deduplication + const errorId = `error-${errorTitle || error.message}`; + + // We don't need to track shown state - instead rely on the notification deduplication system + show({ + title, + message: subtitle, + type: 'error', + duration: 0, + id: errorId, action: { label: t('Show Details'), onClick: () => setShowDetails(true), }, - duration: 0, }); - }, [error, subtitle, t, title]); + }, [error, errorTitle, subtitle, t, title, show]); if (isProduction) { return null; @@ -286,9 +300,8 @@ const ErrorBoundary = ({ ( )} onReset={onResetHandler} diff --git a/platform/ui-next/src/contextProviders/NotificationProvider.tsx b/platform/ui-next/src/contextProviders/NotificationProvider.tsx index 09e526245..e824a4036 100644 --- a/platform/ui-next/src/contextProviders/NotificationProvider.tsx +++ b/platform/ui-next/src/contextProviders/NotificationProvider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useCallback, useEffect, ReactNode } from 'react'; +import React, { createContext, useContext, useCallback, useEffect, ReactNode, useRef } from 'react'; import PropTypes from 'prop-types'; import { Toaster, toast } from '../components'; @@ -6,7 +6,23 @@ const NotificationContext = createContext(null); export const useNotification = () => useContext(NotificationContext); -const NotificationProvider = ({ children, service }) => { +// Notification deduplication cache +interface NotificationCacheEntry { + timestamp: number; + id: string; +} + +interface NotificationProviderProps { + children: ReactNode; + service?: any; + deduplicationInterval?: number; +} + +const NotificationProvider = ({ + children, + service, + deduplicationInterval = 10000, // Default to 10 seconds +}: NotificationProviderProps) => { const DEFAULT_OPTIONS = { title: '', message: '', @@ -15,39 +31,138 @@ const NotificationProvider = ({ children, service }) => { type: 'info', // info, success, error }; + // Cache for recent notifications to prevent duplicates + // Structure: { [title_message_type]: { timestamp, id } } + const recentNotificationsRef = useRef>({}); + + // Use the configurable deduplication interval from props + const show = useCallback(options => { - const { title, message, duration, position, type, promise } = { + const { + title, + message, + duration, + position, + type, + promise, + allowDuplicates = false, + deduplicationInterval: optionsDeduplicationInterval, + action, + } = { ...DEFAULT_OPTIONS, ...options, }; + // Use the provider's deduplicationInterval by default, but allow it to be overridden per notification + const notificationDeduplicationInterval = optionsDeduplicationInterval || deduplicationInterval; + if (promise) { return toast.promise(promise, { loading: title || 'Loading...', - success: (data: unknown) => ({ - title: title || 'Success', - description: typeof message === 'function' ? message(data) : message, - }), - error: (err: unknown) => ({ - title: title || 'Error', - description: typeof message === 'function' ? message(err) : message, - }), + success: (data: unknown) => { + const description = typeof message === 'function' ? message(data) : message; + return { + title: title || 'Success', + description, + }; + }, + error: (err: unknown) => { + const description = typeof message === 'function' ? message(err) : message; + return { + title: title || 'Error', + description, + }; + }, }); } - return toast[type](title, { + // Create a cache key from notification properties + const messageStr = typeof message === 'function' ? 'function' : message; + const cacheKey = `${title}_${messageStr}_${type}`; + + // Handle deduplication + if (!allowDuplicates && type === 'error') { + const now = Date.now(); + const cachedNotification = recentNotificationsRef.current[cacheKey]; + + // First check if we've shown this notification recently + if (cachedNotification) { + const timeSinceLastShown = now - cachedNotification.timestamp; + + // If it's been shown recently and within the deduplication interval, + // don't show it again + if (timeSinceLastShown < notificationDeduplicationInterval) { + console.debug( + `Prevented duplicate notification: "${title}" (${timeSinceLastShown}ms < ${notificationDeduplicationInterval}ms)` + ); + // Return the existing notification ID + return cachedNotification.id; + } + + // If it's been shown before but outside the deduplication interval, + // dismiss the old notification first and allow showing a new one + console.debug( + `Showing notification again after interval: "${title}" (${timeSinceLastShown}ms >= ${notificationDeduplicationInterval}ms)` + ); + toast.dismiss(cachedNotification.id); + } + } + + // Show the notification with action if provided + const toastOptions = { duration, position, description: message, - }); + id: options.id, // Use provided ID if available + }; + + // Add action button if provided + if (action && action.label && typeof action.onClick === 'function') { + toastOptions.action = { + label: action.label, + onClick: action.onClick, + }; + } + + const id = toast[type](title, toastOptions); + + // Cache this notification for deduplication if it's an error + if (type === 'error') { + const timestamp = Date.now(); + recentNotificationsRef.current[cacheKey] = { + timestamp, + id, + }; + + console.debug( + `Stored notification in cache: "${title}" (id: ${id}, timestamp: ${timestamp})` + ); + + // We no longer auto-delete the cache entry after duration + // Instead, we keep it to track when the notification was last shown + // The entry will be checked against the deduplication interval + } + + return id; }, []); const hide = useCallback(id => { toast.dismiss(id); + + // Remove from cache if present + const cacheEntries = Object.entries(recentNotificationsRef.current); + for (const [key, entry] of cacheEntries) { + if (entry.id === id) { + delete recentNotificationsRef.current[key]; + break; + } + } }, []); const hideAll = useCallback(() => { toast.dismiss(); + // Clear notification cache + recentNotificationsRef.current = {}; }, []); /** @@ -61,8 +176,28 @@ const NotificationProvider = ({ children, service }) => { } }, [service, hide, show]); + // Debug function to get the current cache (for development use) + const getNotificationCache = useCallback(() => { + const cache = { ...recentNotificationsRef.current }; + + // Add human-readable timestamps and time since showing + const now = Date.now(); + const enhancedCache = Object.entries(cache).reduce((result, [key, entry]) => { + const timeSince = now - entry.timestamp; + result[key] = { + ...entry, + date: new Date(entry.timestamp).toISOString(), + timeSinceMs: timeSince, + timeSinceStr: `${Math.floor(timeSince / 1000)}s ago`, + }; + return result; + }, {}); + + return enhancedCache; + }, []); + return ( - + {children} @@ -71,6 +206,8 @@ const NotificationProvider = ({ children, service }) => { NotificationProvider.propTypes = { children: PropTypes.node.isRequired, + service: PropTypes.object, + deduplicationInterval: PropTypes.number, }; export const withNotification = Component => { diff --git a/tests/screenshots/chromium/3DFourUp.spec.ts/threeDFourUpDisplayedCorrectly.png b/tests/screenshots/chromium/3DFourUp.spec.ts/threeDFourUpDisplayedCorrectly.png index d1164c47c..cc0000a48 100644 Binary files a/tests/screenshots/chromium/3DFourUp.spec.ts/threeDFourUpDisplayedCorrectly.png and b/tests/screenshots/chromium/3DFourUp.spec.ts/threeDFourUpDisplayedCorrectly.png differ diff --git a/tests/screenshots/chromium/3DMain.spec.ts/threeDMainDisplayedCorrectly.png b/tests/screenshots/chromium/3DMain.spec.ts/threeDMainDisplayedCorrectly.png index a6443b66c..705f91bdd 100644 Binary files a/tests/screenshots/chromium/3DMain.spec.ts/threeDMainDisplayedCorrectly.png and b/tests/screenshots/chromium/3DMain.spec.ts/threeDMainDisplayedCorrectly.png differ diff --git a/tests/screenshots/chromium/AxialPrimary.spec.ts/axialPrimaryDisplayedCorrectly.png b/tests/screenshots/chromium/AxialPrimary.spec.ts/axialPrimaryDisplayedCorrectly.png index 3a35aa4d8..7fdf074ee 100644 Binary files a/tests/screenshots/chromium/AxialPrimary.spec.ts/axialPrimaryDisplayedCorrectly.png and b/tests/screenshots/chromium/AxialPrimary.spec.ts/axialPrimaryDisplayedCorrectly.png differ diff --git a/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsNewDisplayset.png b/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsNewDisplayset.png index bea8498b3..a839ee0af 100644 Binary files a/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsNewDisplayset.png and b/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsNewDisplayset.png differ diff --git a/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsRendered.png b/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsRendered.png index 9edb32170..6e7ea321f 100644 Binary files a/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsRendered.png and b/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsRendered.png differ diff --git a/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsResetToolbar.png b/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsResetToolbar.png index ebd8e0add..4d55b61fd 100644 Binary files a/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsResetToolbar.png and b/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsResetToolbar.png differ diff --git a/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsRotated.png b/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsRotated.png index 5018a5e22..bc6bb78f2 100644 Binary files a/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsRotated.png and b/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsRotated.png differ diff --git a/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsSlabThickness.png b/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsSlabThickness.png index b566310be..faa0a920d 100644 Binary files a/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsSlabThickness.png and b/tests/screenshots/chromium/Crosshairs.spec.ts/crosshairsSlabThickness.png differ diff --git a/tests/screenshots/chromium/MPR.spec.ts/mprDisplayedCorrectly.png b/tests/screenshots/chromium/MPR.spec.ts/mprDisplayedCorrectly.png index 61901ca2f..624a83976 100644 Binary files a/tests/screenshots/chromium/MPR.spec.ts/mprDisplayedCorrectly.png and b/tests/screenshots/chromium/MPR.spec.ts/mprDisplayedCorrectly.png differ diff --git a/tests/screenshots/chromium/SEGHydrationFromMPR.spec.ts/mprAfterSEG.png b/tests/screenshots/chromium/SEGHydrationFromMPR.spec.ts/mprAfterSEG.png index 67c8f700c..8dd00f16a 100644 Binary files a/tests/screenshots/chromium/SEGHydrationFromMPR.spec.ts/mprAfterSEG.png and b/tests/screenshots/chromium/SEGHydrationFromMPR.spec.ts/mprAfterSEG.png differ diff --git a/tests/screenshots/chromium/SEGHydrationFromMPR.spec.ts/mprAfterSegHydrated.png b/tests/screenshots/chromium/SEGHydrationFromMPR.spec.ts/mprAfterSegHydrated.png index 4343542ad..6918e4b21 100644 Binary files a/tests/screenshots/chromium/SEGHydrationFromMPR.spec.ts/mprAfterSegHydrated.png and b/tests/screenshots/chromium/SEGHydrationFromMPR.spec.ts/mprAfterSegHydrated.png differ diff --git a/tests/screenshots/chromium/SEGHydrationFromMPR.spec.ts/mprAfterSegHydratedAfterLayoutChange.png b/tests/screenshots/chromium/SEGHydrationFromMPR.spec.ts/mprAfterSegHydratedAfterLayoutChange.png index 4142f8e84..2d6a61bbe 100644 Binary files a/tests/screenshots/chromium/SEGHydrationFromMPR.spec.ts/mprAfterSegHydratedAfterLayoutChange.png and b/tests/screenshots/chromium/SEGHydrationFromMPR.spec.ts/mprAfterSegHydratedAfterLayoutChange.png differ diff --git a/tests/screenshots/chromium/SEGHydrationThenMPR.spec.ts/segPostHydrationMPRAxialPrimary.png b/tests/screenshots/chromium/SEGHydrationThenMPR.spec.ts/segPostHydrationMPRAxialPrimary.png index 293f5c3e9..cc0118697 100644 Binary files a/tests/screenshots/chromium/SEGHydrationThenMPR.spec.ts/segPostHydrationMPRAxialPrimary.png and b/tests/screenshots/chromium/SEGHydrationThenMPR.spec.ts/segPostHydrationMPRAxialPrimary.png differ