fix(referencelines): and bring back the notification for worker updates but disallow duplication (#5005)
@ -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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@ -4,7 +4,7 @@ const collapsedOutsideBorderSize = 4;
|
||||
const collapsedWidth = 25;
|
||||
|
||||
const rightPanelInitialExpandedWidth = 280;
|
||||
const leftPanelInitialExpandedWidth = 292;
|
||||
const leftPanelInitialExpandedWidth = 282;
|
||||
|
||||
const panelGroupDefinition = {
|
||||
groupId: 'viewerLayoutResizablePanelGroup',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = ({
|
||||
<ReactErrorBoundary
|
||||
fallbackRender={props => (
|
||||
<FallbackComponent
|
||||
error={props.error}
|
||||
{...props}
|
||||
context={context}
|
||||
resetErrorBoundary={props.resetErrorBoundary}
|
||||
/>
|
||||
)}
|
||||
onReset={onResetHandler}
|
||||
|
||||
@ -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<Record<string, NotificationCacheEntry>>({});
|
||||
|
||||
// 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 (
|
||||
<NotificationContext.Provider value={{ show, hide, hideAll }}>
|
||||
<NotificationContext.Provider value={{ show, hide, hideAll, getNotificationCache }}>
|
||||
<Toaster position="bottom-right" />
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
@ -71,6 +206,8 @@ const NotificationProvider = ({ children, service }) => {
|
||||
|
||||
NotificationProvider.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
service: PropTypes.object,
|
||||
deduplicationInterval: PropTypes.number,
|
||||
};
|
||||
|
||||
export const withNotification = Component => {
|
||||
|
||||
|
Before Width: | Height: | Size: 327 KiB After Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 297 KiB |