import React, { useEffect, useRef, useState, useCallback, useId } from 'react'; import { RenderingEngine, Enums as CoreEnums, getRenderingEngine, eventTarget } from '@cornerstonejs/core'; import type { Types as CoreTypes } from '@cornerstonejs/core'; import { init as toolsInit, addTool, ToolGroupManager, StackScrollTool, ZoomTool, PanTool, WindowLevelTool, LengthTool, AngleTool, EllipticalROITool, EraserTool, Enums as ToolEnums, utilities as toolsUtilities, annotation } from '@cornerstonejs/tools'; import type { Types as ToolsTypes } from '@cornerstonejs/tools'; import dicomImageLoader from '@cornerstonejs/dicom-image-loader'; import type { Types as DicomLoaderTypes } from '@cornerstonejs/dicom-image-loader'; import { DismissibleAlert } from '../ui/dismissible-alert'; import { Flex, FlexItem } from '../ui/flex'; import { Text } from '../ui/text'; import AppSpinner from '../AppSpinner/AppSpinner.tsx'; import { configureDicomAuth, initDicomLoader } from './dicomLoader.ts'; import type { DicomAuthProvider } from './dicomLoader.ts'; import type { WadoRsFrame } from './healthImagingToWadoRs.ts'; import { useAnnotationAutosave } from './useAnnotationAutosave.ts'; import { toMeasurementRows } from './measurementRows.ts'; import type { MeasurementRow } from './measurementRows.ts'; import { MeasurementPanel, type PromotionStatusOutcome } from './MeasurementPanel.tsx'; import type { StoredAnnotation, StoredAnnotationState } from './annotationKeying.ts'; import type { PromotedMeasurement, PromotionRequest, PromotionResult, PromotionOperationStatus } from '../../services/ImagingMeasurementService.tsx'; import type { AnnotationEdition, OwnedAnnotationRequest, SaveAnswer, SavedAnnotations } from '../../services/ImagingAnnotationService.tsx'; import './DicomViewport.css'; export interface DicomViewportProps { frames: WadoRsFrame[]; auth: DicomAuthProvider; // REQUIRED EVEN THOUGH useAnnotationAutosave NEVER READS IT. That hook's `studyId` option // exists only so callers "naturally have it alongside the rest" (its own comment) - THIS // component is the caller (it invokes the hook, not ImagingStudyViewer), so it is the one that // must hold the value, not a pass-through nobody uses. studyId: string; // Presentation only - see the tool-registration block below. The endpoint's // `allowedRoles: [PRACTITIONERS]` on both /R4/imaging/annotations routes is the enforcement; // this only decides whether the measurement tools and toolbar exist at all. canMeasure: boolean; // The pane says it is a reference copy unless its host already does, as beside a report. markedForReference?: boolean; // What the host shows under the measurements, such as the study's report. aside?: React.ReactNode; // Already restored against the CURRENT frames - this is fromStored's `.annotations` output, // computed by the caller (which is the one that ran fromStored and therefore the one that // knows the `dropped` count; it surfaces that separately). Each entry is added via // annotation.state.addAnnotation once the stack resolves. initialAnnotations: Record[]; // I4: WHAT THE CALLER'S fromStored COULD NOT RESOLVE ON THIS LOAD (SOPInstanceUID absent // from the current frames - a series that failed to import). This component never adds these // to Cornerstone - there is no current image to attach them to - it only forwards them to // useAnnotationAutosave, which must merge them into every save so a load-time drop does not // become a save-time erasure the moment autosave fires. unresolvedAnnotations: StoredAnnotation[]; // The edition the caller's load answered: null for nothing saved, 'loading' until it answers, 'failed' when it // could not be read - which holds saving and promotion while the images stay usable. loadedEdition: AnnotationEdition | null | 'loading' | 'failed'; // A save over the edition this view saw, sent as this view's own request. onSave: (state: StoredAnnotationState, basedOn: AnnotationEdition | null, owned: OwnedAnnotationRequest) => Promise; // GET only: what is saved now, for Check saved measurements. onRead: (owned: OwnedAnnotationRequest) => Promise; // Reloads the saved measurements into a fresh view; the caller has the practitioner confirm any loss first. onReload?: () => void; // Told whether this view holds drawings that are not saved and cannot be saved now, so closing it can say so. onUnsavedChange?: (unsaved: boolean) => void; // SAME SPLIT AS onSave: this component owns Cornerstone, the caller owns services. It decides // WHEN the chart is asked what it already holds - the answer is only meaningful for the uids // currently drawn, and this is the only side that knows those - while the caller decides HOW. // // A REJECTION MEANS "COULD NOT BE CONFIRMED", NEVER "NOTHING IS CHARTED". The two readings are // collapsed at this component's peril: an empty array is rendered as a chart with nothing in it // and promotion stays offered, which for an unanswered question is how a measurement that is // already charted gets promoted a second time. onRefreshChartState: (annotationUids: string[]) => Promise; // Passed through from the panel exactly as it composed it. Which measurements to chart, and which // angle each drawn angle is, are decisions made in the panel where the clinician made them; this // component's only contribution is the guarantee below that the pending autosave landed first. // Promotion names the edition this view's flush admitted, so the server charts that edition's values and no later. // `operation` is minted here, at the press - never by the caller - so a retried press is a new one. onPromote: ( measurements: PromotionRequest[], basedOn: AnnotationEdition, owned: OwnedAnnotationRequest, operation: string ) => Promise; // Read-only. Scoped to this study by the caller; polled alongside every chart refresh so a // promotion that outlived its own request (a reload, a closed tab) is still found and offered // Check/Continue the next time this study is open. Rejects when the status cannot be read. onPromotionStatus?: (owned: OwnedAnnotationRequest) => Promise<{ operations: PromotionOperationStatus[] }>; // Carries forward exactly the not-dispatched tokens named, for the operation named - never a new plan. // Uids named with no token make it a read-only recovery of that operation's outcome. onContinuePromotion?: ( operation: string, effects: { annotationUid: string; withdrawal?: string }[], owned: OwnedAnnotationRequest ) => Promise; } // Two uid lists are the same list when they hold the same ids in the same order. Used to hold the // chart-state key still through the dozens of annotation events a single caliper drag emits: those // change a measurement's VALUE, and re-asking the chart what it holds on each of them would be dozens // of searches for a set of measurements that has not changed. const sameUids = (left: string[], right: string[]): boolean => left.length === right.length && left.every((uid, index) => uid === right[index]); // A restored entry must carry Cornerstone's `data` object to be worth adding - fromStored already // validated the persisted document's shape before producing `initialAnnotations`, so this is a // defensive backstop against something slipping past that, not the primary drop-detection path // (that count comes from the caller's fromStored call and is surfaced there). // An effect whose write may or may not have landed: only an answer naming it can resolve it. const isUnresolved = (clinical: string): boolean => clinical === 'unknown' || clinical === 'admission-unknown'; const looksLikeAnnotation =(value: Record): boolean => typeof value.data === 'object' && value.data !== null; // GUARDS THE DOCUMENT-LEVEL DELETE/BACKSPACE LISTENER BELOW, NOT ANYTHING SCOPED TO THE CANVAS. // That listener has to be global (see its own comment), so without this check a clinician typing // into any ordinary text field elsewhere on the page - a note, a search box - would have every // Backspace keystroke read as "delete the selected measurement" the moment one happened to be // selected from an earlier click. const isEditableTarget = (target: EventTarget | null): boolean => { if (!(target instanceof HTMLElement)) return false; return target.isContentEditable || target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT'; }; const MEASUREMENT_TOOLS: { name: string; label: string }[] = [ { name: LengthTool.toolName, label: 'Length' }, { name: AngleTool.toolName, label: 'Angle' }, { name: EllipticalROITool.toolName, label: 'Ellipse' } ]; // Used by the unmount cleanup below to clear this viewport's contribution to Cornerstone's // annotation singleton - see the C1 comment at the cleanup call site for why that is necessary. const MEASUREMENT_TOOL_NAMES = MEASUREMENT_TOOLS.map(tool => tool.name); // KEPT OUT OF MEASUREMENT_TOOLS ON PURPOSE. Length/Angle/Ellipse each add annotations under their // own tool name, which is exactly what MEASUREMENT_TOOL_NAMES exists to sweep on unmount. This tool // never adds one - clicking with it only ever removes an existing annotation via Cornerstone's own // removeAnnotation - so it has nothing for that sweep to find, and folding it into that list would // misstate what the list tracks. It still shares the measurement tools' primary-button selection // group (see selectTool): choosing it deselects whichever of Length/Angle/Ellipse/Window-Level was // active, the same way choosing among those three already deselects each other. const DELETE_TOOL = { name: EraserTool.toolName, label: 'Delete' }; // REFCOUNTED, BECAUSE THE LOADER'S METADATA MAP IS MODULE-GLOBAL AND KEYED BY IMAGE ID. Two // viewports showing the same study share every key, so an unrefcounted `remove` on the first unmount // blanks the survivor: it keeps rendering already-decoded slices, then goes blank the moment the user // scrolls past the cached range. That is exactly the compare-prior-study case per-instance ids were // introduced to support, so the leak fix must not break it. const metadataRefCounts = new Map(); const retainMetadata = (imageId: string, metadata: DicomLoaderTypes.WADORSMetaData): void => { dicomImageLoader.wadors.metaDataManager.add(imageId, metadata); metadataRefCounts.set(imageId, (metadataRefCounts.get(imageId) ?? 0) + 1); }; const releaseMetadata = (imageId: string): void => { const next = (metadataRefCounts.get(imageId) ?? 1) - 1; if (next > 0) { metadataRefCounts.set(imageId, next); return; } metadataRefCounts.delete(imageId); try { dicomImageLoader.wadors.metaDataManager.remove(imageId); } catch { // A frame that was never registered is not an error worth surfacing. } }; export const DicomViewport: React.FC = ({ frames, auth, studyId, canMeasure, markedForReference = true, aside, initialAnnotations, unresolvedAnnotations, loadedEdition, onSave, onRead, onReload, onUnsavedChange, onRefreshChartState, onPromote, onPromotionStatus, onContinuePromotion }) => { const elementRef = useRef(null); // PER-INSTANCE IDS, NOT MODULE CONSTANTS. Two viewports sharing one engine id is not a // theoretical concern: enableElement with an already-used viewportId calls disableElement on the // first, blanking it, and whichever instance unmounts first destroys the shared engine, blanking // the other. A compare-prior-study layout - or any route transition where two mounts briefly // overlap - would leave one or both panes black with no error. useId is stable across re-renders // and unique per instance; the colons it emits are stripped because these ids end up in DOM // attributes and CSS-selector positions. const instanceId = useId().replace(/:/g, ''); const renderingEngineId = `akashic-dicom-engine-${instanceId}`; const viewportId = `akashic-dicom-viewport-${instanceId}`; const toolGroupId = `akashic-dicom-tools-${instanceId}`; const [ready, setReady] = useState(false); const [error, setError] = useState(null); const [sliceIndex, setSliceIndex] = useState(0); const [activeToolName, setActiveToolName] = useState(WindowLevelTool.toolName); // What the measurement panel lists: the drawings currently on screen, re-derived from // Cornerstone's own state rather than tracked alongside it. const [rows, setRows] = useState([]); // The uid list the chart has been (or is being) asked about, held separately from `rows` so it // only changes when a measurement is actually added or deleted - see sameUids above. const [chartUids, setChartUids] = useState([]); const [promoted, setPromoted] = useState([]); // Operations the server has not yet finished with, for this study - polled read-only, never // written here. Belongs to the opening that asked, exactly like `promoted` above. const [outstanding, setOutstanding] = useState([]); // Until a status read answers, nothing shows that no earlier write is pending: promotion stays off. const [statusAvailable, setStatusAvailable] = useState(false); // Writes this opening sent whose outcome is unproved. An unknown effect is kept until an answer names it. const [localHolds, setLocalHolds] = useState<{ since: number; status: PromotionOperationStatus }[]>([]); // STARTS FALSE, AND THAT IS THE SAFE DIRECTION. Before the first lookup returns, whether these // measurements are already charted is genuinely unknown - a study opened with measurements // restored from a previous session may well have been promoted already. Starting true would offer // promotion during exactly that window. It flips true on the first successful lookup and only // back to false on a failure, so an ordinary re-check (a new measurement changing the uid list) // does not flicker the panel into a warning it will retract a moment later. const [chartStateAvailable, setChartStateAvailable] = useState(false); // A patient/receptionist opening this viewer must never trigger a write - the endpoint // refuses them anyway (allowedRoles: [PRACTITIONERS]), but a hook that subscribes and fires // doomed requests on every render is its own bug. `enabled: canMeasure` is what keeps it from // subscribing at all when it should not. const { status: autosaveStatus, hold, dirty, flush, checkSaved, promotionRequest } = useAnnotationAutosave({ studyId, frames, enabled: canMeasure, loadedEdition, save: onSave, read: onRead, unresolved: unresolvedAnnotations }); const unsaved = Boolean(hold) && dirty; useEffect(() => { onUnsavedChange?.(unsaved); }, [unsaved, onUnsavedChange]); const [checking, setChecking] = useState(false); const reload = useCallback(() => { if (unsaved && !window.confirm('Reloading shows your saved measurements in place of this view. Drawings made here that are not saved will be lost.')) return; onReload?.(); }, [unsaved, onReload]); // The auth provider is re-registered on every change rather than only at mount: the token getter // and the acting-as reader both close over React state that moves during a session. useEffect(() => { configureDicomAuth(auth); }, [auth]); useEffect(() => { if (frames.length === 0) return; const element = elementRef.current; if (!element) return; let cancelled = false; let resizeObserver: ResizeObserver | undefined; let onStackNewImage: ((event: Event) => void) | undefined; let onImageLoadError: ((event: Event) => void) | undefined; // RELEASE ONLY WHAT WAS RETAINED. retainMetadata runs inside setup(), behind an await and a // cancelled check; this cleanup runs unconditionally. Releasing `frames` directly means an // instance whose setup never reached that point still decrements - and releaseMetadata treats a // missing count as 1, so it deletes. A second viewport on the same study, mounted in a hidden // container so its setup is still deferred behind the size check, would unmount and strip the // metadata out from under the live one, which then goes blank as soon as the user scrolls past // the decoded range. That is precisely the regression the refcount was added to prevent. const retained: string[] = []; // RESET EVERY PIECE OF STATE THE PREVIOUS STUDY LEFT BEHIND. Without this, opening a // second study in the same instance keeps the first one's values: `ready` stays true so no // spinner appears and the step buttons are live while the engine is still being rebuilt, and // `sliceIndex` keeps its old value - a 250-slice study followed by an 80-slice one reads // "137 / 80" and announces "Image 137 of 80" while the viewport is actually on slice 1. // // Clearing `error` is the load-bearing one. It is what makes a failure recoverable: without // it, one study that fails (an expired token, one bad image set) leaves the message showing // for every study opened afterwards, for the life of the page. setError(null); setReady(false); setSliceIndex(0); setActiveToolName(WindowLevelTool.toolName); const setup = async () => { try { await initDicomLoader(); toolsInit(); if (cancelled) return; // Registered here rather than in dicomLoader.ts so that module stays free of any // @cornerstonejs/tools import - it is also loaded by unit tests, which have no // business pulling the tools package in. // // The cast narrows to the type the installed loader declares. WadoRsFrame.metadata is // typed `Value: unknown[]` because the converter builds it generically, while // WADORSMetaData wants string[]/number[] and has no `vr`. At runtime the loader's // accessors read only `element.Value[index]` and never look at `vr`, and every value // the converter emits is a string or a number - so this is narrowing a type // TypeScript cannot see through, not silencing a real mismatch. for (const frame of frames) { retainMetadata(frame.imageId, frame.metadata as unknown as DicomLoaderTypes.WADORSMetaData); retained.push(frame.imageId); } const engine = getRenderingEngine(renderingEngineId) ?? new RenderingEngine(renderingEngineId); engine.enableElement({ viewportId, type: CoreEnums.ViewportType.STACK, element }); const viewport = engine.getViewport(viewportId) as CoreTypes.IStackViewport; await viewport.setStack(frames.map(frame => frame.imageId), 0); // Checked again after the long await: by now the effect may have re-run for a new // study, and without this the abandoned setup would carry on and rebuild the very // tool group the cleanup just destroyed. if (cancelled) return; viewport.render(); // THE COUNTER FOLLOWS THE VIEWPORT, NOT THE BUTTONS. StackScrollTool is bound to // the wheel and the on-screen hint tells the user to scroll - but scrolling changes // the displayed image without going through `step()`. Driving the counter only from // the buttons meant a clinician could wheel from slice 1 to slice 137 with the // readout still saying "1 / 250", and the screen reader still announcing image 1. // In a stack viewer the slice number is the primary orientation and hand-off cue, so // a counter that disagrees with the pixels is worse than no counter at all. onStackNewImage = (event: Event) => { const detail = (event as CustomEvent<{ imageIdIndex?: number }>).detail; if (typeof detail?.imageIdIndex === 'number') { setSliceIndex(detail.imageIdIndex); } }; element.addEventListener(CoreEnums.Events.STACK_NEW_IMAGE, onStackNewImage); // WITHOUT THIS, THE MOST LIKELY REAL FAILURE IS COMPLETELY SILENT. `setStack` does // NOT reject when a frame fails to load under GPU rendering: StackViewport wraps each // load in Promise.allSettled and converts rejections into an IMAGE_LOAD_ERROR on the // GLOBAL eventTarget, then resolves. So an expired token or a 403 from the frame proxy // ran straight through to setReady(true) with error still null - black viewport, // working Previous/Next, "1 / 250", and no message anywhere. // // Scoped to this viewport's own image ids: eventTarget is global, and a sibling // viewport's failure is not this one's to report. const ownImageIds = new Set(frames.map(frame => frame.imageId)); onImageLoadError = (event: Event) => { const detail = (event as CustomEvent<{ imageId?: string }>).detail; if (detail?.imageId && ownImageIds.has(detail.imageId)) { setError('Some images in this study could not be loaded.'); } }; eventTarget.addEventListener(CoreEnums.Events.IMAGE_LOAD_ERROR, onImageLoadError); // addTool is idempotent (it no-ops on an already-registered name), so repeat effect // runs are safe. addTool(StackScrollTool); addTool(ZoomTool); addTool(PanTool); addTool(WindowLevelTool); const existing = ToolGroupManager.getToolGroup(toolGroupId); const toolGroup = existing ?? ToolGroupManager.createToolGroup(toolGroupId); if (!toolGroup) { throw new Error('DicomViewport: could not create the tool group'); } if (!existing) { toolGroup.addTool(StackScrollTool.toolName); toolGroup.addTool(ZoomTool.toolName); toolGroup.addTool(PanTool.toolName); toolGroup.addTool(WindowLevelTool.toolName); // Left drag windows, right drag zooms, middle drag pans, wheel scrolls the stack - // the arrangement every radiology workstation uses, so a clinician's existing // habits transfer without instruction. toolGroup.setToolActive(WindowLevelTool.toolName, { bindings: [{ mouseButton: ToolEnums.MouseBindings.Primary }] }); toolGroup.setToolActive(ZoomTool.toolName, { bindings: [{ mouseButton: ToolEnums.MouseBindings.Secondary }] }); toolGroup.setToolActive(PanTool.toolName, { bindings: [{ mouseButton: ToolEnums.MouseBindings.Auxiliary }] }); toolGroup.setToolActive(StackScrollTool.toolName, { bindings: [{ mouseButton: ToolEnums.MouseBindings.Wheel }] }); // ONLY WHEN THE CALLER MAY MEASURE - DRAWING AND DELETING SHARE ONE GATE. // Not added to the tool group at all when canMeasure is false - this is // presentation, the endpoint's allowedRoles: [PRACTITIONERS] on both annotation // routes is the enforcement. A patient viewing their own study never gets a // toolGroup entry for EraserTool, so there is nothing for the toolbar button (or // the document-level Delete/Backspace listener further down, gated the same way) // to select or invoke. // Registered Passive, not left unregistered-then-activated later: Cornerstone's // AnnotationRenderingEngine draws Active/Passive/Enabled tools alike and skips // only Disabled ones, so Passive is what lets restored measurements render // immediately, before the clinician has touched the toolbar. Exactly one of // these (or WindowLevel) moves onto the primary button at a time - see // selectTool below. if (canMeasure) { addTool(LengthTool); addTool(AngleTool); addTool(EllipticalROITool); addTool(EraserTool); toolGroup.addTool(LengthTool.toolName); toolGroup.addTool(AngleTool.toolName); toolGroup.addTool(EllipticalROITool.toolName); toolGroup.addTool(EraserTool.toolName); toolGroup.setToolPassive(LengthTool.toolName); toolGroup.setToolPassive(AngleTool.toolName); toolGroup.setToolPassive(EllipticalROITool.toolName); toolGroup.setToolPassive(EraserTool.toolName); } } toolGroup.addViewport(viewportId, renderingEngineId); // CONTEXT prefetch, not plain stackPrefetch. The plain one walks the stack from the // beginning; this one fills around the CURRENT index and follows scroll direction // (2 before, 2 after, 10 further in the direction of travel), which is what someone // scrolling toward a finding actually needs. Without it, a frame is fetched only when // it is displayed, so every slice costs a full round trip in the user's critical path. // // Concurrency is bounded in dicomLoader.ts - see the note there on why Prefetch is // capped and Interaction is not. toolsUtilities.stackContextPrefetch.enable(element); // KEEP THE CANVAS IN STEP WITH ITS BOX. Cornerstone sizes its canvas from // getBoundingClientRect at FIRST creation only and reuses that canvas thereafter - // engine.destroy() does not remove it. So without this, resizing the window or // collapsing the wp-admin sidebar stretches the image and desynchronises the // pointer-to-world mapping, making window/level and pan stop tracking the cursor. resizeObserver = new ResizeObserver(() => { getRenderingEngine(renderingEngineId)?.resize(true, false); }); resizeObserver.observe(element); // ANNOTATION RESTORATION IS DELIBERATELY NOT HERE. It used to run in this exact // spot, but that made `initialAnnotations` a dependency of THIS effect - and // ImagingStudyViewer fetches annotations in its OWN effect, resolving moments after // frames (see its I3 fix), so on every ordinary study open this effect would tear // down and rebuild the whole engine - destroying and re-enabling the element, // recreating the tool group, re-running setStack - the instant the annotations // arrived. See the dedicated effect below, which only touches Cornerstone's // annotation state, not the rendering engine. if (!cancelled) setReady(true); } catch (setupError) { // Never surface the raw error: a Cornerstone or network error can carry the frame // url, which names the study id. console.error('DicomViewport setup failed:', setupError instanceof Error ? setupError.message : setupError); if (!cancelled) setError('This study could not be opened.'); } }; // DO NOT enableElement A ZERO-SIZED ELEMENT. Cornerstone caches the canvas it creates at // that first size and never re-creates it, so a viewer that first mounts inside a hidden tab // panel, a collapsed accordion, or a modal that animates in gets a 0x0 (or 300x150 default) // canvas and renders blank or as a tiny smear for the LIFE OF THE PAGE - resizing the window // does not recover it. Waiting for a real size costs nothing when there already is one. if (element.clientWidth > 0 && element.clientHeight > 0) { void setup(); } else { const waitForSize = new ResizeObserver(() => { if (element.clientWidth > 0 && element.clientHeight > 0) { waitForSize.disconnect(); if (!cancelled) void setup(); } }); waitForSize.observe(element); resizeObserver = waitForSize; } return () => { cancelled = true; resizeObserver?.disconnect(); if (onStackNewImage) { element.removeEventListener(CoreEnums.Events.STACK_NEW_IMAGE, onStackNewImage); } if (onImageLoadError) { eventTarget.removeEventListener(CoreEnums.Events.IMAGE_LOAD_ERROR, onImageLoadError); } // Metadata is held in a module-level map for the life of the page, so ~250 entries per // study would accumulate monotonically and pin every frames array against GC. Released // through the refcount so a sibling viewport on the same study keeps what it needs. for (const imageId of retained) { releaseMetadata(imageId); } try { toolsUtilities.stackContextPrefetch.disable(element); } catch (teardownError) { console.error('DicomViewport prefetch teardown failed:', teardownError instanceof Error ? teardownError.message : teardownError); } try { ToolGroupManager.destroyToolGroup(toolGroupId); } catch (teardownError) { console.error('DicomViewport tool teardown failed:', teardownError instanceof Error ? teardownError.message : teardownError); } // THE ANNOTATION STORE IS A MODULE-LEVEL SINGLETON THAT NOTHING ELSE CLEARS. // Cornerstone's default FrameOfReferenceSpecificAnnotationManager.addAnnotation ends in a // plain array push with NO dedupe on annotationUID, and only cornerstoneTools' own // destroy() clears it - never called here, since it would tear down every OTHER open // viewport in the page too. Without this, reopening the same study restores fromStored's // clones ALONGSIDE the originals still sitting in the manager under the SAME // annotationUID: both draw into one SVG node keyed by that UID, so it looks fine right up // until the clinician drags one (only the first match moves; the stale twin redraws last // at the old position, so the caliper appears to snap back) or deletes one (only the first // match splices out; the twin survives and the next autosave re-persists it, doubling the // stored document on every open/measure/close cycle). // // BEFORE engine.destroy(), NOT AFTER: removeAnnotations resolves this element's group via // getEnabledElement, which engine.destroy() (below) tears down. // // Runs after useAnnotationAutosave's own cleanup - that hook is invoked earlier in this // component's body (see its call above), so React tears it down first on unmount, and its // flush() reads getAllAnnotations() synchronously before anything here removes them, so // the last edit is still captured and saved. try { for (const toolName of MEASUREMENT_TOOL_NAMES) { annotation.state.removeAnnotations(toolName, element); } } catch (teardownError) { console.error('DicomViewport annotation teardown failed:', teardownError instanceof Error ? teardownError.message : teardownError); } // IN ITS OWN try, AND LAST. The engine holds GPU textures for every loaded frame and a // study scroll loads ~250 of them; leaking one engine per navigation is how a long clinic // session ends with a tab that will not render. Sharing a try with the tool-group teardown // would let a future throw there silently skip this. try { getRenderingEngine(renderingEngineId)?.destroy(); } catch (teardownError) { console.error('DicomViewport engine teardown failed:', teardownError instanceof Error ? teardownError.message : teardownError); } }; }, [frames, renderingEngineId, viewportId, toolGroupId, canMeasure]); // DELIBERATELY SEPARATE FROM THE HEAVY SETUP EFFECT ABOVE. initialAnnotations resolves on // its own schedule - ImagingStudyViewer fetches it in its own effect once frames AND canMeasure // are both settled (see that file's I3 fix), which on an ordinary study open is moments AFTER // this component is already `ready`. Folding restoration into the setup effect would make // `initialAnnotations` one of its dependencies, rebuilding the entire rendering engine - // destroy and re-enable the element, recreate the tool group, re-run setStack - every time // annotations arrive, on every single study a practitioner opens. This effect only touches // Cornerstone's annotation state, never the engine, so restoring - or a canMeasure/study change // swapping in a new set - costs an annotation-state update and an SVG redraw, not a rebuild. useEffect(() => { if (!ready || !canMeasure) return; const element = elementRef.current; if (!element) return; // Tracks exactly what THIS run adds, by UID, so its own cleanup removes precisely that and // nothing else - never a live measurement the clinician drew in the meantime (this effect // never learns those UIDs, so it can't touch them) and never annotations belonging to a // still-mounted sibling viewport on the same study (removeAnnotation, unlike removeAnnotations, // needs no FrameOfReference group at all). The unmount-time bulk sweep in the setup effect's // cleanup above is what catches user-drawn annotations; this only undoes its own work. const addedUids: string[] = []; for (const restored of initialAnnotations) { if (!looksLikeAnnotation(restored)) continue; // Narrows a type TypeScript cannot see through, the same way the WADORSMetaData cast in // the setup effect does: `restored` is a structuredClone of a real Cornerstone // annotation (annotationKeying.ts's fromStored produces it), so at runtime this is // exactly an Annotation - the intermediate Record typing exists only so // the props boundary does not have to depend on @cornerstonejs/tools' types outside this // file. addAnnotation reuses the annotationUID already on the object (fromStored // restores it verbatim) rather than minting a new one, so this is what makes the // dedupe-by-UID below actually line up with what a later reopen would try to add again. const uid = annotation.state.addAnnotation(restored as unknown as ToolsTypes.Annotation, element); addedUids.push(uid); } return () => { for (const uid of addedUids) { try { annotation.state.removeAnnotation(uid); } catch (teardownError) { console.error('DicomViewport annotation restore cleanup failed:', teardownError instanceof Error ? teardownError.message : teardownError); } } }; }, [ready, canMeasure, initialAnnotations]); // DECLARED AFTER THE RESTORE EFFECT, DELIBERATELY. React runs effects in declaration order, so on // the commit where `ready` flips true the restore effect has already put the persisted drawings // into Cornerstone by the time the first read below happens - no event needed for that case. // Every later change arrives as an event: ANNOTATION_ADDED covers a subsequent restore (Cornerstone // fires it from addAnnotation itself), COMPLETED a freshly drawn measurement, MODIFIED a caliper // being dragged, REMOVED a deletion. Reading Cornerstone's state on each rather than maintaining a // parallel copy is what keeps the panel listing what is actually on screen. useEffect(() => { if (!canMeasure || !ready) { setRows([]); return; } const refreshRows = (): void => { try { setRows(toMeasurementRows(annotation.state.getAllAnnotations() as unknown[], frames)); } catch (rowsError) { // A panel that cannot list is a panel that cannot promote, which is the safe outcome // - but it must not take the images down with it. console.error('DicomViewport: could not read the measurements on screen:', rowsError instanceof Error ? rowsError.message : rowsError); } }; refreshRows(); eventTarget.addEventListener(ToolEnums.Events.ANNOTATION_ADDED, refreshRows); eventTarget.addEventListener(ToolEnums.Events.ANNOTATION_COMPLETED, refreshRows); eventTarget.addEventListener(ToolEnums.Events.ANNOTATION_MODIFIED, refreshRows); eventTarget.addEventListener(ToolEnums.Events.ANNOTATION_REMOVED, refreshRows); return () => { eventTarget.removeEventListener(ToolEnums.Events.ANNOTATION_ADDED, refreshRows); eventTarget.removeEventListener(ToolEnums.Events.ANNOTATION_COMPLETED, refreshRows); eventTarget.removeEventListener(ToolEnums.Events.ANNOTATION_MODIFIED, refreshRows); eventTarget.removeEventListener(ToolEnums.Events.ANNOTATION_REMOVED, refreshRows); }; }, [canMeasure, ready, frames]); // BOUND TO THE DOCUMENT, NOT THE CANVAS. Selecting an annotation and drawing one are both mouse // gestures, and nothing in this file (or in Cornerstone's own tool code) calls.focus() on the // canvas element afterwards, so it cannot be relied on to hold keyboard focus at the moment a // clinician reaches for Delete. Cornerstone's "selected" annotation is a module-level singleton // (see annotation.selection) rather than something scoped to this element regardless, so a // document listener matches what is actually being asked about. // // GATED EXACTLY LIKE THE MEASUREMENT TOOLS, for the same reason: the endpoint's // allowedRoles: [PRACTITIONERS] is what actually stops a patient from deleting a clinician's // measurement, but a viewer that registers a delete shortcut it can never legally use is its own // bug. When canMeasure is false this effect never subscribes at all. // // ENDS IN THE SAME removeAnnotation CALL THE ERASER TOOL USES (see AnnotationEraserTool in // @cornerstonejs/tools), which is what keeps this to exactly one ANNOTATION_REMOVED per // deletion - that function fires the event once per call and is a no-op on an already-removed // uid, so even a second listener (a compare-prior-study layout can have two of these mounted at // once, both reading the same global selection) cannot double it. useEffect(() => { if (!canMeasure || !ready) return; const handleKeyDown = (event: KeyboardEvent): void => { if (event.key !== 'Delete' && event.key !== 'Backspace') return; // A clinician correcting a note or searching elsewhere on the page must have Backspace // behave exactly as it always has - this is checked before anything else here touches // Cornerstone's selection state. if (isEditableTarget(event.target)) return; const selectedUids = annotation.selection.getAnnotationsSelected(); if (selectedUids.length === 0) return; // PREVENTED ONLY NOW THAT THERE IS SOMETHING TO DELETE. An unrelated Backspace press - // nothing selected, focus sitting somewhere inert - is left alone rather than being // swallowed, so it still does whatever the browser would otherwise do with it. event.preventDefault(); for (const uid of selectedUids) { annotation.state.removeAnnotation(uid); } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [canMeasure, ready]); // GROWS WHILE ONE STUDY IS OPEN, AND NEVER SHRINKS. Deleting a caliper erases scratch work; it // does not retract the finding that caliper was promoted into, and that finding stays in the // patient's chart and in the observations list regardless. If this list dropped the uid the moment // the drawing went, the chart lookup would stop asking about it, the panel would have nothing to // show for it, and a chart entry would be left whose origin the clinician can no longer see. // // Keeping the uid costs one search per refresh for a measurement that may never have been charted // at all, bounded by how many the clinician draws in one sitting - a handful. // // RESET WHEN READINESS DROPS, which is what a new study looks like from here: the setup effect // clears `ready` before rebuilding, so this cannot carry one study's uids into a lookup scoped to // the next study's patient. useEffect(() => { if (!ready) { setChartUids([]); return; } const drawn = rows.map(row => row.annotationUid); setChartUids(current => { const next = [...current, ...drawn.filter(uid => !current.includes(uid))]; return sameUids(current, next) ? current : next; }); }, [ready, rows]); // Only the newest lookup may write its answer. Two can be in flight at once - a measurement drawn // while the lookup that follows a promotion is still running - and the older one landing last // would overwrite the newer answer with a staler one. const chartLookupRef = useRef(0); // `belongs`, when given, is the opening the lookup was asked for: a retired one publishes nothing. const refreshChartState = useCallback(async (annotationUids: string[], belongs?: () => boolean): Promise => { const lookup = chartLookupRef.current + 1; chartLookupRef.current = lookup; try { const charted = await onRefreshChartState(annotationUids); if (chartLookupRef.current !== lookup || (belongs && !belongs())) return; setPromoted(charted); // CONFIRMED ONLY WHEN THERE WAS SOMETHING TO CONFIRM. The first lookup runs on mount, // before any drawing has been read out of Cornerstone, so it asks about an empty list - // and an empty list is answered "nothing is charted" without the chart being consulted at // all. Counting that as confirmation would leave promotion offered for the whole round // trip after the restored measurements appear, which is exactly the unconfirmed window // this flag exists to close. Every later re-check keeps the flag where the first real one // left it: a uid appearing after a confirmed lookup is one that was drawn moments ago and // therefore cannot already be charted, so the previous answer still holds for it. if (annotationUids.length > 0) setChartStateAvailable(true); } catch (chartError) { // NOT AN EMPTY RESULT. The lookup rejects precisely when its answer cannot be trusted, so // this path means "unknown", and the panel is told so rather than being handed an empty // list it would render as "nothing is charted yet". console.error('DicomViewport: could not confirm which measurements are already charted:', chartError instanceof Error ? chartError.message : chartError); if (chartLookupRef.current !== lookup || (belongs && !belongs())) return; setPromoted([]); setChartStateAvailable(false); } }, [onRefreshChartState]); // Only the newest status read may publish: an older answer landing last would erase a newer pending write. const statusReadRef = useRef(0); // One order for status reads, named recoveries and holds: an answer to a request issued before a hold // was installed cannot speak for the attempt that installed it, whatever it names. const orderRef = useRef(0); // A failed or unreadable read keeps what is already pending and turns promotion off until one answers. const refreshPromotionStatus = useCallback(async (): Promise => { if (!onPromotionStatus) return 'available'; const read = statusReadRef.current + 1; statusReadRef.current = read; const issued = ++orderRef.current; const { owned, belongs } = promotionRequest(); try { const status = await onPromotionStatus(owned); if (!belongs()) return 'retired'; if (statusReadRef.current !== read) return 'superseded'; setOutstanding(status.operations); // Only a listing of the held operation itself resolves its unknown effects: an absent one may still land. setLocalHolds(current => current.flatMap(hold => { const listed = status.operations.find(op => op.operation === hold.status.operation)?.effects ?? []; const effects = hold.status.effects.filter(effect => { if (hold.since >= issued) return true; const named = listed.find(entry => entry.annotationUid === effect.annotationUid); if (named && !isUnresolved(named.clinical)) return false; return isUnresolved(effect.clinical); }); return effects.length > 0 ? [{ ...hold, status: { ...hold.status, effects } }] : []; })); setStatusAvailable(true); return 'available'; } catch (statusError) { console.error('DicomViewport: could not read promotion status:', statusError instanceof Error ? statusError.message : statusError); if (!belongs()) return 'retired'; if (statusReadRef.current !== read) return 'superseded'; setStatusAvailable(false); return 'unavailable'; } }, [onPromotionStatus, promotionRequest]); // An unknown or incomplete answer is held as its operation's own pending row, so To chart stays off // for its uids even when the follow-up status read fails. const holdUnresolved = useCallback((result: PromotionResult, annotationUids: string[]): void => { if (result.kind !== 'unknown' && result.kind !== 'incomplete') return; const effects = result.kind === 'unknown' ? annotationUids.map(annotationUid => ({ annotationUid, clinical: 'unknown' as const, evidence: 'pending' as const })) : result.effects.filter(effect => effect.clinical !== 'charted' && effect.clinical !== 'amended' && effect.clinical !== 'unchanged'); if (effects.length === 0) return; // A newer hold keeps any uid of the earlier one it does not answer for. const since = ++orderRef.current; setLocalHolds(current => { const earlier = current.find(held => held.status.operation === result.operation)?.status.effects ?? []; const named = new Set(effects.map(effect => effect.annotationUid)); const kept = earlier.filter(effect => !named.has(effect.annotationUid)); return [...current.filter(held => held.status.operation !== result.operation), { since, status: { operation: result.operation, effects: [...kept, ...effects] } }]; }); }, []); // A held effect stands in for the server's entry for the same uid: it may be newer than the server's row. const heldByOperation = new Map(localHolds.map(hold => [hold.status.operation, hold.status])); const pending = [ ...outstanding.map(op => { const hold = heldByOperation.get(op.operation); if (!hold) return op; const heldUids = new Set(hold.effects.map(effect => effect.annotationUid)); return { operation: op.operation, effects: [...op.effects.filter(effect => !heldUids.has(effect.annotationUid)), ...hold.effects] }; }), ...localHolds.filter(hold => !outstanding.some(op => op.operation === hold.status.operation)).map(hold => hold.status) ]; useEffect(() => { if (!canMeasure) { setPromoted([]); setChartStateAvailable(false); setOutstanding([]); setLocalHolds([]); setStatusAvailable(false); return; } void refreshChartState(chartUids); void refreshPromotionStatus(); }, [canMeasure, chartUids, refreshChartState, refreshPromotionStatus]); const promote = useCallback(async (measurements: PromotionRequest[]): Promise => { // Minted here, at the press, beside the owned request - never by the caller, so a retried // press is always a new operation. const operation = crypto.randomUUID(); // Promotion reads the value from the SAVED document, so the pending autosave has to land first // - otherwise a caliper drawn a second ago is on screen and in the panel but absent from the // document, and the server correctly refuses a uid it cannot find. Accepting a client-supplied // value for unsaved annotations would remove the guarantee that a charted number equals what // was measured, so a failed flush abandons the promotion instead. // Captured at the press: the promotion is sent, and its answer shown, only as this opening and practice. const { owned, belongs } = promotionRequest(); const edition = await flush(); if (!edition) { throw new Error('DicomViewport: measurements could not be saved, so none were sent to the chart'); } if (!belongs()) return { kind: 'retired' }; let result: PromotionResult; try { result = await onPromote(measurements, edition, owned, operation); } catch (error) { if (!belongs()) return { kind: 'retired' }; throw error; } if (!belongs()) return { kind: 'retired' }; holdUnresolved(result, measurements.map(measurement => measurement.annotationUid)); // Re-read for the WHOLE list, not just what was promoted: the panel renders one chart state // for every row, and refreshing a subset of it would leave the rest reading from an answer // that is now one promotion out of date. await refreshChartState(chartUids, belongs); if (!belongs()) return { kind: 'retired' }; void refreshPromotionStatus(); return result; }, [flush, onPromote, refreshChartState, refreshPromotionStatus, holdUnresolved, chartUids, promotionRequest]); // Same owned transport and belongs() guard as promote() above: a Continue answered after the // opening moved on publishes nothing either. const continueOperation = useCallback(async ( operation: string, effects: { annotationUid: string; withdrawal: string }[] ): Promise => { if (!onContinuePromotion) return { kind: 'unknown', operation }; const { owned, belongs } = promotionRequest(); let result: PromotionResult; try { result = await onContinuePromotion(operation, effects, owned); } catch (error) { if (!belongs()) return { kind: 'retired' }; throw error; } if (!belongs()) return { kind: 'retired' }; holdUnresolved(result, effects.map(effect => effect.annotationUid)); await refreshChartState(chartUids, belongs); if (!belongs()) return { kind: 'retired' }; void refreshPromotionStatus(); return result; }, [onContinuePromotion, refreshChartState, refreshPromotionStatus, holdUnresolved, chartUids, promotionRequest]); // Named recovery: no tokens, so nothing is sent clinically. A held uid clears only on an answer for // it - terminal, or not-dispatched without a token (proved never admitted, so it may be saved anew). const recoverOperation = useCallback(async (operation: string, annotationUids: string[]): Promise => { if (!onContinuePromotion) return { kind: 'unknown', operation }; const { owned, belongs } = promotionRequest(); const issued = ++orderRef.current; let result: PromotionResult; try { result = await onContinuePromotion(operation, annotationUids.map(annotationUid => ({ annotationUid })), owned); } catch (error) { if (!belongs()) return { kind: 'retired' }; throw error; } if (!belongs()) return { kind: 'retired' }; if (result.kind === 'settled' || result.kind === 'incomplete') { const answered = new Map(result.effects.map(effect => [effect.annotationUid, effect])); setLocalHolds(current => current.flatMap(hold => { if (hold.status.operation !== operation || hold.since >= issued) return [hold]; const effects = hold.status.effects.flatMap(effect => { const answer = answered.get(effect.annotationUid); if (!answer || isUnresolved(answer.clinical)) return [effect]; return answer.clinical === 'not-dispatched' && answer.withdrawal ? [answer] : []; }); return effects.length > 0 ? [{ ...hold, status: { operation, effects } }] : []; })); } await refreshChartState(chartUids, belongs); if (!belongs()) return { kind: 'retired' }; void refreshPromotionStatus(); return result; }, [onContinuePromotion, refreshChartState, refreshPromotionStatus, chartUids, promotionRequest]); const step = useCallback((delta: number) => { // The buttons are disabled until ready, but the canvas is focusable and its arrow keys are // not. Between engine creation and setStack resolving the viewport exists with an empty // stack, so stepping threw "ImageIdIndex provided 1 is invalid" into a discarded promise. if (!ready) return; const viewport = getRenderingEngine(renderingEngineId)?.getViewport(viewportId) as CoreTypes.IStackViewport | undefined; if (!viewport) return; const next = Math.min(Math.max(viewport.getCurrentImageIdIndex() + delta, 0), frames.length - 1); // No setSliceIndex here - the STACK_NEW_IMAGE listener owns the counter, so buttons and wheel // scrolling both report through one path. void viewport.setImageIdIndex(next); }, [ready, frames.length, renderingEngineId, viewportId]); // PASSIVE, NEVER DISABLED, FOR THE TOOL BEING SWITCHED AWAY FROM. Cornerstone's // AnnotationRenderingEngine only draws Active/Passive/Enabled tools (see the tool-registration // comment above) - ToolGroup's own `setActivePrimaryTool` helper calls setToolDisabled on the // previous tool, which would make whatever the clinician just measured vanish from the image // the moment they picked a different tool. Manual Passive/Active toggling keeps every // measurement visible regardless of which tool currently owns the primary button; Zoom // (secondary), Pan (auxiliary) and StackScroll (wheel) are never touched by this. const selectTool = useCallback((toolName: string) => { if (!ready || toolName === activeToolName) return; const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); if (!toolGroup) return; toolGroup.setToolPassive(activeToolName); toolGroup.setToolActive(toolName, { bindings: [{ mouseButton: ToolEnums.MouseBindings.Primary }] }); setActiveToolName(toolName); }, [ready, activeToolName, toolGroupId]); if (frames.length === 0) { return This study has no viewable images.; } return (
{markedForReference && (
{`Image ${sliceIndex + 1} of ${frames.length}`} For reference
)} {/* THE CANVAS DIV STAYS MOUNTED ON ERROR. Replacing it with an error message unmounts the ref'd element, so when the user opens a different study the effect re-runs, finds elementRef.current null, and bails before any setup β€” leaving the viewer dead for the rest of the session with only a page reload to recover. The message is an overlay. */} {/* A SIBLING, NOT A CHILD OF THE role="img" DIV. Children of an img-role element are presentational per ARIA and are stripped from the accessibility tree, so an error rendered inside it is never announced β€” a screen-reader user would hear "Image 1 of 250", find the buttons disabled, and never learn the study failed. Notice carries its own alert semantics. */} {error && ( {error} )}
event.preventDefault()} onKeyDown={event => { if (event.key === 'ArrowDown' || event.key === 'ArrowRight') { event.preventDefault(); step(1); } if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') { event.preventDefault(); step(-1); } }}> {!ready && !error && (
)}
{sliceIndex + 1} / {frames.length} {/* THE WHOLE MEASUREMENT TOOLBAR, NOT JUST INDIVIDUAL BUTTONS DISABLED. When canMeasure is false these tools were never added to the tool group above, so a click here would only warn to the console and do nothing β€” omitting the controls entirely is what makes that state legible instead of a set of buttons that quietly fail. This is presentation only; the server's allowedRoles is the enforcement. */} {/* `aria-pressed` IS WHAT `isPressed` WAS β€” WordPress's prop rendered exactly this attribute and nothing else, so the DOM these tools produce is unchanged and the "choosing one deselects the others" test above them keeps guarding the behaviour through the conversion. The `.btn[aria-pressed="true"]` rule in index.css supplies the look WordPress supplied via `.components-button.is-pressed`. πŸ”΄ This is deliberately the INTERIM shape: these five controls are a radio group wearing buttons, and the honest primitive is a Radix ToggleGroup (which would also bring arrow-key navigation). Scheduled for the design/UX pass rather than smuggled into a styling sweep β€” see akashic-docs/_CLAUDE/2026-08-09-ui-design-pass-backlog.md. */} {canMeasure && ( )} {canMeasure && MEASUREMENT_TOOLS.map(tool => ( ))} {/* SAME PRIMARY-BUTTON GROUP AS THE MEASUREMENT TOOLS ABOVE, via the same selectTool call β€” choosing it deselects whichever of them (or Window/Level) was active, exactly as choosing among those already deselects each other. Clicking a caliper while this is active removes it; see AnnotationEraserTool's own preMouseDownCallback, registered on the tool group above. */} {canMeasure && ( )} {canMeasure && ( {autosaveStatus === 'saving' && 'Saving…'} {autosaveStatus === 'error' && 'Not saved'} )} {canMeasure && hold && ( {hold === 'load' && 'Saved measurements could not be loaded, so saving is paused.'} {hold === 'changed' && 'Your saved measurements changed in another window or session, so saving is paused.'} {hold === 'unknown' && 'A save could not be confirmed, so saving is paused.'} {' '} {hold === 'unknown' ? ( ) : onReload ? ( ) : null} )} Drag to adjust brightness and contrast. Right-drag to zoom, middle-drag to pan, scroll to move through images.
{/* SAME GATE AS THE MEASUREMENT TOOLBAR. Someone who cannot draw a measurement has none to review, and the promotion route is practitioners-only server-side regardless β€” this is presentation, not the enforcement. */} {(canMeasure || aside) && (
{canMeasure && ( )} {aside}
)}
); }; export default DicomViewport;