|
1 |
| -import type { FileBrowserFolderFile, PACSFile } from "@fnndsc/chrisapi"; |
2 |
| -import { |
3 |
| - Button, |
4 |
| - Label, |
5 |
| - Text, |
6 |
| - Toolbar, |
7 |
| - ToolbarItem, |
8 |
| - Tooltip, |
9 |
| -} from "@patternfly/react-core"; |
10 |
| -import ResetIcon from "@patternfly/react-icons/dist/esm/icons/history-icon"; |
11 |
| -import { useMutation } from "@tanstack/react-query"; |
12 |
| -import * as dcmjs from "dcmjs"; |
13 |
| -import React, { |
14 |
| - Fragment, |
15 |
| - useCallback, |
16 |
| - useEffect, |
17 |
| - useState, |
18 |
| - type ReactElement, |
19 |
| -} from "react"; |
| 1 | +import React from "react"; |
20 | 2 | import { ErrorBoundary } from "react-error-boundary";
|
21 |
| -import { |
22 |
| - type IFileBlob, |
23 |
| - fileViewerMap, |
24 |
| - getFileExtension, |
25 |
| -} from "../../api/model"; |
26 |
| -import { useAppSelector } from "../../store/hooks"; |
| 3 | +import { Label, Text } from "@patternfly/react-core"; |
27 | 4 | import { SpinContainer } from "../Common";
|
28 |
| -import { |
29 |
| - AddIcon, |
30 |
| - BrightnessIcon, |
31 |
| - InfoIcon, |
32 |
| - PauseIcon, |
33 |
| - PlayIcon, |
34 |
| - RotateIcon, |
35 |
| - RulerIcon, |
36 |
| - SearchIcon, |
37 |
| - ZoomIcon, |
38 |
| -} from "../Icons"; // Import PlayIcon |
39 |
| -import { TagInfoModal } from "./HelperComponent"; |
40 |
| - |
41 |
| -const ViewerDisplay = React.lazy(() => import("./displays/ViewerDisplay")); |
| 5 | +import type { FileBrowserFolderFile, PACSFile } from "@fnndsc/chrisapi"; |
| 6 | +import { getFileExtension, fileViewerMap } from "../../api/model"; |
| 7 | +import ViewerDisplay from "./displays/ViewerDisplay"; |
42 | 8 |
|
43 | 9 | interface AllProps {
|
44 | 10 | selectedFile?: FileBrowserFolderFile | PACSFile;
|
45 |
| - isDicom?: boolean; |
46 | 11 | preview: "large" | "small";
|
47 | 12 | handleNext?: () => void;
|
48 | 13 | handlePrevious?: () => void;
|
49 |
| - gallery?: boolean; |
50 |
| - // These props enable pagination and fetch on scroll |
51 |
| - list?: IFileBlob[]; |
52 |
| - fetchMore?: boolean; |
53 |
| - handlePagination?: () => void; |
54 |
| - filesLoading?: boolean; |
55 |
| -} |
56 |
| - |
57 |
| -export interface ActionState { |
58 |
| - [key: string]: boolean | string; |
59 | 14 | }
|
60 | 15 |
|
61 |
| -const FileDetailView = (props: AllProps) => { |
62 |
| - const [tagInfo, setTagInfo] = useState<any>(); |
63 |
| - const [actionState, setActionState] = useState<ActionState>({ |
64 |
| - Zoom: false, |
65 |
| - previouslyActive: "", |
66 |
| - }); |
67 |
| - const [parsingError, setParsingError] = useState<string>(""); |
68 |
| - const drawerState = useAppSelector((state) => state.drawers); |
69 |
| - |
70 |
| - const handleKeyboardEvents = useCallback( |
71 |
| - (event: any) => { |
72 |
| - switch (event.keyCode) { |
73 |
| - case 39: { |
74 |
| - event.preventDefault(); |
75 |
| - props.handleNext?.(); |
76 |
| - break; |
77 |
| - } |
78 |
| - |
79 |
| - case 37: { |
80 |
| - event.preventDefault(); |
81 |
| - props.handlePrevious?.(); |
82 |
| - break; |
83 |
| - } |
84 |
| - |
85 |
| - default: |
86 |
| - break; |
87 |
| - } |
88 |
| - }, |
89 |
| - [props], |
90 |
| - ); |
91 |
| - |
92 |
| - useEffect(() => { |
93 |
| - window.addEventListener("keydown", handleKeyboardEvents); |
94 |
| - |
95 |
| - return () => { |
96 |
| - window.removeEventListener("keydown", handleKeyboardEvents); |
97 |
| - }; |
98 |
| - }, [handleKeyboardEvents]); |
99 |
| - |
100 |
| - const displayTagInfo = useCallback(async () => { |
101 |
| - try { |
102 |
| - const blob = await selectedFile?.getFileBlob(); |
103 |
| - if (!blob) { |
104 |
| - setParsingError("Failed to retrieve the file blob"); |
105 |
| - return; |
106 |
| - } |
107 |
| - |
108 |
| - const arrayBuffer = await blob.arrayBuffer(); |
109 |
| - const dicomData = dcmjs.data.DicomMessage.readFile(arrayBuffer); |
110 |
| - const dataset = dcmjs.data.DicomMetaDictionary.naturalizeDataset( |
111 |
| - dicomData.dict, |
112 |
| - ); |
113 |
| - // Sort the dataset keys alphabetically |
114 |
| - const sortedDataset = Object.keys(dataset) |
115 |
| - .sort() |
116 |
| - .reduce((sortedObj: any, key) => { |
117 |
| - sortedObj[key] = dataset[key]; |
118 |
| - return sortedObj; |
119 |
| - }, {}); |
120 |
| - |
121 |
| - setTagInfo(sortedDataset); |
122 |
| - } catch (error) { |
123 |
| - console.error("Error parsing DICOM file:", error); |
124 |
| - setParsingError("Failed to parse the file for DICOM tags"); |
125 |
| - } |
126 |
| - }, []); |
127 |
| - |
128 |
| - const mutation = useMutation({ |
129 |
| - mutationFn: displayTagInfo, |
130 |
| - onError: (error: any) => { |
131 |
| - setParsingError(error.message); |
132 |
| - }, |
133 |
| - }); |
134 |
| - |
135 |
| - const { selectedFile, preview, fetchMore, handlePagination, filesLoading } = |
136 |
| - props; |
| 16 | +export default function FileDetailView(props: AllProps) { |
| 17 | + const { selectedFile, preview } = props; |
137 | 18 | let viewerName = "";
|
138 |
| - const fileType = getFileExtension(selectedFile?.data.fname); |
139 |
| - if (fileType) { |
140 |
| - if (!fileViewerMap[fileType]) { |
141 |
| - viewerName = "CatchallDisplay"; |
142 |
| - } else { |
| 19 | + if (selectedFile) { |
| 20 | + const fileType = getFileExtension(selectedFile.data?.fname); |
| 21 | + if (fileType && fileViewerMap[fileType]) { |
143 | 22 | viewerName = fileViewerMap[fileType];
|
| 23 | + } else { |
| 24 | + viewerName = "CatchallDisplay"; |
144 | 25 | }
|
145 | 26 | }
|
146 | 27 |
|
147 |
| - const handleEvents = (action: string, previouslyActive: string) => { |
148 |
| - if (action === "TagInfo" && selectedFile) { |
149 |
| - mutation.mutate(); |
150 |
| - } |
151 |
| - const currentAction = actionState[action]; |
152 |
| - setActionState({ |
153 |
| - [action]: !currentAction, |
154 |
| - previouslyActive, |
155 |
| - }); |
156 |
| - }; |
157 |
| - |
158 |
| - const handleModalToggle = ( |
159 |
| - actionName: string, |
160 |
| - value: boolean, |
161 |
| - previouslyActive: string, |
162 |
| - ) => { |
163 |
| - setActionState({ |
164 |
| - [actionName]: value, |
165 |
| - previouslyActive, |
166 |
| - }); |
167 |
| - }; |
168 |
| - |
169 |
| - const previewType = preview === "large" ? "large-preview" : "small-preview"; |
170 |
| - |
171 |
| - const errorComponent = (error?: string) => ( |
172 |
| - <span> |
173 |
| - <Label |
174 |
| - icon={<InfoIcon className="pf-v5-svg" />} |
175 |
| - color="red" |
176 |
| - href="#filled" |
177 |
| - > |
178 |
| - <Text component="p"> |
179 |
| - {error |
180 |
| - ? error |
181 |
| - : "Oh snap! Looks like there was an error. Please refresh the browser or try again."} |
182 |
| - </Text> |
183 |
| - </Label> |
184 |
| - </span> |
| 28 | + const errorComponent = (err?: string) => ( |
| 29 | + <Label color="red"> |
| 30 | + <Text>{err ?? "Error. Refresh or try again."}</Text> |
| 31 | + </Label> |
185 | 32 | );
|
186 | 33 |
|
187 |
| - const fullScreen = drawerState.preview.maximized === true; |
188 |
| - |
189 | 34 | return (
|
190 |
| - <Fragment> |
191 |
| - <React.Suspense fallback={<SpinContainer title="" />}> |
192 |
| - <ErrorBoundary fallback={errorComponent()}> |
193 |
| - <div className={previewType}> |
194 |
| - {previewType === "large-preview" && ( |
195 |
| - <DicomHeader |
196 |
| - viewerName={viewerName} |
197 |
| - handleEvents={handleEvents} |
198 |
| - fullScreen={fullScreen} |
199 |
| - actionState={actionState} |
200 |
| - /> |
201 |
| - )} |
202 |
| - |
203 |
| - {selectedFile && ( |
204 |
| - <ViewerDisplay |
205 |
| - preview={preview} |
206 |
| - viewerName={viewerName} |
207 |
| - actionState={actionState} |
208 |
| - selectedFile={selectedFile} |
209 |
| - // Optional for dicom scrolling |
210 |
| - list={props.list} |
211 |
| - fetchMore={fetchMore} |
212 |
| - handlePagination={handlePagination} |
213 |
| - filesLoading={filesLoading} |
214 |
| - /> |
215 |
| - )} |
216 |
| - </div> |
217 |
| - |
218 |
| - <TagInfoModal |
219 |
| - isDrawer={true} |
220 |
| - handleModalToggle={(actionState, toolState) => { |
221 |
| - const previouslyActive = Object.keys(actionState)[0]; |
222 |
| - handleModalToggle(actionState, toolState, previouslyActive); |
223 |
| - }} |
224 |
| - isModalOpen={actionState.TagInfo as boolean} |
225 |
| - output={tagInfo} |
226 |
| - parsingError={parsingError} |
| 35 | + <React.Suspense fallback={<SpinContainer title="Loading..." />}> |
| 36 | + <ErrorBoundary fallback={errorComponent()}> |
| 37 | + {selectedFile && ( |
| 38 | + <ViewerDisplay |
| 39 | + preview={preview} |
| 40 | + viewerName={viewerName} |
| 41 | + selectedFile={selectedFile} |
227 | 42 | />
|
228 |
| - </ErrorBoundary> |
229 |
| - </React.Suspense> |
230 |
| - </Fragment> |
| 43 | + )} |
| 44 | + </ErrorBoundary> |
| 45 | + </React.Suspense> |
231 | 46 | );
|
232 |
| -}; |
233 |
| - |
234 |
| -export default FileDetailView; |
235 |
| - |
236 |
| -const actions = [ |
237 |
| - { |
238 |
| - name: "Zoom", |
239 |
| - icon: <ZoomIcon />, |
240 |
| - }, |
241 |
| - { |
242 |
| - name: "Pan", |
243 |
| - icon: <SearchIcon />, |
244 |
| - }, |
245 |
| - { |
246 |
| - name: "Magnify", |
247 |
| - icon: ( |
248 |
| - // We could be using a better icon here. |
249 |
| - <AddIcon /> |
250 |
| - ), |
251 |
| - }, |
252 |
| - { |
253 |
| - name: "PlanarRotate", |
254 |
| - icon: <RotateIcon />, |
255 |
| - }, |
256 |
| - { |
257 |
| - name: "WindowLevel", |
258 |
| - icon: <BrightnessIcon />, |
259 |
| - }, |
260 |
| - { |
261 |
| - name: "Reset", |
262 |
| - icon: <ResetIcon />, |
263 |
| - }, |
264 |
| - { |
265 |
| - name: "Length", |
266 |
| - icon: <RulerIcon />, |
267 |
| - }, |
268 |
| - { |
269 |
| - name: "TagInfo", |
270 |
| - icon: <InfoIcon />, |
271 |
| - }, |
272 |
| - { |
273 |
| - name: "Play", |
274 |
| - icon: <PlayIcon />, // Add Play icon |
275 |
| - }, |
276 |
| -]; |
277 |
| - |
278 |
| -const getViewerSpecificActions: { |
279 |
| - [key: string]: { name: string; icon: ReactElement }[]; |
280 |
| -} = { |
281 |
| - DcmDisplay: actions, |
282 |
| - NiftiDisplay: actions, |
283 |
| - ImageDisplay: actions, |
284 |
| -}; |
285 |
| - |
286 |
| -export const DicomHeader = ({ |
287 |
| - handleEvents, |
288 |
| - viewerName, |
289 |
| - fullScreen, |
290 |
| - actionState, |
291 |
| -}: { |
292 |
| - viewerName: string; |
293 |
| - handleEvents: (action: string, previouslyActive: string) => void; |
294 |
| - fullScreen: boolean; |
295 |
| - actionState: ActionState; |
296 |
| -}) => { |
297 |
| - const specificActions = getViewerSpecificActions[viewerName]; |
298 |
| - |
299 |
| - const appLauncherItems = |
300 |
| - specificActions && |
301 |
| - specificActions.length > 0 && |
302 |
| - specificActions.map((action) => { |
303 |
| - const spacer: { |
304 |
| - xl?: "spacerLg"; |
305 |
| - lg?: "spacerLg"; |
306 |
| - md?: "spacerMd"; |
307 |
| - sm?: "spacerSm"; |
308 |
| - } = { |
309 |
| - xl: "spacerLg", |
310 |
| - lg: "spacerLg", |
311 |
| - md: "spacerMd", |
312 |
| - sm: "spacerSm", |
313 |
| - }; |
314 |
| - |
315 |
| - // Dynamically set the icon for Play/Pause button |
316 |
| - let icon = action.icon; |
317 |
| - if (action.name === "Play") { |
318 |
| - icon = actionState.Play ? <PauseIcon /> : <PlayIcon />; |
319 |
| - } |
320 |
| - |
321 |
| - return ( |
322 |
| - <ToolbarItem spacer={spacer} key={action.name}> |
323 |
| - <Tooltip content={<span>{action.name}</span>}> |
324 |
| - <Button |
325 |
| - className={`${ |
326 |
| - fullScreen ? "large-button" : "small-button" |
327 |
| - } button-style`} |
328 |
| - variant={ |
329 |
| - actionState[action.name] === true ? "primary" : "control" |
330 |
| - } |
331 |
| - size="sm" |
332 |
| - icon={icon} |
333 |
| - onClick={(ev) => { |
334 |
| - const previouslyActive = Object.keys(actionState)[0]; |
335 |
| - ev.preventDefault(); |
336 |
| - handleEvents(action.name, previouslyActive); |
337 |
| - }} |
338 |
| - aria-label={action.name} |
339 |
| - /> |
340 |
| - </Tooltip> |
341 |
| - </ToolbarItem> |
342 |
| - ); |
343 |
| - }); |
344 |
| - |
345 |
| - return <Toolbar className="centered-container">{appLauncherItems}</Toolbar>; |
346 |
| -}; |
| 47 | +} |
0 commit comments