diff --git a/.agents/skills/ohif-react-compiler/SKILL.md b/.agents/skills/ohif-react-compiler/SKILL.md new file mode 100644 index 00000000000..52e38a43b4f --- /dev/null +++ b/.agents/skills/ohif-react-compiler/SKILL.md @@ -0,0 +1,122 @@ +--- +name: ohif-react-compiler +description: Rules and workflow for editing React code in the OHIF Viewer, which runs React 19 with the React Compiler enabled and two CI gates that fail when a file stops compiling or lint counts move. Use this skill whenever you create or change anything under platform/*/src, extensions/*/src or modes/*/src that contains JSX or a React hook — including small edits, refactors, and requests like "add a useCallback here" — even if the user does not mention React 19, the compiler, or memoization. +--- + +# OHIF React Compiler + +This repo compiles its React with the React Compiler. Most of what that means +is enforced by tooling; this skill tells you how to work with that tooling and +records the decisions already made so you do not relitigate them. + +## How this repo is set up + +- React 19. `ref` is an ordinary prop. There is no `forwardRef`, no + `propTypes`, and manual memoization is unnecessary. +- Which directories the compiler applies to is defined once, in + `react-compiler.scope.cjs` at the repo root. Read it rather than assuming; the + build, the lint rules and the coverage gate all take their scope from it, and + adding a directory to its `excluded` list takes that directory out of all + three together. Files carrying a `'use no memo'` directive at the top are + skipped regardless of directory. +- Two CI gates hold the current state and fail in **both** directions — when + things get worse, and when things get better but the budget was not tightened. + +| gate | command | budget file | +| ----------------- | ------------------------------- | ---------------------------------- | +| compiler coverage | `pnpm run compiler:coverage:ci` | `.react-compiler-budget.json` | +| compiler lint | `pnpm run lint:compiler:ci` | `.react-compiler-lint-budget.json` | + +## The loop — after every React edit + +1. Lint the file you touched: + `npx eslint --config eslint.config.mjs --no-config-lookup ` +2. `pnpm run compiler:coverage:ci` — did any file start refusing or opt out? +3. `pnpm run lint:compiler:ci` — did the error or warning counts move? +4. If either gate says the counts **improved**, tighten the budget to the exact + values it prints, in the same commit. Never loosen a budget to make CI pass. + +Step 1 is fast and catches most problems. Steps 2 and 3 are what CI runs. + +## Hard rules — CI enforces these + +- **No `forwardRef`.** Accept `ref` as a regular prop. An ESLint + `no-restricted-syntax` rule fails on it. +- **No `prop-types`.** Use TypeScript types. An ESLint `no-restricted-imports` + rule fails on the import. +- **No `eslint-disable` on any `react-hooks/*` rule.** The compiler treats a + suppression as a `Suppression` bailout and refuses the whole function. Fix the + code instead. +- **Do not mutate props, and do not read or write `ref.current` during render.** + Both are refusals (`Immutability`, `Refs`). Compute into a local; move ref + access into an effect or event handler. +- **No new `'use no memo'` without all three of:** a reproduced failure, a + comment above the directive saying exactly what broke, and the file added to + `fileOptOuts` in `.react-compiler-budget.json`. An opt-out is a last resort + with a paper trail, not a way to make a refusal go away. + +## Decisions already made + +- **Do not add `useMemo`, `useCallback` or `React.memo`.** The compiler derives + its own memoization from the code and erases hand-written wrappers from the + output. Adding one gains nothing and gives the compiler a dependency list it + has to verify. +- **Removing an existing wrapper: diff the compiled output.** Emit the file + before and after (see "Reading emitted code"). Identical output means nothing + to test. If `const $ = _c(n)` disappears from the output, the compiler has + started refusing — the wrapper was load-bearing; stop and look. +- **A refusal gets fixed, not hidden.** Read the category the gate prints and + see [references/refusal-categories.md](references/refusal-categories.md) for + what it means and the usual fix. +- **Public API changes get a migration note** in + `platform/docs/docs/migration-guide/3p13-to-3p14/`. +- **Components that read or mutate cornerstone3D state during render** are the + one class that genuinely cannot be compiled yet. That is what the existing + opt-outs under `extensions/cornerstone/src/Viewport/` are. Do not extend that + set casually; each one has an issue to remove it. + +## Reading the gate output + +`compiler:coverage:ci` prints every refusal as file, line, category and reason: + +``` +extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx:50 + Todo: (BuildHIR::lowerExpression) Handle ||= operators in AssignmentExpression +``` + +The line points at the offending expression, not the function. A block titled +"Refusals inside opt-out files" is informational — those files are not being +memoized anyway. + +`lint:compiler:ci` printing **"Budget is stale: the counts improved"** is not an +error to work around. It is telling you to write the new numbers into the +budget file. + +## Reading emitted code + +Use this in two situations only: after fixing a refusal, to confirm the function +now compiles; and before and after removing a `useMemo` or `useCallback`, to see +whether the change altered anything. A new component that passes the gates does +not need it. + +To see what the compiler actually produced for a file: + +```bash +node .agents/skills/ohif-react-compiler/assets/emit.mjs +``` + +It compiles that one file with the repo's own babel config, compiler included, +and prints the result. To check whether an edit changed anything the compiler +emits, run it before and after and diff the two outputs — identical output +means identical runtime behaviour, so there is nothing to test. + +What to look for: `const $ = _c(n)` means the function compiled; `if ($[i] !== x)` +is a cache guard on input `x`; `t0`, `t1`… are compiler temporaries. A breakpoint +inside a guard fires once and then stops — that is the cache hitting, not a bug. + +## Authoritative sources + +- [Rules of React](https://react.dev/reference/rules) — what the compiler assumes. +- [eslint-plugin-react-hooks](https://react.dev/reference/eslint-plugin-react-hooks) + — the compiler's diagnostics as lint rules; this repo uses `recommended-latest`. +- [React Compiler](https://react.dev/learn/react-compiler) — directives, opt-outs. diff --git a/.agents/skills/ohif-react-compiler/assets/emit.mjs b/.agents/skills/ohif-react-compiler/assets/emit.mjs new file mode 100644 index 00000000000..581ca6c2bd1 --- /dev/null +++ b/.agents/skills/ohif-react-compiler/assets/emit.mjs @@ -0,0 +1,24 @@ +// Print what the React Compiler emits for one file. +// +// node .agents/skills/ohif-react-compiler/assets/emit.mjs +// +// Uses the repo's own babel config (compiler included), found by walking up +// from the target file, so it works from any working directory. To see whether +// an edit changed anything the compiler produces, run it before and after and +// diff the two outputs. Identical output means identical runtime behaviour. +import { createRequire } from 'node:module'; +import path from 'node:path'; + +const file = path.resolve(process.argv[2] ?? ''); +if (!process.argv[2]) { + console.error('usage: node emit.mjs '); + process.exit(2); +} + +const babel = createRequire(file)('@babel/core'); +const { code } = babel.transformFileSync(file, { + filename: file, + cwd: path.dirname(file), + rootMode: 'upward', +}); +console.log(code); diff --git a/.agents/skills/ohif-react-compiler/references/refusal-categories.md b/.agents/skills/ohif-react-compiler/references/refusal-categories.md new file mode 100644 index 00000000000..394d3accf03 --- /dev/null +++ b/.agents/skills/ohif-react-compiler/references/refusal-categories.md @@ -0,0 +1,152 @@ +# Refusal categories + +When the React Compiler will not memoize a function it emits it unchanged and +reports a category and a reason. `pnpm run compiler:coverage:ci` prints both, +with the line of the offending expression. The reason string is the compiler's +own and is authoritative; this file explains what each category usually means +in this codebase and how it has been fixed before. + +## Sources + +- **The category names are the compiler's own.** This file covers the nine this + codebase has produced; the compiler defines more, and the gate prints + whichever one it hits. +- **Lint rule names and which are enabled:** + [eslint-plugin-react-hooks](https://react.dev/reference/eslint-plugin-react-hooks). + This repo uses the `recommended-latest` preset, which leaves some of the + plugin's rules off. Every rule named below was checked against the installed + plugin. +- **How to read a diagnostic:** + [React Compiler docs](https://react.dev/learn/react-compiler) and the + [Rules of React](https://react.dev/reference/rules) the compiler assumes. +- **The "how it has been fixed" guidance** comes from fixes made in this repo + during the React 19 migration, not from documentation. `Hooks`, `Globals` and + `UseMemo` were cleared early in that work, so their fix guidance is briefer + and less tested than the others'. + +Two things to know first: + +- **A function reports one category at a time.** Fixing it often exposes a + different one underneath. Recompile after every fix and expect a second + round. +- **Most categories have no lint rule that fires here.** `PreserveManualMemo` + in particular has a rule enabled as an error that has never once fired on a + real case. Only compiling finds these — which is why the coverage gate exists. + +## Immutability + +*"This value cannot be modified"*, *"Cannot access variable before it is +declared"*. + +Something is being written to that the compiler needs to treat as read-only: +a prop, a value captured by a closure, or a variable referenced before its +declaration in a circular pair of callbacks. + +Fix by computing into a new local instead of assigning into the existing +object, and by breaking circular references between callbacks (for example an +`AbortController` for listener teardown rather than each handler naming the +other). Check every downstream reader before changing a mutation — today they +observe the mutated object. + +Lint: `react-hooks/immutability` reports some but not all of these. + +## Refs + +*"Cannot access refs during render"*. + +`ref.current` is read or written in the render body rather than in an effect or +an event handler. The compiler cannot cache across a value it cannot see change. + +Fix by moving the access into a `useEffect` or a handler. Where a ref is used to +carry identity across renders synchronously — "reset X when Y changed, before +anything renders" — the fix is a design change, not a mechanical one. See the +`Mode.tsx` issue for a case where the recommendation is to leave it. + +Lint: `react-hooks/refs`. + +## PreserveManualMemo + +*"Existing memoization could not be preserved"*. + +A hand-written `useMemo` or `useCallback` has a dependency list narrower than +what its body actually reads. The compiler infers the real dependencies, finds +they differ from what was written, and refuses the whole function rather than +silently change behaviour. + +Fix by completing the dependency list, or by deleting the wrapper. In a +compiled file deleting is usually safe — the compiler re-derives the +memoization — but diff the compiled output before and after to be sure. + +Lint: `react-hooks/preserve-manual-memoization` is enabled and has never fired +on a real instance here. Do not rely on it. + +## Suppression + +An `eslint-disable` comment for a `react-hooks/*` rule is present in the +function. The compiler treats the suppression as a signal that the code +knowingly breaks a rule, and refuses. + +Fix by removing the disable comment and fixing what it was hiding — usually an +incomplete dependency list. + +Lint: `react-hooks/rule-suppression` exists but is not in the preset this repo +uses. + +## RenderSetState + +A state setter is called during render rather than in an effect or handler. + +Fix by deriving the value instead of storing it, or by moving the call to where +it belongs. If the setter is guarded so it only fires on a real change, restate +that as derived state. + +Lint: `react-hooks/set-state-in-render`. + +## Hooks + +A hook is called conditionally, in a loop, or after an early return, so the +compiler cannot establish a fixed hook order. + +Fix by moving every hook above the first `return` and out of any branch. This +is the same rule `rules-of-hooks` enforces, and it has always applied. + +Lint: `react-hooks/rules-of-hooks`; the compiler-specific rule +`react-hooks/hooks` exists but is not in the preset. + +## Globals + +A module-scope value is mutated during render. + +Fix by moving the mutation into an effect or handler, or by lifting the value +into state or context so React owns it. + +Lint: `react-hooks/globals`. + +## UseMemo + +`useMemo` is called in a shape the compiler will not handle — for example with +something other than a plain function as its first argument. + +Fix per the reason string; usually the wrapper can simply be removed. + +Lint: `react-hooks/use-memo`. + +## Todo + +*"(BuildHIR::lowerExpression) Handle …"*. + +Not a problem with your code. The compiler's front end has not implemented a +syntax form you used. The one instance in this codebase is a logical assignment +operator, `||=`. + +Fix by rewriting the construct longhand (`x = x || y`). This category shrinks +with each compiler release. + +Lint: `react-hooks/todo` reports these verbatim but is not in the preset. + +## Which lint rules are actually on + +This repo uses `eslint-plugin-react-hooks` `recommended-latest`, which does not +enable every rule the plugin ships. The ones that would report `Todo`, +`Suppression`, and the compiler-specific `hooks` are off. So the linter being +clean does not mean the compiler is happy; only the coverage gate tells you that. diff --git a/.agents/skills/ohif-test-agent/SKILL.md b/.agents/skills/ohif-test-agent/SKILL.md index 650e82764ee..ae5a1415a90 100644 --- a/.agents/skills/ohif-test-agent/SKILL.md +++ b/.agents/skills/ohif-test-agent/SKILL.md @@ -243,34 +243,35 @@ Reach for the cheapest *faithful* signal, in this order: 1. **A faithful DOM/SVG/state signal exists → assert on it.** Panel counts, dialog and overlay text, enabled/disabled state, and any overlay that renders as SVG (a vector - overlay's color is readable via `getSvgAttribute`) all have a DOM representation — assert - on it directly, no screenshot. -2. **The thing under test is painted onto the WebGL canvas with no DOM representation → a - screenshot is correct and required.** Raster output on the canvas exposes no attribute to - read for a painted pixel. Scope a `checkForScreenshot` to the viewport (pane or grid) and - assert it — this is the right tool, not a last resort, whenever what you're verifying is - the rendered canvas itself. + overlay's color is readable via `getSvgAttribute`) are all readable from the DOM — + assert on them directly, no screenshot. +2. **The thing under test exists only as pixels on the WebGL canvas → a screenshot is + correct and required.** A painted pixel exposes no element or attribute to read. Capture + a viewport pane with `checkForViewportScreenshot`, or scope a `checkForScreenshot` to the + grid — this is the right tool, not a last resort, whenever what you're verifying is the + rendered canvas itself. 3. **Never substitute a service/state read for a render assertion.** Reading a service's state (any `window.services...`) asserts the *data model*, not the pixels the user sees — it passes even when rendering is broken. `page.evaluate(() => window.services...)` is an escape hatch for *setup*, not for *appearance* assertions. -For anything drawn onto the WebGL canvas with no DOM signal, compare a screenshot scoped to a specific viewport or the viewport grid: +For a screenshot comparison scoped to a specific viewport, use `checkForViewportScreenshot` — it hides the viewport's overlay text for the capture: ```ts -await checkForScreenshot({ +await checkForViewportScreenshot({ page, - locator: viewportPageObject.grid, // scope to the viewport grid — not the whole page + viewport: activeViewport, // captures the viewport pane with its text hidden screenshotPath: screenShotPaths.length.lengthDisplayedCorrectly, }); ``` -`checkForScreenshot` retries up to 10 times at 500 ms intervals. Use `screenShotPaths..` rather than a hand-typed string — the tree of valid keys lives in `tests/utils/screenShotPaths.ts`. +Both helpers retry up to 10 times at 1250 ms intervals by default (`attempts` and `delay` are configurable) (`checkForViewportScreenshot` delegates to `checkForScreenshot`; use the latter directly only for non-viewport locators such as the grid or a panel). Use `screenShotPaths..` rather than a hand-typed string — the tree of valid keys lives in `tests/utils/screenShotPaths.ts`. Rules (apply to all new screenshot assertions): - **Use the object form.** The positional form is legacy; don't introduce it in new code, and don't treat existing positional-form usage as a pattern to copy. -- **Never screenshot the full app.** Full-page screenshots include panels, toolbars, and dialogs that drift independently of what's under test and make baselines fragile. Scope by passing a `locator` — `viewportPageObject.grid` for the grid, or a specific viewport pane. A bare `normalizedClip: { x: 0, y: 0, width: 1, height: 1 }` with no `locator` is **not** scoping — it clips to the full page. Use `normalizedClip` only to target a sub-region *of a locator* (e.g. a scrollbar strip). If you reach for `fullPage: true`, stop and pick a locator. +- **No text in baselines.** Overlay text (date, series description, W/L, slice index) drifts with data, locale, and font rendering, so a baseline that contains it is fragile — new viewport baselines must be text-free. Capture viewports through `checkForViewportScreenshot`, which hides all viewport text for the shot; a raw `checkForScreenshot` on a viewport pane bakes the text in. +- **Never screenshot the full app.** Full-page screenshots include panels, toolbars, and dialogs that drift independently of what's under test and make baselines fragile. Scope by passing a `locator` — `viewportPageObject.grid` for the grid, or a specific viewport pane. A bare `normalizedClip: { x: 0, y: 0, width: 1, height: 1 }` with no `locator` is **not** scoping — it clips to the full page. Use `normalizedClip` only to target a sub-region *of a locator* (e.g. a scrollbar strip). `fullPage: true` only takes effect when no `locator` is passed — that *is* the full-app capture this rule forbids, so pass a `locator` instead. (Through `checkForViewportScreenshot` the flag is inert: the capture is always scoped to the viewport pane.) - **Do not tune `maxDiffPixelRatio` or `threshold`** to make a screenshot pass. If a baseline mismatches, regenerate it after a human review of the diff, or fix the underlying flake. ## Playwright config facts worth remembering @@ -323,7 +324,7 @@ Before returning a generated OHIF test, confirm all items: 3. Uses normalized viewport interactions (`normalizedClickAt` / `normalizedDragAt`) unless there is a strong reason otherwise. 4. Uses a valid canonical StudyInstanceUID and compatible mode. 5. Handles hydration or measurement tracking prompts when the workflow requires them. -6. Uses the faithful signal for each assertion — DOM/SVG where the result has a DOM representation, a viewport-scoped screenshot when what's verified is canvas-only raster output, and never a `window.services` state read in place of a render check. Any `checkForScreenshot` call uses the object form, scoped via a `locator` (viewport pane or grid) — no full-app screenshots. +6. Uses the faithful signal for each assertion — DOM/SVG where the result is readable from the DOM, a viewport-scoped screenshot when what's verified exists only as pixels on the canvas, and never a `window.services` state read in place of a render check. Any `checkForScreenshot` call uses the object form, scoped via a `locator` (viewport pane or grid) — no full-app screenshots. 7. Replaces `page.waitForTimeout(...)` after viewport-rendering actions with `waitForViewportRenderCycle(page)` (started before the action) — keeps `waitForTimeout` only for non-render waits like the hydration prompt in `beforeEach`. 8. If execution was skipped, states that explicitly and provides concrete run commands. 9. Every application control is reached through a page object — no raw `getByTestId`/`getByRole` in the spec for buttons, menus, dialogs, or fields. Any control not already covered was added to the right page object (or a new one), with a source `data-cy` if it lacked one. diff --git a/.agents/skills/ohif-test-agent/references/utilities.md b/.agents/skills/ohif-test-agent/references/utilities.md index 6471b366186..7d8cdf06086 100644 --- a/.agents/skills/ohif-test-agent/references/utilities.md +++ b/.agents/skills/ohif-test-agent/references/utilities.md @@ -57,7 +57,7 @@ So: pick `10000` when the mode is `tmtv`, `2000` otherwise, and only ramp up if ### `checkForScreenshot` — use the object form, never screenshot the full app -**This is the direction going forward** The suite is being migrated off *full-app* screenshots — not off screenshots altogether. Screenshots are the correct and required tool whenever what you're verifying is canvas-only raster output with no DOM signal; don't avoid them there. Avoid them only where a faithful DOM/SVG signal exists (e.g. a vector overlay's color via `getSvgAttribute`) or where you'd be capturing the whole app. See SKILL.md → "Screenshot vs. DOM assertion — how to choose". Any new spec must follow the rules below, and any modification to an older spec should bring it in line when reasonable. +**This is the direction going forward** The suite is being migrated off *full-app* screenshots — not off screenshots altogether. Screenshots are the correct and required tool whenever what you're verifying exists only as pixels on the canvas; don't avoid them there. Avoid them only where a faithful DOM/SVG signal exists (e.g. a vector overlay's color via `getSvgAttribute`) or where you'd be capturing the whole app. See SKILL.md → "Screenshot vs. DOM assertion — how to choose". Any new spec must follow the rules below, and any modification to an older spec should bring it in line when reasonable. - **Object form** (required for all new specs): `checkForScreenshot({ page, screenshotPath, normalizedClip?, ... })` - **Positional form**: legacy. It still appears in older specs because they haven't been migrated yet. **Do not treat existing positional-form usage as a pattern to copy** — those specs are the thing being moved away from. Do not introduce the positional form in new code. @@ -65,7 +65,8 @@ So: pick `10000` when the mode is `tmtv`, `2000` otherwise, and only ramp up if **Hard rules for new screenshots:** 1. Use the object form. -2. Scope by passing a `locator` — `viewportPageObject.grid` for the whole grid, or a specific viewport pane locator. **Never screenshot the full app.** `normalizedClip` is computed *relative to the locator* (and defaults to the full page when no locator is given), so `{ x: 0, y: 0, width: 1, height: 1 }` alone does not scope anything — reserve `normalizedClip` for clipping to a sub-region of a locator. If you find yourself reaching for `fullPage: true`, stop and pass a locator instead. +2. Scope by passing a `locator` — `viewportPageObject.grid` for the whole grid, or a panel locator; a single viewport pane goes through `checkForViewportScreenshot` instead (rule 3). **Never screenshot the full app.** `normalizedClip` is computed *relative to the locator* (and defaults to the full page when no locator is given), so `{ x: 0, y: 0, width: 1, height: 1 }` alone does not scope anything — reserve `normalizedClip` for clipping to a sub-region of a locator. `fullPage: true` only takes effect when no `locator` is passed — that *is* the full-app capture this rule forbids, so pass a `locator` instead. (Through `checkForViewportScreenshot` the flag is inert: the capture is always scoped to the viewport pane.) +3. **No text in baselines.** Capture viewports with `checkForViewportScreenshot({ page, viewport, screenshotPath })` — it hides all viewport text (overlays, annotation text, orientation markers) for the capture, then delegates to `checkForScreenshot` for the retry/compare. A raw `checkForScreenshot` on a viewport pane bakes overlay text (date, series description, W/L, slice index) into the baseline, which drifts with data, locale, and font rendering. Reserve raw `checkForScreenshot` for non-viewport locators (grid, panels). Do not tune `maxDiffPixelRatio` or `threshold` to make a screenshot pass — those are intentionally rarely touched and not the right knob for flakes. If a baseline mismatches, regenerate it (`--update-snapshots`) after a human review of the diff, or fix the underlying instability. Check the current signature in `tests/utils/checkForScreenshot.ts` if something looks off. diff --git a/.circleci/config.yml b/.circleci/config.yml index 91bfabdbc84..6b3ec4f2ef9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -23,7 +23,7 @@ commands: # The cimg/node global modules dir (/usr/local/lib/node_modules) is # root-owned, so a plain `npm install -g` fails with EACCES. Install # with sudo so the global pnpm binary lands in the shared prefix. - sudo npm install -g pnpm@11.5.2 + sudo npm install -g pnpm@12.8.1 echo 'export PATH="$(pnpm store path)/../.bin:$PATH"' >> $BASH_ENV source $BASH_ENV @@ -42,6 +42,12 @@ jobs: - run: name: 'JavaScript Test Suite' command: pnpm run test:unit:ci + - run: + name: 'React Compiler lint budget' + command: pnpm run lint:compiler:ci + - run: + name: 'React Compiler coverage budget' + command: pnpm run compiler:coverage:ci # platform/app - run: name: 'VIEWER: Combine report output' diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..edb05b78b36 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,75 @@ +# Review routing for CI-critical paths. Requires an approval from one of the +# listed owners when a PR touches a matching path (enforced via branch +# protection's "require review from Code Owners" on master). +# +# Notes on how GitHub evaluates this file: +# - gitignore-style patterns; the LAST matching pattern wins. +# - Owners must have write access or the rule silently does not bind. +# - Any single listed owner's approval satisfies the rule; an author's own +# approval never counts, so keep at least two owners per rule. +# - Only the copy of this file on the PR's base branch is enforced, so a PR +# that rewrites these rules is still judged by the old ones. +# +# No default (catch-all) owner: paths not listed here keep the normal review +# flow. +# +# Two groups of paths are protected, for two different reasons. A path belongs +# here when a change to it changes what CI executes, or with what rights, so +# that the review is routed to someone who will look at it in those terms. + +# ── Workflows ───────────────────────────────────────────────────────────────── +# Every workflow file, whichever runner it targets. + +/.github/ @jbocce @sedghi @wayfarer3130 + +# ── Self-hosted runner ──────────────────────────────────────────────────────── +# Code and configuration that decide what executes on the shared self-hosted +# "nashua" box. The `gate` job of .github/workflows/playwright.yml covers an +# overlapping set of paths; the two are maintained separately and are not +# mirrors. They act at different moments: the gate keeps a fork PR off the box +# before review, these rules put a code owner in front of the merge. + +/.scripts/ @jbocce @sedghi @wayfarer3130 +/preinstall.js @jbocce @sedghi @wayfarer3130 +/package.json @jbocce @sedghi @wayfarer3130 +/pnpm-lock.yaml @jbocce @sedghi @wayfarer3130 +/pnpm-workspace.yaml @jbocce @sedghi @wayfarer3130 +/.npmrc @jbocce @sedghi @wayfarer3130 + +# These do not exist in the repository today, and the rules have to be here +# before the files are: pnpm executes a root pnpmfile if one appears. Both +# extensions are listed because either name is loaded. +/.pnpmfile.mjs @jbocce @sedghi @wayfarer3130 +/.pnpmfile.cjs @jbocce @sedghi @wayfarer3130 +/pnpmfile.mjs @jbocce @sedghi @wayfarer3130 +/pnpmfile.cjs @jbocce @sedghi @wayfarer3130 + +# The gate does not defer a fork PR that only touches /package.json or +# /pnpm-lock.yaml, so the two rules above are what put a code owner in front of +# those files. The gate does defer a fork PR that adds a per-package `.npmrc` +# (`*/.npmrc`), which is not listed here. +# +# Neither list covers the workspace manifests below the root +# (`extensions//package.json` and the like), even though `pnpm install` runs +# the lifecycle scripts of every workspace project. A rule here would cost every +# PR in the repository that adds a dependency to any package an approval from +# one of three named people, on a path that changes several times a week. +# Master already requires a review before any merge, so a lifecycle script added +# to an extension still faces a reviewer; what it does not face is these three. +# Add `package.json` (no leading slash) here to change that decision. + +# ── Release pipeline ────────────────────────────────────────────────────────── +# OHIF does not release from GitHub Actions: CircleCI publishes to npm with +# NPM_TOKEN, pushes the Docker images, and runs the versioning scripts below. A +# change to any of these changes what runs with those credentials, and the +# review before merge is the only check on it. netlify.toml is here for the same +# reason — it sets the command Netlify runs in its own build context. +# +# These do NOT belong in the `gate` list above: none of them runs on the +# self-hosted box. + +/.circleci/ @jbocce @sedghi @wayfarer3130 +/version.mjs @jbocce @sedghi @wayfarer3130 +/publish-version.mjs @jbocce @sedghi @wayfarer3130 +/publish-package.mjs @jbocce @sedghi @wayfarer3130 +/netlify.toml @jbocce @sedghi @wayfarer3130 diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 9a400134d91..d1238389010 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24.15.0 diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml new file mode 100644 index 00000000000..45af1f41ae0 --- /dev/null +++ b/.github/workflows/github-release.yml @@ -0,0 +1,118 @@ +name: GitHub Release + +# The run's title in the Actions list, including why a `create` run is skipped. +# The skip reasons must match the `if:` of the release job below. +run-name: >- + ${{ + github.event_name == 'workflow_dispatch' && + format('{0} {1}', inputs.dry_run && 'Dry run' || 'Release', inputs.tag) || + github.event_name == 'pull_request' && + format('Dry run for PR #{0}', github.event.pull_request.number) || + github.ref_type != 'tag' && format('Skipped: branch {0}', github.ref_name) || + !startsWith(github.ref_name, 'v') && + format('Skipped: {0} is not a version tag', github.ref_name) || + github.repository != 'OHIF/Viewers' && + format('Skipped: {0} (not OHIF/Viewers)', github.ref_name) || + format('Release {0}', github.ref_name) + }} + +# Creates the GitHub Release for each version tag that CircleCI's NPM_PUBLISH +# job pushes. The logic lives in .scripts/create-github-release.mjs, which +# waits until the version's packages are on npm before creating the release. +# +# When npm publishing moves to GitHub Actions, the npm wait can be replaced by +# `needs:` on the publish job. +on: + # Not `push: tags`: CircleCI tags its `[skip ci]` version commit, and GitHub + # skips push workflows for such a commit. `create` also fires for new + # branches, so the job below only runs for new version tags. + create: + # Manual runs, for testing and for retrying a tag that was missed. + workflow_dispatch: + inputs: + tag: + description: 'Version tag, e.g. v3.14.0-beta.37' + required: true + type: string + dry_run: + description: 'Only report what would be created' + required: true + type: boolean + default: true + # A dry run against the newest tag whenever the release logic changes. + pull_request: + branches: [master, release/*] + paths: + - .github/workflows/github-release.yml + - .scripts/create-github-release.mjs + +permissions: + contents: read + +# One run at a time per tag, whether it came from the tag or the button. +concurrency: + group: ${{ github.workflow }}-${{ inputs.tag || github.ref_name }} + cancel-in-progress: false + +jobs: + release: + # For `create`, only new version tags, and only in the OHIF repository + # (forks copy OHIF's tags). Manual and pull request runs work anywhere. + if: >- + github.event_name != 'create' || + (github.ref_type == 'tag' && startsWith(github.ref_name, 'v') && + github.repository == 'OHIF/Viewers') + runs-on: ubuntu-latest + # The script waits up to 60 minutes for npm. + timeout-minutes: 75 + permissions: + contents: write # create the release and generate its notes + issues: write # open the review issue for a minor release draft + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + # All history and tags, to find the previous tag and decide Latest. + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.15.0 + + - name: Choose the tag + id: choose + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} + run: | + case "$EVENT_NAME" in + create) + tag="$GITHUB_REF_NAME" + dry_run=false + ;; + workflow_dispatch) + tag="$INPUT_TAG" + dry_run="$INPUT_DRY_RUN" + ;; + pull_request) + # The newest tag in the format the script accepts. `sed -n 1p` + # reads to the end, unlike `head`, so grep never hits a closed pipe. + tag=$(git tag --list 'v[0-9]*' --sort=-creatordate | + grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' | sed -n 1p) + dry_run=true + ;; + esac + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "dry_run=$dry_run" >> "$GITHUB_OUTPUT" + + - name: Create the release + env: + GITHUB_TOKEN: ${{ github.token }} + TAG: ${{ steps.choose.outputs.tag }} + DRY_RUN: ${{ steps.choose.outputs.dry_run }} + run: | + if [ "$DRY_RUN" = "true" ]; then + node .scripts/create-github-release.mjs "$TAG" --dry-run + else + node .scripts/create-github-release.mjs "$TAG" + fi diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index c5787466acd..9e0584c2270 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -6,24 +6,517 @@ on: inputs: cs3d_ref: description: >- - CS3D branch (e.g. main, origin:feat/foo) or version (e.g. 4.18.2, 4.19+, 4.x). Only used - when ohif-integration label is present or via workflow_dispatch. + Leave empty to just run the test suite against the CS3D version this repo already pins. + Give a CS3D branch (e.g. main, feat/foo) or version (e.g. 5.10.3, 5.x) to run the + integration path against that instead. Branches resolve against + cornerstonejs/cornerstone3D; forks cannot be named. On a pull request, add a line + "CS3D_REF: " to the PR body instead of using this. required: false - default: '4.19+' permissions: contents: read pull-requests: read - issues: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + # Metadata-only gate on a hosted VM. It deliberately does NOT check out the + # repository: everything it decides must be derived from GitHub-supplied event + # data and the API, never from code the pull request controls. That is also + # why its logic is inline rather than a script under .scripts/ — running a + # script from the PR's own checkout would let the PR rewrite its own gate. + # + # It answers three questions: + # proceed — may this run reach the shared self-hosted "nashua" box? + # integration — is a CS3D ref requested, and if so which (validated)? + # approval — must a named reviewer approve before the box is used? + # + # .github/CODEOWNERS covers an overlapping set of paths but is not a mirror of + # the protected-path list below; the two are maintained separately. They act + # at different moments: this gate keeps a fork PR off the box before review, + # CODEOWNERS puts a code owner in front of the merge. + # + # This job is part of the workflow it protects: a fork PR can delete it, along + # with the `needs:` and `if:` below, and dispatch straight to the box. What + # stops that is a repository setting — + # `actions/permissions/fork-pr-contributor-approval` set to + # `all_external_contributors`, so a maintainer approves every fork PR run. + # Relaxing it removes that barrier silently, so treat it as part of this file. + # + # This job's value is catching an approval given without full attention. + # Anyone setting out to get past it deletes it instead. Other layers exist + # outside this repository; this is not the last line of defence. + gate: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: read # list changed files, read the PR body + outputs: + proceed: ${{ steps.decide.outputs.proceed }} + integration: ${{ steps.decide.outputs.integration }} + approval: ${{ steps.decide.outputs.approval }} + cs3d_ref: ${{ steps.decide.outputs.cs3d_ref }} + # `none`, `version` or `branch`. Decided here, off the box, so that one + # parser and one grammar answer the question for every consumer: the + # steps on the runner and the merge guard both read this rather than + # each classifying the ref again. + cs3d_kind: ${{ steps.decide.outputs.cs3d_kind }} + # The branch name from a ` now ` clause, kept only for + # log lines; the ref that is acted on is cs3d_ref. + cs3d_history: ${{ steps.decide.outputs.cs3d_history }} + cs3d_defer: ${{ steps.decide.outputs.cs3d_defer }} + env: + GH_TOKEN: ${{ github.token }} + steps: + - id: decide + name: Decide whether this PR may run on the self-hosted runner + env: + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + CS3D_REF_INPUT: ${{ github.event.inputs.cs3d_ref }} + # Whether the PR branch lives in this repo. If it does, someone with + # write access pushed it — a fact, unlike anything derivable from the + # author's identity. Do NOT substitute + # `github.event.pull_request.author_association`: it does not track + # repo access in either direction — its MEMBER value only means + # "member of the owning org" (no write implied), and a repo admin can + # report CONTRIBUTOR. There is no reliable alternative from inside a + # fork PR's run either: the authoritative collaborator-permission API + # needs a token with push access, and fork PRs get a read-only one. + IS_SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + run: | + set -euo pipefail + + # ── 1. May this run reach the box at all? ────────────────────────── + # Fork PRs that modify CI-defining files are not dispatched to nashua; + # they run once the change has been reviewed and merged. Same-repo PRs + # always proceed — the branch lives in this repo, so someone with + # write access pushed it. + proceed=true + if [ "$EVENT_NAME" = "pull_request" ]; then + files=$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" --jq '.[].filename') + count=$(wc -l <<<"$files") + + ci_touched=false + while IFS= read -r f; do + [ -n "$f" ] || continue + case "$f" in + # Workflows, and the scripts they exec on the box. + .github/*|.scripts/*) + ci_touched=true ;; + # The install surface. The root package.json runs preinstall.js + # and pnpm executes a root pnpmfile, so editing or adding either + # runs code on the box during `pnpm install`. .npmrc can repoint + # the registry, and pnpm-workspace.yaml steers what gets + # installed: its `allowBuilds` list decides which dependencies + # may run install scripts on the box, and `minimumReleaseAge` is + # the release-age quarantine. Most of these files are untracked + # today — that is the point: a fork can ADD one, and a list of + # only existing files would not notice. + pnpm-workspace.yaml|preinstall.js|.npmrc) + ci_touched=true ;; + # A per-package .npmrc, at any depth: pnpm reads it during a + # root install just as it reads the root one. A `case` pattern + # is not a path glob — `*` matches `/` too — so `*/.npmrc` + # covers extensions//.npmrc and a project a fork adds. + */.npmrc) + ci_touched=true ;; + .pnpmfile.mjs|.pnpmfile.cjs|pnpmfile.mjs|pnpmfile.cjs) + ci_touched=true ;; + esac + done <<<"$files" + # The list endpoint caps at 3000 files; past that we cannot see every + # path, so treat the PR as CI-touching. + if [ "$count" -ge 3000 ]; then ci_touched=true; fi + + if [ "$ci_touched" = true ] && [ "$IS_SAME_REPO" != "true" ]; then + echo "::notice::Playwright deferred: this PR changes CI-defining files and comes from a fork. It will run on the self-hosted runner once the change is reviewed and merged." + proceed=false + fi + fi + echo "proceed=$proceed" >> "$GITHUB_OUTPUT" + # A deferred PR does NOT stop here. The parse below still has to run: + # `cs3d-branch-merge-guard` reads its result, and that job must report + # on a fork PR that this gate defers — which is the PR most worth + # reporting on. Nothing below reaches the box either way; + # `playwright-tests` is skipped on `proceed` alone. + + # ── 2. Is a CS3D ref requested, and which? ───────────────────────── + # A `CS3D_REF:` line in the PR body is the request; on a manual run the + # workflow input is. Both are optional and absence means the same + # thing: run the ordinary suite against the CS3D version this repo + # already pins, with no clone, no version rewrite and no preview + # deploy. There is deliberately no default ref — one would go stale + # against the pinned version and silently downgrade it. + # + # The body is read live from the API rather than from the event + # payload, because a re-run replays the original payload — a line + # added after the PR was opened would otherwise be acted on while + # escaping the approval below. + raw="" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + raw="${CS3D_REF_INPUT:-}" + elif [ "$EVENT_NAME" = "pull_request" ]; then + # The body goes into a variable before it is parsed, rather than + # straight down a pipe into awk. The parser stops at the first + # match, so on a body larger than the pipe buffer it can close the + # pipe while `gh` is still writing; `gh` then dies on SIGPIPE, and + # `pipefail` turns that into status 141, which `set -e` reads as a + # gate failure and skips Playwright for the whole PR. + if ! body=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq '.body'); then + echo "::error::Could not read the body of pull request ${PR_NUMBER}, so this gate cannot tell whether a CS3D integration run was requested. Failing instead of running the ordinary suite." + exit 1 + fi + + # A CS3D_REF line is a request only where a reader takes it as one: + # at the top level of the body. Three markdown constructs quote text + # rather than state it, and a line inside any of them is an example: + # + # - a fenced code block (``` or ~~~), so that a PR can document + # this syntax without triggering it; + # - an indented code block, four spaces or more, which is the same + # thing written the other way; + # - an HTML comment, which matters here because the OHIF pull + # request template is almost entirely comment blocks and the + # author writes between them. + # + # Leading whitespace is therefore NOT stripped before these tests. + # Up to three spaces still count as top level, which is what + # markdown itself allows in front of a paragraph or a fence. + # + # The parser also reports a CS3D_REF line that it SKIPPED, and + # whether the construct that hid the line was ever closed. An + # unclosed fence or comment swallows the whole remainder of the + # body, so without that report a real request written below one + # would vanish without a word, and the merge guard would then see + # nothing to flag on a PR that depends on an unreleased branch. + # + # Take the WHOLE remainder of a matching line, not just its first + # token — the first-token parse silently dropped the rest, so + # `CS3D_REF: fix/x (released as 5.25.80)` read as an annotation and + # behaved as a live branch directive. Taking the whole line lets the + # grammar below reject that instead. + # + # Output is one record per line, tab separated: + # REF the live request + # SKIP the first skipped CS3D_REF line + # OPENSKIP that skip sits in a construct + # the body never closes + parsed=$(awk ' + function refval(s, v) { + v = s + sub(/^.*CS3D_REF:[ \t]*/, "", v) + sub(/[ \t\r]+$/, "", v) + gsub(/\t/, " ", v) + return v + } + function note(reason, s) { + if (skips == 0) { printf "SKIP\t%s\t%d\t%s\n", reason, NR, refval(s) } + skips++ + } + { + line = $0 + sub(/\r$/, "", line) + + # A tab counts as four columns, as markdown reads it. + ind = 0 + for (i = 1; i <= length(line); i++) { + c = substr(line, i, 1) + if (c == " ") ind++ + else if (c == "\t") ind += 4 + else break + } + text = line + sub(/^[ \t]+/, "", text) + + if (fence != "") { + ch = substr(text, 1, 1) + n = 0 + if (ind < 4 && (ch == "`" || ch == "~")) { while (substr(text, n + 1, 1) == ch) n++ } + # A closing fence repeats the opening marker, is at least as + # long, and carries nothing else. Anything else is content: + # a three-backtick line inside a four-backtick block, or a + # tilde line inside a backtick block, must not end it. + if (n >= 3 && ch == fence && n >= flen && substr(text, n + 1) ~ /^[ \t]*$/) { + fence = ""; open_skip = 0; next + } + if (text ~ /^CS3D_REF:/) { note("fence", text); open_skip = 1 } + next + } + + if (comment) { + closes = index(line, "-->") ? 1 : 0 + if (text ~ /^CS3D_REF:/) { note("comment", text); open_skip = 1 } + if (closes) { comment = 0; open_skip = 0 } + next + } + + # An indented code block cannot be left unclosed, so a line + # skipped here never sets open_skip. + if (ind >= 4) { + if (text ~ /^CS3D_REF:/) note("indent", text) + next + } + + ch = substr(text, 1, 1) + n = 0 + if (ch == "`" || ch == "~") { while (substr(text, n + 1, 1) == ch) n++ } + if (n >= 3) { fence = ch; flen = n; open_skip = 0; next } + + if (text ~ /^")) comment = 1 + if (text ~ /CS3D_REF:/) { note("comment", text); if (comment) open_skip = 1 } + next + } + + if (text ~ /^CS3D_REF:/) { + v = text + sub(/^CS3D_REF:[ \t]*/, "", v) + sub(/[ \t\r]+$/, "", v) + gsub(/\t/, " ", v) + found = 1 + printf "REF\t%s\n", v + exit + } + } + END { + if (found) exit + if ((fence != "" || comment) && open_skip) { print "OPENSKIP" } + }' <<<"$body") + + skip_reason="" + skip_line="" + skip_value="" + unclosed=false + while IFS=$'\t' read -r kind f2 f3 f4; do + case "$kind" in + REF) raw="$f2" ;; + SKIP) + if [ -z "$skip_reason" ]; then + skip_reason="$f2"; skip_line="$f3"; skip_value="$f4" + fi ;; + OPENSKIP) unclosed=true ;; + esac + done <<<"$parsed" + + # A skipped line is only worth mentioning when nothing live was + # found: with a request in hand, the examples above or below it are + # exactly what the skipping is for. + if [ -z "$raw" ] && [ -n "$skip_reason" ]; then + # Same treatment as `shown` below: the text comes from the PR + # body, so strip control characters and cap the length before it + # reaches a log line. + skip_value=$(printf '%s' "$skip_value" | tr -d '\000-\037' | cut -c1-200) + case "$skip_reason" in + fence) where="inside a fenced code block" ;; + indent) where="indented by four spaces or more, which markdown reads as a code block" ;; + comment) where="inside an HTML comment" ;; + *) where="inside a quoted block" ;; + esac + if [ "$unclosed" = true ]; then + echo "::error::Line ${skip_line} of the pull request body reads [CS3D_REF: ${skip_value}], and that line is ${where} which the body never closes. An unclosed fence or comment hides every line below it, so this gate cannot tell a request from an example, and the merge guard would report all clear. Close the block, or move the line out of it." + exit 1 + fi + echo "::warning::Ignored the CS3D_REF line on line ${skip_line} of the pull request body, read as [CS3D_REF: ${skip_value}], because that line is ${where}. No CS3D integration run was requested. Move the line to the top level of the body if you meant it as a request." + fi + fi + raw="${raw#"${raw%%[![:space:]]*}"}"; raw="${raw%"${raw##*[![:space:]]}"}" + + if [ -z "$raw" ]; then + echo "No CS3D ref requested; running the ordinary Playwright suite." + echo "integration=false" >> "$GITHUB_OUTPUT" + echo "approval=false" >> "$GITHUB_OUTPUT" + # The merge guard reads cs3d_kind and reports on this value, so it + # is written on every path out of this step, not only on success. + echo "cs3d_kind=none" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Three accepted forms: + # a branch or tag in cornerstonejs/cornerstone3D + # a published version, e.g. 5.10.3, 5.x + # now the branch, and the concrete release it became + # + # The third is the retiring form: once the cornerstone3D fix ships, the + # author changes the line rather than deleting it, and the branch name + # stays visible as the reason the pinned version moved. Its version is a + # RECORD, not a request — `cs3d_defer` tells the version step to keep + # whatever the repo already pins when that is the same or newer, so a + # stale note can never drag the pin backwards. + # + # Anything explicit is obeyed as given: a bare ref in the body, or + # anything typed into the workflow_dispatch box. A `now` form typed + # into that box parses the same way but does not defer — there is no + # stored line there to go stale, so it is a direct request like any + # other. + history="" + defer=false + if [[ "$raw" =~ ^(.*[^[:space:]])[[:space:]]+now[[:space:]]+([^[:space:]]+)$ ]]; then + history="${BASH_REMATCH[1]}" + ref="${BASH_REMATCH[2]}" + if [ "$EVENT_NAME" = "pull_request" ]; then defer=true; fi + else + ref="$raw" + fi + + # ── 3. Validate the ref before it goes anywhere ──────────────────── + # On a fork PR this value is written by someone outside the project: + # it is read out of the PR body and later steps put it into a git + # clone and into log lines. Validate it against a conservative git-ref + # grammar here, off the box, before it is published as a job output. + # + # The grammar bars whitespace and newlines (which would let the value + # append extra entries to GITHUB_OUTPUT, a key=value file), a leading + # '-' or '.' (which would make it look like a flag), and '..' (a ref + # path escape). It deliberately does NOT permit ':' — a ref no longer + # names a repository, only a branch or tag within + # cornerstonejs/cornerstone3D. + # + # Fails loudly rather than falling back to the default, and quotes what + # it read, because without that a rejection is undiagnosable — the + # author cannot tell a typo from a line picked up somewhere they did + # not expect. The value came from the PR body, which anyone can + # already read, so quoting it discloses nothing; newlines and control + # characters are stripped and the length capped so it cannot be read + # as a workflow command of its own. + # Quoted in both rejections below, so the author can see what was read. + shown=$(printf '%s' "$raw" | tr -d '\000-\037' | cut -c1-200) + + ok=true + if [[ ! "$ref" =~ ^[A-Za-z0-9][A-Za-z0-9._/+-]*$ ]] || [[ "$ref" == *..* ]]; then ok=false; fi + if [ -n "$history" ]; then + if [[ ! "$history" =~ ^[A-Za-z0-9][A-Za-z0-9._/+-]*$ ]] || [[ "$history" == *..* ]]; then ok=false; fi + fi + if [ "$ok" != true ]; then + echo "::error::Rejected the requested CS3D ref, read as: [${shown}]. Write it as one of: , , or ' now '. Each part must start with a letter or digit, contain only letters, digits and the characters . _ / + - and contain no '..'. Forks cannot be named: give a branch or tag in cornerstonejs/cornerstone3D, or a published version." + exit 1 + fi + + # The grammar above constrains which characters may appear; git also + # constrains the shape. A trailing slash, a double slash, a path + # component starting with a dot, and a '.lock' suffix all pass the + # grammar and are still illegal refs. Without this, such a value + # reaches the clone step and fails there with a git error, several + # steps away from the typo that caused it. Every accepted version form + # (4.19+, 4.x, 5.11.0-beta.1) is also a legal ref name, so one rule + # covers both kinds of value. + # + # `refs/heads/$x` rather than `--branch $x`: the latter also expands + # git's previous-branch syntax (@{-1}), which consults repository + # state. The grammar already bars '@', so that cannot arise here, but a + # validator that only validates is the simpler thing to reason about — + # and this form needs no repository, which matters because this job + # deliberately has no checkout. + for part in "$ref" $history; do + if ! git check-ref-format "refs/heads/${part}"; then + echo "::error::Rejected the requested CS3D ref, read as: [${shown}]. Git does not accept that ref name — check for a trailing or doubled '/', a path component starting with '.', or a '.lock' suffix." + exit 1 + fi + done + + # The version after `now` records what a branch shipped as, so it has + # to be one concrete release. A range would resolve to a different + # version on a later run and the record would stop meaning what it + # said. A bare version request may still be a range. + # + # The prerelease class is the one semver allows: dot-separated + # identifiers of letters, digits and hyphens. The hyphen matters — + # `5.11.0-rc-1` is a legal release, and a class without it rejects the + # ref and fails the run. `_` is NOT legal and is deliberately absent. + # One other copy of this class must agree: the version classifier + # below. There were four copies; the merge guard no longer parses or + # classifies anything of its own, and reads cs3d_kind instead. + if [ -n "$history" ] && [[ ! "$ref" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Rejected the requested CS3D ref, read as: [${shown}]. The version after 'now' must be one concrete release, e.g. 5.10.6 or 5.11.0-beta.1 — not a range." + exit 1 + fi + + # ── 4. Is it a published version, or a branch? ───────────────────── + # Classified here, not on the box. Classification needs no checkout — + # only RESOLVING a range does, which is why that stays on the runner — + # and deciding here means the malformed cases fail in front of the + # author rather than several steps later in a job they have to open. + # + # The accepted version forms, and no other: + # 5.10.3 exact release + # 5.11.0-beta.1 exact prerelease + # 5.x / 5.10.x latest of that major, or of that major and minor + # 4.19+ latest >=4.19.0 within the same major + # `.scripts/cs3d-resolve-version.mjs` accepts exactly these. + # + # A ref that is written only from the version alphabet — digits, dots, + # `x` and `+` — and still does not match is a typed version, not a + # branch name, so say so instead of cloning it. `5.10` used to pass as + # a version, resolve to itself unchanged, and then fail on the box in + # cs3d-set-version.mjs; `5` used to be classified as a branch and fail + # in `git clone --branch 5`. A branch whose name merely starts with a + # digit, such as `5.x-backport`, contains a character outside that + # alphabet and is still treated as a branch. + if [[ "$ref" =~ ^[0-9]+(\.[0-9]+(\.([0-9]+(-[0-9A-Za-z.-]+)?|x)|\+)|\.x)$ ]]; then + kind=version + elif [[ "$ref" =~ ^[0-9][0-9.x+]*$ ]]; then + echo "::error::Rejected the requested CS3D ref, read as: [${shown}]. It looks like a version, but it is not one of the accepted forms: 5.10.3, 5.11.0-beta.1, 5.x, 5.10.x or 4.19+. A two-part number such as 5.10 is not one of them — write 5.10.x for the latest 5.10 release, or name the exact release." + exit 1 + else + kind=branch + fi + + if [ -n "$history" ]; then + echo "::notice::CS3D ref: ${ref} (${kind}; was branch ${history})" + else + echo "::notice::CS3D ref requested: ${ref} (${kind})" + fi + echo "integration=true" >> "$GITHUB_OUTPUT" + echo "cs3d_ref=$ref" >> "$GITHUB_OUTPUT" + echo "cs3d_kind=$kind" >> "$GITHUB_OUTPUT" + echo "cs3d_history=$history" >> "$GITHUB_OUTPUT" + echo "cs3d_defer=$defer" >> "$GITHUB_OUTPUT" + + # ── 5. Does it need a named reviewer's approval? ─────────────────── + # An integration run clones and builds a CS3D branch on the shared box. + # From a fork that is a request, not a decision, so it waits for one of + # the reviewers on the `cs3d-integration` environment. A same-repo PR + # or a manual run was already started by someone with write access, so + # it proceeds unattended. + # + # Every accepted ref asks for approval, including a retired + # ` now ` clause that this repo has already moved past + # and that will therefore change nothing on the box. Telling those two + # apart needs the version the repo pins, and this job has no checkout + # by design — so the conservative answer is the only one available + # here. A spent line still costs a fork PR one approval per run; the + # steps on the box do recognise it, and skip the preview build and the + # deploy. Delete the line to stop the approval prompt. + if [ "$EVENT_NAME" = "pull_request" ] && [ "$IS_SAME_REPO" != "true" ]; then + echo "::notice::This fork PR requests a CS3D integration run, so it needs approval from a reviewer on the cs3d-integration environment before it reaches the self-hosted runner." + echo "approval=true" >> "$GITHUB_OUTPUT" + else + echo "approval=false" >> "$GITHUB_OUTPUT" + fi + playwright-tests: + needs: gate + if: needs.gate.outputs.proceed == 'true' timeout-minutes: 120 - environment: fork-pr-approval + # `cs3d-integration` carries required reviewers, so naming it makes the job + # wait for an approval that shows up on the pull request itself. + # `unrestricted` carries no protection rules and is the ordinary path. The + # choice comes from the gate rather than from the event payload — see the + # note on reading the PR body live, above. + # + # `unrestricted` replaced an environment named `fork-pr-approval`. That + # name promised a gate it never had: it carries no protection rules either, + # so it stopped nothing, and the rename adds and removes no control. What + # does hold an ordinary fork run is the repository setting recorded at the + # top of this file, `fork-pr-contributor-approval`, currently + # `all_external_contributors`. Do not read either environment name as a + # second barrier. + environment: + ${{ needs.gate.outputs.approval == 'true' && 'cs3d-integration' || 'unrestricted' }} runs-on: [self-hosted, nashua] strategy: fail-fast: false @@ -46,7 +539,7 @@ jobs: # on this self-hosted runner is corrupted (Cannot find module # '../lib/cli.js'), so it dies regardless of `standalone:true`. Corepack # ships inside the Node that setup-node just installed, reads the pinned - # `packageManager` (pnpm@11.5.2) from package.json, and fetches pnpm via + # `packageManager` (pnpm@12.8.1) from package.json, and fetches pnpm via # Node's own https — it never invokes the npm CLI. - name: Enable Corepack (pnpm) shell: bash @@ -54,53 +547,44 @@ jobs: corepack enable corepack prepare --activate - # ── CS3D integration: detect label and ref type ────────────────────── - - name: Check for CS3D integration label - id: cs3d-check - run: bash .scripts/ci/cs3d-check-integration.sh - env: - GH_TOKEN: ${{ github.token }} - EVENT_NAME: ${{ github.event_name }} - CS3D_REF_INPUT: ${{ github.event.inputs.cs3d_ref || '4.19+' }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - - - name: Detect CS3D ref type + # ── CS3D integration: resolve a version range ──────────────────────── + # The gate parsed the ref, validated it and classified it as a version or + # a branch, all off the box. Only the resolution of a range is left here, + # because that reads `.scripts/` and the gate has no checkout by design. + # A branch needs no resolution, so this step runs for a version alone. + - name: Resolve the CS3D version id: cs3d-ref - if: steps.cs3d-check.outputs.enabled == 'true' + if: needs.gate.outputs.cs3d_kind == 'version' run: | - REF="${CS3D_REF}" - if [[ "$REF" =~ ^[0-9]+\.[0-9x]+\+?(\.[0-9x]+)?(-[a-zA-Z0-9._]+)?$ ]]; then - echo "type=version" >> "$GITHUB_OUTPUT" - RESOLVED=$(node .scripts/cs3d-resolve-version.mjs "$REF") - echo "version=$RESOLVED" >> "$GITHUB_OUTPUT" - echo "::notice::CS3D version: $REF -> $RESOLVED" - else - echo "type=branch" >> "$GITHUB_OUTPUT" - echo "::notice::CS3D branch: $REF" - fi + RESOLVED=$(node .scripts/cs3d-resolve-version.mjs "$CS3D_REF") + echo "version=$RESOLVED" >> "$GITHUB_OUTPUT" + echo "::notice::CS3D version: $CS3D_REF -> $RESOLVED" env: - CS3D_REF: ${{ steps.cs3d-check.outputs.cs3d_ref }} + CS3D_REF: ${{ needs.gate.outputs.cs3d_ref }} # ── CS3D branch path: clone and build before OHIF install ─────────── + # The repository is fixed. A ref names a branch or tag in + # cornerstonejs/cornerstone3D and nothing else — it previously accepted an + # `owner:branch` form, which let a pull request point this clone at any + # GitHub account's fork and then run its install scripts and build on the + # shared self-hosted box. + # + # $CS3D_REF is read as a shell variable, never interpolated as ${{ }}: + # GitHub pastes ${{ }} in as literal text before the shell parses the + # command, so punctuation in the value would become program structure. The + # shell expands an env var only after parsing, so it can only ever be an + # argument. - name: Clone CS3D - if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'branch' + if: needs.gate.outputs.cs3d_kind == 'branch' run: | - REF="${CS3D_REF}" - if [[ "$REF" == *:* ]]; then - REPO="https://github.com/${REF%%:*}/cornerstone3D.git" - BRANCH="${REF#*:}" - else - REPO="https://github.com/cornerstonejs/cornerstone3D.git" - BRANCH="$REF" - fi - echo "::notice::Cloning CS3D from $REPO branch $BRANCH" - git clone --depth 1 --branch "$BRANCH" "$REPO" libs/@cornerstonejs + echo "::notice::Cloning cornerstonejs/cornerstone3D branch $CS3D_REF" + git clone --depth 1 --branch "$CS3D_REF" \ + https://github.com/cornerstonejs/cornerstone3D.git libs/@cornerstonejs env: - CS3D_REF: ${{ steps.cs3d-check.outputs.cs3d_ref }} + CS3D_REF: ${{ needs.gate.outputs.cs3d_ref }} - name: Install & Build CS3D - if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'branch' + if: needs.gate.outputs.cs3d_kind == 'branch' working-directory: libs/@cornerstonejs run: pnpm install --frozen-lockfile && pnpm run build:esm @@ -110,18 +594,35 @@ jobs: # ── CS3D branch path: link packages after OHIF install ────────────── - name: Link CS3D packages - if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'branch' + if: needs.gate.outputs.cs3d_kind == 'branch' working-directory: libs/@cornerstonejs run: node scripts/link-ohif-cornerstone-node-modules.mjs "$GITHUB_WORKSPACE" # ── CS3D version path: update versions after OHIF install ─────────── + # Split into two steps on purpose. Rewriting the manifests puts them out of + # step with the lockfile, so the reinstall cannot be frozen — but that only + # applies if something was actually rewritten. When the repo already pins + # the requested version (or, for a `now` clause, something newer), nothing + # changes and the frozen install from "Install dependencies" above already + # describes the tree under test. Reinstalling then would replace a faithful + # tree with a re-resolved one, which is the opposite of what an integration + # run is for. - name: Set CS3D version - if: steps.cs3d-check.outputs.enabled == 'true' && steps.cs3d-ref.outputs.type == 'version' + id: set-version + if: needs.gate.outputs.cs3d_kind == 'version' run: | - node .scripts/cs3d-set-version.mjs "${CS3D_VERSION}" - pnpm install --no-frozen-lockfile + if [ "$CS3D_DEFER" = "true" ]; then + node .scripts/cs3d-set-version.mjs "$CS3D_VERSION" --only-if-newer + else + node .scripts/cs3d-set-version.mjs "$CS3D_VERSION" + fi env: CS3D_VERSION: ${{ steps.cs3d-ref.outputs.version }} + CS3D_DEFER: ${{ needs.gate.outputs.cs3d_defer }} + + - name: Reinstall after the version change + if: steps.set-version.outputs.changed == 'true' + run: pnpm install --no-frozen-lockfile # ── Common: run tests ─────────────────────────────────────────────── - name: Install Playwright browsers @@ -130,7 +631,10 @@ jobs: # avoids downloading + dep-validating browsers we never launch (the WebKit # validation is what surfaces the "missing libwoff1/libflite1/..." error on # hosts without those libs). - run: npx playwright install chromium + # + # `pnpm exec`, not `npx`: this runs the playwright the lockfile pins, + # rather than whatever the registry serves at that moment. + run: pnpm exec playwright install chromium - name: Run Playwright tests run: | export NODE_OPTIONS="--max_old_space_size=10192" @@ -163,27 +667,43 @@ jobs: # ── CS3D: build and deploy preview to Netlify ─────────────────────── - name: Log build context (OHIF/CS3D branch and version for build diagnosis) - if: steps.cs3d-check.outputs.enabled == 'true' + if: needs.gate.outputs.integration == 'true' run: | if [[ "$CS3D_REF_TYPE" == "branch" ]]; then - echo "::notice::Build type: ohif-downstream | OHIF: ${{ github.repository }}@${{ github.ref }} (${{ github.sha }}) | CS3D: branch ${{ steps.cs3d-check.outputs.cs3d_ref }}" + echo "::notice::Build type: ohif-downstream | OHIF: ${{ github.repository }}@${{ github.ref }} (${{ github.sha }}) | CS3D: branch ${CS3D_REF}" else - echo "::notice::Build type: ohif-upstream | OHIF: ${{ github.repository }}@${{ github.ref }} (${{ github.sha }}) | CS3D: version ${{ steps.cs3d-ref.outputs.version }}" + echo "::notice::Build type: ohif-upstream | OHIF: ${{ github.repository }}@${{ github.ref }} (${{ github.sha }}) | CS3D: version ${CS3D_VERSION}" fi node .scripts/log-build-context.mjs env: BUILD_TYPE: - ${{ steps.cs3d-ref.outputs.type == 'branch' && 'ohif-downstream' || 'ohif-upstream' }} - CS3D_REF_TYPE: ${{ steps.cs3d-ref.outputs.type }} + ${{ needs.gate.outputs.cs3d_kind == 'branch' && 'ohif-downstream' || 'ohif-upstream' }} + CS3D_REF_TYPE: ${{ needs.gate.outputs.cs3d_kind }} + CS3D_REF: ${{ needs.gate.outputs.cs3d_ref }} + CS3D_VERSION: ${{ steps.cs3d-ref.outputs.version }} + # The preview build and deploy exist to show a tree that differs from an + # ordinary run. A branch run always differs. A version run differs only + # when the manifests were actually rewritten — which a retired + # ` now ` clause stops doing once the repo pins that + # version or something newer. Without this condition a spent line keeps + # building and deploying a preview identical to the ordinary suite on + # every re-run, for as long as the line stays in the body as history. - name: Build OHIF viewer (CS3D preview) - if: steps.cs3d-check.outputs.enabled == 'true' + if: needs.gate.outputs.cs3d_kind == 'branch' || steps.set-version.outputs.changed == 'true' run: pnpm run build:ci - name: Deploy CS3D preview to Netlify - if: steps.cs3d-check.outputs.enabled == 'true' + if: needs.gate.outputs.cs3d_kind == 'branch' || steps.set-version.outputs.changed == 'true' + # The version is pinned here rather than in package.json. This ran + # `npx netlify-cli`, which fetched whatever the registry served at that + # moment and ran it on the self-hosted box with NETLIFY_AUTH_TOKEN in + # its environment. + # + # `--package` is required: its binaries are `netlify` and `ntl`, neither + # matching the package name, so pnpm cannot pick a default. run: | - RESULT=$(npx netlify-cli deploy --dir=platform/app/dist --alias="cs3d-pr-${PR_NUM}" --json --filter=@ohif/app) || { + RESULT=$(pnpm dlx --package=netlify-cli@27.5.2 netlify deploy --dir=platform/app/dist --alias="cs3d-pr-${PR_NUM}" --json --filter=@ohif/app) || { echo "::error::Netlify deploy command failed" exit 1 } @@ -201,32 +721,76 @@ jobs: # ── CS3D: log results ─────────────────────────────────────────────── - name: Log CS3D build used - if: steps.cs3d-check.outputs.enabled == 'true' + if: needs.gate.outputs.integration == 'true' run: | if [[ "$CS3D_REF_TYPE" == "branch" ]]; then echo "::notice::CS3D integration PASSED with branch ${CS3D_REF} (linked from libs/@cornerstonejs)" - else + elif [[ "$CS3D_CHANGED" == "true" ]]; then echo "::notice::CS3D integration PASSED with @cornerstonejs/*@${CS3D_VERSION}" + else + # The retired form, now spent: the repo already pins this version or + # something newer, so nothing was rewritten and this run tested the + # same tree as the ordinary suite. Say so, rather than reporting an + # integration run that did not happen. + echo "::notice::CS3D_REF names ${CS3D_VERSION}, which this repo already pins (or has moved past), so no version change was made. The line is now a record of history and no longer changes the run; it is safe to leave in place." fi env: - CS3D_REF_TYPE: ${{ steps.cs3d-ref.outputs.type }} - CS3D_REF: ${{ steps.cs3d-check.outputs.cs3d_ref }} + CS3D_REF_TYPE: ${{ needs.gate.outputs.cs3d_kind }} + CS3D_REF: ${{ needs.gate.outputs.cs3d_ref }} CS3D_VERSION: ${{ steps.cs3d-ref.outputs.version }} + CS3D_CHANGED: ${{ steps.set-version.outputs.changed }} - # ── Separate job: block merge when using a CS3D branch ───────────── + # ── Separate job: flag a merge that would depend on unreleased CS3D ── + # A `CS3D_REF:` line naming a branch means the change was validated against + # cornerstone3D that npm does not carry, so merging it would leave master + # depending on code no released version provides. + # + # It keys off the presence of the line itself, not off a label and not off + # whether the integration tests actually ran — a PR that declares a dependency + # on an unreleased branch is worth flagging either way. + # + # Advisory, deliberately: it reports, it does not block. `master` requires no + # status checks, so a red result here leaves the merge button enabled. Making + # this a required status check would change that, but it also changes how + # every PR in the repo merges, and that is not this workflow's decision to + # make. Read a red result as "a human should look", not as a lock. + # + # It reads the gate's answer rather than parsing the PR body again. Two copies + # of that parser drifted apart before: one accepted a prerelease form the + # other rejected, and a body shape that defeated one defeated the other only + # by accident. This job also no longer checks out the pull request, so there + # is no script of the PR's own for a fork to neuter. + # + # `needs: gate` means a gate failure skips this job. That is intended: the + # gate fails only when it rejects the CS3D_REF line outright, and it says so + # on the pull request itself. cs3d-branch-merge-guard: name: 'CS3D Branch Merge Guard' + needs: gate + if: github.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - persist-credentials: false - name: Check for CS3D branch usage - run: bash .scripts/ci/cs3d-branch-merge-guard.sh env: - GH_TOKEN: ${{ github.token }} - EVENT_NAME: ${{ github.event_name }} - CS3D_REF_INPUT: ${{ github.event.inputs.cs3d_ref || '4.19+' }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} + CS3D_KIND: ${{ needs.gate.outputs.cs3d_kind }} + CS3D_REF: ${{ needs.gate.outputs.cs3d_ref }} + CS3D_HISTORY: ${{ needs.gate.outputs.cs3d_history }} + run: | + set -euo pipefail + if [ "$CS3D_KIND" = "none" ]; then + echo "::notice::No CS3D_REF line in the pull request body — nothing to flag." + exit 0 + fi + if [ "$CS3D_KIND" = "version" ]; then + if [ -n "$CS3D_HISTORY" ]; then + echo "::notice::CS3D ref '${CS3D_REF}' is a published version (branch '${CS3D_HISTORY}' shipped as it) — merge allowed." + else + echo "::notice::CS3D ref '${CS3D_REF}' is a published version — merge allowed." + fi + exit 0 + fi + echo "::error::This pull request declares CS3D_REF '${CS3D_REF}', a branch rather than a published version, so merging it would leave master depending on unreleased cornerstone3D code." + echo "::error::Once the cornerstone3D change is released, change the line to '${CS3D_REF} now ' to keep the branch name as history while depending on the release." + echo "::error::Change the CS3D_REF line to a published version (e.g. 5.x) once the cornerstone3D change has been released, or remove the line if it is stale." + exit 1 diff --git a/.gitignore b/.gitignore index 2b6c3f0447e..7ccea674f3a 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ libs/ link-cs3d.js unlink-cs3d.js auth.json + +# platform/ui-next UMD build emits font assets at the package root +platform/ui-next/*.woff2 diff --git a/.netlify/package.json b/.netlify/package.json index 5635f3aed25..77ba04bf9bd 100644 --- a/.netlify/package.json +++ b/.netlify/package.json @@ -3,7 +3,7 @@ "private": true, "engines": { "node": ">=24", - "pnpm": ">=11" + "pnpm": ">=12" }, "scripts": { "deploy": "netlify deploy --prod --dir ./../platform/app/dist" diff --git a/.react-compiler-budget.json b/.react-compiler-budget.json new file mode 100644 index 00000000000..5b5d9dc6f98 --- /dev/null +++ b/.react-compiler-budget.json @@ -0,0 +1,20 @@ +{ + "refusals": { + "extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx": 1, + "platform/app/src/routes/Mode/Mode.tsx": 1, + "platform/ui-next/src/components/Dialog/useDraggable.ts": 1 + }, + "fileOptOuts": [ + "extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx", + "extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx", + "extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx", + "extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx", + "extensions/cornerstone/src/Viewport/Overlays/ViewportImageSliceLoadingIndicator.tsx", + "extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx", + "extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/ViewportSliceProgressScrollbar.tsx", + "extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/hooks.ts", + "extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx", + "platform/app/src/routes/LegacyWorkList/LegacyWorkList.tsx" + ], + "functionOptOuts": {} +} diff --git a/.react-compiler-lint-budget.json b/.react-compiler-lint-budget.json new file mode 100644 index 00000000000..ab530305058 --- /dev/null +++ b/.react-compiler-lint-budget.json @@ -0,0 +1,4 @@ +{ + "errors": 96, + "warnings": 94 +} diff --git a/.scripts/ci/cs3d-branch-merge-guard.sh b/.scripts/ci/cs3d-branch-merge-guard.sh deleted file mode 100644 index 94d481afea0..00000000000 --- a/.scripts/ci/cs3d-branch-merge-guard.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -# CS3D branch merge guard: blocks merge when tests ran against a CS3D branch (not a version). -# Exits 0 when merge is allowed or guard is skipped; exits 1 when merge must be blocked. -# -# Required env: GH_TOKEN, EVENT_NAME, REPO, PR_NUMBER -# Optional env: CS3D_REF_INPUT (for workflow_dispatch, default 4.19+) - -set -e - -if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - echo "::notice::workflow_dispatch — no merge to block, skipping guard." - exit 0 -elif [[ "$EVENT_NAME" == "pull_request" ]]; then - LABELS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/labels" --jq '.[].name') - if echo "$LABELS" | grep -q "ohif-integration"; then - ENABLED=true - CS3D_REF=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body' \ - | sed -n 's/^[[:space:]]*CS3D_REF:[[:space:]]*\([^[:space:]]*\).*/\1/p' | head -1) - if [[ -z "$CS3D_REF" ]]; then - CS3D_REF="4.19+" - fi - else - ENABLED=false - fi -else - ENABLED=false -fi - -if [[ "$ENABLED" != "true" ]]; then - echo "::notice::No ohif-integration label — skipping merge guard." - exit 0 -fi - -# Check if the ref is a branch (not a version) -if [[ "$CS3D_REF" =~ ^[0-9]+\.[0-9x]+\+?(\.[0-9x]+)?(-[a-zA-Z0-9._]+)?$ ]]; then - echo "::notice::CS3D ref '$CS3D_REF' is a version — merge allowed." - exit 0 -fi - -echo "::error::Tests ran against CS3D branch '${CS3D_REF}' — this build cannot be merged." -echo "::error::Re-run with a published CS3D version (e.g. 4.19+) before merging." -exit 1 diff --git a/.scripts/ci/cs3d-check-integration.sh b/.scripts/ci/cs3d-check-integration.sh deleted file mode 100644 index 376ab96918c..00000000000 --- a/.scripts/ci/cs3d-check-integration.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# CS3D integration check: detects ohif-integration label and parses CS3D_REF. -# Writes to GITHUB_OUTPUT: enabled (true|false), cs3d_ref (when enabled). -# -# Required env: GH_TOKEN, EVENT_NAME, REPO, PR_NUMBER, GITHUB_OUTPUT -# Optional env: CS3D_REF_INPUT (for workflow_dispatch, default 4.19+) - -set -e - -if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - echo "enabled=true" >> "$GITHUB_OUTPUT" - echo "cs3d_ref=${CS3D_REF_INPUT:-4.19+}" >> "$GITHUB_OUTPUT" -elif [[ "$EVENT_NAME" == "pull_request" ]]; then - LABELS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/labels" --jq '.[].name') - if echo "$LABELS" | grep -q "ohif-integration"; then - echo "enabled=true" >> "$GITHUB_OUTPUT" - REF=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body' \ - | sed -n 's/^[[:space:]]*CS3D_REF:[[:space:]]*\([^[:space:]]*\).*/\1/p' | head -1) - if [[ -z "$REF" ]]; then - REF="4.19+" - fi - echo "cs3d_ref=${REF}" >> "$GITHUB_OUTPUT" - echo "::notice::CS3D ref from PR body: ${REF}" - else - echo "enabled=false" >> "$GITHUB_OUTPUT" - fi -else - echo "enabled=false" >> "$GITHUB_OUTPUT" -fi diff --git a/.scripts/create-github-release.mjs b/.scripts/create-github-release.mjs new file mode 100644 index 00000000000..c99b4a4c174 --- /dev/null +++ b/.scripts/create-github-release.mjs @@ -0,0 +1,355 @@ +#!/usr/bin/env node + +/** + * Creates the GitHub Release for an OHIF version tag. + * + * Usage: create-github-release.mjs [--dry-run] + * e.g. create-github-release.mjs v3.14.0-beta.37 --dry-run + * + * Rules: + * - Only tags of the form vX.Y.Z or vX.Y.Z- are accepted. + * - If a release (or draft) already exists for the tag, it is left untouched + * and the script exits successfully, so hand-edited notes are never overwritten. + * - The release is only created once the public packages (non-private + * package.json under extensions/, platform/ and modes/ at the tagged commit) + * are on npm at that version. A package npm has never had is skipped with a + * warning, so one that has never been published does not block every release. + * This wait is temporary, until npm publishing moves to GitHub Actions. + * - A prerelease version (e.g. a beta) is marked as a pre-release and is never + * "Latest". A stable version is marked "Latest" only if it is the highest + * stable version among all tags, so a 3.12.x patch never takes Latest from 3.13.x. + * - Notes are GitHub generated. They count from the nearest earlier tag + * reachable from this one, except for a minor release (X.Y.0), which counts + * from the previous minor release so the notes cover the whole release. + * - A minor release (X.Y.0) is created as a draft, because it usually gets + * hand-written notes. An issue is opened, assigned to the owners of /.github/ + * in .github/CODEOWNERS, asking them to review and publish it. + * + * --dry-run makes no changes: it checks npm once instead of waiting and prints + * what would be created, even for a tag that already has a release. + * + * Environment: + * GITHUB_TOKEN token with contents: write and issues: write + * (read is enough for --dry-run) + * GITHUB_REPOSITORY owner/repo, defaults to OHIF/Viewers + * NPM_WAIT_MINUTES how long to wait for npm, defaults to 60 + * NPM_POLL_SECONDS how often to check npm, defaults to 30 + */ + +import { execFileSync } from 'child_process'; +import fs from 'fs'; + +const TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; +const PACKAGE_JSON_PATTERN = /^(extensions|platform|modes)\/[^/]+\/package\.json$/; +const CODEOWNERS_PATH = '.github/CODEOWNERS'; + +const args = process.argv.slice(2); +const dryRun = args.includes('--dry-run'); +const tag = args.find(arg => !arg.startsWith('--')); + +const repository = process.env.GITHUB_REPOSITORY || 'OHIF/Viewers'; +const token = process.env.GITHUB_TOKEN; +const npmWaitMinutes = Number(process.env.NPM_WAIT_MINUTES || 60); +const npmPollSeconds = Number(process.env.NPM_POLL_SECONDS || 30); + +function git(...gitArgs) { + return execFileSync('git', gitArgs, { encoding: 'utf8' }).trim(); +} + +function parseVersion(tagName) { + const match = tagName.match(TAG_PATTERN); + if (!match) { + return null; + } + return { + tagName, + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] || null, + }; +} + +// Compares stable versions only; prereleases never take part in these decisions. +function compareStable(a, b) { + return a.major - b.major || a.minor - b.minor || a.patch - b.patch; +} + +function getStableVersions() { + return git('tag', '--list', 'v*') + .split('\n') + .map(parseVersion) + .filter(parsed => parsed && !parsed.prerelease); +} + +async function github(method, path, body) { + const response = await fetch(`https://api.github.com/repos/${repository}${path}`, { + method, + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await response.text(); + return { status: response.status, data: text ? JSON.parse(text) : null }; +} + +// Drafts are not returned by /releases/tags/{tag}, so look through the release +// list as well, or a re-run would create a second draft and a second issue. +async function findExistingRelease() { + const published = await github('GET', `/releases/tags/${encodeURIComponent(tag)}`); + if (published.status === 200) { + return published.data; + } + if (published.status !== 404) { + throw new Error(`Could not check for an existing release: HTTP ${published.status}`); + } + + for (let page = 1; ; page++) { + const recent = await github('GET', `/releases?per_page=100&page=${page}`); + if (recent.status !== 200) { + throw new Error(`Could not list releases: HTTP ${recent.status}`); + } + const match = recent.data.find(release => release.tag_name === tag); + if (match) { + return match; + } + if (recent.data.length < 100) { + break; + } + } + return null; +} + +function getPublicPackages() { + return git('ls-tree', '-r', '--name-only', tag, '--', 'extensions', 'platform', 'modes') + .split('\n') + .filter(file => PACKAGE_JSON_PATTERN.test(file)) + .map(file => JSON.parse(git('show', `${tag}:${file}`))) + .filter(packageJson => !packageJson.private) + .map(packageJson => packageJson.name); +} + +// Returns the HTTP status for a package (or one version of it) on npm, or null +// when npm could not be reached. +async function npmStatus(packageName, version) { + const packagePath = packageName.replace('/', '%2F'); + const url = `https://registry.npmjs.org/${packagePath}${version ? `/${version}` : ''}`; + try { + // A time limit, so one stuck request cannot hold up the wait's deadline. + const response = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(30_000) }); + return response.status; + } catch (error) { + console.warn(`Could not reach npm for ${packageName}: ${error.message}`); + return null; + } +} + +// The public packages npm has had at least once. A package that has never been +// published would otherwise make every release wait and then fail. +async function getPackagesToWaitFor() { + const packages = getPublicPackages(); + const statuses = await Promise.all(packages.map(name => npmStatus(name))); + const neverPublished = packages.filter((_, index) => statuses[index] === 404); + if (neverPublished.length) { + console.warn(`Not waiting for packages npm has never had: ${neverPublished.join(', ')}`); + } + return packages.filter((_, index) => statuses[index] !== 404); +} + +async function waitForNpm(version) { + const packages = await getPackagesToWaitFor(); + const deadline = Date.now() + npmWaitMinutes * 60_000; + + while (true) { + const statuses = await Promise.all(packages.map(name => npmStatus(name, version))); + const missing = packages.filter((_, index) => statuses[index] !== 200); + if (!missing.length) { + console.log(`All ${packages.length} packages are on npm at ${version}.`); + return; + } + if (dryRun) { + console.log(`Dry run: not on npm at ${version} yet: ${missing.join(', ')}`); + return; + } + if (Date.now() >= deadline) { + throw new Error( + `Gave up after ${npmWaitMinutes} minutes. Not on npm at ${version}: ${missing.join(', ')}` + ); + } + console.log(`Waiting for ${missing.length} package(s) on npm: ${missing.join(', ')}`); + await new Promise(resolve => setTimeout(resolve, npmPollSeconds * 1000)); + } +} + +function getPreviousTag(version, isMinorRelease) { + if (isMinorRelease) { + // The highest earlier X.Y.0, e.g. v3.13.0 for v3.14.0 and v3.x.0 for v4.0.0. + const previousMinor = getStableVersions() + .filter(other => other.patch === 0 && compareStable(other, version) < 0) + .sort(compareStable) + .pop(); + return previousMinor?.tagName ?? null; + } + // The nearest version tag in this branch's history before this one: + // `${tag}^` starts from the parent commit, --abbrev=0 prints just the tag name. + // Throws when there is no earlier tag. + try { + return git('describe', '--tags', '--abbrev=0', '--match', 'v[0-9]*', `${tag}^`); + } catch { + return null; + } +} + +function isHighestStable(version) { + return getStableVersions().every(other => compareStable(version, other) >= 0); +} + +// The individual owners of /.github/ in CODEOWNERS (teams cannot be assigned). +function getReleaseOwners() { + const line = fs + .readFileSync(CODEOWNERS_PATH, 'utf8') + .split('\n') + .find(entry => entry.trim().split(/\s+/)[0] === '/.github/'); + + return (line?.match(/@[\w-]+(?![\w/-])/g) ?? []).map(owner => owner.slice(1)); +} + +async function openReviewIssue(draftUrl, makeLatest) { + const owners = getReleaseOwners(); + const latestStep = makeLatest + ? '2. Under **Release label**, keep **Latest** selected.' + : '2. Under **Release label**, choose **None**, not Latest: a higher stable version exists.'; + const issue = { + title: `Review and publish the ${tag} release`, + body: [ + `The ${tag} release was created as a draft: ${draftUrl}`, + '', + 'Minor releases usually get hand-written notes, so it is not public yet. To publish it:', + '', + '1. Open the draft and review the generated notes. Add a summary or a link to the release notes on ohif.org if needed.', + latestStep, + '3. Click **Publish release**, then close this issue.', + '', + owners.map(owner => `@${owner}`).join(' '), + ].join('\n'), + }; + + if (dryRun) { + console.log(`Dry run: would open an issue assigned to ${owners.join(', ') || '(nobody)'}:`); + console.log(`\n${issue.title}\n\n${issue.body}\n`); + return; + } + + let created = await github('POST', '/issues', { ...issue, assignees: owners }); + if (created.status === 422) { + // An owner who can no longer be assigned; the mentions still notify everyone. + console.warn('Could not assign the owners, opening the issue without assignees.'); + created = await github('POST', '/issues', issue); + } + if (created.status !== 201) { + throw new Error( + `Created the draft, but could not open the review issue: HTTP ${created.status} ${created.data?.message ?? ''}` + ); + } + console.log(`Opened ${created.data.html_url}`); +} + +async function run() { + if (!tag) { + throw new Error('Usage: create-github-release.mjs [--dry-run]'); + } + + const version = parseVersion(tag); + if (!version) { + throw new Error(`"${tag}" is not a version tag (expected vX.Y.Z or vX.Y.Z-).`); + } + + try { + git('rev-parse', '--verify', '--quiet', `refs/tags/${tag}`); + } catch { + throw new Error(`Tag ${tag} does not exist in this checkout.`); + } + + const existing = await findExistingRelease(); + if (existing) { + const kind = existing.draft ? 'A draft release' : 'A release'; + if (!dryRun) { + console.log(`${kind} for ${tag} already exists (${existing.html_url}). Leaving it unchanged.`); + return; + } + // Keep previewing, so dry runs still exercise the logic for released tags. + console.log(`${kind} for ${tag} already exists (${existing.html_url}).`); + console.log('A real run would stop here. Previewing what would be created anyway:'); + } + + const packageVersion = tag.slice(1); + await waitForNpm(packageVersion); + + const prerelease = Boolean(version.prerelease); + const isMinorRelease = !prerelease && version.patch === 0; + const makeLatest = !prerelease && isHighestStable(version); + const previousTag = getPreviousTag(version, isMinorRelease); + + const notes = await github('POST', '/releases/generate-notes', { + tag_name: tag, + ...(previousTag ? { previous_tag_name: previousTag } : {}), + }); + if (notes.status !== 200) { + const message = `Could not generate release notes: HTTP ${notes.status} ${notes.data?.message ?? ''}`; + if (!dryRun) { + throw new Error(message); + } + console.warn(`Dry run: ${message}`); + } + + // A draft cannot be Latest; the maintainer sets it when publishing. + const release = { + tag_name: tag, + name: tag, + body: notes.data?.body ?? '', + draft: isMinorRelease, + prerelease, + ...(isMinorRelease ? {} : { make_latest: makeLatest ? 'true' : 'false' }), + }; + + console.log(`Tag: ${tag}`); + console.log(`Previous tag: ${previousTag ?? '(none)'}`); + console.log(`Draft: ${release.draft}`); + console.log(`Pre-release: ${prerelease}`); + console.log(`Latest: ${makeLatest}`); + + if (dryRun) { + console.log(`Dry run: would create this release. Notes:\n\n${release.body}\n`); + if (release.draft) { + await openReviewIssue('(the new draft)', makeLatest); + } + return; + } + + const created = await github('POST', '/releases', release); + if (created.status === 201) { + console.log(`Created ${created.data.html_url}`); + if (release.draft) { + await openReviewIssue(created.data.html_url, makeLatest); + } + return; + } + // Another run created it between our check and now. + const alreadyExists = created.data?.errors?.some(error => error.code === 'already_exists'); + if (created.status === 422 && alreadyExists) { + console.log(`A release for ${tag} was created by another run. Leaving it unchanged.`); + return; + } + throw new Error( + `Could not create the release: HTTP ${created.status} ${created.data?.message ?? ''}` + ); +} + +run().catch(error => { + console.error(error.message); + process.exit(1); +}); diff --git a/.scripts/cs3d-set-version.mjs b/.scripts/cs3d-set-version.mjs index bfd803e25db..f139347ad12 100644 --- a/.scripts/cs3d-set-version.mjs +++ b/.scripts/cs3d-set-version.mjs @@ -3,43 +3,126 @@ /** * Updates all @cornerstonejs/* package versions across the OHIF workspace. * - * Usage: node .scripts/cs3d-set-version.mjs + * Usage: node .scripts/cs3d-set-version.mjs [--only-if-newer] * - * Only updates the 8 main CS3D packages (not codec packages): - * adapters, ai, core, dicom-image-loader, labelmap-interpolation, - * nifti-volume-loader, polymorphic-segmentation, tools + * Only updates the packages that the cornerstone3D monorepo releases together + * (not the codec packages, and not calculate-suv): + * adapters, ai, core, dicom-image-loader, labelmap-interpolation, metadata, + * nifti-volume-loader, polymorphic-segmentation, tools, utils + * + * An @cornerstonejs/* dependency that is in neither group stops the run with an + * error, so that a new package cannot be left silently at an old version. + * + * --only-if-newer + * Do nothing when the version already committed is the same as, or newer + * than, . Used for the `CS3D_REF: now ` form, + * where the version is a record of what the branch became rather than a + * request — so a stale note cannot drag the pinned version backwards. An + * explicit request (a bare ref, or anything typed into the workflow_dispatch + * box) omits the flag and is obeyed as given, downgrades included. + * + * Reports whether anything changed, on stdout and — when GITHUB_OUTPUT is set — + * as a `changed` step output. The caller needs this because rewriting the + * manifests forces the following install to drop --frozen-lockfile; when + * nothing changed, the frozen install already done earlier in the job stands + * and no reinstall is needed at all. */ -import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs'; +import { readFileSync, writeFileSync, existsSync, readdirSync, appendFileSync } from 'fs'; import { resolve, dirname, join } from 'path'; import { fileURLToPath } from 'url'; +import semver from 'semver'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = resolve(__dirname, '..'); -const version = process.argv[2]; +const args = process.argv.slice(2); +const onlyIfNewer = args.includes('--only-if-newer'); +const version = args.find(a => !a.startsWith('--')); if (!version) { - console.error('Usage: cs3d-set-version.mjs '); - console.error(' e.g. 4.18.2, 4.19.0-beta.1'); + console.error('Usage: cs3d-set-version.mjs [--only-if-newer]'); + console.error(' e.g. 5.10.3, 5.11.0-beta.1'); process.exit(1); } -// The 8 CS3D packages that are built from source (not codecs) +if (!semver.valid(version)) { + console.error(`"${version}" is not a concrete semver version.`); + console.error('Ranges must be resolved first (see cs3d-resolve-version.mjs).'); + process.exit(1); +} + +/** Tell the calling workflow step whether the manifests were rewritten. */ +function reportChanged(changed) { + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `changed=${changed}\n`); + } +} + +// The packages the cornerstone3D monorepo releases together, all carrying the +// same version. These are the ones this script rewrites. +// +// `@cornerstonejs/metadata` was missing from this list while the workspace +// pinned it, so a run rewrote the others and left metadata behind. +// `@cornerstonejs/core` peer-depends on metadata at its own exact version, so +// the run then tested a mixed tree and still reported a passing integration. +// `scanManifests` below rejects an unrecognised @cornerstonejs/* package for +// that reason: the next addition cannot go missing quietly. const CS3D_PACKAGES = [ '@cornerstonejs/adapters', '@cornerstonejs/ai', '@cornerstonejs/core', '@cornerstonejs/dicom-image-loader', '@cornerstonejs/labelmap-interpolation', + '@cornerstonejs/metadata', '@cornerstonejs/nifti-volume-loader', '@cornerstonejs/polymorphic-segmentation', '@cornerstonejs/tools', + '@cornerstonejs/utils', ]; -// Read root package.json to get workspace globs +// Packages under the same npm scope that the cornerstone3D monorepo does NOT +// release: the WASM codecs and calculate-suv each have their own repository and +// their own version line (1.2.5, 2.4.9, 1.1.0 today). Rewriting them to a CS3D +// version would ask npm for releases that do not exist. +const INDEPENDENT_PACKAGES = [/^@cornerstonejs\/codec-/, /^@cornerstonejs\/calculate-suv$/]; + +// Workspace globs. This repo declares them in pnpm-workspace.yaml; the +// package.json `workspaces` field is read too, for repos that use it. +// +// Reading only package.json was a silent failure: this repo has no +// `workspaces` field, so the glob list came back empty, only the root manifest +// was scanned, and the root carries no @cornerstonejs/* dependency. The script +// then rewrote nothing and reported success — "0 version(s) updated" reads like +// a no-op rather than a fault. The checks at the end of this file exist so that +// cannot happen quietly again. +function readPnpmWorkspaceGlobs() { + const p = resolve(rootDir, 'pnpm-workspace.yaml'); + if (!existsSync(p)) return []; + const globs = []; + let inPackages = false; + for (const line of readFileSync(p, 'utf8').split(/\r?\n/)) { + if (/^packages:\s*$/.test(line)) { + inPackages = true; + continue; + } + if (inPackages) { + const m = line.match(/^\s+-\s*['"]?([^'"#]+?)['"]?\s*$/); + if (m) { + globs.push(m[1]); + continue; + } + if (/^\S/.test(line)) inPackages = false; // next top-level key + } + } + return globs; +} + const rootPkgPath = resolve(rootDir, 'package.json'); const rootPkg = JSON.parse(readFileSync(rootPkgPath, 'utf8')); -const workspaceGlobs = rootPkg.workspaces?.packages || rootPkg.workspaces || []; +const workspaceGlobs = [ + ...readPnpmWorkspaceGlobs(), + ...(rootPkg.workspaces?.packages || rootPkg.workspaces || []), +]; // Collect all package.json paths from workspace globs function findWorkspacePackageJsons() { @@ -97,6 +180,126 @@ function updateDeps(deps, targetVersion) { } const pkgPaths = findWorkspacePackageJsons(); + +const relToRoot = p => p.replace(rootDir + '/', '').replace(rootDir + '\\', ''); + +const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'resolutions']; + +/** + * Scans every manifest once and sorts the @cornerstonejs/* dependencies it + * finds into two lists. + * + * `found` holds every occurrence of a tracked package, with where it was + * found. All of them, not the first match: one manifest already carrying the + * requested version would otherwise satisfy the no-change check below, leaving + * every other manifest stale and skipping the reinstall. + * + * `unknown` holds every @cornerstonejs/* dependency that is neither tracked nor + * known to be released separately. A package this script does not recognise is + * a question it cannot answer, not a package to leave alone — leaving one alone + * is exactly how the workspace came to pin a metadata version that no longer + * matched the core version beside it. + */ +function scanManifests() { + const found = []; + const unknown = []; + for (const pkgPath of pkgPaths) { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + for (const field of DEP_FIELDS) { + const deps = pkg[field]; + if (!deps) continue; + for (const name of Object.keys(deps)) { + if (!name.startsWith('@cornerstonejs/')) continue; + const where = { file: relToRoot(pkgPath), field, name, value: deps[name] }; + if (CS3D_PACKAGES.includes(name)) { + found.push(where); + } else if (!INDEPENDENT_PACKAGES.some(re => re.test(name))) { + unknown.push(where); + } + } + } + } + return { found, unknown }; +} + +const { found: occurrences, unknown } = scanManifests(); + +// Fail on an unrecognised @cornerstonejs/* package rather than skipping it. The +// caller must decide which of the two lists at the top of this file the package +// belongs in: CS3D_PACKAGES when the cornerstone3D monorepo releases it, and +// INDEPENDENT_PACKAGES when it has a version line of its own. +if (unknown.length > 0) { + console.error( + `Found ${unknown.length} @cornerstonejs/* dependency/dependencies that this script does not recognise:` + ); + for (const o of unknown) { + console.error(` ${o.file} ${o.field}.${o.name} = ${o.value}`); + } + console.error( + 'Add each one to CS3D_PACKAGES in this file when cornerstone3D releases it with the other\n' + + 'packages, or to INDEPENDENT_PACKAGES when it carries its own version. Guessing would\n' + + 'either leave the package behind at an old version or ask npm for a release that does\n' + + 'not exist.' + ); + process.exit(1); +} + +// No @cornerstonejs/* dependency anywhere means the discovery above is wrong or +// the repository has changed shape — not that there is nothing to do. Say so +// rather than reporting a successful no-op, which is how this went unnoticed +// before. +if (occurrences.length === 0) { + console.error( + `Found no @cornerstonejs/* dependency in any of the ${pkgPaths.length} manifest(s) scanned.` + ); + console.error('Workspace globs used: ' + (workspaceGlobs.join(', ') || '(none)')); + console.error('Expected at least one; check the globs in pnpm-workspace.yaml.'); + process.exit(1); +} + +// The comparisons below only mean something if the workspace speaks with one +// voice. Mixed values, or a range where a pin belongs, leave no baseline to +// compare the request against, so name the offenders rather than pick one and +// treat the rest as agreed. +const distinct = [...new Set(occurrences.map(o => o.value))]; +if (distinct.length > 1 || !semver.valid(distinct[0])) { + console.error( + distinct.length > 1 + ? `@cornerstonejs/* is not pinned consistently: found ${distinct.join(', ')}.` + : `@cornerstonejs/* is pinned at "${distinct[0]}", which is not one concrete version.` + ); + for (const o of occurrences) { + console.error(` ${o.file} ${o.field}.${o.name} = ${o.value}`); + } + console.error('Pin every occurrence to the same concrete version, then run this again.'); + process.exit(1); +} + +const committed = distinct[0]; + +if (semver.eq(committed, version)) { + console.log(`@cornerstonejs/* already pinned at ${version}; nothing to change.`); + reportChanged(false); + process.exit(0); +} + +if (onlyIfNewer && semver.gt(committed, version)) { + console.log( + `@cornerstonejs/* is pinned at ${committed}, which is newer than the recorded ${version}.\n` + + 'Keeping the committed version: a "now" clause records what a branch became, so it never ' + + 'moves the pin backwards. Use a bare CS3D_REF to request an older version deliberately.' + ); + reportChanged(false); + process.exit(0); +} + +if (semver.lt(version, committed)) { + // Reached only without --only-if-newer, i.e. someone asked for this outright. + console.log( + `::warning::Requested ${version} is older than the committed ${committed}; downgrading as requested.` + ); +} + let totalChanges = 0; for (const pkgPath of pkgPaths) { @@ -114,18 +317,29 @@ for (const pkgPath of pkgPaths) { // we don't accidentally capture a CRLF newline as part of the indent string) const indent = content.match(/^([ \t]+)/m)?.[1] || ' '; writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n'); - const rel = pkgPath.replace(rootDir + '/', '').replace(rootDir + '\\', ''); + const rel = relToRoot(pkgPath); console.log(` Updated ${rel} (${changes} packages)`); totalChanges += changes; } } +// Reaching here means the committed version differs from the requested one, so +// at least one manifest had to change. Zero means the write loop and the +// version lookup disagree — a fault, not a no-op. +if (totalChanges === 0) { + console.error( + `Found @cornerstonejs/* pinned at ${committed} but updated nothing when asked for ${version}.` + ); + console.error(`Scanned ${pkgPaths.length} manifest(s) from globs: ${workspaceGlobs.join(', ')}`); + process.exit(1); +} + console.log( `\nDone: ${totalChanges} version(s) updated to ${version} across ${pkgPaths.length} package files.` ); +reportChanged(true); console.log( - 'This step changes package.json; the following install must not use a frozen Bun lockfile ' + - '(OHIF+CS3D combined “version” CI does: `bun install --config=./bunfig.update-lockfile.toml`). ' + - 'Other installs stay frozen. Locally after this script, use that bun command and/or ' + - '`bun run install:update-lockfile` when you intend to commit lockfile updates.\n' + 'This rewrites package.json, so the lockfile no longer matches it and the next install cannot ' + + 'be frozen. In CI the workflow handles that. Locally, run `pnpm run install:update-lockfile` ' + + '(pnpm install --no-frozen-lockfile) when you intend to commit the lockfile update.\n' ); diff --git a/.webpack/webpack.base.js b/.webpack/webpack.base.js index 43d5d50bb13..3ab9da4367b 100644 --- a/.webpack/webpack.base.js +++ b/.webpack/webpack.base.js @@ -70,7 +70,13 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => { const config = { mode: isProdBuild ? 'production' : 'development', - devtool: isProdBuild ? 'source-map' : 'cheap-module-source-map', + // Full source maps in development as well as production. The React + // Compiler restructures function bodies, so the line-only maps a + // 'cheap-*' devtool produces can no longer place a breakpoint on the + // statement you clicked. Measured on this repo: no rebuild cost, and the + // .map files are fetched only when DevTools is open, so the payload the + // browser downloads is unchanged. + devtool: 'source-map', // `rspack serve` (@rspack/cli) auto-enables lazyCompilation for web-only // apps unless the config defines it explicitly. The on-demand proxy chunks // it produces fail to load in the headless cypress/electron e2e run @@ -115,12 +121,19 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => { { test: /\.[jt]sx?$/, exclude: /node_modules/, - use: { - loader: 'babel-loader', - options: { - presets: ['@babel/preset-typescript', '@babel/preset-react'], - plugins: ['istanbul'], - }, + loader: 'babel-loader', + options: { + // Rely on the root babel.config.js (preset-env, + // preset-react automatic runtime, preset-typescript, and + // babel-plugin-react-compiler) and only add coverage + // instrumentation. Supplying inline presets here + // re-added a classic-runtime preset-react that shadowed + // the compiler, so the coverage/e2e builds shipped the + // cleanup-era components without the memoization the + // compiler is meant to restore - breaking behavior (e.g. + // orientation markers after rotate/flip) that works in + // the production and dev builds. + plugins: ['istanbul'], }, }, ] diff --git a/AGENTS.md b/AGENTS.md index 1733a4b3042..033b2fbd25e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ Aggregates and exposes extension modules throughout the OHIF application, manage ### Key Technologies -- **React 18 + TypeScript**: UI framework +- **React 19 + React Compiler + TypeScript**: UI framework. The compiler is on for all workspace source; see the `ohif-react-compiler` skill before editing components or hooks. - **Cornerstone.js**: Medical image rendering - **DICOM**: Medical imaging standard support - **ONNX Runtime**: AI model inference (SAM segmentation models) @@ -178,7 +178,8 @@ Do not modify the core and always find a way to implement the solution via the e ## Skills -The `ohif-test-agent` skill (Playwright E2E test guidance) lives at `.agents/skills/ohif-test-agent/`. +- `ohif-test-agent` — Playwright E2E test guidance. `.agents/skills/ohif-test-agent/` +- `ohif-react-compiler` — rules and workflow for editing React code under the React Compiler: the CI gates, the budgets, what is banned, and how to read a refusal. `.agents/skills/ohif-react-compiler/` ## Configuration diff --git a/CUSTOMIZATION_TYPING_PLAN.md b/CUSTOMIZATION_TYPING_PLAN.md new file mode 100644 index 00000000000..4929d82dcef --- /dev/null +++ b/CUSTOMIZATION_TYPING_PLAN.md @@ -0,0 +1,244 @@ +# Typed Customizations: Design and Rollout Plan + +This document accompanies the PR that introduces the `AppTypes.Customizations` +registry and describes the follow-up work needed to make every customization id +in OHIF fully typed and discoverable. + +User-facing instructions for declaring keys live in the docs: +[Typing Customizations](platform/docs/docs/platform/services/customization-service/typing.md). +This file is the design rationale and the rollout tracker. + +## Problem + +`customizationService.getCustomization(id)` accepts any string and returns the +`Customization` union, which is broad enough to be effectively untyped. As a +result: + +- There is no way to discover which customization ids exist from the code; the + docs table (`platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx`) + is hand-maintained and already drifts from the registered keys. +- Consumers cast at the call site (`as unknown as ColorbarCustomization`, + `as string`, `as any`, ...) — roughly 20 such casts exist across the repo. +- `setCustomizations` payloads (including `$set` / `$push` / `$merge` command + specs used by modes and app config) are not checked at all. + +## Design (implemented in this PR) + +A declaration-merged registry, following the same pattern already used for +`AppTypes.Services` and `PresentationIds`: + +1. `platform/core/src/types/AppTypes.ts` seeds the global interface. + +2. Each package (in-tree or third-party) merges the keys it owns: + + ```ts + declare global { + namespace AppTypes { + interface Customizations { + 'viewportOverlay.topLeft': OverlayItem[]; + 'panelSegmentation.disableEditing': boolean; + } + } + } + ``` + +3. The service API is overloaded so registered ids get autocomplete and precise + types, while unregistered ids (dynamic keys such as + `` `${buttonSectionId}.config` ``, third-party keys that have not been + declared) keep working through a plain-string fallback: + + - `getCustomization('viewportOverlay.topLeft')` returns `OverlayItem[]`. + - `getCustomization('some.dynamic.key')` returns `Customization | undefined` + exactly as before. + - `setCustomizations({...})` checks registered ids against their declared + value type, either as a direct value or as an immutability-helper spec + (`{ $set: ... }`, `{ $push: [...] }`, the custom `$filter`, ...), via the + `CustomizationEntries` type in + `platform/core/src/services/CustomizationService/types.ts`. + - `CustomizationPhaseInput` (the `bootstrap` / `global` / `mode` blocks of + `appConfig.customizationService`) reuses `CustomizationEntries`, so + phase-tagged config written in TypeScript is checked the same way. + +Nothing about the runtime changes; this is purely additive typing. + +### Read-time markers are a write-side concern + +`$reference` and `$transform` are read-time markers, not update commands: the +service substitutes/invokes them when a value is *read* (`_resolveReferences`, +`transform`), and `hasDollarKey` deliberately exempts them from the +immutability-helper path. A value may therefore be *authored* with a marker +standing in wherever the resolver walks — as a whole value, as an array item (a +referenced array is flattened into the surrounding list), or as a plain object's +property value — while the value that comes back out of `getCustomization` never +contains one. + +`Authorable` in `types.ts` encodes exactly that, mirroring the resolver's +walk: it recurses through arrays and plain objects and stops at the things +`_resolveReferences` returns untouched (functions, constructors, React elements, +`Date`, `RegExp`). + +The important structural decision is that `Authorable` is applied **only** on +the write side, in `CustomizationEntries`. The registry declares what a key +*resolves to*, so `getCustomization`'s return type stays clean. Declaring marker +unions in the registry instead — the other obvious option — would push +`{ $reference }` into the type of every read site, which is both wrong and +unusable. + +Without this, the highest-value keys could not be typed at all: +`toolbarButtons`, `toolbarSections` and `toolGroupAdditions` are composed +*exclusively* through `{ $reference }` markers. + +### Custom update commands are a registry too + +`$filter` is registered by the service itself; extensions can add more at +runtime through `registerCustomUpdateCommand`. Those are declared the same way +customization ids are, via `AppTypes.CustomizationUpdateCommands`, so a spec +using a third-party command type-checks without a cast. + +One non-obvious constraint, worth not re-discovering: `Spec` surfaces custom +commands through `C extends CustomCommands ? O : never`, and `O` infers +to the **whole** registry interface. The projection must therefore be +`CustomCommands>`. Without `Partial`, every spec would have to +supply *all* registered commands at once, and a registry holding more than one +command rejects a plain `{ $filter: ... }` outright with a misleading error. The +single-command form this PR started with only worked because there was exactly +one command. + +### Conventions for declaring keys + +- **The package that consumes a key declares its type**, next to that consumer. + This is usually also the package that registers the default, but not always — + see `studyBrowser.sortFunctions`, consumed by `platform/ui-next` and defaulted + by `extension-default`. Declaring at the consumer is what lets the provider's + default be checked against the contract. +- Keys consumed by `platform/core` or `platform/ui-next` but defaulted in + `extension-default` (`sortingCriteria`, `instanceSortingCriteria`, + `studyBrowser.sortFunctions`) are declared in core/ui-next so the dependency + direction stays extension-free. +- Declared types describe the resolved value after `inheritsFrom` / + `$transform`, i.e. what `getCustomization` actually returns — not what may be + written for the key. +- **A declaration without `| undefined` is a promise that a default is + registered.** Consumers may read it and use the value directly; that is how + existing consumers are already written (`StudyBrowserSort` indexes `[0]` with + no guard). Add `| undefined` only for keys that genuinely ship no default. + + The residual risk is deliberate and worth stating: the promise is made by the + declaring package but kept by whichever package registers the default, so a + deployment whose `pluginConfig.json` omits that provider gets `undefined` + where the type says otherwise. That is already a runtime failure today, so the + type does not make it worse — but the invariant lives in convention rather + than in the compiler until the Phase 4 check below exists. + +## Known limitations + +These are accepted trade-offs of the fallback design, not bugs to fix. They are +listed so reviewers and adopters do not have to rediscover them. + +- **`any`-typed keys degrade to `any`.** Once the registry is non-empty, + `getCustomization(k)` where `k` is `any` (e.g. destructured from an untyped + props bag) selects the generic overload and returns `any`, so *all* checking + at that call site is lost — including the `Customization | undefined` it used + to get. This is inherent to making the method generic over the key; the + single-signature conditional-return form does not avoid it. The fix is at the + call site: annotate the key as `string`. Two such sites exist today + (`MoreDropdownMenu`, `DataSourceConfigurationComponent`) and are Phase 2 work. +- **Typos in registered ids pass silently.** The `[customizationId: string]: + unknown` fallback in `CustomizationEntries` disables excess-property checking, + so `'panelSegmentation.disabledEditing'` is accepted as an unregistered + dynamic key. Unavoidable while undeclared ids must keep working. +- **`getValue`'s fallback is not checked.** A wrong-typed `fallbackValue` on a + registered id falls through to the loose string overload and returns that + wrong type rather than erroring. Overload fallthrough; would need + `getValue` restructured into with-/without-fallback overloads. +- **`$transform`'s return type is not checked.** `Spec` already admits a bare + `(value: T) => T` form, so a `$transform` is validated as a function but not + against `T`. Acceptable for a dynamic escape hatch whose `this` is untyped. + +## Next steps + +### Phase 1 (this PR): infrastructure + a cross-package proof + +Beyond the types themselves, three keys are declared so the mechanism is +exercised in-tree rather than only in a scratch project, and so Phase 2 has a +copyable reference: + +- `platform/core/src/types/AppTypes.ts` — `sortingCriteria`, + `instanceSortingCriteria`. +- `platform/ui-next/src/types/AppTypes.ts` — `studyBrowser.sortFunctions`. + +Together these prove the declaration crosses package boundaries in both +directions: `sortingCriteria` is declared in core, defaulted in +`extension-default`, and consumed in core *and* `platform/app` (deleting the +identical cast in both); `studyBrowser.sortFunctions` is declared and consumed +in `ui-next` while being defaulted in `extension-default`. + +### Phase 2: populate the registry for extension-default and extension-cornerstone + +The bulk of the value: these two extensions register roughly 75 of the ~90 +known ids. For each key: + +1. Export its value type from the producer file (most already have local + interfaces or obvious shapes). +2. Add the entry to the extension's `types/AppTypes.ts` augmentation. +3. Remove the now-redundant casts at consumer call sites; each removed cast is + a free correctness check. +4. Annotate any dynamic key feeding `getCustomization` as `string` rather than + leaving it `any` — otherwise declaring keys silently converts those sites to + `any` (see Known limitations). + +Suggested PR split: one PR per extension. + +### Phase 3: remaining extensions + +- `cornerstone-dicom-seg` (`segmentation.store.*`, `segmentation.segmentLabel`, + `cornerstone.segmentation.loadMultiframeAsPart10`), `cornerstone-dicom-sr` + (`onBeforeSRAddMeasurement`, `onBeforeDicomStore`, `codingValues`, ...), + `cornerstone-dicom-rt`, `cornerstone-dicom-pmap`, `dicom-microscopy`, + `measurement-tracking` (`measurement.prompt*`, `viewportNotification.*`), + `cornerstone-dynamic-volume`. +- Modes' `setCustomizations` calls in `onModeEnter` become checked + automatically once the keys they touch are declared. + +### Phase 4 (optional follow-ups) + +- Add a committed compile-time test (`// @ts-expect-error` assertions, or + `tsd` / `expectTypeOf`) run by a script. The mechanism is purely + compile-time, so the unit tests cover none of it and the repo has no tsc CI + gate — without this it can regress silently. The `Partial` constraint above + is exactly the kind of thing such a test pins down. +- Generate the docs customization table from the registry instead of the + hand-maintained `sampleCustomizations.tsx`, so docs cannot drift. +- Reuse that same enumeration to assert every id declared **without** + `| undefined` actually has a default registered after `init()`. That closes + the residual risk in the nullability convention above, and does so by + catching the real failure (a missing default) rather than taxing every read + site with `?.`. +- Generate a JSON Schema from `AppTypes.Customizations` to give editor + IntelliSense and validation for the JSONC `?customization=` files and the + `customizationService` section of config files. +- Consider a lint rule nudging away from `getValue`-with-cast toward the typed + `getCustomization` overload. + +## Verification recipe used for this PR + +- `npx jest platform/core/src/services/CustomizationService` — 79 tests across + 7 suites pass. +- Full-repo `npx tsc --noEmit --emitDeclarationOnly false -p tsconfig.json` + diffed against a pre-change baseline: 3341 -> 3337 errors, **zero new**. + (The repo has thousands of pre-existing tsc errors and no tsc CI gate; + diffing sorted error lists is the only reliable check.) + + Of the four that cleared, two are genuine wins from the declared keys + (`StudyBrowserSort` losing `Property 'map' does not exist on type + 'Customization'`, and `CustomizationService.transform`). The other two + (`MoreDropdownMenu`, `DataSourceConfigurationComponent`) are **not** wins — + they are the `any`-key degradation described under Known limitations, i.e. + checks being switched off rather than satisfied. Counting them as fixes would + be misleading. +- A standalone compile-time smoke test exercised the mechanism end to end under + both the repo's compiler settings and `--strict`: registry augmentation from + another package, typed reads, direct values, `$set` / `$push` element + checking, `$filter`, a declaration-merged third-party command, all four + `$reference` marker positions, `$transform`, and the plain-string fallback — + each paired with a negative case asserting the wrong shape is still rejected. diff --git a/Dockerfile b/Dockerfile index ac63036accb..324182df2d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ FROM node:24.15.0-slim as builder RUN apt-get update && apt-get install -y --no-install-recommends build-essential python3 \ && rm -rf /var/lib/apt/lists/* -RUN npm install -g pnpm@11 +RUN npm install -g pnpm@12 RUN mkdir /usr/src/app WORKDIR /usr/src/app diff --git a/README.md b/README.md index 005fa5fbe29..fc8e6377ab0 100644 --- a/README.md +++ b/README.md @@ -176,52 +176,114 @@ validated together before merging. #### Setting up an integration build -1. Add the **`ohif-integration`** label to your OHIF pull request. -2. In the PR body, add a line specifying the CS3D ref: - ``` - CS3D_REF: feat/my-feature - ``` - - **Version ref** (e.g. `4.19+`, `4.18.2`) — the workflow resolves it to an - exact published version and swaps the CS3D dependency via npm. - - **Branch ref** (e.g. `main`, `cornerstonejs:feat/foo`) — the workflow - clones the branch, builds CS3D from source with `bun run build:esm`, and - symlinks the built packages into OHIF's `node_modules`. - - For forks, use the `:` format - (e.g. `myGithubUser:feat/foo`). - - If no `CS3D_REF` is specified, the default is `4.19+`. -3. The workflow can also be triggered manually via **workflow_dispatch** with a - `cs3d_ref` input. +Add a `CS3D_REF:` line to the PR body. No label is involved — the line itself is +the request. + +The line must sit at the top level of the body. A line inside a fenced code +block, inside an indented code block (four spaces or more), or inside an HTML +comment is an example rather than a request, so you can document this syntax in +a PR without triggering it. The PR template is mostly HTML comments, so write +the line outside them. The workflow prints a warning when it ignores a +`CS3D_REF:` line for one of these reasons, and it fails when the block that +hides the line is never closed. + +``` +CS3D_REF: feat/my-feature +``` + +The line takes one of three forms: + +| Form | Meaning | +|------|---------| +| `CS3D_REF: feat/my-feature` | A branch or tag in `cornerstonejs/cornerstone3D`. The workflow clones it, builds CS3D from source with `pnpm run build:esm`, and symlinks the built packages into OHIF's `node_modules`. | +| `CS3D_REF: 5.10.6` | A published version. The workflow rewrites every `@cornerstonejs/*` entry across the workspace to that version and reinstalls. The accepted forms are `5.10.6`, `5.11.0-beta.1`, `5.x`, `5.10.x` and `4.19+`. A two-part number such as `5.10` is not one of them; write `5.10.x`. | +| `CS3D_REF: feat/my-feature now 5.10.6` | The branch, and the concrete release it became. See [Retiring a branch ref](#retiring-a-branch-ref) below. | + +Three rules are worth knowing: + +- **The branch must live in `cornerstonejs/cornerstone3D`.** The + `:` form is no longer accepted: it let a PR point the clone at + any GitHub account and run that account's install scripts on the shared + self-hosted runner. Push your branch to the CS3D repository instead. +- **There is no default.** A PR with no `CS3D_REF` line runs the ordinary + Playwright suite against the CS3D version this repo already pins. The same + holds for a manual run with an empty `cs3d_ref` box. +- **From a fork, an integration run needs approval.** The run waits on the + `cs3d-integration` environment until one of the named reviewers approves that + specific run, because it builds and executes CS3D code on a shared machine. + A PR from a branch in this repository proceeds unattended. + +The workflow can also be triggered manually via **workflow_dispatch** with a +`cs3d_ref` input, which accepts the same three forms. #### What happens in CI -The [Playwright workflow](.github/workflows/playwright.yml) runs two jobs: +The [Playwright workflow](.github/workflows/playwright.yml) runs three jobs: | Job | Purpose | |-----|---------| -| **Playwright Tests** | Builds OHIF (with CS3D linked or version-swapped), runs the full Playwright suite, uploads test results and coverage, and deploys a Netlify preview when `ohif-integration` is active. | -| **CS3D Branch Merge Guard** | A lightweight check that **fails** when the `ohif-integration` label is present and `CS3D_REF` points to a branch (not a version). This prevents merging while still letting the Playwright tests show green so you can see whether the code actually works. | +| **Gate** | Runs on GitHub's own runners and never checks out the repository. It decides whether the PR may reach the self-hosted runner at all, reads and validates any `CS3D_REF` line, and decides whether the run needs a reviewer's approval. | +| **Playwright Tests** | Builds OHIF (with CS3D linked or version-swapped), runs the full Playwright suite, and uploads test results and coverage. When a `CS3D_REF` line changes the tree under test, it also deploys a Netlify preview. | +| **CS3D Branch Merge Guard** | Reports when `CS3D_REF` names a branch rather than a published version, because merging would leave `master` depending on unreleased CS3D code. It is **advisory**: it reports a red result, but it does not block the merge button. | + +#### Playwright does not run on every fork PR + +A PR from a fork that changes a CI-defining file does not run Playwright on the +self-hosted runner. It runs once the change is reviewed and merged. The affected +paths are `.github/`, `.scripts/`, `pnpm-workspace.yaml`, `preinstall.js`, any +root pnpmfile, and **every** `.npmrc` in the workspace, not only the one in the +root directory. + +`package.json` and `pnpm-lock.yaml` are not on the list, so a fork PR that adds +a dependency or moves a module between workspace packages runs Playwright as +usual. Changes to the root `package.json` and `pnpm-lock.yaml` still need a code +owner's review before merge. A PR from a branch in this repository is not +affected by either rule. #### Testing changes that span both repos If a feature requires changes in both Cornerstone3D and OHIF: -1. Create your feature branch in CS3D and push it. +1. Create your feature branch in the `cornerstonejs/cornerstone3D` repository and + push it. 2. Create a matching branch in OHIF. -3. Add the `ohif-integration` label to the OHIF pull request. -4. In the PR body, add: `CS3D_REF: `. -5. Playwright tests will build CS3D from source, link it, and run the full - suite. The merge guard will block merge until you switch to a published - version — but you can see the test results and the preview deploy while - iterating. -6. Once the CS3D side is merged and published, update the PR body to reference - the published version (e.g. `CS3D_REF: 4.19+`). The tests will run against - the registry version and the merge guard will pass. +3. In the PR body, add: `CS3D_REF: `. +4. Playwright tests build CS3D from source, link it, and run the full suite. The + merge guard reports a red result for as long as the line names a branch, so + you can see the test results and the preview deploy while you iterate. +5. Once the CS3D side is merged and published, change the line to the retiring + form below. + +#### Retiring a branch ref + +Once the CS3D fix ships, change the line rather than deleting it: + +``` +CS3D_REF: feat/my-feature now 5.10.6 +``` + +The version after `now` is a **record, not a request**. It never moves the pin +backwards: on every run the workflow compares it against the version this repo +pins and keeps whichever is newer. So a line still saying `now 5.10.6` in a repo +that has moved on to 5.11.0 tests 5.11.0, not 5.10.6. + +That is what lets the line stay in place as history — the branch name remains +visible as the reason the pinned version moved, and the line stops changing what +the run does. Once the repo pins that version or something newer, no rewrite +happens, no reinstall happens, and no preview is built or deployed. The version +must be one concrete release, such as `5.10.6` or `5.11.0-beta.1`, not a range. + +A fork PR still asks for the `cs3d-integration` approval while the line is +present, even after the line is spent, because the gate has no checkout and +cannot tell which version the repo pins. Delete the line to stop the prompt. #### Preview deploys -When `ohif-integration` is active, the Playwright workflow also builds the OHIF -viewer and deploys it to Netlify as a preview. This gives you a live URL to -manually test the combined CS3D + OHIF changes without running anything locally. +When a `CS3D_REF` line changes the tree under test, the Playwright workflow also +builds the OHIF viewer and deploys it to Netlify as a preview. This gives you a +live URL to manually test the combined CS3D + OHIF changes without running +anything locally. A spent retiring form tests the same tree as an ordinary run, +so it does not produce a preview. For details on linking CS3D locally for development, see the [Cornerstone3D README](libs/@cornerstonejs/README.md#local-development-linking--unlinking). diff --git a/babel.config.js b/babel.config.js index ad8f27e6189..45a972cbf72 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,7 +1,40 @@ // https://babeljs.io/docs/en/options#babelrcroots + +// React Compiler (babel-plugin-react-compiler) must run before any other +// transform so it sees the original JSX/hooks. Which directories it applies to, +// and the REACT_COMPILER=off diagnostic switch, live in +// react-compiler.scope.cjs - shared with rsbuild, eslint and the coverage gate. +// +// The package (UMD) builds deliberately compile too, so the published bytes +// match what the app build produces. There was a thought to disable the +// compiler there on the theory that `react/compiler-runtime` escapes an +// `externals: { react: 'React' }` map (true — object externals match the +// specifier exactly) and drags in a second copy of React. Measured on +// platform/ui-next, the only package that externalizes React: it does not. +// react/compiler-runtime is 463 bytes whose sole dependency is +// `require('react')`, which *does* hit the external, so the emitted module is +// three lines reading useMemoCache off the host's React. Building with the +// compiler on vs. off differs by +3.2%, all of it memo-cache scaffolding, with +// no React version string, error text, or dispatcher in the output. +const compilerScope = require('./react-compiler.scope.cjs'); +const reactCompilerPlugin = ['babel-plugin-react-compiler', { target: '19' }]; + +// Individual files that must not be compiled carry a `'use no memo'` directive +// at the top of the file, next to the code and the reason - see the components +// under extensions/cornerstone/src/Viewport/, which read and mutate external +// cornerstone3D state during render. Whole directories are excluded in +// react-compiler.scope.cjs instead, so every consumer of the decision agrees. + module.exports = { + // Which packages may carry their own babel.config.js when built on their own. + // Unrelated to react-compiler.scope.cjs, and deliberately wider: platform/ui + // is built as a package but never compiled, so it belongs here and not there. babelrcRoots: ['./platform/*', './extensions/*', './modes/*'], - presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'], + presets: [ + '@babel/preset-env', + ['@babel/preset-react', { runtime: 'automatic' }], + '@babel/preset-typescript', + ], plugins: [ ['@babel/plugin-transform-class-properties', { loose: true }], '@babel/plugin-transform-typescript', @@ -9,6 +42,9 @@ module.exports = { ['@babel/plugin-transform-private-methods', { loose: true }], '@babel/plugin-transform-class-static-block', ], + overrides: compilerScope.enabled + ? [{ test: [compilerScope.isCompiled], plugins: [reactCompilerPlugin] }] + : [], env: { test: { presets: [ @@ -22,7 +58,7 @@ module.exports = { bugfixes: true, }, ], - '@babel/preset-react', + ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript', ], plugins: [ @@ -44,7 +80,7 @@ module.exports = { presets: [ // WebPack handles ES6 --> Target Syntax ['@babel/preset-env', { modules: false }], - '@babel/preset-react', + ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript', ], ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], @@ -53,7 +89,7 @@ module.exports = { presets: [ // WebPack handles ES6 --> Target Syntax ['@babel/preset-env', { modules: false }], - '@babel/preset-react', + ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript', ], ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000000..4f15e8e28de --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,85 @@ +// Minimal flat config dedicated to React Compiler health (run via +// `pnpm lint:compiler`). eslint-plugin-react-hooks v7 ships the +// compiler-powered rules; every diagnostic it reports marks a component +// the compiler bails out on (or memoization it cannot preserve), which is +// exactly the "do not hand-remove memoization here" list for the cleanup +// waves. Deliberately not wired into the legacy .eslintrc.json world. +import reactHooks from 'eslint-plugin-react-hooks'; +import tsParser from '@typescript-eslint/parser'; +// Which directories the compiler applies to. Shared with babel.config.js, +// rsbuild.config.ts and the coverage gate so the lint scope cannot drift from +// the compile scope. +import compilerScope from './react-compiler.scope.cjs'; + +const hooksPreset = reactHooks.configs['recommended-latest'] ?? reactHooks.configs.recommended; + +export default [ + { + ignores: [ + '**/coverage/**', + '**/dist/**', + '**/build/**', + 'platform/docs/**', + ...compilerScope.ignoreGlobs, + ], + }, + { + files: compilerScope.globs, + ignores: ['**/*.test.*', '**/__tests__/**', '**/__mocks__/**'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + plugins: { + 'react-hooks': reactHooks, + }, + rules: { + ...hooksPreset.rules, + // The formal gate for the memoization-removal codemods: a file is only + // eligible when the compiler provably preserves its manual memoization. + 'react-hooks/preserve-manual-memoization': 'error', + }, + }, + { + // The workspace is compiler-first: React 19 idioms are enforced so the + // removed patterns (forwardRef wrappers, runtime propTypes) do not creep + // back in. Legacy platform/ui is exempt (frozen, outside the app graph). + files: compilerScope.globs, + ignores: ['**/*.test.*'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'prop-types', + message: 'propTypes were removed; use TypeScript types.', + }, + ], + }, + ], + 'no-restricted-properties': [ + 'error', + { + object: 'React', + property: 'forwardRef', + message: 'React 19: accept ref as a regular prop instead of forwardRef.', + }, + ], + 'no-restricted-syntax': [ + 'error', + { + selector: "CallExpression[callee.name='forwardRef']", + message: 'React 19: accept ref as a regular prop instead of forwardRef.', + }, + { + selector: "AssignmentExpression[left.property.name='propTypes']", + message: 'propTypes were removed; use TypeScript types.', + }, + ], + }, + }, +]; diff --git a/extensions/cornerstone-dicom-pmap/babel.config.js b/extensions/cornerstone-dicom-pmap/babel.config.js index c117797d5b9..325ca2a8ee7 100644 --- a/extensions/cornerstone-dicom-pmap/babel.config.js +++ b/extensions/cornerstone-dicom-pmap/babel.config.js @@ -1,44 +1 @@ -module.exports = { - plugins: ['@babel/plugin-transform-class-properties'], - env: { - test: { - presets: [ - [ - // TODO: https://babeljs.io/blog/2019/03/19/7.4.0#migration-from-core-js-2 - '@babel/preset-env', - { - modules: 'commonjs', - debug: false, - }, - '@babel/preset-typescript', - ], - '@babel/preset-react', - ], - plugins: [ - '@babel/plugin-transform-object-rest-spread', - '@babel/plugin-syntax-dynamic-import', - '@babel/plugin-transform-regenerator', - '@babel/plugin-transform-runtime', - ], - }, - production: { - presets: [ - // WebPack handles ES6 --> Target Syntax - ['@babel/preset-env', { modules: false }], - '@babel/preset-react', - '@babel/preset-typescript', - ], - ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], - }, - development: { - presets: [ - // WebPack handles ES6 --> Target Syntax - ['@babel/preset-env', { modules: false }], - '@babel/preset-react', - '@babel/preset-typescript', - ], - plugins: ['react-hot-loader/babel'], - ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], - }, - }, -}; +module.exports = require('../../babel.config.js'); diff --git a/extensions/cornerstone-dicom-pmap/package.json b/extensions/cornerstone-dicom-pmap/package.json index 57b7b0c5f9d..6f9fd775bc0 100644 --- a/extensions/cornerstone-dicom-pmap/package.json +++ b/extensions/cornerstone-dicom-pmap/package.json @@ -1,6 +1,6 @@ { "name": "@ohif/extension-cornerstone-dicom-pmap", - "version": "3.13.0-beta.135", + "version": "3.14.0-beta.44", "description": "DICOM Parametric Map read workflow", "author": "OHIF", "license": "MIT", @@ -32,17 +32,16 @@ "@ohif/extension-cornerstone": "workspace:*", "@ohif/extension-default": "workspace:*", "@ohif/i18n": "workspace:*", - "prop-types": "15.8.1", - "react": "18.3.1", - "react-dom": "18.3.1", + "react": "19.2.7", + "react-dom": "19.2.7", "react-i18next": "12.3.1", "react-router": "6.30.3", "react-router-dom": "6.30.3" }, "dependencies": { "@babel/runtime": "7.29.7", - "@cornerstonejs/adapters": "5.6.8", - "@cornerstonejs/core": "5.6.8", + "@cornerstonejs/adapters": "5.10.3", + "@cornerstonejs/core": "5.10.3", "@kitware/vtk.js": "36.4.1" }, "devDependencies": { diff --git a/extensions/cornerstone-dicom-pmap/src/getSopClassHandlerModule.ts b/extensions/cornerstone-dicom-pmap/src/getSopClassHandlerModule.ts index 124c10d30e1..558fd5745db 100644 --- a/extensions/cornerstone-dicom-pmap/src/getSopClassHandlerModule.ts +++ b/extensions/cornerstone-dicom-pmap/src/getSopClassHandlerModule.ts @@ -19,13 +19,16 @@ function _getDisplaySetsFromSeries( SOPInstanceUID, SeriesDescription, SeriesNumber, - SeriesDate, SOPClassUID, wadoRoot, wadoUri, wadoUriRoot, } = instance; + // The date/time of a display set is the date/time of the instance it shows, + // chosen from all the attributes that instance carries. + const { SeriesDate, SeriesTime } = utils.getLatestInstanceDateTime(instance); + const displaySet = { // Parametric map use to have the same modality as its referenced volume but // "PMAP" is used in the viewer even though this is not a valid DICOM modality @@ -35,6 +38,7 @@ function _getDisplaySetsFromSeries( SeriesDescription, SeriesNumber, SeriesDate, + SeriesTime, SOPInstanceUID, SeriesInstanceUID, StudyInstanceUID, diff --git a/extensions/cornerstone-dicom-pmap/src/viewports/OHIFCornerstonePMAPViewport.tsx b/extensions/cornerstone-dicom-pmap/src/viewports/OHIFCornerstonePMAPViewport.tsx index f5480e3ee21..7c75c018837 100644 --- a/extensions/cornerstone-dicom-pmap/src/viewports/OHIFCornerstonePMAPViewport.tsx +++ b/extensions/cornerstone-dicom-pmap/src/viewports/OHIFCornerstonePMAPViewport.tsx @@ -1,5 +1,4 @@ -import PropTypes from 'prop-types'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useViewportGrid } from '@ohif/ui-next'; import { OHIFCornerstoneViewport } from '@ohif/extension-cornerstone'; @@ -20,19 +19,8 @@ function OHIFCornerstonePMAPViewport(props: withAppTypes) { const pmapDisplaySet = displaySets[0]; const [viewportGrid, viewportGridService] = useViewportGrid(); - const referencedDisplaySetRef = useRef(null); const { viewports, activeViewportId } = viewportGrid; const referencedDisplaySet = pmapDisplaySet.getReferenceDisplaySet(); - const referencedDisplaySetMetadata = _getReferencedDisplaySetMetadata( - referencedDisplaySet, - pmapDisplaySet - ); - - referencedDisplaySetRef.current = { - displaySet: referencedDisplaySet, - metadata: referencedDisplaySetMetadata, - }; - const [pmapIsLoading, setPmapIsLoading] = useState(!pmapDisplaySet.isLoaded); // Add effect to listen for loading complete @@ -51,31 +39,29 @@ function OHIFCornerstonePMAPViewport(props: withAppTypes) { }; }, [pmapDisplaySet]); - const getCornerstoneViewport = useCallback(() => { - const { displaySet: referencedDisplaySet } = referencedDisplaySetRef.current; - - displaySetOptions.unshift({}); - const [pmapDisplaySetOptions] = displaySetOptions; - - // Make sure `options` exists - pmapDisplaySetOptions.options = pmapDisplaySetOptions.options ?? {}; - - Object.assign(pmapDisplaySetOptions.options, { - colormap: { - name: 'rainbow_2', - opacity: [ - { value: 0, opacity: 0 }, - { value: 0.25, opacity: 0.25 }, - { value: 0.5, opacity: 0.5 }, - { value: 0.75, opacity: 0.75 }, - { value: 0.9, opacity: 0.99 }, - ], - }, - voi: { - windowCenter: 50, - windowWidth: 100, + const getCornerstoneViewport = () => { + // A local, not a mutation of the `displaySetOptions` prop: the array handed + // to the viewport below is assembled fresh, so the previous `unshift` only + // served to manufacture this object - while growing the caller's array on + // every call. + const pmapDisplaySetOptions = { + options: { + colormap: { + name: 'rainbow_2', + opacity: [ + { value: 0, opacity: 0 }, + { value: 0.25, opacity: 0.25 }, + { value: 0.5, opacity: 0.5 }, + { value: 0.75, opacity: 0.75 }, + { value: 0.9, opacity: 0.99 }, + ], + }, + voi: { + windowCenter: 50, + windowWidth: 100, + }, }, - }); + }; uiNotificationService.show({ title: 'Parametric Map', @@ -97,15 +83,7 @@ function OHIFCornerstonePMAPViewport(props: withAppTypes) { displaySetOptions={[{}, pmapDisplaySetOptions]} /> ); - }, [ - displaySetOptions, - props, - pmapDisplaySet, - viewportOptions.orientation, - viewportOptions.viewportId, - viewportOptions.presentationIds, - uiNotificationService, - ]); + }; // Cleanup the PMAP viewport when the viewport is destroyed useEffect(() => { @@ -159,44 +137,4 @@ function OHIFCornerstonePMAPViewport(props: withAppTypes) { ); } -OHIFCornerstonePMAPViewport.propTypes = { - displaySets: PropTypes.arrayOf(PropTypes.object), - viewportId: PropTypes.string.isRequired, - dataSource: PropTypes.object, - children: PropTypes.node, -}; - -function _getReferencedDisplaySetMetadata(referencedDisplaySet, pmapDisplaySet) { - const { SharedFunctionalGroupsSequence } = pmapDisplaySet.instance; - - const SharedFunctionalGroup = Array.isArray(SharedFunctionalGroupsSequence) - ? SharedFunctionalGroupsSequence[0] - : SharedFunctionalGroupsSequence; - - const { PixelMeasuresSequence } = SharedFunctionalGroup; - - const PixelMeasures = Array.isArray(PixelMeasuresSequence) - ? PixelMeasuresSequence[0] - : PixelMeasuresSequence; - - const { SpacingBetweenSlices, SliceThickness } = PixelMeasures; - - const image0 = referencedDisplaySet.images[0]; - const referencedDisplaySetMetadata = { - PatientID: image0.PatientID, - PatientName: image0.PatientName, - PatientSex: image0.PatientSex, - PatientAge: image0.PatientAge, - SliceThickness: image0.SliceThickness || SliceThickness, - StudyDate: image0.StudyDate, - SeriesDescription: image0.SeriesDescription, - SeriesInstanceUID: image0.SeriesInstanceUID, - SeriesNumber: image0.SeriesNumber, - ManufacturerModelName: image0.ManufacturerModelName, - SpacingBetweenSlices: image0.SpacingBetweenSlices || SpacingBetweenSlices, - }; - - return referencedDisplaySetMetadata; -} - export default OHIFCornerstonePMAPViewport; diff --git a/extensions/cornerstone-dicom-rt/babel.config.js b/extensions/cornerstone-dicom-rt/babel.config.js index 24adaea8d29..325ca2a8ee7 100644 --- a/extensions/cornerstone-dicom-rt/babel.config.js +++ b/extensions/cornerstone-dicom-rt/babel.config.js @@ -1,43 +1 @@ -module.exports = { - plugins: ['@babel/plugin-transform-class-properties'], - env: { - test: { - presets: [ - [ - // TODO: https://babeljs.io/blog/2019/03/19/7.4.0#migration-from-core-js-2 - '@babel/preset-env', - { - modules: 'commonjs', - debug: false, - }, - '@babel/preset-typescript', - ], - '@babel/preset-react', - ], - plugins: [ - '@babel/plugin-transform-object-rest-spread', - '@babel/plugin-syntax-dynamic-import', - '@babel/plugin-transform-regenerator', - '@babel/plugin-transform-runtime', - ], - }, - production: { - presets: [ - // WebPack handles ES6 --> Target Syntax - ['@babel/preset-env', { modules: false }], - '@babel/preset-react', - '@babel/preset-typescript', - ], - ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], - }, - development: { - presets: [ - // WebPack handles ES6 --> Target Syntax - ['@babel/preset-env', { modules: false }], - '@babel/preset-react', - '@babel/preset-typescript', - ], - ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], - }, - }, -}; +module.exports = require('../../babel.config.js'); diff --git a/extensions/cornerstone-dicom-rt/package.json b/extensions/cornerstone-dicom-rt/package.json index c1f7ad65765..4564e094e18 100644 --- a/extensions/cornerstone-dicom-rt/package.json +++ b/extensions/cornerstone-dicom-rt/package.json @@ -1,6 +1,6 @@ { "name": "@ohif/extension-cornerstone-dicom-rt", - "version": "3.13.0-beta.135", + "version": "3.14.0-beta.44", "description": "DICOM RT read workflow", "author": "OHIF", "license": "MIT", @@ -32,9 +32,8 @@ "@ohif/extension-cornerstone": "workspace:*", "@ohif/extension-default": "workspace:*", "@ohif/i18n": "workspace:*", - "prop-types": "15.8.1", - "react": "18.3.1", - "react-dom": "18.3.1", + "react": "19.2.7", + "react-dom": "19.2.7", "react-i18next": "12.3.1", "react-router": "6.30.3", "react-router-dom": "6.30.3" diff --git a/extensions/cornerstone-dicom-rt/src/getSopClassHandlerModule.ts b/extensions/cornerstone-dicom-rt/src/getSopClassHandlerModule.ts index 50f3fa0426d..aa81a118746 100644 --- a/extensions/cornerstone-dicom-rt/src/getSopClassHandlerModule.ts +++ b/extensions/cornerstone-dicom-rt/src/getSopClassHandlerModule.ts @@ -26,10 +26,6 @@ function _getDisplaySetsFromSeries( SOPInstanceUID, SeriesDescription = '', SeriesNumber, - SeriesDate, - SeriesTime, - StructureSetDate, - StructureSetTime, SOPClassUID, wadoRoot, wadoUri, @@ -37,6 +33,14 @@ function _getDisplaySetsFromSeries( imageId: predecessorImageId, } = instance; + /** + * The "SeriesDate" for a display set is really the display set date, which + * should be the date of the instance being used - for a structure set that is + * usually the structure set date/time, and for one saved into an existing + * series only the instance level date/time reflects the save. + */ + const { SeriesDate, SeriesTime } = utils.getLatestInstanceDateTime(instance); + const displaySet = { Modality: 'RTSTRUCT', loading: false, @@ -44,13 +48,8 @@ function _getDisplaySetsFromSeries( displaySetInstanceUID: utils.guid(), SeriesDescription, SeriesNumber, - /** - * The "SeriesDate" for a display set is really the display set date, which - * should be the date of the instance being used, which will be the structure - * set date in this case. - */ - SeriesDate: StructureSetDate || SeriesDate, - SeriesTime: StructureSetTime || SeriesTime, + SeriesDate, + SeriesTime, SOPInstanceUID, SeriesInstanceUID, StudyInstanceUID, diff --git a/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx b/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx index 5ff9e24d033..7b74313a777 100644 --- a/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx +++ b/extensions/cornerstone-dicom-rt/src/viewports/OHIFCornerstoneRTViewport.tsx @@ -1,5 +1,4 @@ -import React, { Component, useCallback, useEffect, useRef, useState } from 'react'; -import PropTypes from 'prop-types'; +import React, { useEffect, useState } from 'react'; import { useViewportGrid } from '@ohif/ui-next'; import { utils, @@ -48,8 +47,6 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) { totalSegments: null, }); - const referencedDisplaySetRef = useRef(null); - const referencedDisplaySetInstanceUID = rtDisplaySet.referencedDisplaySetInstanceUID; // If the referencedDisplaySetInstanceUID is not found, it means the RTStruct series is being // launched without its corresponding referenced display set (e.g., the RTStruct series is launched using @@ -57,25 +54,30 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) { // In such cases, we attempt to handle this scenario gracefully by // invoking a custom handler. Ideally, if a user tries to launch a series that isn't viewable, // (eg.: we can prompt them with an explanation and provide a link to the full study). + // Additional guard: If no customization handler is registered for missing + // referenced display sets, skip RT rendering to avoid a viewport crash. + // Only the *return* moves below the hooks; the handler still runs here, at the + // same point in the render it always did. + let skipRendering = false; if (!referencedDisplaySetInstanceUID) { const missingReferenceDisplaySetHandler = customizationService.getCustomization( 'missingReferenceDisplaySetHandler' ); - const { handled } = missingReferenceDisplaySetHandler(); - if (handled) { - return; + if (typeof missingReferenceDisplaySetHandler === 'function') { + ({ handled: skipRendering } = missingReferenceDisplaySetHandler()); + } else { + console.log( + "No customization 'missingReferenceDisplaySetHandler' registered. Skipping RT rendering." + ); + skipRendering = true; } } - const referencedDisplaySet = displaySetService.getDisplaySetByUID( - referencedDisplaySetInstanceUID - ); - const referencedDisplaySetMetadata = _getReferencedDisplaySetMetadata(referencedDisplaySet); - - referencedDisplaySetRef.current = { - displaySet: referencedDisplaySet, - metadata: referencedDisplaySetMetadata, - }; - + // getDisplaySetByUID throws on a non-string, and the UID is absent on exactly + // the path handled above - so this has to be guarded now that the early return + // has moved below the hooks. + const referencedDisplaySet = referencedDisplaySetInstanceUID + ? displaySetService.getDisplaySetByUID(referencedDisplaySetInstanceUID) + : undefined; useEffect(() => { if (rtIsLoading) { return; @@ -169,14 +171,11 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) { return () => { // remove the segmentation representations if seg displayset changed segmentationService.removeRepresentationsFromViewport(viewportId); - referencedDisplaySetRef.current = null; toolGroupService.destroyToolGroup(toolGroupId); }; }, []); - const getCornerstoneViewport = useCallback(() => { - const { displaySet: referencedDisplaySet } = referencedDisplaySetRef.current; - + const getCornerstoneViewport = () => { // Todo: jump to the center of the first segment return ( ); - }, [viewportId, rtDisplaySet, toolGroupId]); + }; let childrenWithProps = null; - if ( - !referencedDisplaySetRef.current || - referencedDisplaySet.displaySetInstanceUID !== - referencedDisplaySetRef.current.displaySet.displaySetInstanceUID - ) { + if (skipRendering || !referencedDisplaySet) { return null; } @@ -236,30 +231,4 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) { ); } -OHIFCornerstoneRTViewport.propTypes = { - displaySets: PropTypes.arrayOf(PropTypes.object), - viewportId: PropTypes.string.isRequired, - dataSource: PropTypes.object, - children: PropTypes.node, -}; - -function _getReferencedDisplaySetMetadata(referencedDisplaySet) { - const image0 = referencedDisplaySet.images[0]; - const referencedDisplaySetMetadata = { - PatientID: image0.PatientID, - PatientName: image0.PatientName, - PatientSex: image0.PatientSex, - PatientAge: image0.PatientAge, - SliceThickness: image0.SliceThickness, - StudyDate: image0.StudyDate, - SeriesDescription: image0.SeriesDescription, - SeriesInstanceUID: image0.SeriesInstanceUID, - SeriesNumber: image0.SeriesNumber, - ManufacturerModelName: image0.ManufacturerModelName, - SpacingBetweenSlices: image0.SpacingBetweenSlices, - }; - - return referencedDisplaySetMetadata; -} - export default OHIFCornerstoneRTViewport; diff --git a/extensions/cornerstone-dicom-seg/babel.config.js b/extensions/cornerstone-dicom-seg/babel.config.js index 24adaea8d29..325ca2a8ee7 100644 --- a/extensions/cornerstone-dicom-seg/babel.config.js +++ b/extensions/cornerstone-dicom-seg/babel.config.js @@ -1,43 +1 @@ -module.exports = { - plugins: ['@babel/plugin-transform-class-properties'], - env: { - test: { - presets: [ - [ - // TODO: https://babeljs.io/blog/2019/03/19/7.4.0#migration-from-core-js-2 - '@babel/preset-env', - { - modules: 'commonjs', - debug: false, - }, - '@babel/preset-typescript', - ], - '@babel/preset-react', - ], - plugins: [ - '@babel/plugin-transform-object-rest-spread', - '@babel/plugin-syntax-dynamic-import', - '@babel/plugin-transform-regenerator', - '@babel/plugin-transform-runtime', - ], - }, - production: { - presets: [ - // WebPack handles ES6 --> Target Syntax - ['@babel/preset-env', { modules: false }], - '@babel/preset-react', - '@babel/preset-typescript', - ], - ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], - }, - development: { - presets: [ - // WebPack handles ES6 --> Target Syntax - ['@babel/preset-env', { modules: false }], - '@babel/preset-react', - '@babel/preset-typescript', - ], - ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], - }, - }, -}; +module.exports = require('../../babel.config.js'); diff --git a/extensions/cornerstone-dicom-seg/package.json b/extensions/cornerstone-dicom-seg/package.json index e8e9a31524d..3f438876ddd 100644 --- a/extensions/cornerstone-dicom-seg/package.json +++ b/extensions/cornerstone-dicom-seg/package.json @@ -1,6 +1,6 @@ { "name": "@ohif/extension-cornerstone-dicom-seg", - "version": "3.13.0-beta.135", + "version": "3.14.0-beta.44", "description": "DICOM SEG read workflow", "author": "OHIF", "license": "MIT", @@ -32,17 +32,16 @@ "@ohif/extension-cornerstone": "workspace:*", "@ohif/extension-default": "workspace:*", "@ohif/i18n": "workspace:*", - "prop-types": "15.8.1", - "react": "18.3.1", - "react-dom": "18.3.1", + "react": "19.2.7", + "react-dom": "19.2.7", "react-i18next": "12.3.1", "react-router": "6.30.3", "react-router-dom": "6.30.3" }, "dependencies": { "@babel/runtime": "7.29.7", - "@cornerstonejs/adapters": "5.6.8", - "@cornerstonejs/core": "5.6.8", + "@cornerstonejs/adapters": "5.10.3", + "@cornerstonejs/core": "5.10.3", "@kitware/vtk.js": "36.4.1" }, "devDependencies": { diff --git a/extensions/cornerstone-dicom-seg/src/commandsModule.ts b/extensions/cornerstone-dicom-seg/src/commandsModule.ts index b70f6007141..a4cf5ad6444 100644 --- a/extensions/cornerstone-dicom-seg/src/commandsModule.ts +++ b/extensions/cornerstone-dicom-seg/src/commandsModule.ts @@ -141,7 +141,7 @@ const commandsModule = ({ } const frameIndex = referencedFrameIndexById - ? referencedFrameIndexById.get(segImage.referencedImageId) ?? -1 + ? (referencedFrameIndexById.get(segImage.referencedImageId) ?? -1) : z++; if (frameIndex < 0) { @@ -251,8 +251,28 @@ const commandsModule = ({ labelmaps3D = buildLabelmap3D(imageIds, metadata); } + // `...generateOptions` has the last word on the predecessor: the store + // dialog sets it to `undefined` to say "a new series", which overrides the + // one the segmentation itself was loaded from. + const storeIntoSeriesImageId = + 'predecessorImageId' in generateOptions + ? generateOptions.predecessorImageId + : predecessorImageId; + + // dcmjs stamps every date/time of the object it derives in UTC, which is + // the wrong wall clock reading anywhere else and, around midnight, the + // wrong day - DICOM DA and TM are displayed as they are stored. So they + // are supplied in the local zone instead. The content date/time say when + // this instance was created whichever series it goes into, whereas a + // segmentation added to an existing series takes that series' date and + // time, which the adapter copies from the predecessor instance. + const { date: creationDate, time: creationTime } = utils.getCurrentDicomDateTime(); + const saveOptions = { predecessorImageId, + ContentDate: creationDate, + ContentTime: creationTime, + ...(storeIntoSeriesImageId ? {} : { SeriesDate: creationDate, SeriesTime: creationTime }), ...getSegmentationSaveOptions(customizationService, dataSourceStoreOverride), ...generateOptions, }; @@ -321,20 +341,27 @@ const commandsModule = ({ throw new Error('No segmentation found'); } - const { label, predecessorImageId } = segmentation; + const { label, predecessorImageId, labelIsGenerated } = segmentation; + // Only a name the user chose goes to `itemName`, which the dialog offers + // first; a generated one goes to `defaultSeriesDescription`, offered last. + const chosenLabel = labelIsGenerated ? '' : label || ''; + const defaultSeriesDescription = + (labelIsGenerated && label) || (modality === 'RTSTRUCT' ? 'Contours' : 'Segmentation'); const { value: reportName, dataSourceName, series, - priorSeriesNumber, + seriesNumber, action, } = await createReportDialogPrompt({ servicesManager, extensionManager, predecessorImageId, - title: 'Store Segmentation', + title: modality === 'RTSTRUCT' ? 'Save Contours' : 'Save Segmentation', modality, + itemName: chosenLabel, + defaultSeriesDescription, enableDownload: true, }); @@ -360,8 +387,8 @@ const commandsModule = ({ options: { // Resolve store overrides against the data source we are storing into. dataSource: dataSourceName, - SeriesDescription: series ? undefined : reportName || label || 'Contour Series', - SeriesNumber: series ? undefined : 1 + priorSeriesNumber, + SeriesDescription: series ? undefined : reportName || label || defaultSeriesDescription, + SeriesNumber: series ? undefined : seriesNumber, predecessorImageId: series, }, }; @@ -376,6 +403,9 @@ const commandsModule = ({ const { dataset: naturalizedReport } = generatedData; + // After the generation, which is what names the series numbered here. + utils.updateNewInstanceMetadata(naturalizedReport); + // DCMJS assigns a dummy study id during creation, and this can cause problems, so clearing it out if (naturalizedReport.StudyID === 'No Study ID') { naturalizedReport.StudyID = ''; @@ -408,8 +438,20 @@ const commandsModule = ({ }); const predecessorImageId = contourOptions.predecessorImageId ?? segmentations.predecessorImageId; + + // The adapter stamps the structure set date/time in UTC, which is the + // wrong wall clock reading anywhere else and, around midnight, the wrong + // day - DICOM DA and TM are displayed as they are stored. This is the + // creation date/time of the structure set itself, so it is stamped + // whichever series it is stored into. There is no series date/time to + // supply alongside it: an RTSTRUCT gets one only from the series it is + // added to, which the adapter copies from the predecessor instance. + const { date: structureSetDate, time: structureSetTime } = utils.getCurrentDicomDateTime(); + const dataset = await generateRTSSFromRepresentation(segmentations, { predecessorImageId, + StructureSetDate: structureSetDate, + StructureSetTime: structureSetTime, ...contourOptions, }); return { dataset }; diff --git a/extensions/cornerstone-dicom-seg/src/components/LogicalContourOperationsOptions.tsx b/extensions/cornerstone-dicom-seg/src/components/LogicalContourOperationsOptions.tsx index 6183dd2d6bb..1ddefb51f8b 100644 --- a/extensions/cornerstone-dicom-seg/src/components/LogicalContourOperationsOptions.tsx +++ b/extensions/cornerstone-dicom-seg/src/components/LogicalContourOperationsOptions.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useRunCommand, useSystem } from '@ohif/core'; import { useActiveViewportSegmentationRepresentations } from '@ohif/extension-cornerstone'; import { @@ -112,6 +112,8 @@ function LogicalContourOperationOptions() { ) : 1; + const segmentationId = activeRepresentation?.segmentation?.segmentationId; + const activeSegment = segments.find(segment => segment.active); const activeSegmentIndex = activeSegment?.segmentIndex || 0; @@ -132,12 +134,12 @@ function LogicalContourOperationOptions() { const runCommand = useRunCommand(); - const applyLogicalContourOperation = useCallback(() => { + const applyLogicalContourOperation = () => { let resultSegmentIndex = segmentA; if (createNewSegment) { resultSegmentIndex = nextSegmentIndex.toString(); runCommand('addSegment', { - segmentationId: activeRepresentation.segmentation.segmentationId, + segmentationId, config: { label: newSegmentName, segmentIndex: nextSegmentIndex, @@ -146,29 +148,20 @@ function LogicalContourOperationOptions() { } runCommand('applyLogicalContourOperation', { segmentAInfo: { - segmentationId: activeRepresentation.segmentation.segmentationId, + segmentationId, segmentIndex: parseInt(segmentA), }, segmentBInfo: { - segmentationId: activeRepresentation.segmentation.segmentationId, + segmentationId, segmentIndex: parseInt(segmentB), }, resultSegmentInfo: { - segmentationId: activeRepresentation.segmentation.segmentationId, + segmentationId, segmentIndex: parseInt(resultSegmentIndex), }, logicalOperation: operation.logicalOperation, }); - }, [ - activeRepresentation?.segmentation?.segmentationId, - createNewSegment, - newSegmentName, - nextSegmentIndex, - operation.logicalOperation, - runCommand, - segmentA, - segmentB, - ]); + }; return (
diff --git a/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.ts b/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.ts index 88cd7a166b6..74ca3790b43 100644 --- a/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.ts +++ b/extensions/cornerstone-dicom-seg/src/getSopClassHandlerModule.ts @@ -1,12 +1,17 @@ -import { utils, Types as OhifTypes, DicomMetadataStore, classes, log } from '@ohif/core'; +import { utils, Types as OhifTypes, DicomMetadataStore, classes } from '@ohif/core'; import i18n from '@ohif/i18n'; import { metaData, eventTarget, utilities as csUtils } from '@cornerstonejs/core'; import { CONSTANTS, segmentation as cstSegmentation } from '@cornerstonejs/tools'; import { adaptersSEG, Enums } from '@cornerstonejs/adapters'; +import dcmjs from 'dcmjs'; import { SOPClassHandlerId } from './id'; import { dicomlabToRGB } from './utils/dicomlabToRGB'; import { getSegmentationParserType } from './utils/segmentationConfig'; +import { dicomLoaderService } from '@ohif/extension-cornerstone'; + +const { DicomMetaDictionary } = dcmjs.data; +const { naturalizeDataset } = DicomMetaDictionary; import { getFrameIndexFromImageId, isLocalSchemeImageId, @@ -18,8 +23,6 @@ const LABELMAP_SEG_SOP_CLASS_UID = '1.2.840.10008.5.1.4.1.1.66.7'; const loadPromises = {}; -const SEG_LOAD_LOG_PREFIX = '[SEG load]'; - // Max number of SEG frames fetched/decoded concurrently by the segmentation // loader. Hard-coded to 16 for now; intended to become configurable (and to // pair with the full-instance prefetch capability) in a follow-up. @@ -203,35 +206,6 @@ function _resolveFrameImageIds( return frameImageIds.length ? frameImageIds : [segImageIdStr]; } -function _logSegImageIds({ - segDisplaySet, - segImageIdStr, - frameImageIds, - referencedImageIds, -}: { - segDisplaySet: AppTypes.DisplaySet; - segImageIdStr: string; - frameImageIds: string[]; - referencedImageIds: string[]; -}) { - const instance = segDisplaySet.instance as Record; - const numberOfFrames = Number(instance?.NumberOfFrames) || 1; - - log.debug(SEG_LOAD_LOG_PREFIX, 'Loading SEG pixel data', { - SOPInstanceUID: segDisplaySet.SOPInstanceUID, - SeriesInstanceUID: segDisplaySet.SeriesInstanceUID, - SOPClassUID: segDisplaySet.SOPClassUID, - NumberOfFrames: numberOfFrames, - segmentCount: Object.keys(segDisplaySet.segments || {}).length, - referencedDisplaySetInstanceUID: segDisplaySet.referencedDisplaySetInstanceUID, - referencedImageIdCount: referencedImageIds.length, - referencedImageIds, - segImageIdForMetadata: segImageIdStr, - frameImageIds, - loadSegFramesIndividually: frameImageIds.length > 1, - }); -} - function _getDisplaySetsFromSeries( instances, servicesManager: AppTypes.ServicesManager, @@ -248,8 +222,6 @@ function _getDisplaySetsFromSeries( SOPInstanceUID, SeriesDescription = '', SeriesNumber, - SeriesDate, - StructureSetDate, SOPClassUID, FrameOfReferenceUID, wadoRoot, @@ -258,6 +230,13 @@ function _getDisplaySetsFromSeries( imageId: predecessorImageId, } = instance; + // The date/time of a display set is the date/time of the instance it shows, + // chosen from all the attributes that instance carries - for a SEG that is + // typically the content or structure set date/time rather than the series + // one, and for a SEG saved into an existing series only the instance level + // date/time reflects the save. + const { SeriesDate, SeriesTime } = utils.getLatestInstanceDateTime(instance); + const displaySet = { Modality: 'SEG', loading: false, @@ -265,7 +244,8 @@ function _getDisplaySetsFromSeries( displaySetInstanceUID: utils.guid(), SeriesDescription, SeriesNumber, - SeriesDate: SeriesDate || StructureSetDate || '', + SeriesDate, + SeriesTime, SOPInstanceUID, SeriesInstanceUID, StudyInstanceUID, @@ -404,7 +384,8 @@ async function _loadSegments({ extensionManager, servicesManager, segDisplaySet, -}: withAppTypes<{ segDisplaySet: AppTypes.DisplaySet }>) { + headers, +}: withAppTypes<{ segDisplaySet: AppTypes.DisplaySet; headers?: Record }>) { const { segmentationService, uiNotificationService, customizationService } = servicesManager.services; const instance = segDisplaySet.instance as Record; @@ -459,13 +440,6 @@ async function _loadSegments({ ? stripFrameFromImageId(segImageIdStr) : segImageIdStr; - _logSegImageIds({ - segDisplaySet, - segImageIdStr: segImageIdForMetadata, - frameImageIds, - referencedImageIds: imageIds, - }); - _ensureSegInstanceMetadataAvailable(segImageIdForMetadata, instance); frameImageIds.forEach(id => _ensureSegInstanceMetadataAvailable(id, instance)); @@ -507,22 +481,100 @@ async function _loadSegments({ } } + /** + * Some DICOMweb servers (e.g. IDC's static WADO) omit large sequences like + * PerFrameFunctionalGroupsSequence from the JSON metadata to save bandwidth. + * The metadata-based loader (createFromDicomSegImageId) requires this sequence + * to map frames to segments. + * + * Loading strategy (in order of preference): + * 1. If PerFrameFunctionalGroupsSequence is inline (array) → use metadata-based loader + * 2. If PerFrameFunctionalGroupsSequence has BulkDataURI → fetch bulk data, then use metadata-based loader + * 3. If bulk data fetch fails or no BulkDataURI → fall back to buffer-based loader (full Part 10 file) + */ + let hasPerFrameFunctionalGroups = + Array.isArray(instance.PerFrameFunctionalGroupsSequence) && + instance.PerFrameFunctionalGroupsSequence.length > 0; + + /** + * Check if PerFrameFunctionalGroupsSequence is available via bulk data. + * Some servers return it as { BulkDataURI: '...' } instead of inline array. + */ + const perFrameValue = instance.PerFrameFunctionalGroupsSequence as + | unknown[] + | { BulkDataURI?: string; retrieveBulkData?: () => Promise } + | undefined; + + if ( + !hasPerFrameFunctionalGroups && + perFrameValue && + typeof perFrameValue === 'object' && + !Array.isArray(perFrameValue) && + (perFrameValue.BulkDataURI || typeof perFrameValue.retrieveBulkData === 'function') + ) { + try { + let buffer: ArrayBuffer | undefined; + + if (typeof perFrameValue.retrieveBulkData === 'function') { + buffer = await perFrameValue.retrieveBulkData(); + } else if (perFrameValue.BulkDataURI && dataSource.retrieve?.bulkDataURI) { + buffer = await dataSource.retrieve.bulkDataURI({ + StudyInstanceUID: instance.StudyInstanceUID, + BulkDataURI: perFrameValue.BulkDataURI, + }); + } + + if (buffer && buffer.byteLength > 0) { + /** + * Parse the bulk data as JSON. The server returns the sequence as a JSON array + * following the DICOMweb JSON model (denaturalized form). + */ + const jsonText = new TextDecoder().decode(buffer); + const denaturalizedSequence = JSON.parse(jsonText); + + if (Array.isArray(denaturalizedSequence) && denaturalizedSequence.length > 0) { + /** Naturalize each item in the sequence to match OHIF's internal format */ + const naturalizedSequence = denaturalizedSequence.map(item => naturalizeDataset(item)); + + /** Update the instance metadata with the fetched sequence */ + instance.PerFrameFunctionalGroupsSequence = naturalizedSequence; + hasPerFrameFunctionalGroups = true; + } + } + } catch { + /** Bulk data fetch failed; fall back to buffer-based loader */ + } + } + let results; try { - results = await adaptersSEG.Cornerstone3D.Segmentation.createFromDicomSegImageId( - imageIds, - segImageIdForMetadata, - { - metadataProvider: metaData, - tolerance, - parserType: getSegmentationParserType( - segDisplaySet.SOPClassUID, - customizationService - ), - frameImageIds, - concurrency: SEG_FRAME_DECODE_CONCURRENCY, - } - ); + if (!hasPerFrameFunctionalGroups) { + const arrayBuffer = await dicomLoaderService.findDicomDataPromise( + segDisplaySet, + null, + headers + ); + results = await adaptersSEG.Cornerstone3D.Segmentation.createFromDICOMSegBuffer( + imageIds, + arrayBuffer, + { metadataProvider: metaData, tolerance } + ); + } else { + results = await adaptersSEG.Cornerstone3D.Segmentation.createFromDicomSegImageId( + imageIds, + segImageIdForMetadata, + { + metadataProvider: metaData, + tolerance, + parserType: getSegmentationParserType( + segDisplaySet.SOPClassUID, + customizationService + ), + frameImageIds, + concurrency: SEG_FRAME_DECODE_CONCURRENCY, + } + ); + } } finally { eventTarget.removeEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, onProgress); prefetch?.cancel?.(); @@ -557,17 +609,6 @@ async function _loadSegments({ } Object.assign(segDisplaySet, results); - - const labelMapImageIds = (results as { labelMapImages?: { imageId: string }[][] }) - .labelMapImages?.flat() - .map(image => image.imageId); - - log.debug(SEG_LOAD_LOG_PREFIX, 'SEG parse complete', { - SOPInstanceUID: segDisplaySet.SOPInstanceUID, - labelMapImageCount: labelMapImageIds?.length ?? 0, - labelMapImageIds, - segmentIndices: Object.keys(segDisplaySet.segments || {}), - }); } function _segmentationExists(segDisplaySet) { diff --git a/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx b/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx index c565d5292c3..1d0f15fc2d3 100644 --- a/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx +++ b/extensions/cornerstone-dicom-seg/src/viewports/OHIFCornerstoneSEGViewport.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useViewportGrid } from '@ohif/ui-next'; import createSEGToolGroupAndAddTools from '../utils/initSEGToolGroup'; import promptHydrateSEG from '../utils/promptHydrateSEG'; @@ -49,8 +49,6 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) { }); // refs - const referencedDisplaySetRef = useRef(null); - const { viewports, activeViewportId } = viewportGrid; const referencedDisplaySetInstanceUID = segDisplaySet.referencedDisplaySetInstanceUID; @@ -63,38 +61,30 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) { // Additional guard: If no customization handler is registered for missing // referenced display sets, skip SEG rendering to avoid a viewport crash. + // Only the *returns* move below the hooks; the handler still runs here, at the + // same point in the render it always did. + let skipRendering = false; if (!referencedDisplaySetInstanceUID) { const missingReferenceDisplaySetHandler = customizationService.getCustomization( 'missingReferenceDisplaySetHandler' ); if (typeof missingReferenceDisplaySetHandler === 'function') { - const { handled } = missingReferenceDisplaySetHandler(); - if (handled) { - return; - } + ({ handled: skipRendering } = missingReferenceDisplaySetHandler()); } else { console.log( "No customization 'missingReferenceDisplaySetHandler' registered. Skipping SEG rendering." ); - return; + skipRendering = true; } } - const referencedDisplaySet = displaySetService.getDisplaySetByUID( - referencedDisplaySetInstanceUID - ); + // getDisplaySetByUID throws on a non-string, and the UID is absent on exactly + // the path handled above - reachable whenever a handler returns handled:false. + const referencedDisplaySet = referencedDisplaySetInstanceUID + ? displaySetService.getDisplaySetByUID(referencedDisplaySetInstanceUID) + : undefined; - const referencedDisplaySetMetadata = _getReferencedDisplaySetMetadata( - referencedDisplaySet, - segDisplaySet - ); - - referencedDisplaySetRef.current = { - displaySet: referencedDisplaySet, - metadata: referencedDisplaySetMetadata, - }; - - const getCornerstoneViewport = useCallback(() => { + const getCornerstoneViewport = () => { // Stack uses the referenced series (data[0]); SEG is applied as an overlay (data[1]). // Passing only the SEG display set leaves the stack with derived labelmap imageIds, // which are not displayable without the underlying grayscale series. @@ -114,14 +104,7 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) { }} /> ); - }, [ - viewportId, - segDisplaySet, - referencedDisplaySet, - toolGroupId, - props, - viewportOptions, - ]); + }; useEffect(() => { if (segIsLoading) { @@ -255,11 +238,7 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) { // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ let childrenWithProps = null; - if ( - !referencedDisplaySetRef.current || - referencedDisplaySet.displaySetInstanceUID !== - referencedDisplaySetRef.current.displaySet.displaySetInstanceUID - ) { + if (skipRendering || !referencedDisplaySet) { return null; } @@ -293,37 +272,4 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) { ); } -function _getReferencedDisplaySetMetadata(referencedDisplaySet, segDisplaySet) { - const { SharedFunctionalGroupsSequence } = segDisplaySet.instance; - - const SharedFunctionalGroup = Array.isArray(SharedFunctionalGroupsSequence) - ? SharedFunctionalGroupsSequence[0] - : SharedFunctionalGroupsSequence; - - const { PixelMeasuresSequence } = SharedFunctionalGroup; - - const PixelMeasures = Array.isArray(PixelMeasuresSequence) - ? PixelMeasuresSequence[0] - : PixelMeasuresSequence; - - const { SpacingBetweenSlices, SliceThickness } = PixelMeasures; - - const image0 = referencedDisplaySet.images[0]; - const referencedDisplaySetMetadata = { - PatientID: image0.PatientID, - PatientName: image0.PatientName, - PatientSex: image0.PatientSex, - PatientAge: image0.PatientAge, - SliceThickness: image0.SliceThickness || SliceThickness, - StudyDate: image0.StudyDate, - SeriesDescription: image0.SeriesDescription, - SeriesInstanceUID: image0.SeriesInstanceUID, - SeriesNumber: image0.SeriesNumber, - ManufacturerModelName: image0.ManufacturerModelName, - SpacingBetweenSlices: image0.SpacingBetweenSlices || SpacingBetweenSlices, - }; - - return referencedDisplaySetMetadata; -} - export default OHIFCornerstoneSEGViewport; diff --git a/extensions/cornerstone-dicom-sr/jest.config.js b/extensions/cornerstone-dicom-sr/jest.config.js new file mode 100644 index 00000000000..5625492b00c --- /dev/null +++ b/extensions/cornerstone-dicom-sr/jest.config.js @@ -0,0 +1,16 @@ +const base = require('../../jest.config.base.js'); + +module.exports = { + ...base, + moduleNameMapper: { + ...base.moduleNameMapper, + // Must precede the catch-all below, which would send this to a + // `platform/extension-cornerstone` that does not exist. + '^@ohif/extension-cornerstone$': '/src/__mocks__/ohifExtensionCornerstone.js', + // Deep imports already name `src`, so they must be matched before the + // catch-all below appends a second one (e.g. `@ohif/core/src/utils/x` + // would otherwise resolve to `platform/core/src/utils/x/src`). + '^@ohif/([^/]+)/src/(.*)$': '/../../platform/$1/src/$2', + '@ohif/(.*)': '/../../platform/$1/src', + }, +}; diff --git a/extensions/cornerstone-dicom-sr/package.json b/extensions/cornerstone-dicom-sr/package.json index 42bac0829dc..e4e7ce14f76 100644 --- a/extensions/cornerstone-dicom-sr/package.json +++ b/extensions/cornerstone-dicom-sr/package.json @@ -1,6 +1,6 @@ { "name": "@ohif/extension-cornerstone-dicom-sr", - "version": "3.13.0-beta.135", + "version": "3.14.0-beta.44", "description": "OHIF extension for an SR Cornerstone Viewport", "author": "OHIF", "license": "MIT", @@ -31,18 +31,16 @@ "peerDependencies": { "@ohif/core": "workspace:*", "@ohif/extension-cornerstone": "workspace:*", - "@ohif/ui": "workspace:*", "dcmjs": "0.52.0", "dicom-parser": "1.8.21", "hammerjs": "2.0.8", - "prop-types": "15.8.1", - "react": "18.3.1" + "react": "19.2.7" }, "dependencies": { "@babel/runtime": "7.29.7", - "@cornerstonejs/adapters": "5.6.8", - "@cornerstonejs/core": "5.6.8", - "@cornerstonejs/tools": "5.6.8", + "@cornerstonejs/adapters": "5.10.3", + "@cornerstonejs/core": "5.10.3", + "@cornerstonejs/tools": "5.10.3", "classnames": "2.5.1" }, "devDependencies": { diff --git a/extensions/cornerstone-dicom-sr/src/__mocks__/ohifExtensionCornerstone.js b/extensions/cornerstone-dicom-sr/src/__mocks__/ohifExtensionCornerstone.js new file mode 100644 index 00000000000..464633b86b6 --- /dev/null +++ b/extensions/cornerstone-dicom-sr/src/__mocks__/ohifExtensionCornerstone.js @@ -0,0 +1,15 @@ +/** + * Stand-in for `@ohif/extension-cornerstone` under jest. + * + * The SOP class handler reads the cornerstone tool source name and version from + * the cornerstone extension, and importing that package for real pulls in the + * cornerstone rendering stack, which does not load under jsdom. The catch-all + * `@ohif/(.*)` mapping also sends it to `platform/extension-cornerstone/src`, which + * does not exist. + */ +module.exports = { + Enums: { + CORNERSTONE_3D_TOOLS_SOURCE_NAME: 'Cornerstone3DTools', + CORNERSTONE_3D_TOOLS_SOURCE_VERSION: '0.1', + }, +}; diff --git a/extensions/cornerstone-dicom-sr/src/commandsModule.ts b/extensions/cornerstone-dicom-sr/src/commandsModule.ts index 7588a9fcaec..489a57387e3 100644 --- a/extensions/cornerstone-dicom-sr/src/commandsModule.ts +++ b/extensions/cornerstone-dicom-sr/src/commandsModule.ts @@ -1,6 +1,6 @@ import { metaData } from '@cornerstonejs/core'; -import OHIF from '@ohif/core'; +import OHIF, { utils } from '@ohif/core'; import { adaptersSR } from '@cornerstonejs/adapters'; import getFilteredCornerstoneToolState from './utils/getFilteredCornerstoneToolState'; @@ -97,11 +97,24 @@ const commandsModule = (props: withAppTypes) => { } try { - const naturalizedReport = _generateReport( - measurementData, - additionalFindingTypes, - options - ); + // dcmjs stamps the series date/time of the series it derives in UTC, + // which is the wrong wall clock reading anywhere else and, around + // midnight, the wrong day - DICOM DA and TM are displayed as they are + // stored. So the series being created here is given the current + // date/time in its own zone instead. A report added to an existing + // series takes that series' date and time, which the adapter copies + // from the predecessor instance. + const { date, time } = utils.getCurrentDicomDateTime(); + const naturalizedReport = _generateReport(measurementData, additionalFindingTypes, { + ...(options.predecessorImageId ? {} : { SeriesDate: date, SeriesTime: time }), + ...options, + }); + + // A report saved into an existing series inherits that series' date and + // time, and its instance number is derived from the one predecessor + // instance, which is not necessarily the highest in the series. Stamp + // both so this report is identifiable as the most recent instance. + utils.updateNewInstanceMetadata(naturalizedReport); const { ContentSequence } = naturalizedReport; // The content sequence has 5 or more elements, of which diff --git a/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRContainer.tsx b/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRContainer.tsx index 461eba1acfd..50264674132 100644 --- a/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRContainer.tsx +++ b/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRContainer.tsx @@ -1,45 +1,54 @@ -import PropTypes from 'prop-types'; import React from 'react'; import { OHIFCornerstoneSRContentItem } from './OHIFCornerstoneSRContentItem'; -export function OHIFCornerstoneSRContainer(props) { - const { container, nodeIndexesTree = [0], containerNumberedTree = [1] } = props; - const { ContinuityOfContent, ConceptNameCodeSequence } = container; - const { CodeMeaning } = ConceptNameCodeSequence ?? {}; +/** + * Builds the rendered children of a container's content sequence. Container + * children are numbered 1, 2, 3... in order; everything else renders as a + * content item. Lives at module scope so the running counter is not a variable + * captured by a lambda, which the compiler cannot lower. + */ +function renderContentItems(container, nodeIndexesTree, containerNumberedTree) { + const { ContinuityOfContent } = container; + const contentSequence = container.ContentSequence; + + if (!contentSequence) { + return undefined; + } + let childContainerIndex = 1; - const contentItems = container.ContentSequence?.map((contentItem, i) => { + + return contentSequence.map((contentItem, i) => { const { ValueType } = contentItem; const childNodeLevel = [...nodeIndexesTree, i]; const key = childNodeLevel.join('.'); - let Component; - let componentProps; - if (ValueType === 'CONTAINER') { - const childContainerNumberedTree = [...containerNumberedTree, childContainerIndex++]; - - Component = OHIFCornerstoneSRContainer; - componentProps = { - container: contentItem, - nodeIndexesTree: childNodeLevel, - containerNumberedTree: childContainerNumberedTree, - }; - } else { - Component = OHIFCornerstoneSRContentItem; - componentProps = { - contentItem, - nodeIndexesTree: childNodeLevel, - continuityOfContent: ContinuityOfContent, - }; + return ( + + ); } return ( - ); }); +} + +export function OHIFCornerstoneSRContainer(props) { + const { container, nodeIndexesTree = [0], containerNumberedTree = [1] } = props; + const { ConceptNameCodeSequence } = container; + const { CodeMeaning } = ConceptNameCodeSequence ?? {}; + const contentItems = renderContentItems(container, nodeIndexesTree, containerNumberedTree); return (
@@ -51,28 +60,3 @@ export function OHIFCornerstoneSRContainer(props) {
); } - -OHIFCornerstoneSRContainer.propTypes = { - /** - * A tree node that may contain another container or one or more content items - * (text, code, uidref, pname, etc.) - */ - container: PropTypes.object, - /** - * A 0-based index list - */ - nodeIndexesTree: PropTypes.arrayOf(PropTypes.number), - /** - * A 1-based index list that represents a container in a multi-level numbered - * list (tree). - * - * Example: - * 1. History - * 1.1. Chief Complaint - * 1.2. Present Illness - * 1.3. Past History - * 1.4. Family History - * 2. Findings - * */ - containerNumberedTree: PropTypes.arrayOf(PropTypes.number), -}; diff --git a/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRContentItem.tsx b/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRContentItem.tsx index bca59018849..9015eeeb277 100644 --- a/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRContentItem.tsx +++ b/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRContentItem.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import { CodeNameCodeSequenceValues } from '../enums'; import formatContentItemValue from '../utils/formatContentItem'; @@ -54,10 +53,6 @@ function OHIFCornerstoneSRContentItem(props) { ); } -OHIFCornerstoneSRContentItem.propTypes = { - contentItem: PropTypes.object, - nodeIndexesTree: PropTypes.arrayOf(PropTypes.number), - continuityOfContent: PropTypes.string, -}; + export { OHIFCornerstoneSRContentItem }; diff --git a/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRMeasurementViewport.tsx b/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRMeasurementViewport.tsx index 9d6b8f213b6..7c83a6e1022 100644 --- a/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRMeasurementViewport.tsx +++ b/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRMeasurementViewport.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React, { useCallback, useEffect, useState } from 'react'; import { setTrackingUniqueIdentifiersForElement } from '../tools/modules/dicomSRModule'; @@ -92,16 +91,27 @@ function OHIFCornerstoneSRMeasurementViewport(props) { }); }); }, - [dataSource, srDisplaySet, activeImageDisplaySetData, viewportId] + [ + dataSource, + srDisplaySet, + activeImageDisplaySetData, + viewportId, + displaySetService, + viewportOptions, + setPositionPresentation, + ] ); - const getCornerstoneViewport = useCallback(() => { + const getCornerstoneViewport = () => { if (!activeImageDisplaySetData) { return null; } + // A newly selected SR has not loaded its measurements yet, and this now reads + // the current display set rather than the one captured when the callback was + // last memoized - so it can legitimately be undefined for a render or two. const { measurements } = srDisplaySet; - const measurement = measurements[measurementSelected]; + const measurement = measurements?.[measurementSelected]; if (!measurement) { return null; @@ -134,7 +144,7 @@ function OHIFCornerstoneSRMeasurementViewport(props) { isJumpToMeasurementDisabled={true} /> ); - }, [activeImageDisplaySetData, viewportId, measurementSelected]); + }; /** Cleanup the SR viewport when the viewport is destroyed @@ -221,14 +231,7 @@ function OHIFCornerstoneSRMeasurementViewport(props) { ); } -OHIFCornerstoneSRMeasurementViewport.propTypes = { - displaySets: PropTypes.arrayOf(PropTypes.object), - viewportId: PropTypes.string.isRequired, - dataSource: PropTypes.object, - children: PropTypes.node, - viewportLabel: PropTypes.string, - viewportOptions: PropTypes.object, -}; + async function _getViewportReferencedDisplaySetData( displaySet, diff --git a/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRTextViewport.tsx b/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRTextViewport.tsx index 6fd05e2ffb2..cc337ee3bb7 100644 --- a/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRTextViewport.tsx +++ b/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRTextViewport.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import { ExtensionManager } from '@ohif/core'; import { OHIFCornerstoneSRContainer } from './OHIFCornerstoneSRContainer'; @@ -18,15 +17,6 @@ function OHIFCornerstoneSRTextViewport(props: withAppTypes) { ); } -OHIFCornerstoneSRTextViewport.propTypes = { - displaySets: PropTypes.arrayOf(PropTypes.object), - viewportId: PropTypes.string.isRequired, - dataSource: PropTypes.object, - children: PropTypes.node, - viewportLabel: PropTypes.string, - viewportOptions: PropTypes.object, - servicesManager: PropTypes.object.isRequired, - extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired, -}; + export default OHIFCornerstoneSRTextViewport; diff --git a/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRViewport.tsx b/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRViewport.tsx index 3bbb73a5b8c..5ea9bbd933b 100644 --- a/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRViewport.tsx +++ b/extensions/cornerstone-dicom-sr/src/components/OHIFCornerstoneSRViewport.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import { ExtensionManager } from '@ohif/core'; @@ -16,15 +15,6 @@ function OHIFCornerstoneSRViewport(props: withAppTypes) { return ; } -OHIFCornerstoneSRViewport.propTypes = { - displaySets: PropTypes.arrayOf(PropTypes.object), - viewportId: PropTypes.string.isRequired, - dataSource: PropTypes.object, - children: PropTypes.node, - viewportLabel: PropTypes.string, - viewportOptions: PropTypes.object, - servicesManager: PropTypes.object.isRequired, - extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired, -}; + export default OHIFCornerstoneSRViewport; diff --git a/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.test.ts b/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.test.ts new file mode 100644 index 00000000000..6b5d36ee213 --- /dev/null +++ b/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.test.ts @@ -0,0 +1,159 @@ +const mockGetUIDsFromImageID = jest.fn(); +const mockAddSRAnnotation = jest.fn(); + +jest.mock('@ohif/core', () => ({ + utils: { + sopClassDictionary: { + BasicTextSR: 'basic-text-sr', + EnhancedSR: 'enhanced-sr', + ComprehensiveSR: 'comprehensive-sr', + Comprehensive3DSR: 'comprehensive-3d-sr', + }, + }, + classes: { + MetadataProvider: { + getUIDsFromImageID: (...args: unknown[]) => mockGetUIDsFromImageID(...args), + }, + }, + Types: {}, +})); + +jest.mock('@ohif/i18n', () => ({ __esModule: true, default: { t: (key: string) => key } })); + +jest.mock('@cornerstonejs/adapters', () => ({ + adaptersSR: { + Cornerstone3D: { TEXT_ANNOTATION_POSITION: {}, COMMENT_CODE: {}, CodeScheme: {} }, + }, +})); + +jest.mock('./utils/addSRAnnotation', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockAddSRAnnotation(...args), +})); + +jest.mock('./utils/isRehydratable', () => ({ __esModule: true, default: jest.fn() })); + +import { _checkIfCanAddMeasurementsToDisplaySet } from './getSopClassHandlerModule'; + +const servicesManager = { + services: { customizationService: { getCustomization: () => undefined } }, +}; + +const srWithOneMeasurementOn = (SOPInstanceUID: string) => ({ + SOPClassUID: 'comprehensive-sr', + measurements: [ + { + loaded: false, + coords: [{ ReferencedSOPSequence: { ReferencedSOPInstanceUID: SOPInstanceUID } }], + }, + ], +}); + +describe('_checkIfCanAddMeasurementsToDisplaySet', () => { + beforeEach(() => { + mockGetUIDsFromImageID.mockReset(); + mockAddSRAnnotation.mockReset(); + }); + + // Loading an SR after a segmentation was opened ended the session: the hydrated + // segmentation's display set carries labelmap `derived:` image ids, which resolve + // to no UIDs. + it('leaves the display set of a hydrated segmentation alone', () => { + const srDisplaySet = srWithOneMeasurementOn('sop-1'); + const segDisplaySet = { + displaySetInstanceUID: 'seg', + Modality: 'SEG', + isDerivedDisplaySet: true, + images: [{ imageId: 'derived:labelmap-1' }], + }; + const dataSource = { getImageIdsForDisplaySet: jest.fn(() => ['derived:labelmap-1']) }; + mockGetUIDsFromImageID.mockReturnValue(undefined); + + expect(() => + _checkIfCanAddMeasurementsToDisplaySet( + srDisplaySet as never, + segDisplaySet as never, + dataSource, + servicesManager as never + ) + ).not.toThrow(); + + expect(dataSource.getImageIdsForDisplaySet).not.toHaveBeenCalled(); + expect(srDisplaySet.measurements[0].loaded).toBe(false); + }); + + // A segmentation that the client makes carries `isDerived` and + // `isOverlayDisplaySet`, and it carries no `isDerivedDisplaySet`. + it('leaves the display set of a segmentation that the client made alone', () => { + const srDisplaySet = srWithOneMeasurementOn('sop-1'); + const clientSegDisplaySet = { + displaySetInstanceUID: 'seg-in-client', + Modality: 'SEG', + madeInClient: true, + isDerived: true, + isOverlayDisplaySet: true, + }; + const dataSource = { getImageIdsForDisplaySet: jest.fn(() => []) }; + + _checkIfCanAddMeasurementsToDisplaySet( + srDisplaySet as never, + clientSegDisplaySet as never, + dataSource, + servicesManager as never + ); + + expect(dataSource.getImageIdsForDisplaySet).not.toHaveBeenCalled(); + expect(srDisplaySet.measurements[0].loaded).toBe(false); + }); + + // A custom SOP class handler, or a custom data source, can give an image id + // that the metadata provider does not know, and it can set no derived flag. + it('skips an image id that the metadata provider does not know', () => { + const srDisplaySet = srWithOneMeasurementOn('sop-1'); + const displaySet = { displaySetInstanceUID: 'custom', Modality: 'OT' }; + const dataSource = { + getImageIdsForDisplaySet: jest.fn(() => ['custom:unknown-1', 'wadors:known-1']), + }; + mockGetUIDsFromImageID.mockImplementation(imageId => + imageId === 'wadors:known-1' ? { SOPInstanceUID: 'sop-1', frameNumber: '1' } : undefined + ); + + expect(() => + _checkIfCanAddMeasurementsToDisplaySet( + srDisplaySet as never, + displaySet as never, + dataSource, + servicesManager as never + ) + ).not.toThrow(); + + expect(srDisplaySet.measurements[0]).toMatchObject({ + loaded: true, + imageId: 'wadors:known-1', + }); + }); + + it('still places a measurement on the source image it references', () => { + const srDisplaySet = srWithOneMeasurementOn('sop-1'); + const sourceDisplaySet = { displaySetInstanceUID: 'nm', Modality: 'NM' }; + const imageId = 'wadors:https://host/studies/st/series/se/instances/sop-1/frames/1'; + const dataSource = { getImageIdsForDisplaySet: jest.fn(() => [imageId]) }; + mockGetUIDsFromImageID.mockReturnValue({ SOPInstanceUID: 'sop-1', frameNumber: '1' }); + + _checkIfCanAddMeasurementsToDisplaySet( + srDisplaySet as never, + sourceDisplaySet as never, + dataSource, + servicesManager as never + ); + + expect(mockAddSRAnnotation).toHaveBeenCalledWith( + expect.objectContaining({ imageId, displaySet: sourceDisplaySet }) + ); + expect(srDisplaySet.measurements[0]).toMatchObject({ + loaded: true, + imageId, + displaySetInstanceUID: 'nm', + }); + }); +}); diff --git a/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts b/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts index 6d27fa3b903..783897145b5 100644 --- a/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts +++ b/extensions/cornerstone-dicom-sr/src/getSopClassHandlerModule.ts @@ -1,4 +1,11 @@ -import { utils, classes, DisplaySetService, Types as OhifTypes } from '@ohif/core'; +import { + utils, + classes, + DisplaySetService, + DisplaySetMessage, + DisplaySetMessageList, + Types as OhifTypes, +} from '@ohif/core'; import i18n from '@ohif/i18n'; import { Enums as CSExtensionEnums } from '@ohif/extension-cornerstone'; import { adaptersSR } from '@cornerstonejs/adapters'; @@ -61,6 +68,14 @@ function addInstances(instances: InstanceMetadata[], _displaySetService: Display // Eventually, the SR viewer should have the ability to choose which SR // gets loaded, and to navigate among them. this.instance = this.instances[this.instances.length - 1]; + // The date/time of the display set is that of the instance it shows, so it + // has to move with that instance. The series level SeriesDate/SeriesTime + // still hold those of the first report saved into the series, so leaving + // them would show, and summarize by, a date older than the report shown and + // older than the position the series list has just sorted this one into. + const { SeriesDate, SeriesTime } = utils.getLatestInstanceDateTime(this.instance); + this.SeriesDate = SeriesDate; + this.SeriesTime = SeriesTime; this.isLoaded = false; return this; } @@ -96,19 +111,37 @@ function _getDisplaySetsFromSeries( SOPInstanceUID, SeriesDescription, SeriesNumber, - SeriesDate, - SeriesTime, ConceptNameCodeSequence, SOPClassUID, imageId: predecessorImageId, } = instance; validateSameStudyUID(instance.StudyInstanceUID, instances); + // The date/time of the display set is that of the instance it shows. A + // report saved into an existing series keeps the original SeriesDate, so only + // the instance level content date/time places it as the newest one. + const { SeriesDate, SeriesTime } = utils.getLatestInstanceDateTime(instance); + const is3DSR = SOPClassUID === sopClassDictionary.Comprehensive3DSR; - const isImagingMeasurementReport = + const conceptIsImagingMeasurementReport = ConceptNameCodeSequence?.CodeValue === CodeNameCodeSequenceValues.ImagingMeasurementReport; + // A report flagged as an Imaging Measurement Report but stored without its + // report body (no ContentSequence / (0040,A730)) cannot be parsed or rendered + // as one. Treat it as a plain SR so neither the loader nor the SR viewport + // takes the measurement path (which calls `.find` on the missing content and + // assumes at least one measurement exists), both of which would crash. + const hasReportContent = !!instance.ContentSequence; + const isImagingMeasurementReport = conceptIsImagingMeasurementReport && hasReportContent; + + // Surface the empty report through the standard display set message list, so + // it is reported in the display set tray like any other display set problem. + const messages = new DisplaySetMessageList(); + if (!hasReportContent) { + messages.addMessage(DisplaySetMessage.CODES.MISSING_REPORT_CONTENT); + } + const displaySet = { Modality: 'SR', displaySetInstanceUID: utils.guid(), @@ -127,6 +160,7 @@ function _getDisplaySetsFromSeries( isDerivedDisplaySet: true, isLoaded: false, isImagingMeasurementReport, + messages, sopClassUids, instance, predecessorImageId, @@ -251,7 +285,22 @@ function _checkIfCanAddMeasurementsToDisplaySet( measurement => measurement.loaded === false ); - if (!unloadedMeasurements.length || newDisplaySet.unsupported) { + // An SR measurement references source images, never the images of a derived + // display set, which is why hydration already leaves those out. Walking one + // here is worse than useless: once a SEG is hydrated its display set carries the + // labelmap's `derived:` image ids, which resolve to no UIDs (the destructure + // below then throws), and a SEG shares its source's FrameOfReferenceUID, so a + // SCOORD3D measurement could otherwise land on it. The three flags below all + // mark a derived display set: a SEG loaded from the server sets + // `isDerivedDisplaySet`, and one made in the client sets `isDerived` and + // `isOverlayDisplaySet`. + if ( + !unloadedMeasurements.length || + newDisplaySet.unsupported || + newDisplaySet.isDerivedDisplaySet || + newDisplaySet.isDerived || + newDisplaySet.isOverlayDisplaySet + ) { return; } @@ -260,7 +309,16 @@ function _checkIfCanAddMeasurementsToDisplaySet( const imageIds = dataSource.getImageIdsForDisplaySet(newDisplaySet); for (const imageId of imageIds) { - const { SOPInstanceUID, frameNumber } = metadataProvider.getUIDsFromImageID(imageId); + // A metadata provider returns undefined for an image id that it does not + // know, for example an id of a custom SOP class handler or of a data source + // that marks no derived flag. Skip that image id, and do not throw. + const uids = metadataProvider.getUIDsFromImageID(imageId); + + if (!uids) { + continue; + } + + const { SOPInstanceUID, frameNumber } = uids; const key = `${SOPInstanceUID}:${frameNumber || 1}`; imageIdMap.set(key, imageId); } @@ -795,4 +853,5 @@ function isTextPosition(group) { ); } +export { _checkIfCanAddMeasurementsToDisplaySet }; export default getSopClassHandlerModule; diff --git a/extensions/cornerstone-dicom-sr/src/tools/modules/dicomSRModule.js b/extensions/cornerstone-dicom-sr/src/tools/modules/dicomSRModule.js index 5636e6563d2..72ad19209c5 100644 --- a/extensions/cornerstone-dicom-sr/src/tools/modules/dicomSRModule.js +++ b/extensions/cornerstone-dicom-sr/src/tools/modules/dicomSRModule.js @@ -11,6 +11,10 @@ const state = { * if there are two viewports rendering the same imageId, we don't want to show * the same SR annotation twice on irrelevant viewport, hence, we are storing the state * of the SR tools in state here, so that we can filter them later. + * + * Each lookup below tolerates a disabled element: switching display sets in a + * viewport tears the old one down while callers may still hold a reference to + * it, and getEnabledElement returns undefined for it rather than throwing. */ function setTrackingUniqueIdentifiersForElement( @@ -19,6 +23,12 @@ function setTrackingUniqueIdentifiersForElement( activeIndex = 0 ) { const enabledElement = getEnabledElement(element); + + // The element may have been disabled since the caller captured it. + if (!enabledElement) { + return; + } + const { viewport } = enabledElement; state.trackingIdentifiersByViewportId[viewport.id] = { @@ -29,6 +39,12 @@ function setTrackingUniqueIdentifiersForElement( function setActiveTrackingUniqueIdentifierForElement(element, TrackingUniqueIdentifier) { const enabledElement = getEnabledElement(element); + + // The element may have been disabled since the caller captured it. + if (!enabledElement) { + return; + } + const { viewport } = enabledElement; const trackingIdentifiersForElement = state.trackingIdentifiersByViewportId[viewport.id]; @@ -44,6 +60,12 @@ function setActiveTrackingUniqueIdentifierForElement(element, TrackingUniqueIden function getTrackingUniqueIdentifiersForElement(element) { const enabledElement = getEnabledElement(element); + + // The element may have been disabled since the caller captured it. + if (!enabledElement) { + return { trackingUniqueIdentifiers: [] }; + } + const { viewport } = enabledElement; if (state.trackingIdentifiersByViewportId[viewport.id]) { diff --git a/extensions/cornerstone-dynamic-volume/package.json b/extensions/cornerstone-dynamic-volume/package.json index 4bbf5f16893..482a3d44a1c 100644 --- a/extensions/cornerstone-dynamic-volume/package.json +++ b/extensions/cornerstone-dynamic-volume/package.json @@ -1,6 +1,6 @@ { "name": "@ohif/extension-cornerstone-dynamic-volume", - "version": "3.13.0-beta.135", + "version": "3.14.0-beta.44", "description": "OHIF extension for 4D volumes data", "author": "OHIF", "license": "MIT", @@ -36,17 +36,15 @@ "@ohif/extension-cornerstone": "workspace:*", "@ohif/extension-default": "workspace:*", "@ohif/i18n": "workspace:*", - "@ohif/ui": "workspace:*", "dcmjs": "0.52.0", "dicom-parser": "1.8.21", "hammerjs": "2.0.8", - "prop-types": "15.8.1", - "react": "18.3.1" + "react": "19.2.7" }, "dependencies": { "@babel/runtime": "7.29.7", - "@cornerstonejs/core": "5.6.8", - "@cornerstonejs/tools": "5.6.8", + "@cornerstonejs/core": "5.10.3", + "@cornerstonejs/tools": "5.10.3", "classnames": "2.5.1" }, "devDependencies": { diff --git a/extensions/cornerstone-dynamic-volume/src/panels/PanelGenerateImage.tsx b/extensions/cornerstone-dynamic-volume/src/panels/PanelGenerateImage.tsx index 983af578900..7ffdee09d66 100644 --- a/extensions/cornerstone-dynamic-volume/src/panels/PanelGenerateImage.tsx +++ b/extensions/cornerstone-dynamic-volume/src/panels/PanelGenerateImage.tsx @@ -7,6 +7,22 @@ import DynamicVolumeControls from './DynamicVolumeControls'; const SOPClassHandlerId = '@ohif/extension-default.sopClassHandlerModule.stack'; +/** + * Sets the displayed dimension group (time point) on a dynamic volume. + * + * Module scope on purpose: writing to a value the render produced - dynamicVolume + * is held in state - is a mutation the React Compiler cannot account for, and it + * refuses to optimize the whole component. Passing the volume to a named helper + * keeps the operation explicit and lets the compiler key the callback on the + * volume reference rather than on a dereferenced property of it. + */ +function setDimensionGroup(volume, dimensionGroupNumber) { + if (!volume) { + return; + } + volume.dimensionGroupNumber = dimensionGroupNumber; +} + export default function PanelGenerateImage({ servicesManager, commandsManager }: withAppTypes) { const { cornerstoneViewportService, viewportGridService, displaySetService } = servicesManager.services; @@ -24,9 +40,13 @@ export default function PanelGenerateImage({ servicesManager, commandsManager }: const [displayingComputed, setDisplayingComputed] = useState(false); // - const uuidComputedVolume = useRef(csUtils.uuidv4()); + // State, not a ref: this id is generated once and never changes, and it is read + // during render to build computedVolumeId. Reading ref.current in render is the + // thing refs are not for, and the compiler refuses it. + const [uuidComputedVolume] = useState(() => csUtils.uuidv4()); const uuidDynamicVolume = useRef(null); - const computedVolumeId = `cornerstoneStreamingImageVolume:${uuidComputedVolume.current}`; + const computedVolumeId = `cornerstoneStreamingImageVolume:${uuidComputedVolume}`; + useEffect(() => { const viewportDataChangedEvt = cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED; @@ -153,12 +173,12 @@ export default function PanelGenerateImage({ servicesManager, commandsManager }: if (!computedDisplaySet) { const displaySet = { volumeLoaderSchema: computedVolume.volumeId.split(':')[0], - displaySetInstanceUID: uuidComputedVolume.current, + displaySetInstanceUID: uuidComputedVolume, SOPClassHandlerId: SOPClassHandlerId, Modality: dynamicVolume.metadata.Modality, isMultiFrame: false, numImageFrames: 1, - uid: uuidComputedVolume.current, + uid: uuidComputedVolume, referenceDisplaySetUID: dynamicVolume.volumeId.split(':')[1], madeInClient: true, FrameOfReferenceUID: dynamicVolume.metadata.FrameOfReferenceUID, @@ -215,7 +235,7 @@ export default function PanelGenerateImage({ servicesManager, commandsManager }: currentDimensionGroupNumber={dimensionGroupNumberRendered} numDimensionGroups={dynamicVolume?.numDimensionGroups || 1} onDimensionGroupChange={dimensionGroupNumber => { - dynamicVolume.dimensionGroupNumber = dimensionGroupNumber; + setDimensionGroup(dynamicVolume, dimensionGroupNumber); }} onGenerate={onGenerateImage} onDynamicClick={displayingComputed ? () => renderDynamicImage(computedDisplaySet) : null} diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index d9701ca38c5..56546b50f0c 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -1,6 +1,6 @@ { "name": "@ohif/extension-cornerstone", - "version": "3.13.0-beta.135", + "version": "3.14.0-beta.44", "description": "OHIF extension for Cornerstone", "author": "OHIF", "license": "MIT", @@ -34,31 +34,28 @@ "test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests" }, "peerDependencies": { - "@cornerstonejs/codec-charls": "1.2.3", - "@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2", - "@cornerstonejs/codec-openjpeg": "1.3.0", - "@cornerstonejs/codec-openjph": "2.4.7", - "@cornerstonejs/dicom-image-loader": "5.6.8", + "@cornerstonejs/codec-charls": "1.2.5", + "@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.4", + "@cornerstonejs/codec-openjpeg": "1.3.2", + "@cornerstonejs/codec-openjph": "2.4.9", + "@cornerstonejs/dicom-image-loader": "5.10.3", "@ohif/core": "workspace:*", "@ohif/extension-default": "workspace:*", - "@ohif/ui": "workspace:*", "dcmjs": "0.52.0", "dicom-parser": "1.8.21", "hammerjs": "2.0.8", - "prop-types": "15.8.1", - "react": "18.3.1", - "react-dom": "18.3.1", - "react-resize-detector": "10.0.1" + "react": "19.2.7", + "react-dom": "19.2.7" }, "dependencies": { "@babel/runtime": "7.29.7", - "@cornerstonejs/adapters": "5.6.8", - "@cornerstonejs/ai": "5.6.8", - "@cornerstonejs/core": "5.6.8", - "@cornerstonejs/labelmap-interpolation": "5.6.8", - "@cornerstonejs/metadata": "5.6.8", - "@cornerstonejs/polymorphic-segmentation": "5.6.8", - "@cornerstonejs/tools": "5.6.8", + "@cornerstonejs/adapters": "5.10.3", + "@cornerstonejs/ai": "5.10.3", + "@cornerstonejs/core": "5.10.3", + "@cornerstonejs/labelmap-interpolation": "5.10.3", + "@cornerstonejs/metadata": "5.10.3", + "@cornerstonejs/polymorphic-segmentation": "5.10.3", + "@cornerstonejs/tools": "5.10.3", "@icr/polyseg-wasm": "0.4.0", "@itk-wasm/morphological-contour-interpolation": "1.1.0", "@kitware/vtk.js": "36.4.1", diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index 328975ec971..0b2fac32e1b 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -1,7 +1,13 @@ +// React Compiler opt-out: this file reads and mutates external cornerstone3D +// state (the enabled element, the camera, GL actors) during render and from +// imperative event handlers. The compiler's memoization assumes referential +// purity, so compiling it silently drops updates. +'use no memo'; + import React, { useEffect, useRef, useCallback, useState } from 'react'; import * as cs3DTools from '@cornerstonejs/tools'; import { Enums, eventTarget, getEnabledElement } from '@cornerstonejs/core'; -import { MeasurementService, useViewportRef } from '@ohif/core'; +import { MeasurementService, useViewportElementRegistration } from '@ohif/core'; import { useViewportDialog } from '@ohif/ui-next'; import type { Types as csTypes } from '@cornerstonejs/core'; @@ -80,8 +86,9 @@ const OHIFCornerstoneViewport = React.memo( const [scrollbarHeight, setScrollbarHeight] = useState('100px'); const [enabledVPElement, setEnabledVPElement] = useState(null); - const elementRef = useRef() as React.MutableRefObject; - const viewportRef = useViewportRef(viewportId); + const elementRef = useRef(undefined) as React.MutableRefObject; + const { register: registerViewportElement, unregister: unregisterViewportElement } = + useViewportElementRegistration(viewportId); const { displaySetService, @@ -220,7 +227,7 @@ const OHIFCornerstoneViewport = React.memo( } cornerstoneViewportService.disableElement(viewportId); - viewportRef.unregister(); + unregisterViewportElement(); eventTarget.removeEventListener(Enums.Events.ELEMENT_ENABLED, elementEnabledHandler); }; @@ -280,7 +287,15 @@ const OHIFCornerstoneViewport = React.memo( initialImageIndex ); - const presentations = getViewportPresentations(viewportId, viewportOptions); + // displaySets is this viewport's new state; the segmentation + // presentation needs it to decide which hydrated segmentations are + // overlayable on this viewport's background. + const presentations = getViewportPresentations( + viewportId, + viewportOptions, + displaySets, + displaySetService + ); // Note: This is a hack to get the grid to re-render the OHIFCornerstoneViewport component // Used for segmentation hydration right now, since the logic to decide whether @@ -318,7 +333,7 @@ const OHIFCornerstoneViewport = React.memo( ref={el => { elementRef.current = el; if (el) { - viewportRef.register(el); + registerViewportElement(el); } }} >
diff --git a/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx b/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx index acef3c380b5..e4be473ad95 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/CornerstoneOverlays.tsx @@ -1,3 +1,9 @@ +// React Compiler opt-out: this file reads and mutates external cornerstone3D +// state (the enabled element, the camera, GL actors) during render and from +// imperative event handlers. The compiler's memoization assumes referential +// purity, so compiling it silently drops updates. +'use no memo'; + import React, { useEffect, useState } from 'react'; import ViewportImageScrollbar from './ViewportImageScrollbar'; diff --git a/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx b/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx index 557ba77ee3d..1b76f5427a4 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx @@ -1,6 +1,11 @@ +// React Compiler opt-out: this file reads and mutates external cornerstone3D +// state (the enabled element, the camera, GL actors) during render and from +// imperative event handlers. The compiler's memoization assumes referential +// purity, so compiling it silently drops updates. +'use no memo'; + import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { vec3 } from 'gl-matrix'; -import PropTypes from 'prop-types'; import { metaData, Enums, eventTarget } from '@cornerstonejs/core'; import { Enums as csToolsEnums, UltrasoundPleuraBLineTool } from '@cornerstonejs/tools'; import type { ImageSliceData } from '@cornerstonejs/core/types'; @@ -457,11 +462,7 @@ function InstanceNumberOverlayItem({ ); } -CustomizableViewportOverlay.propTypes = { - viewportData: PropTypes.object, - imageIndex: PropTypes.number, - viewportId: PropTypes.string, -}; + export default CustomizableViewportOverlay; diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx b/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx index 11b4d4009f0..ea61df6bb87 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx @@ -1,5 +1,10 @@ +// React Compiler opt-out: this file reads and mutates external cornerstone3D +// state (the enabled element, the camera, GL actors) during render and from +// imperative event handlers. The compiler's memoization assumes referential +// purity, so compiling it silently drops updates. +'use no memo'; + import React, { useEffect } from 'react'; -import PropTypes from 'prop-types'; import { utilities as csUtils } from '@cornerstonejs/core'; import { ImageScrollbar } from '@ohif/ui-next'; import { isVolume3DViewportType } from '../../utils/getLegacyViewportType'; @@ -96,14 +101,6 @@ function CornerstoneImageScrollbar({ ); } -CornerstoneImageScrollbar.propTypes = { - viewportData: PropTypes.object, - viewportId: PropTypes.string.isRequired, - element: PropTypes.instanceOf(Element), - scrollbarHeight: PropTypes.string, - imageSliceData: PropTypes.object.isRequired, - setImageSliceData: PropTypes.func.isRequired, - servicesManager: PropTypes.object.isRequired, -}; + export default CornerstoneImageScrollbar; diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportImageSliceLoadingIndicator.tsx b/extensions/cornerstone/src/Viewport/Overlays/ViewportImageSliceLoadingIndicator.tsx index bb86a0df5ac..2bfb11a24a7 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportImageSliceLoadingIndicator.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportImageSliceLoadingIndicator.tsx @@ -1,5 +1,10 @@ +// React Compiler opt-out: this file reads and mutates external cornerstone3D +// state (the enabled element, the camera, GL actors) during render and from +// imperative event handlers. The compiler's memoization assumes referential +// purity, so compiling it silently drops updates. +'use no memo'; + import React, { useEffect, useState, useRef } from 'react'; -import PropTypes from 'prop-types'; import { Enums } from '@cornerstonejs/core'; function ViewportImageSliceLoadingIndicator({ viewportData, element }) { @@ -77,9 +82,6 @@ function ViewportImageSliceLoadingIndicator({ viewportData, element }) { return null; } -ViewportImageSliceLoadingIndicator.propTypes = { - error: PropTypes.object, - element: PropTypes.object, -}; + export default ViewportImageSliceLoadingIndicator; diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx b/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx index 19eb04ac1cb..1e2787b248a 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx @@ -1,3 +1,10 @@ +// React Compiler opt-out: this file reads and mutates external cornerstone3D +// state (the enabled element, the camera, GL actors) during render and from +// imperative event handlers. The compiler's memoization assumes referential +// purity, so compiling it silently drops updates - this component kept its +// pre-transform letters after rotate/flip/reset even though the camera changed. +'use no memo'; + import React, { useEffect, useState, useMemo } from 'react'; import classNames from 'classnames'; import { metaData, Enums, getEnabledElement } from '@cornerstonejs/core'; diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/ViewportSliceProgressScrollbar.tsx b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/ViewportSliceProgressScrollbar.tsx index d58becb40be..1de5d5aa428 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/ViewportSliceProgressScrollbar.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/ViewportSliceProgressScrollbar.tsx @@ -1,5 +1,10 @@ +// React Compiler opt-out: this file reads and mutates external cornerstone3D +// state (the enabled element, the camera, GL actors) during render and from +// imperative event handlers. The compiler's memoization assumes referential +// purity, so compiling it silently drops updates. +'use no memo'; + import React, { useMemo } from 'react'; -import PropTypes from 'prop-types'; import { utilities as csUtils } from '@cornerstonejs/core'; import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType'; import { @@ -50,17 +55,22 @@ function ViewportSliceProgressScrollbar({ const { numberOfSlices, imageIndex } = imageSliceData; - const imageIds = useMemo(() => getViewportImageIds(viewportData), [viewportData]); - const imageIdToIndex = useMemo(() => { - const map = new Map(); - for (let i = 0; i < imageIds.length; i++) { - const imageId = imageIds[i]; + // Manual memoization is load-bearing here: this component is excluded from + // the React Compiler (see rsbuild.config.ts / babel.config.js), and the + // byte-array hooks below list these in their effect deps — fresh identities + // every render re-run the seeding effects, whose publish re-renders this + // component in an infinite setState loop. + const { imageIds, imageIdToIndex } = useMemo(() => { + const ids = getViewportImageIds(viewportData); + const idToIndex = new Map(); + for (let i = 0; i < ids.length; i++) { + const imageId = ids[i]; if (imageId) { - map.set(imageId, i); + idToIndex.set(imageId, i); } } - return map; - }, [imageIds]); + return { imageIds: ids, imageIdToIndex: idToIndex }; + }, [viewportData]); const isFullMode = useProgressScrollbarMode({ viewportData, @@ -77,11 +87,7 @@ function ViewportSliceProgressScrollbar({ setImageSliceData, }); - const { - bytes: loadedBytes, - version: loadedVersion, - isFull: isFullyLoaded, - } = useLoadedSliceBytes({ + const { bytes: loadedBytes, isFull: isFullyLoaded } = useLoadedSliceBytes({ isFullMode, numberOfSlices, viewportData, @@ -90,7 +96,7 @@ function ViewportSliceProgressScrollbar({ loadedBatchIntervalMs, }); - const { bytes: viewedBytes, version: viewedVersion } = useViewedSliceBytes({ + const { bytes: viewedBytes } = useViewedSliceBytes({ isFullMode, numberOfSlices, imageIndex, @@ -162,7 +168,6 @@ function ViewportSliceProgressScrollbar({ {isFullMode && showLoadedFill && ( @@ -170,7 +175,6 @@ function ViewportSliceProgressScrollbar({ {isFullMode && showViewedFill && ( @@ -178,10 +182,7 @@ function ViewportSliceProgressScrollbar({ {isFullMode && showLoadedEndpoints && ( - + )} @@ -189,13 +190,6 @@ function ViewportSliceProgressScrollbar({ ); } -ViewportSliceProgressScrollbar.propTypes = { - viewportData: PropTypes.object, - viewportId: PropTypes.string.isRequired, - element: PropTypes.instanceOf(Element), - imageSliceData: PropTypes.object.isRequired, - setImageSliceData: PropTypes.func.isRequired, - servicesManager: PropTypes.object.isRequired, -}; + export default ViewportSliceProgressScrollbar; diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/hooks.ts b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/hooks.ts index 6943d96dabe..28261f389ba 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/hooks.ts +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/hooks.ts @@ -1,3 +1,9 @@ +// React Compiler opt-out: these hooks read and mutate external cornerstone3D +// state (the image cache, the enabled element) during render and from +// imperative event handlers. The compiler's memoization assumes referential +// purity, so compiling them silently drops updates. +'use no memo'; + import { useEffect, useRef, useState } from 'react'; import { cache as cornerstoneCache, Enums, eventTarget, utilities } from '@cornerstonejs/core'; import { useByteArray } from '@ohif/ui-next'; diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index cd71c33860b..344c5fb99df 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -55,6 +55,7 @@ import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownload import { updateSegmentBidirectionalStats } from './utils/updateSegmentationStats'; import { generateSegmentationCSVReport } from './utils/generateSegmentationCSVReport'; import { getUpdatedViewportsForSegmentation } from './utils/hydrationUtils'; +import { loadDisplaySetData } from './utils/loadDisplaySetData'; import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; import { EasingFunctionEnum } from './utils/transitions'; import { createSegmentationForViewport } from './utils/createSegmentationForViewport'; @@ -320,27 +321,45 @@ function commandsModule({ } }, - hydrateSecondaryDisplaySet: async ({ displaySet, viewportId }) => { - if (!displaySet) { + /** + * Loads a display set's data without reference to a viewport. + * + * The viewport-independent counterpart to displaying it: this makes the data + * available (for a segmentation, present in the segmentation state), while + * where it is shown remains a separate decision. Loading is memoized per + * display set, so calling this early or more than once is free. + */ + loadDisplaySetData: async ({ displaySet, displaySetInstanceUID }) => { + const displaySetToLoad = + displaySet ?? displaySetService.getDisplaySetByUID(displaySetInstanceUID); + + if (!displaySetToLoad) { return; } - const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + await loadDisplaySetData(displaySetToLoad, servicesManager); + }, - if (!viewport) { + hydrateSecondaryDisplaySet: async ({ displaySet, viewportId }) => { + if (!displaySet) { return; } + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + if (displaySet.isOverlayDisplaySet) { // update the previously stored segmentationPresentation with the new viewportId // presentation so that when we put the referencedDisplaySet back in the viewport // it will have the correct segmentation representation hydrated + // Recorded as a hint only: _setSegmentationPresentation corrects + // Labelmap <-> Surface against the viewport that actually renders it, + // so this does not have to be resolved against a viewport here. const segmentationType = // Todo: check if PMAP modality should be handled such as SEG displaySet.Modality !== 'SEG' ? SegmentationRepresentations.Contour - : isVolume3DViewportType(viewport) + : viewport && isVolume3DViewportType(viewport) ? SegmentationRepresentations.Surface : SegmentationRepresentations.Labelmap; @@ -350,6 +369,10 @@ function commandsModule({ }); } + // isHydrated means "display this display set as part of a standard view". + // That is decided here and does not depend on a viewport existing yet. + displaySet.isHydrated = true; + const referencedDisplaySetInstanceUID = displaySet.referencedDisplaySetInstanceUID; const storePositionPresentation = refDisplaySet => { @@ -371,6 +394,7 @@ function commandsModule({ const results = commandsManager.runCommand('loadSegmentationDisplaySetsForViewport', { viewportId, displaySetInstanceUIDs: [referencedDisplaySet.displaySetInstanceUID], + derivedDisplaySetInstanceUID: displaySet.displaySetInstanceUID, // RTSTRUCT-on-next pins the referenced image to stack mode on hydrate; // see the policy's rationale in utils/nextViewportPolicies. viewportType: getHydrationViewportTypeForModality(displaySet.Modality), @@ -380,22 +404,15 @@ function commandsModule({ 'panelSegmentation.disableEditing' ); if (disableEditing) { - const segmentationRepresentations = segmentationService.getSegmentationRepresentations( - viewportId, - { - segmentationId: displaySet.displaySetInstanceUID, - } - ); - - segmentationRepresentations.forEach(representation => { - const segmentIndices = Object.keys(representation.segments); - segmentIndices.forEach(segmentIndex => { - segmentationService.setSegmentLocked( - representation.segmentationId, - parseInt(segmentIndex), - true - ); - }); + // Locking is a property of the segmentation, not of a per-viewport + // representation. Reading the segments from the representations would + // silently skip locking whenever hydration ran before the viewport + // had one. + const segmentationId = displaySet.displaySetInstanceUID; + const segmentation = segmentationService.getSegmentation(segmentationId); + + Object.keys(segmentation?.segments ?? {}).forEach(segmentIndex => { + segmentationService.setSegmentLocked(segmentationId, parseInt(segmentIndex), true); }); } return results; @@ -551,14 +568,54 @@ function commandsModule({ commandsManager.run(options, optionsToUse); }, - updateStoredSegmentationPresentation: ({ displaySet, type }) => { - const { addSegmentationPresentationItem } = useSegmentationPresentationStore.getState(); + /** + * Records the desired presentation of a derived (SEG/RTSTRUCT) display set + * against the display set it references. + * + * @param props.hydrated - true to display it in the standard viewports for + * the referenced display set, false to stop displaying it there. Writing + * false matters: the store is the desired state a viewport converges on, + * so simply omitting an entry would let an earlier `true` keep restoring + * a segmentation that was removed. + */ + updateStoredSegmentationPresentation: ({ displaySet, type, hydrated = true }) => { + const { + addSegmentationPresentationItem, + setHydrationForSegmentation, + segmentationPresentationStore, + } = useSegmentationPresentationStore.getState(); const referencedDisplaySetInstanceUID = displaySet.referencedDisplaySetInstanceUID; + const segmentationId = displaySet.displaySetInstanceUID; + + // The store is keyed by the referenced display set, so there is no key to + // create an entry under without one - segmentations created in the client + // (see the SEGMENTATION_ADDED handler) have no referenced display set, + // and writing them would land every one of them under a single + // `undefined` key. + // + // They are still recorded, though: storePresentation records a + // client-drawn segmentation as hydrated under the key of whatever + // viewport it was drawn in (see _getInitialHydrationForSync), and that + // record is what re-adds it on the next mount. Nothing else supersedes it + // - syncSegmentationPresentation only ever merges - so a removal has to + // update it in place wherever it was written, or a segmentation the user + // dismissed or deleted comes back. + if (!referencedDisplaySetInstanceUID) { + setHydrationForSegmentation(segmentationId, { hydrated, type }); + return; + } + + // A caller that only changes hydration (removing the layer) has no reason + // to know the representation type, so keep whatever hydration recorded. + const existingType = segmentationPresentationStore[referencedDisplaySetInstanceUID]?.find( + item => item.segmentationId === segmentationId + )?.type; + addSegmentationPresentationItem(referencedDisplaySetInstanceUID, { - segmentationId: displaySet.displaySetInstanceUID, - hydrated: true, - type, + segmentationId, + hydrated, + type: type ?? existingType, }); }, @@ -604,6 +661,14 @@ function commandsModule({ } : presentations.positionPresentation; + // With no live viewport there is no position to record - getPresentations + // returns an object whose positionPresentation is undefined. Writing that + // would clobber the referenced series' stored slice/pan/zoom with + // undefined, which matters now that hydration runs without a viewport. + if (!presentationData) { + return; + } + if (previousReferencedDisplaySetStoreKey) { setPositionPresentation(previousReferencedDisplaySetStoreKey, presentationData); return; @@ -784,6 +849,46 @@ function commandsModule({ measurementService.update(updatedMeasurement.uid, updatedMeasurement, true); }, + /** + * Records the instance that the given measurements were just stored as, so + * that storing them again offers to extend that series rather than creating + * another one. + * + * The predecessor is kept on the annotation as well as on the measurement, + * because the measurement is re-derived from the annotation whenever the + * annotation is edited. + * + * @param props.measurements - the measurements that were stored + * @param props.predecessorImageId - imageId of the instance they were stored as + * @returns the number of measurements the predecessor was recorded on + */ + recordMeasurementsPredecessor: ({ measurements = [], predecessorImageId }) => { + if (!predecessorImageId) { + return 0; + } + + let recorded = 0; + + for (const { uid } of measurements) { + const measurement = measurementService.getMeasurement(uid); + + if (!measurement) { + continue; + } + + measurement.predecessorImageId = predecessorImageId; + + const targetAnnotation = annotation.state.getAnnotation(uid); + if (targetAnnotation) { + targetAnnotation.predecessorImageId = predecessorImageId; + } + + recorded++; + } + + return recorded; + }, + /** * Jumps to the specified (by uid) measurement in the active viewport. * Also marks any provided display measurements isActive value @@ -1700,7 +1805,15 @@ function commandsModule({ }, /** - * Removes a segmentation from the viewport + * Removes a segmentation from the viewport. + * + * This is the segmentation panel's Remove from Viewport, which lists the + * segmentations of the study rather than the layers of one pane, so it is + * the global statement: it un-hydrates the display set as well as clearing + * it from the active viewport. The per-viewport equivalent is the viewport + * data overlay menu's Remove, which runs `removeDisplaySetLayer` without + * `unhydrate`. + * * @param props.segmentationId - The ID of the segmentation to remove */ removeSegmentationFromViewportCommand: ({ segmentationId: displaySetInstanceUID }) => { @@ -1710,6 +1823,7 @@ function commandsModule({ commandsManager.runCommand('removeDisplaySetLayer', { viewportId, displaySetInstanceUID, + unhydrate: true, }); }, @@ -1759,6 +1873,7 @@ function commandsModule({ placeholder: i18n.t('Tools:Enter new label'), defaultValue: label, }).then(label => { + // The update clears `labelIsGenerated` - the user chose this label. segmentationService.addOrUpdateSegmentation({ segmentationId, label }); }); }, @@ -2064,11 +2179,17 @@ function commandsModule({ viewportId, displaySetInstanceUIDs, viewportType, + // The SEG/RTSTRUCT being hydrated. Passing it lets the target selection + // include panes that share its frame of reference rather than only those + // hung with the exact referenced display set, and lets it work when there + // is no viewport to match against at all. + derivedDisplaySetInstanceUID, }) => { const updatedViewports = getUpdatedViewportsForSegmentation({ viewportId, servicesManager, displaySetInstanceUIDs, + derivedDisplaySetInstanceUID, }); if (!updatedViewports?.length) { @@ -2080,14 +2201,23 @@ function commandsModule({ csViewport?.setNeedsRender?.(); }); + // A pinned viewportType (RTSTRUCT contour hydration on a native "next" + // viewport requests 'stack') is a statement about the pane hydration was + // invoked on, whose background is being set to the referenced image. The + // other panes here were matched because they already show something the + // segmentation can be drawn over - by frame of reference, and keeping the + // display sets they already have - so their own render mode is the right + // one and forcing 'stack' onto them would flip an MPR pane to a stack. + // Note the merged entries carry no viewportOptions at all, so the grid + // reducer would merge a bare `{ viewportType }` straight over the pane's + // real options. + const targetViewportId = viewportId || viewportGridService.getActiveViewportId(); + actions.setDisplaySetsForViewports({ viewportsToUpdate: updatedViewports.map(viewport => ({ viewportId: viewport.viewportId, displaySetInstanceUIDs: viewport.displaySetInstanceUIDs, - // When the caller pins a viewportType (RTSTRUCT contour hydration on a - // native "next" viewport requests 'stack'), force it so the referenced - // image stays in that render mode instead of resolving to a volume slice. - ...(viewportType + ...(viewportType && viewport.viewportId === targetViewportId ? { viewportOptions: { ...viewport.viewportOptions, viewportType } } : {}), })), @@ -2456,6 +2586,9 @@ function commandsModule({ commandFn: actions.updateMeasurement, }, jumpToMeasurement: actions.jumpToMeasurement, + recordMeasurementsPredecessor: { + commandFn: actions.recordMeasurementsPredecessor, + }, removeMeasurement: { commandFn: actions.removeMeasurement, }, @@ -2723,6 +2856,7 @@ function commandsModule({ loadSegmentationDisplaySetsForViewport: actions.loadSegmentationDisplaySetsForViewport, setViewportOrientation: actions.setViewportOrientation, hydrateSecondaryDisplaySet: actions.hydrateSecondaryDisplaySet, + loadDisplaySetData: actions.loadDisplaySetData, getVolumeIdForDisplaySet: actions.getVolumeIdForDisplaySet, triggerCreateAnnotationMemo: actions.triggerCreateAnnotationMemo, startRecordingForAnnotationGroup: actions.startRecordingForAnnotationGroup, diff --git a/extensions/cornerstone/src/components/ActiveViewportWindowLevel/ActiveViewportWindowLevel.tsx b/extensions/cornerstone/src/components/ActiveViewportWindowLevel/ActiveViewportWindowLevel.tsx index 404c9cf57a9..178915b0fb6 100644 --- a/extensions/cornerstone/src/components/ActiveViewportWindowLevel/ActiveViewportWindowLevel.tsx +++ b/extensions/cornerstone/src/components/ActiveViewportWindowLevel/ActiveViewportWindowLevel.tsx @@ -1,9 +1,8 @@ import React, { ReactElement } from 'react'; -import PropTypes from 'prop-types'; import { useViewportGrid } from '@ohif/ui-next'; import ViewportWindowLevel from '../ViewportWindowLevel/ViewportWindowLevel'; -const ActiveViewportWindowLevel = ({ servicesManager }: withAppTypes): ReactElement => { +const ActiveViewportWindowLevel = ({ servicesManager }: withAppTypes): ReactElement => { const [viewportGrid] = useViewportGrid(); const { activeViewportId } = viewportGrid; @@ -19,8 +18,6 @@ const ActiveViewportWindowLevel = ({ servicesManager }: withAppTypes): ReactElem ); }; -ActiveViewportWindowLevel.propTypes = { - servicesManager: PropTypes.object.isRequired, -}; + export default ActiveViewportWindowLevel; diff --git a/extensions/cornerstone/src/components/DicomUpload/DicomUpload.tsx b/extensions/cornerstone/src/components/DicomUpload/DicomUpload.tsx index edcdcaffe5f..22e8fd6f49d 100644 --- a/extensions/cornerstone/src/components/DicomUpload/DicomUpload.tsx +++ b/extensions/cornerstone/src/components/DicomUpload/DicomUpload.tsx @@ -1,7 +1,5 @@ -import React, { useCallback, useState } from 'react'; -import { ReactElement } from 'react'; +import { ReactElement, useState } from 'react'; import Dropzone from 'react-dropzone'; -import PropTypes from 'prop-types'; import classNames from 'classnames'; import DicomFileUploader from '../../utils/DicomFileUploader'; import DicomUploadProgress from './DicomUploadProgress'; @@ -14,17 +12,17 @@ type DicomUploadProps = { onStarted: () => void; }; -function DicomUpload({ dataSource, onComplete, onStarted }: DicomUploadProps): ReactElement { +function DicomUpload({ dataSource, onComplete, onStarted }: DicomUploadProps): ReactElement { const baseClassNames = 'min-h-[375px] flex flex-col bg-background select-none rounded-lg overflow-hidden'; const [dicomFileUploaderArr, setDicomFileUploaderArr] = useState([]); - const onDrop = useCallback(async acceptedFiles => { + const onDrop = async acceptedFiles => { onStarted(); setDicomFileUploaderArr(acceptedFiles.map(file => new DicomFileUploader(file, dataSource))); - }, []); + }; - const getDropZoneComponent = (): ReactElement => { + const getDropZoneComponent = (): ReactElement => { return ( { @@ -108,10 +106,6 @@ function DicomUpload({ dataSource, onComplete, onStarted }: DicomUploadProps): R ); } -DicomUpload.propTypes = { - dataSource: PropTypes.object.isRequired, - onComplete: PropTypes.func.isRequired, - onStarted: PropTypes.func.isRequired, -}; + export default DicomUpload; diff --git a/extensions/cornerstone/src/components/DicomUpload/DicomUploadProgress.tsx b/extensions/cornerstone/src/components/DicomUpload/DicomUploadProgress.tsx index 2843ce8628c..bb2974eda13 100644 --- a/extensions/cornerstone/src/components/DicomUpload/DicomUploadProgress.tsx +++ b/extensions/cornerstone/src/components/DicomUpload/DicomUploadProgress.tsx @@ -1,5 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState, ReactElement } from 'react'; -import PropTypes from 'prop-types'; +import { useEffect, useRef, useState, ReactElement } from 'react'; import { useSystem } from '@ohif/core'; import { Button } from '@ohif/ui-next'; import { Icons } from '@ohif/ui-next'; @@ -40,7 +39,7 @@ const NO_WRAP_ELLIPSIS_CLASS_NAMES = 'text-ellipsis whitespace-nowrap overflow-h function DicomUploadProgress({ dicomFileUploaderArr, onComplete, -}: DicomUploadProgressProps): ReactElement { +}: DicomUploadProgressProps): ReactElement { const { servicesManager } = useSystem(); const ProgressLoadingBar = @@ -64,7 +63,10 @@ function DicomUploadProgress({ const [showFailedOnly, setShowFailedOnly] = useState(false); - const progressBarContainerRef = useRef(); + // Held in state, not a ref: the width below is read during render, and a + // ref is not guaranteed to be populated then. State also re-renders once the + // element arrives, so the first measurement is not missed. + const [progressBarContainer, setProgressBarContainer] = useState(null); /** * The effect for measuring and setting the current upload rate. This is @@ -208,7 +210,7 @@ function DicomUploadProgress({ }; }, []); - const cancelAllUploads = useCallback(async () => { + const cancelAllUploads = async () => { for (const dicomFileUploader of dicomFileUploaderArr) { // Important: we need a non-blocking way to cancel every upload, // otherwise the UI will freeze and the user will not be able @@ -220,9 +222,9 @@ function DicomUploadProgress({ }, 0); }); } - }, []); + }; - const getFormattedTimeRemaining = useCallback((): string => { + const getFormattedTimeRemaining = (): string => { if (timeRemaining == null) { return ''; } @@ -239,31 +241,28 @@ function DicomUploadProgress({ const hoursRemaining = Math.ceil(timeRemaining / ONE_HOUR); return `${hoursRemaining} ${hoursRemaining === 1 ? 'hour' : 'hours'}`; - }, [timeRemaining]); + }; - const getPercentCompleteRounded = useCallback( - () => Math.min(100, Math.round(percentComplete)), - [percentComplete] - ); + const getPercentCompleteRounded = () => Math.min(100, Math.round(percentComplete)); /** * Determines if the progress bar should show the infinite animation or not. * Show the infinite animation for progress less than 1% AND if less than * one pixel of the progress bar would be displayed. */ - const showInfiniteProgressBar = useCallback((): boolean => { + const showInfiniteProgressBar = (): boolean => { return ( getPercentCompleteRounded() < 1 && - (progressBarContainerRef?.current?.offsetWidth ?? 0) * (percentComplete / 100) < 1 + (progressBarContainer?.offsetWidth ?? 0) * (percentComplete / 100) < 1 ); - }, [getPercentCompleteRounded, percentComplete]); + }; /** * Gets the CSS style for the 'n of m' (files completed) text. * The width changes according to numFilesCompleted and can vary, * e.g. "1 of 200", "10 of 200", "100 of 200" all have differents width. */ - const getNofMFilesStyle = useCallback(() => { + const getNofMFilesStyle = () => { // the number of digits accounts for the digits being on each side of the ' of ' const numDigits = numFilesCompleted.toString().length + dicomFileUploaderArr.length.toString().length; @@ -272,9 +271,9 @@ function DicomUploadProgress({ // The font may play a part in this discrepancy. const numChars = numDigits + 3; return { width: `${numChars}ch` }; - }, [numFilesCompleted]); + }; - const getNumCompletedAndTimeRemainingComponent = (): ReactElement => { + const getNumCompletedAndTimeRemainingComponent = (): ReactElement => { return (
{numFilesCompleted === dicomFileUploaderArr.length ? ( @@ -320,7 +319,7 @@ function DicomUploadProgress({ ); }; - const getShowFailedOnlyIconComponent = (): ReactElement => { + const getShowFailedOnlyIconComponent = (): ReactElement => { return (
{numFails > 0 && ( @@ -335,7 +334,7 @@ function DicomUploadProgress({ ); }; - const getPercentCompleteComponent = (): ReactElement => { + const getPercentCompleteComponent = (): ReactElement => { return (
@@ -351,7 +350,7 @@ function DicomUploadProgress({ ) : ( <>
{ - const [percentComplete, setPercentComplete] = useState(dicomFileUploader.getPercentComplete()); - const [failedReason, setFailedReason] = useState(''); - const [status, setStatus] = useState(dicomFileUploader.getStatus()); +function DicomUploadProgressItem({ + dicomFileUploader, +}: DicomUploadProgressItemProps): ReactElement { + const [percentComplete, setPercentComplete] = useState(dicomFileUploader.getPercentComplete()); + const [failedReason, setFailedReason] = useState(''); + const [status, setStatus] = useState(dicomFileUploader.getStatus()); - const isComplete = useCallback(() => { - return ( - status === UploadStatus.Failed || - status === UploadStatus.Cancelled || - status === UploadStatus.Success - ); - }, [status]); + const isComplete = () => + status === UploadStatus.Failed || + status === UploadStatus.Cancelled || + status === UploadStatus.Success; - useEffect(() => { - const progressSubscription = dicomFileUploader.subscribe( - EVENTS.PROGRESS, - (dicomFileUploaderProgressEvent: DicomFileUploaderProgressEvent) => { - setPercentComplete(dicomFileUploaderProgressEvent.percentComplete); - } - ); + useEffect(() => { + const progressSubscription = dicomFileUploader.subscribe( + EVENTS.PROGRESS, + (dicomFileUploaderProgressEvent: DicomFileUploaderProgressEvent) => { + setPercentComplete(dicomFileUploaderProgressEvent.percentComplete); + // The uploader flips to InProgress as it starts sending. Mirror that + // into state: reading getStatus() during render instead would be a read + // of mutable data on an object whose identity never changes, which the + // compiler caches for the life of the component. + setStatus(dicomFileUploader.getStatus()); + } + ); - dicomFileUploader - .load() - .catch((reason: UploadRejection) => { - setStatus(reason.status); - setFailedReason(reason.message ?? ''); - }) - .finally(() => setStatus(dicomFileUploader.getStatus())); + dicomFileUploader + .load() + .catch((reason: UploadRejection) => { + setStatus(reason.status); + setFailedReason(reason.message ?? ''); + }) + .finally(() => setStatus(dicomFileUploader.getStatus())); - return () => progressSubscription.unsubscribe(); - }, []); + return () => progressSubscription.unsubscribe(); + }, []); - const cancelUpload = useCallback(() => { - dicomFileUploader.cancel(); - }, []); + const cancelUpload = () => { + dicomFileUploader.cancel(); + }; - const getStatusIcon = (): ReactElement => { - switch (dicomFileUploader.getStatus()) { - case UploadStatus.Success: - return ( - - ); - case UploadStatus.InProgress: - return ; - case UploadStatus.Failed: - return ; - case UploadStatus.Cancelled: - return ; - default: - return <>; - } - }; + const getStatusIcon = (): ReactElement => { + switch (status) { + case UploadStatus.Success: + return ( + + ); + case UploadStatus.InProgress: + return ( + + ); + case UploadStatus.Failed: + return ( + + ); + case UploadStatus.Cancelled: + return ( + + ); + default: + return <>; + } + }; - return ( -
-
-
-
{getStatusIcon()}
-
- {dicomFileUploader.getFileName()} -
+ return ( +
+
+
+
{getStatusIcon()}
+
+ {dicomFileUploader.getFileName()}
- {failedReason &&
{failedReason}
} -
-
- {!isComplete() && ( - <> - {dicomFileUploader.getStatus() === UploadStatus.InProgress && ( -
{percentComplete}%
- )} -
- -
- - )}
+ {failedReason &&
{failedReason}
}
- ); - } -); - -DicomUploadProgressItem.propTypes = { - dicomFileUploader: PropTypes.instanceOf(DicomFileUploader).isRequired, -}; +
+ {!isComplete() && ( + <> + {status === UploadStatus.InProgress && ( +
{percentComplete}%
+ )} +
+ +
+ + )} +
+
+ ); +} export default DicomUploadProgressItem; diff --git a/extensions/cornerstone/src/components/ExportSegmentationSubMenuItem.tsx b/extensions/cornerstone/src/components/ExportSegmentationSubMenuItem.tsx index 56bc5ab30f8..ad15b5a1cd1 100644 --- a/extensions/cornerstone/src/components/ExportSegmentationSubMenuItem.tsx +++ b/extensions/cornerstone/src/components/ExportSegmentationSubMenuItem.tsx @@ -33,7 +33,7 @@ export const ExportSegmentationSubMenuItem: React.FC - {t('Export')} + {t('Save')} diff --git a/extensions/cornerstone/src/components/MeasurementsMenu.tsx b/extensions/cornerstone/src/components/MeasurementsMenu.tsx index 34cdbcb339e..2f1109f8389 100644 --- a/extensions/cornerstone/src/components/MeasurementsMenu.tsx +++ b/extensions/cornerstone/src/components/MeasurementsMenu.tsx @@ -13,16 +13,19 @@ import { useTranslation } from 'react-i18next'; export function MeasumentsMenu(props) { const { group, classNames } = props; const { t } = useTranslation('MeasurementTable'); + const system = useSystem(); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + // Hooks must run before this guard, or the hook count changes with the group. if (!group.items?.length) { console.log('No items to iterate', group.items); return null; } + const { items } = group; const [item] = items; const { isSelected, isVisible } = item; - const system = useSystem(); - const onAction = (event, command, args?) => { const uid = items.map(item => item.uid); // Some commands use displayMeasurements and some use items @@ -35,8 +38,6 @@ export function MeasumentsMenu(props) { }); }; - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - return (
{/* Visibility Toggle Icon */} diff --git a/extensions/cornerstone/src/components/NavigationComponent/NavigationComponent.tsx b/extensions/cornerstone/src/components/NavigationComponent/NavigationComponent.tsx index 5deb9985346..67a37c0c92b 100644 --- a/extensions/cornerstone/src/components/NavigationComponent/NavigationComponent.tsx +++ b/extensions/cornerstone/src/components/NavigationComponent/NavigationComponent.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useState } from 'react'; import { ViewportActionArrows } from '@ohif/ui-next'; import { useSystem } from '@ohif/core/src'; import { utils } from '../..'; @@ -43,82 +43,61 @@ function NavigationComponent({ viewportId }: { viewportId: string }) { ? 'measurement' : null; - const handleMeasurementNavigation = useCallback( - (direction: number) => { - const measurementDisplaySet = viewportDisplaySets.find( - displaySet => displaySet?.Modality === 'SR' - ); - - if (measurementDisplaySet) { - const measurements = measurementDisplaySet.measurements; - if (measurements.length <= 0) { - return; - } - - const newIndex = getNextIndex(measurementSelected, direction, measurements.length); - setMeasurementSelected(newIndex); - - const measurement = measurements[newIndex]; - cornerstoneViewport.setViewReference({ - referencedImageId: measurement.imageId, - }); - return; - } - - if (isTracked && trackedMeasurementUIDs.length > 0) { - const newIndex = getNextIndex( - measurementSelected, - direction, - trackedMeasurementUIDs.length - ); - setMeasurementSelected(newIndex); - measurementService.jumpToMeasurement(viewportId, trackedMeasurementUIDs[newIndex]); - } - }, - [ - viewportId, - cornerstoneViewport, - measurementSelected, - measurementService, - isTracked, - trackedMeasurementUIDs, - viewportDisplaySets, - ] - ); + const handleMeasurementNavigation = (direction: number) => { + const measurementDisplaySet = viewportDisplaySets.find( + displaySet => displaySet?.Modality === 'SR' + ); - const handleSegmentNavigation = useCallback( - (direction: number) => { - if (!segmentationsWithRepresentations.length) { + if (measurementDisplaySet) { + const measurements = measurementDisplaySet.measurements; + if (measurements.length <= 0) { return; } - const activeSegmentationWithRepresentation = segmentationsWithRepresentations.find( - segmentation => segmentation?.representation?.active - ); - const segmentationId = activeSegmentationWithRepresentation.segmentation.segmentationId; - - utils.handleSegmentChange({ - direction, - segmentationId, - viewportId, - selectedSegmentObjectIndex: 0, - segmentationService, + const newIndex = getNextIndex(measurementSelected, direction, measurements.length); + setMeasurementSelected(newIndex); + + const measurement = measurements[newIndex]; + cornerstoneViewport.setViewReference({ + referencedImageId: measurement.imageId, }); - }, - [segmentationsWithRepresentations, viewportId, segmentationService] - ); + return; + } + + if (isTracked && trackedMeasurementUIDs.length > 0) { + const newIndex = getNextIndex(measurementSelected, direction, trackedMeasurementUIDs.length); + setMeasurementSelected(newIndex); + measurementService.jumpToMeasurement(viewportId, trackedMeasurementUIDs[newIndex]); + } + }; + + const handleSegmentNavigation = (direction: number) => { + if (!segmentationsWithRepresentations.length) { + return; + } + + const activeSegmentationWithRepresentation = segmentationsWithRepresentations.find( + segmentation => segmentation?.representation?.active + ); + const segmentationId = activeSegmentationWithRepresentation.segmentation.segmentationId; + + utils.handleSegmentChange({ + direction, + segmentationId, + viewportId, + selectedSegmentObjectIndex: 0, + segmentationService, + }); + }; // Handle navigation between segments/measurements - const handleNavigate = useCallback( - (direction: number) => { - if (navigationMode === 'segment') { - handleSegmentNavigation(direction); - } else if (navigationMode === 'measurement') { - handleMeasurementNavigation(direction); - } - }, - [navigationMode, handleSegmentNavigation, handleMeasurementNavigation] - ); + const handleNavigate = (direction: number) => { + if (navigationMode === 'segment') { + handleSegmentNavigation(direction); + } else if (navigationMode === 'measurement') { + handleMeasurementNavigation(direction); + } + }; // Only render if we have a navigation mode if (!navigationMode) { diff --git a/extensions/cornerstone/src/components/SegmentationUtilityButton.tsx b/extensions/cornerstone/src/components/SegmentationUtilityButton.tsx index 1dd29020c14..a582e099891 100644 --- a/extensions/cornerstone/src/components/SegmentationUtilityButton.tsx +++ b/extensions/cornerstone/src/components/SegmentationUtilityButton.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import { cn, ToolButton } from '@ohif/ui-next'; import { useUIStateStore } from '@ohif/extension-default'; @@ -34,22 +34,19 @@ function SegmentationUtilityButton(props: SegmentationUtilityButtonProps) { isActive && 'bg-primary/30' ); - const handleMouseDownCapture = useCallback( - event => { - if (activeSegmentationUtility === id) { - // If this active button is clicked, prevent the default Popover - // behaviour of closing the Popover on a pointer/mouse down. - // Not doing this will cause the Popover to close and then reopen again. - // Why? Because propagating this event will cause PanelSegmentation to - // close the Popover by clearing the activeSegmentationUtility. Then - // this button will set the activeSegmentationUtility again and the - // Popover will reopen. - event.preventDefault(); - event.stopPropagation(); - } - }, - [activeSegmentationUtility, id] - ); + const handleMouseDownCapture = event => { + if (activeSegmentationUtility === id) { + // If this active button is clicked, prevent the default Popover + // behaviour of closing the Popover on a pointer/mouse down. + // Not doing this will cause the Popover to close and then reopen again. + // Why? Because propagating this event will cause PanelSegmentation to + // close the Popover by clearing the activeSegmentationUtility. Then + // this button will set the activeSegmentationUtility again and the + // Popover will reopen. + event.preventDefault(); + event.stopPropagation(); + } + }; return (
diff --git a/extensions/cornerstone/src/components/SelectItemWithModality.tsx b/extensions/cornerstone/src/components/SelectItemWithModality.tsx index 237d46c17bc..d9b1ff1fd4b 100644 --- a/extensions/cornerstone/src/components/SelectItemWithModality.tsx +++ b/extensions/cornerstone/src/components/SelectItemWithModality.tsx @@ -1,10 +1,13 @@ -import React from 'react'; +import React, { type JSX } from 'react'; // should be used in a Select component +const defaultDataCY = (displaySet: AppTypes.DisplaySet) => + `${displaySet.label}-${displaySet.Modality}`; + const SelectItemWithModality = ({ displaySet, showModality = true, - dataCY = `${displaySet.label}-${displaySet.Modality}`, + dataCY = defaultDataCY(displaySet), }: { displaySet: AppTypes.DisplaySet; showModality?: boolean; diff --git a/extensions/cornerstone/src/components/StudyMeasurementsActions.tsx b/extensions/cornerstone/src/components/StudyMeasurementsActions.tsx index ca6a5ec1ec1..9446f8c1969 100644 --- a/extensions/cornerstone/src/components/StudyMeasurementsActions.tsx +++ b/extensions/cornerstone/src/components/StudyMeasurementsActions.tsx @@ -47,7 +47,7 @@ export function StudyMeasurementsActions({ items, StudyInstanceUID, measurementF }} > - {t('Create SR')} + {t('Save')}
); -}); +} export default ViewportColorbarsContainer; diff --git a/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/utils.ts b/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/utils.ts index d797e773ce8..30a47c6eefe 100644 --- a/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/utils.ts +++ b/extensions/cornerstone/src/components/ViewportDataOverlaySettingMenu/utils.ts @@ -1,9 +1,12 @@ -import { utilities as csUtils } from '@cornerstonejs/core'; +import { + DERIVED_OVERLAY_MODALITIES, + isDisplaySetOverlayable, +} from '../../utils/isDisplaySetOverlayable'; export const DEFAULT_COLORMAP = 'hsv'; export const DEFAULT_OPACITY = 0.5; export const DEFAULT_OPACITY_PERCENT = DEFAULT_OPACITY * 100; -export const DERIVED_OVERLAY_MODALITIES = ['SEG', 'RTSTRUCT']; +export { DERIVED_OVERLAY_MODALITIES }; /** * Get modality-specific color and opacity settings from the customization service @@ -29,11 +32,10 @@ export function getModalityOverlayColormap(customizationService, modality) { * 2. Are evaluated for their ability to be overlaid onto the background display set * 3. Have an "isOverlayable" flag indicating if they're compatible with the viewport * - * A display set is considered overlayable when: - * - The background display set is reconstructable - * - The display set is not unsupported - * - The Frame of Reference matches the background display set - * - For non-derived modalities: background can be a volume and display set is either multiframe or valid volume + * This is the viewport-driven caller of `isDisplaySetOverlayable`: it resolves + * the viewport's background display set and applies the shared rule to every + * other display set. The rule itself lives in utils/isDisplaySetOverlayable so + * that hydration - which has no viewport to start from - can use it too. * * @returns {Object} Object containing: * - viewportDisplaySets: Display sets already in the viewport @@ -60,60 +62,12 @@ export function getEnhancedDisplaySets({ viewportId, services }) { displaySetService.getDisplaySetByUID(displaySetUID) ); - const backgroundCanBeVolume = csUtils.isValidVolume(viewportDisplaySets[0].imageIds || []); const backgroundDisplaySet = viewportDisplaySets[0]; - const enhancedDisplaySets = otherDisplaySets.map(displaySet => { - if (!backgroundDisplaySet.isReconstructable) { - return { - ...displaySet, - isOverlayable: false, - }; - } - - if (displaySet.unsupported) { - return { - ...displaySet, - isOverlayable: false, - }; - } - - // Check if Frame of Reference matches - if ( - displaySet.FrameOfReferenceUID && - displaySet.FrameOfReferenceUID !== backgroundDisplaySet.FrameOfReferenceUID - ) { - return { - ...displaySet, - isOverlayable: false, - }; - } - - // Special handling for derived modalities - if (!DERIVED_OVERLAY_MODALITIES.includes(displaySet.Modality)) { - if (!backgroundCanBeVolume) { - return { - ...displaySet, - isOverlayable: false, - }; - } - - const imageIds = displaySet.imageIds || displaySet.images?.map(image => image.imageId); - const isMultiframe = displaySet.isMultiFrame; - - if (!isMultiframe && imageIds?.length > 0 && !csUtils.isValidVolume(imageIds)) { - return { - ...displaySet, - isOverlayable: false, - }; - } - } - - return { - ...displaySet, - isOverlayable: true, - }; - }); + const enhancedDisplaySets = otherDisplaySets.map(displaySet => ({ + ...displaySet, + isOverlayable: isDisplaySetOverlayable({ displaySet, backgroundDisplaySet }), + })); return { viewportDisplaySets, diff --git a/extensions/cornerstone/src/components/ViewportWindowLevel/ViewportWindowLevel.tsx b/extensions/cornerstone/src/components/ViewportWindowLevel/ViewportWindowLevel.tsx index c26dc134ea4..ad2413768b5 100644 --- a/extensions/cornerstone/src/components/ViewportWindowLevel/ViewportWindowLevel.tsx +++ b/extensions/cornerstone/src/components/ViewportWindowLevel/ViewportWindowLevel.tsx @@ -1,8 +1,7 @@ import React, { useEffect, useCallback, useState, ReactElement, useMemo } from 'react'; -import PropTypes from 'prop-types'; import debounce from 'lodash.debounce'; import { PanelSection, WindowLevel } from '@ohif/ui-next'; -import { Enums, eventTarget, utilities as csUtils, Types } from '@cornerstonejs/core'; +import { Enums, eventTarget, cache, utilities as csUtils, Types } from '@cornerstonejs/core'; import { useActiveViewportDisplaySets } from '@ohif/core'; import { getNodeOpacity, @@ -13,50 +12,78 @@ import { const { Events } = Enums; +/** + * True when every volume in the viewport has finished loading. + * + * A volume that completed before this panel mounted never fires + * IMAGE_VOLUME_LOADING_COMPLETED, so seeding isLoading from the event alone + * leaves it true forever and the histogram interval then runs for the life of + * the panel. Asking the cache directly avoids depending on an announcement + * that may already have happened. + */ +const areViewportVolumesLoaded = (viewport): boolean => { + if (!viewport || !csUtils.viewportSupportsVolumeId(viewport)) { + return false; + } + + const volumeIds = (viewport as Types.IVolumeViewport).getAllVolumeIds(); + if (!volumeIds.length) { + return false; + } + + return volumeIds.every( + volumeId => + (cache.getVolume(volumeId)?.loadStatus as { loaded?: boolean } | undefined)?.loaded === true + ); +}; + +// Depends only on its arguments, so it lives at module scope and keeps a stable +// identity — that is what lets updateViewportHistograms below be memoized. +const getVolumeOpacity = (viewport, volumeId) => { + const volumeActor = viewport.getActors().find(actor => actor.referencedId === volumeId)?.actor; + + if (isPetVolumeWithDefaultOpacity(volumeId, volumeActor)) { + return getNodeOpacity(volumeActor, 1); + } else if (isVolumeWithConstantOpacity(volumeActor)) { + return getNodeOpacity(volumeActor, 0); + } + + return undefined; +}; + const ViewportWindowLevel = ({ servicesManager, viewportId, }: withAppTypes<{ viewportId: string; -}>): ReactElement => { +}>): ReactElement => { const { cornerstoneViewportService } = servicesManager.services; const [windowLevels, setWindowLevels] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const displaySets = useActiveViewportDisplaySets(); - - const getViewportsWithVolumeIds = useCallback( - (volumeIds: string[]) => { - const renderingEngine = cornerstoneViewportService.getRenderingEngine(); - // getVolumeViewports() was removed in the GenericViewport architecture - // (a PLANAR_NEXT viewport can be volume-capable without being a VolumeViewport). - // Official replacement: getViewports() + the viewportSupportsVolumeCompatibility - // capability guard (cornerstone codemod cornerstone3d/5/generic-viewport). - const viewports = renderingEngine - .getViewports() - .filter(csUtils.viewportSupportsVolumeCompatibility); - - return viewports.filter(vp => { - const viewportVolumeIds = (vp as Types.IVolumeViewport).getAllVolumeIds(); - return ( - volumeIds.length === viewportVolumeIds.length && - volumeIds.every(volumeId => viewportVolumeIds.includes(volumeId)) - ); - }); - }, - [cornerstoneViewportService] + // Lazy initializer rather than an effect: this runs exactly once, so it needs + // no setState-in-effect and cannot go stale. + const [isLoading, setIsLoading] = useState( + () => !areViewportVolumesLoaded(cornerstoneViewportService.getCornerstoneViewport(viewportId)) ); + const displaySets = useActiveViewportDisplaySets(); - const getVolumeOpacity = useCallback((viewport, volumeId) => { - const volumeActor = viewport.getActors().find(actor => actor.referencedId === volumeId)?.actor; - - if (isPetVolumeWithDefaultOpacity(volumeId, volumeActor)) { - return getNodeOpacity(volumeActor, 1); - } else if (isVolumeWithConstantOpacity(volumeActor)) { - return getNodeOpacity(volumeActor, 0); - } - - return undefined; - }, []); + const getViewportsWithVolumeIds = (volumeIds: string[]) => { + const renderingEngine = cornerstoneViewportService.getRenderingEngine(); + // getVolumeViewports() was removed in the GenericViewport architecture + // (a PLANAR_NEXT viewport can be volume-capable without being a VolumeViewport). + // Official replacement: getViewports() + the viewportSupportsVolumeCompatibility + // capability guard (cornerstone codemod cornerstone3d/5/generic-viewport). + const viewports = renderingEngine + .getViewports() + .filter(csUtils.viewportSupportsVolumeCompatibility); + + return viewports.filter(vp => { + const viewportVolumeIds = (vp as Types.IVolumeViewport).getAllVolumeIds(); + return ( + volumeIds.length === viewportVolumeIds.length && + volumeIds.every(volumeId => viewportVolumeIds.includes(volumeId)) + ); + }); + }; const updateViewportHistograms = useCallback(() => { const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); @@ -65,7 +92,7 @@ const ViewportWindowLevel = ({ getWindowLevelsData(viewport, viewportInfo, getVolumeOpacity).then(data => { setWindowLevels(data); }); - }, [viewportId, cornerstoneViewportService, getVolumeOpacity]); + }, [cornerstoneViewportService, viewportId]); const handleCornerstoneVOIModified = useCallback( e => { @@ -107,43 +134,38 @@ const ViewportWindowLevel = ({ [handleCornerstoneVOIModified] ); - const handleVOIChange = useCallback( - (volumeId, voi) => { - const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + const handleVOIChange = (volumeId, voi) => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); - const newRange = { - lower: voi.windowCenter - voi.windowWidth / 2, - upper: voi.windowCenter + voi.windowWidth / 2, - }; + const newRange = { + lower: voi.windowCenter - voi.windowWidth / 2, + upper: voi.windowCenter + voi.windowWidth / 2, + }; - viewport.setProperties({ voiRange: newRange }, volumeId); - viewport.render(); - }, - [cornerstoneViewportService, viewportId] - ); + viewport.setProperties({ voiRange: newRange }, volumeId); + viewport.render(); + }; - const handleOpacityChange = useCallback( - (viewportId, _volumeIndex, volumeId, opacity) => { - const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + const handleOpacityChange = (viewportId, _volumeIndex, volumeId, opacity) => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); - if (!viewport) { - return; - } + if (!viewport) { + return; + } - const viewportVolumeIds = csUtils.viewportSupportsVolumeId(viewport) - ? (viewport as Types.IVolumeViewport).getAllVolumeIds() - : []; - const viewports = getViewportsWithVolumeIds(viewportVolumeIds); + const viewportVolumeIds = csUtils.viewportSupportsVolumeId(viewport) + ? (viewport as Types.IVolumeViewport).getAllVolumeIds() + : []; + const viewports = getViewportsWithVolumeIds(viewportVolumeIds); - viewports.forEach(vp => { - vp.setProperties({ colormap: { opacity } }, volumeId); - vp.render(); - }); - }, - [getViewportsWithVolumeIds, cornerstoneViewportService] - ); + viewports.forEach(vp => { + vp.setProperties({ colormap: { opacity } }, volumeId); + vp.render(); + }); + }; - // New function to handle image volume loading completion + // Memoized so the effect below does not tear down and re-register its + // listeners (and restart its interval) on every render. const handleImageVolumeLoadingCompleted = useCallback(() => { setIsLoading(false); updateViewportHistograms(); @@ -157,11 +179,9 @@ const ViewportWindowLevel = ({ handleImageVolumeLoadingCompleted ); - const intervalId = setInterval(() => { - if (isLoading) { - updateViewportHistograms(); - } - }, 1000); + const intervalId = isLoading + ? setInterval(() => updateViewportHistograms(), 1000) + : undefined; return () => { document.removeEventListener( @@ -183,9 +203,7 @@ const ViewportWindowLevel = ({ ]); // Create a memoized version of displaySet IDs for comparison - const displaySetIds = useMemo(() => { - return displaySets?.map(ds => ds.displaySetInstanceUID).sort() || []; - }, [displaySets]); + const displaySetIds = displaySets?.map(ds => ds.displaySetInstanceUID).sort() || []; useEffect(() => { const { unsubscribe } = cornerstoneViewportService.subscribe( @@ -242,9 +260,6 @@ const ViewportWindowLevel = ({ ); }; -ViewportWindowLevel.propTypes = { - servicesManager: PropTypes.object.isRequired, - viewportId: PropTypes.string.isRequired, -}; + export default ViewportWindowLevel; diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/Colorbar.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/Colorbar.tsx index bbe393b14df..1628316f78c 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/Colorbar.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/Colorbar.tsx @@ -1,15 +1,15 @@ -import React, { ReactElement, useCallback } from 'react'; +import React, { ReactElement } from 'react'; import { Switch } from '@ohif/ui-next'; import { useViewportRendering } from '../../hooks/useViewportRendering'; import { useTranslation } from 'react-i18next'; -export function Colorbar({ viewportId }: { viewportId?: string } = {}): ReactElement { +export function Colorbar({ viewportId }: { viewportId?: string } = {}): ReactElement { const { hasColorbar, toggleColorbar } = useViewportRendering(viewportId); const { t } = useTranslation('WindowLevelActionMenu'); - const handleToggle = useCallback(() => { + const handleToggle = () => { toggleColorbar(); - }, [toggleColorbar]); + }; return (
diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/Colormap.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/Colormap.tsx index aacf0f3c44d..f6e991ba233 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/Colormap.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/Colormap.tsx @@ -1,9 +1,9 @@ -import React, { ReactElement, useEffect, useRef, useState } from 'react'; +import React, { ReactElement, useEffect, useState } from 'react'; import { AllInOneMenu, ScrollArea, Switch, Tabs, TabsList, TabsTrigger } from '@ohif/ui-next'; import { useViewportRendering } from '../../hooks/useViewportRendering'; import { useTranslation } from 'react-i18next'; -export function Colormap({ viewportId }: { viewportId?: string } = {}): ReactElement { +export function Colormap({ viewportId }: { viewportId?: string } = {}): ReactElement { const { viewportDisplaySets } = useViewportRendering(viewportId); const { t } = useTranslation('WindowLevelActionMenu'); @@ -22,13 +22,6 @@ export function Colormap({ viewportId }: { viewportId?: string } = {}): ReactEle const [prePreviewColormap, setPrePreviewColormap] = useState(null); const [currentColormap, setCurrentColormap] = useState(null); - const showPreviewRef = useRef(showPreview); - showPreviewRef.current = showPreview; - const prePreviewColormapRef = useRef(prePreviewColormap); - prePreviewColormapRef.current = prePreviewColormap; - const currentColormapRef = useRef(currentColormap); - currentColormapRef.current = currentColormap; - useEffect(() => { setCurrentColormap(null); setPrePreviewColormap(null); @@ -83,8 +76,8 @@ export function Colormap({ viewportId }: { viewportId?: string } = {}): ReactEle onCheckedChange={checked => { setShowPreview(checked); - if (!checked && currentColormapRef.current) { - handleSetColorLUT(currentColormapRef.current); + if (!checked && currentColormap) { + handleSetColorLUT(currentColormap); } }} /> @@ -106,16 +99,16 @@ export function Colormap({ viewportId }: { viewportId?: string } = {}): ReactEle setPrePreviewColormap(null); }} onMouseEnter={() => { - if (showPreviewRef.current) { - if (!prePreviewColormapRef.current) { + if (showPreview) { + if (!prePreviewColormap) { setPrePreviewColormap(colormap); } handleSetColorLUT(colormap); } }} onMouseLeave={() => { - if (showPreviewRef.current && prePreviewColormapRef.current) { - handleSetColorLUT(prePreviewColormapRef.current); + if (showPreview && prePreviewColormap) { + handleSetColorLUT(prePreviewColormap); } }} /> diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeLighting.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeLighting.tsx index ff1f1e92707..d4c5a269e89 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeLighting.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeLighting.tsx @@ -4,7 +4,7 @@ import { Numeric } from '@ohif/ui-next'; import { useSystem } from '@ohif/core'; import { useTranslation } from 'react-i18next'; -export function VolumeLighting({ viewportId, hasShade }: VolumeLightingProps): ReactElement { +export function VolumeLighting({ viewportId, hasShade }: VolumeLightingProps): ReactElement { const { servicesManager, commandsManager } = useSystem(); const { cornerstoneViewportService } = servicesManager.services; const [lightingValues, setLightingValues] = useState({ diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingOptions.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingOptions.tsx index 9fd35ee69bb..8d647c10d4d 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingOptions.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingOptions.tsx @@ -7,7 +7,7 @@ import { VolumeShade } from './VolumeShade'; import { useViewportRendering } from '../../hooks/useViewportRendering'; import { useTranslation } from 'react-i18next'; -export function VolumeRenderingOptions({ viewportId }: { viewportId?: string } = {}): ReactElement { +export function VolumeRenderingOptions({ viewportId }: { viewportId?: string } = {}): ReactElement { const { volumeRenderingQualityRange } = useViewportRendering(viewportId); const [hasShade, setShade] = useState(false); const { t } = useTranslation('WindowLevelActionMenu'); diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresets.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresets.tsx index 4276e8fc6cb..b2fc6bd0b29 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresets.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresets.tsx @@ -6,7 +6,7 @@ import { useSystem } from '@ohif/core'; import { useViewportRendering } from '../../hooks/useViewportRendering'; import { useTranslation } from 'react-i18next'; -export function VolumeRenderingPresets({ viewportId }: { viewportId?: string } = {}): ReactElement { +export function VolumeRenderingPresets({ viewportId }: { viewportId?: string } = {}): ReactElement { const { volumeRenderingPresets } = useViewportRendering(viewportId); const { servicesManager } = useSystem(); const { uiDialogService } = servicesManager.services; diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresetsContent.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresetsContent.tsx index b4ae6589a87..d81c2c31bd5 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresetsContent.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingPresetsContent.tsx @@ -1,5 +1,5 @@ import { Icons, FooterAction } from '@ohif/ui-next'; -import React, { ReactElement, useState, useCallback } from 'react'; +import React, { ReactElement, useState } from 'react'; import { PresetDialog } from '@ohif/ui-next'; import { ViewportPreset, VolumeRenderingPresetsContentProps } from '../../types/ViewportPresets'; import { useSystem } from '@ohif/core'; @@ -9,24 +9,21 @@ interface Props extends VolumeRenderingPresetsContentProps { hide: () => void; } -export function VolumeRenderingPresetsContent({ presets, viewportId, hide }: Props): ReactElement { +export function VolumeRenderingPresetsContent({ presets, viewportId, hide }: Props): ReactElement { const { commandsManager } = useSystem(); const [searchValue, setSearchValue] = useState(''); const [selectedPreset, setSelectedPreset] = useState(null); const { t } = useTranslation('WindowLevelActionMenu'); - const handleSearchChange = useCallback((event: React.ChangeEvent) => { + const handleSearchChange = (event: React.ChangeEvent) => { setSearchValue(event.target.value); - }, []); + }; - const handleApply = useCallback( - props => { - commandsManager.runCommand('setViewportPreset', { - ...props, - }); - }, - [commandsManager] - ); + const handleApply = props => { + commandsManager.runCommand('setViewportPreset', { + ...props, + }); + }; const filteredPresets = searchValue ? presets.filter(preset => preset.name.toLowerCase().includes(searchValue.toLowerCase())) diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingQuality.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingQuality.tsx index fe00a35c1de..8e5e8156beb 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingQuality.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeRenderingQuality.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'; export function VolumeRenderingQuality({ volumeRenderingQualityRange, viewportId, -}: VolumeRenderingQualityProps): ReactElement { +}: VolumeRenderingQualityProps): ReactElement { const { servicesManager, commandsManager } = useSystem(); const { cornerstoneViewportService } = servicesManager.services; const { min, max, step } = volumeRenderingQualityRange; diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeShade.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeShade.tsx index e9e5d5f6937..cd163ae5507 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeShade.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeShade.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'; export function VolumeShade({ viewportId, onClickShade = bool => {}, -}: VolumeShadeProps): ReactElement { +}: VolumeShadeProps): ReactElement { const { t } = useTranslation('WindowLevelActionMenu'); const { servicesManager, commandsManager } = useSystem(); const { cornerstoneViewportService } = servicesManager.services; diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeShift.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeShift.tsx index ba0e4501b7b..516561c4869 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeShift.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/VolumeShift.tsx @@ -1,10 +1,27 @@ -import React, { ReactElement, useCallback, useEffect, useState, useRef } from 'react'; +import React, { ReactElement, useEffect, useState, useRef } from 'react'; import { VolumeShiftProps } from '../../types/ViewportPresets'; import { Numeric } from '@ohif/ui-next'; import { useSystem } from '@ohif/core'; import { useTranslation } from 'react-i18next'; -export function VolumeShift({ viewportId }: VolumeShiftProps): ReactElement { +/** + * Records the shift applied to a viewport's opacity transfer function. + * + * Module scope on purpose: shiftedBy is stashed on the cornerstone viewport + * object, and writing to a value the render produced is a mutation the React + * Compiler cannot account for - it refuses to optimize the whole component. + * Passing the viewport to a named helper keeps the operation explicit and lets + * the compiler key the callback on the viewport reference rather than on a + * property path it would have to dereference during render. + */ +function setShiftedBy(viewport, shift) { + if (!viewport) { + return; + } + viewport.shiftedBy = shift; +} + +export function VolumeShift({ viewportId }: VolumeShiftProps): ReactElement { const { servicesManager, commandsManager } = useSystem(); const { cornerstoneViewportService } = servicesManager.services; const [minShift, setMinShift] = useState(null); @@ -38,19 +55,16 @@ export function VolumeShift({ viewportId }: VolumeShiftProps): ReactElement { setStep(Math.pow(10, Math.floor(Math.log10(transferFunctionWidth / 500)))); }, [cornerstoneViewportService, viewportId, actor, ofun, isBlocking]); - const onChangeRange = useCallback( - newShift => { - const shiftDifference = newShift - prevShiftRef.current; - prevShiftRef.current = newShift; - viewport.shiftedBy = newShift; - commandsManager.runCommand('shiftVolumeOpacityPoints', { - viewportId, - shift: shiftDifference, - }); - setShift(newShift); - }, - [commandsManager, viewportId, viewport] - ); + const onChangeRange = newShift => { + const shiftDifference = newShift - prevShiftRef.current; + prevShiftRef.current = newShift; + setShiftedBy(viewport, newShift); + commandsManager.runCommand('shiftVolumeOpacityPoints', { + viewportId, + shift: shiftDifference, + }); + setShift(newShift); + }; return (
diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevel.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevel.tsx index 7f0b264e3d2..294f84745e8 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevel.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevel.tsx @@ -5,7 +5,7 @@ import { useViewportDisplaySets } from '../../hooks/useViewportDisplaySets'; import { WindowLevelPreset } from '../../types/WindowLevel'; import { useTranslation } from 'react-i18next'; -export function WindowLevel({ viewportId }: { viewportId?: string } = {}): ReactElement { +export function WindowLevel({ viewportId }: { viewportId?: string } = {}): ReactElement { const { t } = useTranslation('WindowLevelActionMenu'); const { viewportDisplaySets, foregroundDisplaySets } = useViewportDisplaySets(viewportId); // Default the active tab to the foreground layer (e.g. the PT in a PET/CT @@ -59,13 +59,6 @@ export function WindowLevel({ viewportId }: { viewportId?: string } = {}): React const [prePreviewPreset, setPrePreviewPreset] = useState(null); const [currentPreset, setCurrentPreset] = useState(null); - const showPreviewRef = useRef(showPreview); - showPreviewRef.current = showPreview; - const prePreviewPresetRef = useRef(prePreviewPreset); - prePreviewPresetRef.current = prePreviewPreset; - const currentPresetRef = useRef(currentPreset); - currentPresetRef.current = currentPreset; - // Reset presets when active display set changes useEffect(() => { setCurrentPreset(null); @@ -118,8 +111,8 @@ export function WindowLevel({ viewportId }: { viewportId?: string } = {}): React setShowPreview(checked); // When turning off preview, restore the current preset if one exists - if (!checked && currentPresetRef.current) { - handleSetWindowLevel(currentPresetRef.current, true); + if (!checked && currentPreset) { + handleSetWindowLevel(currentPreset, true); } }} /> @@ -142,16 +135,16 @@ export function WindowLevel({ viewportId }: { viewportId?: string } = {}): React setPrePreviewPreset(null); }} onMouseEnter={() => { - if (showPreviewRef.current) { - if (!prePreviewPresetRef.current) { - setPrePreviewPreset(currentPresetRef.current || preset); + if (showPreview) { + if (!prePreviewPreset) { + setPrePreviewPreset(currentPreset || preset); } handleSetWindowLevel(preset, true); } }} onMouseLeave={() => { - if (showPreviewRef.current && prePreviewPresetRef.current) { - handleSetWindowLevel(prePreviewPresetRef.current, true); + if (showPreview && prePreviewPreset) { + handleSetWindowLevel(prePreviewPreset, true); } }} /> diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevelActionMenu.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevelActionMenu.tsx index 6946127f6ea..844a627f26b 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevelActionMenu.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevelActionMenu.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useMemo } from 'react'; +import React, { ReactElement } from 'react'; import { useTranslation } from 'react-i18next'; import { AllInOneMenu } from '@ohif/ui-next'; import { Colormap } from './Colormap'; @@ -21,7 +21,7 @@ export function WindowLevelActionMenu({ align, side, onVisibilityChange, -}: WindowLevelActionMenuProps): ReactElement { +}: WindowLevelActionMenuProps): ReactElement { return ( void; -}): ReactElement { +}): ReactElement { const { t } = useTranslation('WindowLevelActionMenu'); // Use a stable key for the menu to avoid infinite re-renders - const menuKey = useMemo(() => `${viewportId}`, [viewportId]); + const menuKey = `${viewportId}`; const { is3DVolume, diff --git a/extensions/cornerstone/src/customizations/CustomDropdownMenuContent.tsx b/extensions/cornerstone/src/customizations/CustomDropdownMenuContent.tsx index f93e7708512..9ec2489a9a7 100644 --- a/extensions/cornerstone/src/customizations/CustomDropdownMenuContent.tsx +++ b/extensions/cornerstone/src/customizations/CustomDropdownMenuContent.tsx @@ -30,22 +30,18 @@ export const CustomDropdownMenuContent = () => { disableEditing, } = useSegmentationTableContext('CustomDropdownMenu'); - // Try to get segmentation data from expanded context first, fall back to table context - let segmentation; - let segmentationId; + // Prefer the expanded context when rendered inside one, otherwise fall back to + // the active segmentation from the table context. useSegmentationExpanded returns + // undefined outside a provider rather than throwing, so no try/catch is needed - + // catching it would put a hook call inside a try block, which breaks the rules of + // hooks and makes the React Compiler bail on the whole component. + const expandedContext = useSegmentationExpanded(); + const segmentation = expandedContext ? expandedContext.segmentation : activeSegmentation; + const segmentationId = expandedContext + ? expandedContext.segmentation.segmentationId + : activeSegmentationId; let allowExport = false; - try { - // Try to get from expanded context - const context = useSegmentationExpanded(); - segmentation = context.segmentation; - segmentationId = segmentation.segmentationId; - } catch (e) { - // If not in expanded context, fallback to active segmentation from table context - segmentation = activeSegmentation; - segmentationId = activeSegmentationId; - } - if (!segmentation || !segmentationId) { return null; } diff --git a/extensions/cornerstone/src/customizations/segmentationHydrationCustomization.ts b/extensions/cornerstone/src/customizations/segmentationHydrationCustomization.ts new file mode 100644 index 00000000000..f6893c73107 --- /dev/null +++ b/extensions/cornerstone/src/customizations/segmentationHydrationCustomization.ts @@ -0,0 +1,16 @@ +export default { + /** + * Viewport types a hydrated segmentation is allowed to appear in + * automatically. + * + * `null` (the default) means every viewport type that can render it, which is + * the behaviour hydration has always had. Set it to a list of OHIF viewport + * types - the vocabulary of a hanging protocol's + * `viewportOptions.viewportType` - to narrow that, e.g. + * `['stack', 'volume']` to keep automatic hydration out of 3D viewports, + * where the labelmap has to be converted to a surface first. Narrowing this + * does not stop a user adding the segmentation to such a viewport by hand + * from the overlay menu. + */ + 'cornerstone.segmentation.autoHydrateViewportTypes': null, +}; diff --git a/extensions/cornerstone/src/customizations/segmentationToolbarCustomization.ts b/extensions/cornerstone/src/customizations/segmentationToolbarCustomization.ts index 0eea2c1b7e1..67be97ca921 100644 --- a/extensions/cornerstone/src/customizations/segmentationToolbarCustomization.ts +++ b/extensions/cornerstone/src/customizations/segmentationToolbarCustomization.ts @@ -726,8 +726,9 @@ const segmentationToolbarButtons: Button[] = [ name: 'ThresholdRange', type: 'double-range', id: 'threshold-range', - min: -1000, + min: 0, max: 1000, + allowTypedExpansion: true, step: 1, value: [50, 600], condition: ({ options }) => diff --git a/extensions/cornerstone/src/getCustomizationModule.tsx b/extensions/cornerstone/src/getCustomizationModule.tsx index ddb4e8dfe91..f89247c535d 100644 --- a/extensions/cornerstone/src/getCustomizationModule.tsx +++ b/extensions/cornerstone/src/getCustomizationModule.tsx @@ -7,6 +7,7 @@ import measurementsCustomization from './customizations/measurementsCustomizatio import volumeRenderingCustomization from './customizations/volumeRenderingCustomization'; import colorbarCustomization from './customizations/colorbarCustomization'; import modalityColorMapCustomization from './customizations/modalityColorMapCustomization'; +import segmentationHydrationCustomization from './customizations/segmentationHydrationCustomization'; import windowLevelPresetsCustomization from './customizations/windowLevelPresetsCustomization'; import toolbarButtonsCustomization from './customizations/toolbarButtonsCustomization'; import segmentationToolbarCustomization from './customizations/segmentationToolbarCustomization'; @@ -34,6 +35,7 @@ function getCustomizationModule({ commandsManager, servicesManager, extensionMan ...volumeRenderingCustomization, ...colorbarCustomization, ...modalityColorMapCustomization, + ...segmentationHydrationCustomization, ...windowLevelPresetsCustomization, ...toolbarButtonsCustomization, ...segmentationToolbarCustomization, diff --git a/extensions/cornerstone/src/hooks/useViewportRendering.tsx b/extensions/cornerstone/src/hooks/useViewportRendering.tsx index 19d43dbbea0..0ecda98e212 100644 --- a/extensions/cornerstone/src/hooks/useViewportRendering.tsx +++ b/extensions/cornerstone/src/hooks/useViewportRendering.tsx @@ -117,6 +117,65 @@ const resolveOpacityScalar = (opacityVal: unknown): number | undefined => { * @param options - Options for the hook, including location and displaySetInstanceUID * @returns Window level API for the specified viewport */ +/** + * Resolves the colormap currently applied to the active display set, falling + * back to Grayscale (or the first available colormap) when none is applied or + * resolution fails. + */ +function resolveActiveColormap( + viewport, + activeDisplaySetInstanceUID, + viewportDisplaySets, + colormaps +) { + if (!activeDisplaySetInstanceUID || !viewportDisplaySets?.length) { + return null; + } + try { + if (!viewport) { + return null; + } + const colormap = getViewportAdapter(viewport).getColormap(activeDisplaySetInstanceUID); + return colormap || colormaps?.find(c => c.Name === 'Grayscale') || colormaps?.[0]; + } catch (error) { + console.error('Error getting viewport colormap:', error); + return colormaps?.find(c => c.Name === 'Grayscale') || colormaps?.[0]; + } +} + +/** + * Reads the viewport's stored presentation (VOI range, colormap) for a display + * set. Kept at module scope: the `??` inside the try/catch trips a React + * Compiler limitation ("value blocks within a try/catch") that bails the whole + * hook when this code is inlined. + */ +function readPresentation(viewport, activeDisplaySetInstanceUID) { + try { + const adapter = getViewportAdapter(viewport); + const dataId = adapter.getDataIdForDisplaySet(activeDisplaySetInstanceUID); + const properties = adapter.getPresentation(dataId ?? activeDisplaySetInstanceUID); + + if (!properties) { + return null; + } + + let voiRange = properties.voiRange; + if (!voiRange) { + // Native ("next") viewports store only explicit VOI overrides in the + // per-display-set presentation; a freshly shown series has none, so fall + // back to its computed default VOI (undefined on legacy, whose + // getProperties always returns the applied VOI). Without this, changing + // the series left the overlay showing the previous series' window level. + voiRange = adapter.getDefaultVOIRange(dataId ?? activeDisplaySetInstanceUID); + } + + return { voiRange, colormap: properties.colormap }; + } catch (error) { + console.error('Error initializing VOI range:', error); + return null; + } +} + export function useViewportRendering( viewportId?: string, options?: ViewportRenderingOptions @@ -130,7 +189,7 @@ export function useViewportRendering( options?.location ? getPosition(options.location) : 'bottom' ); const [voiRange, setVoiRange] = useState<{ lower: number; upper: number } | undefined>(); - const voiRangeRef = React.useRef<{ lower: number; upper: number } | undefined>(); + const voiRangeRef = React.useRef<{ lower: number; upper: number } | undefined>(undefined); // Viewport from service; kept in state so we can subscribe to VIEWPORT_DATA_CHANGED when null and re-run effects when it becomes available const [viewport, setViewport] = useState(() => viewportId ? (cornerstoneViewportService.getCornerstoneViewport(viewportId) ?? null) : null @@ -285,45 +344,26 @@ export function useViewportRendering( if (!viewport || !activeDisplaySetInstanceUID) { return; } - try { - const adapter = getViewportAdapter(viewport); - const dataId = adapter.getDataIdForDisplaySet(activeDisplaySetInstanceUID); - const properties = adapter.getPresentation(dataId ?? activeDisplaySetInstanceUID); - - if (!properties) { - return; - } + const presentation = readPresentation(viewport, activeDisplaySetInstanceUID); + if (!presentation) { + return; + } - if (properties.voiRange) { - setVoiRange(properties.voiRange); - voiRangeRef.current = properties.voiRange; - } else { - // Native ("next") viewports store only explicit VOI overrides in the - // per-display-set presentation; a freshly shown series has none, so fall - // back to its computed default VOI (undefined on legacy, whose - // getProperties always returns the applied VOI). Without this, changing - // the series left the overlay showing the previous series' window level. - const defaultVOIRange = adapter.getDefaultVOIRange(dataId ?? activeDisplaySetInstanceUID); - - if (defaultVOIRange) { - setVoiRange(defaultVOIRange); - voiRangeRef.current = defaultVOIRange; - } - } + if (presentation.voiRange) { + setVoiRange(presentation.voiRange); + voiRangeRef.current = presentation.voiRange; + } - if (properties.colormap?.opacity !== undefined) { - const opacity = resolveOpacityScalar(properties.colormap.opacity); - if (opacity !== undefined) { - setOpacityState(opacity); - setOpacityLinearState(opacityToLinear(opacity)); - } + if (presentation.colormap?.opacity !== undefined) { + const opacity = resolveOpacityScalar(presentation.colormap.opacity); + if (opacity !== undefined) { + setOpacityState(opacity); + setOpacityLinearState(opacityToLinear(opacity)); } + } - if (properties.colormap?.threshold !== undefined) { - setThresholdState(properties.colormap.threshold); - } - } catch (error) { - console.error('Error initializing VOI range:', error); + if (presentation.colormap?.threshold !== undefined) { + setThresholdState(presentation.colormap.threshold); } }, [activeDisplaySetInstanceUID, viewport]); @@ -640,32 +680,20 @@ export function useViewportRendering( [validateActiveDisplaySet, viewport] ); - // Get the current colormap for the active display set - const colormap = useMemo(() => { - if (!activeDisplaySetInstanceUID || !viewportDisplaySets?.length) { - return null; - } - - try { - if (!viewport) { - return null; - } - - const colormap = getViewportAdapter(viewport).getColormap(activeDisplaySetInstanceUID); - - return ( - colormap || - colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') || - colorbarProperties?.colormaps?.[0] - ); - } catch (error) { - console.error('Error getting viewport colormap:', error); - return ( - colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') || - colorbarProperties?.colormaps?.[0] - ); - } - }, [activeDisplaySetInstanceUID, viewportDisplaySets, colorbarProperties?.colormaps, viewport]); + // Get the current colormap for the active display set. Deliberately not + // memoized: resolveActiveColormap returns a reference that already exists — + // the viewport presentation's colormap, or one of the `colormaps` presets — + // rather than constructing one, so repeated calls hand back the same + // identity and no consumer sees churn. The lookup is a WeakMap-cached + // adapter fetch plus a property read, not worth a dependency comparison. + // (It was a useMemo whose optional-chained deps tripped + // preserve-manual-memoization.) + const colormap = resolveActiveColormap( + viewport, + activeDisplaySetInstanceUID, + viewportDisplaySets, + colorbarProperties?.colormaps + ); // 3D volume rendering functions const setVolumeRenderingPreset = useCallback( diff --git a/extensions/cornerstone/src/hooks/useViewportSegmentations.ts b/extensions/cornerstone/src/hooks/useViewportSegmentations.ts index fc20d9f5713..ee6ae77f33e 100644 --- a/extensions/cornerstone/src/hooks/useViewportSegmentations.ts +++ b/extensions/cornerstone/src/hooks/useViewportSegmentations.ts @@ -198,6 +198,17 @@ export function useViewportSegmentations({ segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED, debouncedUpdate ), + // What this hook reads is `getSegmentationRepresentations(viewportId)`, so a + // representation leaving the viewport changes its answer and has to re-run it. + // Without this, "Remove from Viewport" cleared the overlay from the image but + // left the segmentation listed in the panel: the data was already correct, the + // panel simply never asked again. Nothing else covers it — a hydrated + // segmentation is not a display set in the viewport, so removing it moves no + // grid state and fires no grid event either. + segmentationService.subscribe( + segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_REMOVED, + debouncedUpdate + ), viewportGridService.subscribe( viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED, debouncedUpdate diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index 5e5fe954db7..e2b1f9e1c4c 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -116,8 +116,12 @@ const cornerstoneExtension: Types.Extensions.Extension = { id, onModeEnter: ({ servicesManager, commandsManager, extensionManager }: withAppTypes): void => { - const { cornerstoneViewportService, toolbarService, segmentationService } = - servicesManager.services; + const { + cornerstoneViewportService, + toolbarService, + segmentationService, + customizationService, + } = servicesManager.services; const { unsubscriptions: segmentationUnsubscriptions } = setUpSegmentationEventHandlers({ servicesManager, @@ -142,6 +146,12 @@ const cornerstoneExtension: Types.Extensions.Extension = { cornerstoneTools.Enums.Events.TOOL_ACTIVATED, ]); + // Apply the undo/redo history size on mode entry rather than in + // preRegistration, because the global customizations are applied after + // the extensions register. + cornerstone.utilities.HistoryMemo.DefaultHistoryMemo.size = + customizationService.getCustomization('cornerstone.maxUndoRedoCacheSize') || 50; + // Configure the interleaved/HTJ2K loader imageRetrieveMetadataProvider.clear(); // The default volume interleaved options are to interleave the diff --git a/extensions/cornerstone/src/panels/PanelSegmentation.tsx b/extensions/cornerstone/src/panels/PanelSegmentation.tsx index c43a08cb0a2..f08fd405133 100644 --- a/extensions/cornerstone/src/panels/PanelSegmentation.tsx +++ b/extensions/cornerstone/src/panels/PanelSegmentation.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { IconPresentationProvider, Popover, @@ -8,7 +8,7 @@ import { ToolSettings, } from '@ohif/ui-next'; import { useActiveViewportSegmentationRepresentations } from '../hooks/useActiveViewportSegmentationRepresentations'; -import { useActiveToolOptions, useSystem } from '@ohif/core/src'; +import { useActiveToolOptions, useCustomization, useSystem } from '@ohif/core/src'; import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; import { Toolbar, useUIStateStore } from '@ohif/extension-default'; import SegmentationUtilityButton from '../components/SegmentationUtilityButton'; @@ -32,7 +32,6 @@ export default function PanelSegmentation({ }: PanelSegmentationProps) { const { commandsManager, servicesManager } = useSystem(); const { - customizationService, displaySetService, viewportGridService, toolbarService, @@ -87,30 +86,30 @@ export default function PanelSegmentation({ // The Popover is made visible whenever the options associated with the // activeSegmentationUtility exist. Thus clearing the activeSegmentationUtility // clears the associated options and will keep the Popover closed. - const handlePopoverOpenChange = useCallback( - (open: boolean) => { - if (!open) { - setUIState('activeSegmentationUtility', null); - toolbarService.refreshToolbarState({ viewportId: activeViewportId }); - } - }, - [activeViewportId, setUIState, toolbarService] - ); + const handlePopoverOpenChange = (open: boolean) => { + if (!open) { + setUIState('activeSegmentationUtility', null); + toolbarService.refreshToolbarState({ viewportId: activeViewportId }); + } + }; - // Extract customization options - const segmentationTableMode = customizationService.getCustomization( - 'panelSegmentation.tableMode' - ) as unknown as string; - const onSegmentationAdd = customizationService.getCustomization( - 'panelSegmentation.onSegmentationAdd' - ); - const disableEditing = customizationService.getCustomization('panelSegmentation.disableEditing'); - const showAddSegment = customizationService.getCustomization('panelSegmentation.showAddSegment'); - const CustomDropdownMenuContent = customizationService.getCustomization( + // Customizations are read through useCustomization rather than calling + // customizationService.getCustomization during render. Modes register these in + // onModeEnter, which can land after this panel has already mounted, and a + // render-time getCustomization call gets memoized by the React Compiler on the + // (stable) service reference - so it would serve the pre-registration default + // for the life of the panel. useCustomization subscribes to the service's + // change events instead. TMTV hit this twice: its 'expanded' tableMode stayed + // stuck on the 'collapsed' default, and its create-labelmap-from-PT + // onSegmentationAdd handler was ignored in favour of the default. + const segmentationTableMode = useCustomization('panelSegmentation.tableMode') as string; + const onSegmentationAdd = useCustomization('panelSegmentation.onSegmentationAdd'); + const disableEditing = useCustomization('panelSegmentation.disableEditing'); + const showAddSegment = useCustomization('panelSegmentation.showAddSegment'); + const CustomDropdownMenuContent = useCustomization( 'panelSegmentation.customDropdownMenuContent' ); - - const CustomSegmentStatisticsHeader = customizationService.getCustomization( + const CustomSegmentStatisticsHeader = useCustomization( 'panelSegmentation.customSegmentStatisticsHeader' ); diff --git a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts index 73096bab6fc..b16f1ed2173 100644 --- a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts +++ b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts @@ -2,6 +2,7 @@ import { Types } from '@ohif/core'; import { cache as cs3DCache, Enums, volumeLoader } from '@cornerstonejs/core'; import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType'; +import { loadDisplaySetData } from '../../utils/loadDisplaySetData'; import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService'; import { VOLUME_LOADER_SCHEME } from '../../constants'; @@ -197,23 +198,11 @@ class CornerstoneCacheService { initialImageIndex, viewportType: Enums.ViewportType ): Promise { - const { uiNotificationService } = this.servicesManager.services; + // Overlays are loaded before the loop below so that a segmentation can + // back-fill the imageIds of the series it references. const overlayDisplaySets = displaySets.filter(ds => ds.isOverlayDisplaySet); for (const overlayDisplaySet of overlayDisplaySets) { - if (overlayDisplaySet.load && overlayDisplaySet.load instanceof Function) { - const { userAuthenticationService } = this.servicesManager.services; - const headers = userAuthenticationService.getAuthorizationHeader(); - try { - await overlayDisplaySet.load({ headers }); - } catch (e) { - uiNotificationService.show({ - title: 'Error loading displaySet', - message: e.message, - type: 'error', - }); - console.error(e); - } - } + await loadDisplaySetData(overlayDisplaySet, this.servicesManager); } // Ensuring the first non-overlay `displaySet` is always the primary one @@ -221,20 +210,7 @@ class CornerstoneCacheService { for (const displaySet of displaySets) { const { displaySetInstanceUID, StudyInstanceUID, isCompositeStack } = displaySet; - if (displaySet.load && displaySet.load instanceof Function) { - const { userAuthenticationService } = this.servicesManager.services; - const headers = userAuthenticationService.getAuthorizationHeader(); - try { - await displaySet.load({ headers }); - } catch (e) { - uiNotificationService.show({ - title: 'Error loading displaySet', - message: e.message, - type: 'error', - }); - console.error(e); - } - } + await loadDisplaySetData(displaySet, this.servicesManager); let stackImageIds = this.stackImageIds.get(displaySet.displaySetInstanceUID); @@ -280,21 +256,8 @@ class CornerstoneCacheService { // and they take care of their own loading after they are created in their // getSOPClassHandler method - if (displaySet.load && displaySet.load instanceof Function) { - const { userAuthenticationService } = this.servicesManager.services; - const headers = userAuthenticationService.getAuthorizationHeader(); - - try { - await displaySet.load({ headers }); - } catch (e) { - const { uiNotificationService } = this.servicesManager.services; - uiNotificationService.show({ - title: 'Error loading displaySet', - message: e.message, - type: 'error', - }); - console.error(e); - } + if (displaySet.load instanceof Function) { + await loadDisplaySetData(displaySet, this.servicesManager); // Parametric maps have a `load` method but it should not be loaded in the // same way as SEG and RTSTRUCT but like a normal volume diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.test.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.test.ts index 754c4aaf0a7..fec04a454ac 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.test.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.test.ts @@ -1242,6 +1242,8 @@ describe('SegmentationService', () => { info: 'S1: Series Description', }, label: 'Segmentation 2', + // The caller gave no label, so the service invented this one. + labelIsGenerated: true, fallbackLabel: 'S:1 SEG', segments: { '1': { @@ -1263,6 +1265,56 @@ describe('SegmentationService', () => { expect(retrievedSegmentationId).toEqual(expect.any(String)); }); + describe('labelIsGenerated', () => { + // `storeSegmentation` offers the label of a segmentation as the first name + // for a new series, but only when the user chose that name. The service + // marks the label that the service invents, so the save can tell the two + // apart without a comparison of two strings. The mark goes into the + // public input, and `normalizeSegmentationInput` puts the mark on the + // segmentation; the service writes nothing onto the state afterwards. + const displaySet = { + imageIds: ['imageId'], + isDynamicVolume: false, + SeriesNumber: 1, + SeriesDescription: 'Series Description', + Modality: 'SEG', + } as unknown as AppTypes.DisplaySet; + + const createWith = async (options?: Record) => { + jest + .spyOn(imageLoader, 'createAndCacheDerivedLabelmapImages') + .mockReturnValue([{ imageId: 'imageId' }] as csTypes.IImage[]); + jest + .spyOn(cstSegmentation.state, 'getSegmentations') + .mockReturnValue([{ segmentationId: 'segmentationId' }] as cstTypes.Segmentation[]); + const add = jest.spyOn(service, 'addOrUpdateSegmentation').mockReturnValue(undefined); + + await service.createLabelmapForDisplaySet(displaySet, options); + return (add.mock.calls[0][0] as cstTypes.SegmentationPublicInput).config; + }; + + it('marks a label that the service invents', async () => { + const config = await createWith(); + + expect(config.label).toBe('Segmentation 2'); + expect(config.labelIsGenerated).toBe(true); + }); + + it('marks nothing for a label that the caller gives', async () => { + const config = await createWith({ label: 'Liver' }); + + expect(config.label).toBe('Liver'); + expect(config.labelIsGenerated).toBe(false); + }); + + it('marks a label that the caller reports as generated', async () => { + const config = await createWith({ label: 'Segmentation 7', labelIsGenerated: true }); + + expect(config.label).toBe('Segmentation 7'); + expect(config.labelIsGenerated).toBe(true); + }); + }); + it('should create a labelmap for a dynamic volume display set', async () => { const segmentationId = 'segmentationId'; const displaySet = { @@ -1311,6 +1363,8 @@ describe('SegmentationService', () => { info: 'S1: Series Description', }, label: 'Segmentation 2', + // The caller gave the label, so the label is not a generated one. + labelIsGenerated: false, fallbackLabel: 'S:1 SEG', segments: { '1': { @@ -1331,6 +1385,51 @@ describe('SegmentationService', () => { expect(retrievedSegmentationId).toEqual(segmentationId); }); + + it('should create a dedicated color LUT and remember its index so every viewport representation reuses it', async () => { + const displaySet = { + imageIds: ['imageId'], + isDynamicVolume: false, + SeriesNumber: 1, + SeriesDescription: 'Series Description', + } as unknown as AppTypes.DisplaySet; + + jest + .spyOn(imageLoader, 'createAndCacheDerivedLabelmapImages') + .mockReturnValue([{ imageId: 'imageId' }] as csTypes.IImage[]); + jest.spyOn(cstSegmentation.state, 'getSegmentations').mockReturnValue([]); + jest.spyOn(service, 'addOrUpdateSegmentation').mockReturnValue(undefined); + jest.mocked(cstSegmentation.state.addColorLUT).mockReturnValue(7); + + const segmentationId = await service.createLabelmapForDisplaySet(displaySet); + + expect(cstSegmentation.state.addColorLUT).toHaveBeenCalledWith([[0, 0, 0, 0]]); + expect(service['_segmentationIdToColorLUTIndexMap'].get(segmentationId)).toBe(7); + }); + + it('should keep the existing color LUT when called again with the same segmentation id', async () => { + const displaySet = { + imageIds: ['imageId'], + isDynamicVolume: false, + SeriesNumber: 1, + SeriesDescription: 'Series Description', + } as unknown as AppTypes.DisplaySet; + + jest + .spyOn(imageLoader, 'createAndCacheDerivedLabelmapImages') + .mockReturnValue([{ imageId: 'imageId' }] as csTypes.IImage[]); + jest.spyOn(cstSegmentation.state, 'getSegmentations').mockReturnValue([]); + jest.spyOn(service, 'addOrUpdateSegmentation').mockReturnValue(undefined); + jest.mocked(cstSegmentation.state.addColorLUT).mockReturnValueOnce(7).mockReturnValueOnce(8); + + const segmentationId = await service.createLabelmapForDisplaySet(displaySet); + await service.createLabelmapForDisplaySet(displaySet, { segmentationId }); + + // Updating an existing segmentation must not strand the representations + // already rendering it on the previous LUT. + expect(cstSegmentation.state.addColorLUT).toHaveBeenCalledTimes(1); + expect(service['_segmentationIdToColorLUTIndexMap'].get(segmentationId)).toBe(7); + }); }); describe('createSegmentationForSEGDisplaySet', () => { diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts index a1dd3226449..5dbc7bf1c16 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -428,6 +428,8 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI segments?: { [segmentIndex: number]: Partial }; FrameOfReferenceUID?: string; label?: string; + /** The caller invented the label, so the user has not chosen a name. */ + labelIsGenerated?: boolean; } ): Promise { return this._createSegmentationForDisplaySet(displaySet, LABELMAP, options); @@ -440,6 +442,8 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI segments?: { [segmentIndex: number]: Partial }; FrameOfReferenceUID?: string; label?: string; + /** The caller invented the label, so the user has not chosen a name. */ + labelIsGenerated?: boolean; } ): Promise { return this._createSegmentationForDisplaySet(displaySet, CONTOUR, options); @@ -461,6 +465,8 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI segments?: { [segmentIndex: number]: Partial }; FrameOfReferenceUID?: string; label?: string; + /** The caller invented the label, so the user has not chosen a name. */ + labelIsGenerated?: boolean; } ): Promise { // Todo: random does not makes sense, make this better, like @@ -496,6 +502,8 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI }, config: { label, + // Explicit, because `label` below always has a value by this point. + labelIsGenerated: options?.labelIsGenerated ?? !options?.label, fallbackLabel: `S:${displaySet.SeriesNumber} ${displaySet.Modality}`, segments: options?.segments && Object.keys(options.segments).length > 0 @@ -512,6 +520,19 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI }, }; + // Create a dedicated color LUT up front and remember its index so that every + // representation of this segmentation (one per viewport) reuses the same LUT. + // Otherwise each viewport would get its own default LUT copy and editing a + // segment color on one viewport would not be reflected on the others (the + // segment color appears to revert to the default when interacting elsewhere). + // The caller may pass the id of an existing segmentation, which this method + // updates rather than replaces; keep its LUT so representations already + // rendering it don't diverge from the ones created afterwards. + if (!this._segmentationIdToColorLUTIndexMap.has(segmentationId)) { + const colorLUTIndex = addColorLUT([[0, 0, 0, 0]] as csTypes.ColorLUT); + this._segmentationIdToColorLUTIndexMap.set(segmentationId, colorLUTIndex); + } + this.addOrUpdateSegmentation(segmentationPublicInput); return segmentationId; } diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 8e35219f7ff..b2eafece760 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -44,6 +44,7 @@ import { } from '../../utils/getLegacyViewportType'; import { BlendModes } from '@cornerstonejs/core/enums'; import { isNextViewportsEnabled } from '../../utils/nextViewports'; +import { isAutoHydrateViewportType } from '../../utils/autoHydrateViewportTypes'; import type { IViewportBackend } from './backends/IViewportBackend'; import type { IViewportServiceInternals } from './backends/IViewportServiceInternals'; import { LegacyViewportBackend } from './backends/LegacyViewportBackend'; @@ -360,7 +361,7 @@ class CornerstoneViewportService const { setLutPresentation } = useLutPresentationStore.getState(); const { setPositionPresentation } = usePositionPresentationStore.getState(); const { setSynchronizers } = useSynchronizersStore.getState(); - const { setSegmentationPresentation } = useSegmentationPresentationStore.getState(); + const { syncSegmentationPresentation } = useSegmentationPresentationStore.getState(); if (lutPresentationId) { setLutPresentation(lutPresentationId, lutPresentation); @@ -371,7 +372,13 @@ class CornerstoneViewportService } if (segmentationPresentationId) { - setSegmentationPresentation(segmentationPresentationId, segmentationPresentation); + syncSegmentationPresentation( + segmentationPresentationId, + (segmentationPresentation ?? []).map(item => ({ + ...item, + hydrated: this._getInitialHydrationForSync(item.segmentationId), + })) + ); } if (synchronizers?.length) { @@ -474,6 +481,28 @@ class CornerstoneViewportService return presentation; } + /** + * The `hydrated` to record for a segmentation the presentation store has not + * heard of yet (`syncSegmentationPresentation` keeps the recorded value for + * one it has). + * + * Hydration is owned by the hydration paths exactly when they can record it, + * and `updateStoredSegmentationPresentation` keys the store by the referenced + * display set - so a segmentation whose display set has one gets `null` ("no + * statement"), leaving it to the hydration prompt, the segmentation panel and + * the study browser to say whether it belongs in the standard view. Anything + * else - a segmentation drawn in the client, or one whose display set has not + * been registered yet - has no other record, so the viewport it lives in is + * the authority and it is recorded as shown. + */ + private _getInitialHydrationForSync(segmentationId: string): boolean | null { + const { displaySetService } = this.servicesManager.services; + + const displaySet = displaySetService.getDisplaySetByUID(segmentationId); + + return displaySet?.referencedDisplaySetInstanceUID ? null : true; + } + /** * Sets the viewport data for a viewport. * @param viewportId - The ID of the viewport to set the data for. @@ -1676,7 +1705,32 @@ class CornerstoneViewportService return; } - const { segmentationService } = this.servicesManager.services; + const { segmentationService, customizationService, viewportGridService } = + this.servicesManager.services; + + // A site can narrow which viewport types a hydrated segmentation appears in + // automatically (surface generation for a 3D viewport being the expensive + // case). This gates only the automatic add - an explicit add from the + // overlay menu goes through addSegmentationRepresentation directly. + // + // The type comes from the grid, not from ViewportInfo: the customization is + // written in OHIF viewport types and getUpdatedViewportsForSegmentation + // reads the same field, so one configured list means the same thing in both + // places. ViewportInfo holds the cornerstone type, which under + // `useNextViewports` is 'planarNext' for stack and MPR alike. + const autoHydrateAllowed = isAutoHydrateViewportType({ + viewportType: viewportGridService.getState().viewports.get(viewport.id)?.viewportOptions + ?.viewportType, + customizationService, + }); + + // Display sets the viewport carries as explicit layers. Those are added by + // _addOverlayRepresentations as part of this same mount, and the two run in + // opposite orders on the stack and volume paths, so a stored `hydrated: + // false` must not remove one of them - being listed in the viewport is the + // more specific statement, and it is what the overlay menu's per-viewport + // Add records. + const explicitLayerUIDs = new Set(this.viewportsDisplaySets.get(viewport.id) || []); segmentationPresentation.forEach((presentationItem: SegmentationPresentationItem) => { const { segmentationId, type, hydrated } = presentationItem; @@ -1684,12 +1738,21 @@ class CornerstoneViewportService const { Labelmap, Surface } = csToolsEnums.SegmentationRepresentations; const isVolume3D = isVolume3DViewportType(viewport); - // Determine the appropriate segmentation representation for the viewport. - // If the current type is Surface but the viewport is not 3D, fallback to Labelmap. - // Otherwise, use the existing type. - const representationType = type === Surface && !isVolume3D ? Labelmap : type; + // The stored type is a hint: it was recorded when the segmentation was + // hydrated, possibly against a viewport of a different kind than this + // one, or with no viewport at all. Surface only renders in a 3D viewport + // and a 3D viewport renders a labelmap as a surface, so correct in both + // directions here. Being tolerant of the stored value is what lets the + // hydration path record a type without needing a live viewport. + let representationType = type; + + if (type === Surface && !isVolume3D) { + representationType = Labelmap; + } else if (type === Labelmap && isVolume3D) { + representationType = Surface; + } - if (hydrated) { + if (hydrated && autoHydrateAllowed) { segmentationService.addSegmentationRepresentation(viewport.id, { segmentationId, type: representationType, @@ -1700,6 +1763,19 @@ class CornerstoneViewportService : undefined, }, }); + return; + } + + // hydrated === false means this segmentation was explicitly removed from + // the presentation (closed from the segmentation panel, or the layer + // removed). Converge the viewport on the stored state instead of only + // ever adding, so a re-created viewport does not restore a segmentation + // the user dismissed. hydrated == null is "no representation yet" and is + // deliberately left alone. + if (hydrated === false && !explicitLayerUIDs.has(segmentationId)) { + segmentationService.removeRepresentationsFromViewport(viewport.id, { + segmentationId, + }); } }); } diff --git a/extensions/cornerstone/src/stores/useSegmentationPresentationStore.test.ts b/extensions/cornerstone/src/stores/useSegmentationPresentationStore.test.ts new file mode 100644 index 00000000000..ef26c0956fe --- /dev/null +++ b/extensions/cornerstone/src/stores/useSegmentationPresentationStore.test.ts @@ -0,0 +1,146 @@ +import { useSegmentationPresentationStore } from './useSegmentationPresentationStore'; +import type { SegmentationPresentationItem } from '../types/Presentation'; + +const item = ( + segmentationId: string, + overrides: Partial = {} +): SegmentationPresentationItem => + ({ + segmentationId, + type: 'Labelmap', + hydrated: true, + ...overrides, + }) as SegmentationPresentationItem; + +const storeFor = (presentationId: string) => + useSegmentationPresentationStore.getState().segmentationPresentationStore[presentationId]; + +describe('useSegmentationPresentationStore', () => { + beforeEach(() => { + useSegmentationPresentationStore.getState().clearSegmentationPresentationStore(); + }); + + describe('addSegmentationPresentationItem', () => { + it('replaces the entry for a segmentation rather than appending', () => { + const { addSegmentationPresentationItem } = useSegmentationPresentationStore.getState(); + + addSegmentationPresentationItem('volume-1', item('seg-1')); + addSegmentationPresentationItem('volume-1', item('seg-1', { hydrated: false })); + + expect(storeFor('volume-1')).toEqual([item('seg-1', { hydrated: false })]); + }); + }); + + // A segmentation drawn in the client has no referenced display set, so it has + // no key of its own - it is recorded under whatever viewport drew it, and a + // removal has to supersede that record where it actually lives. + describe('setHydrationForSegmentation', () => { + it('restates the hydration of a segmentation in every presentation holding it', () => { + const { addSegmentationPresentationItem, setHydrationForSegmentation } = + useSegmentationPresentationStore.getState(); + + addSegmentationPresentationItem('volume-1', item('seg-1')); + addSegmentationPresentationItem('volume-2', item('seg-1')); + + setHydrationForSegmentation('seg-1', { hydrated: false }); + + expect(storeFor('volume-1')).toEqual([item('seg-1', { hydrated: false })]); + expect(storeFor('volume-2')).toEqual([item('seg-1', { hydrated: false })]); + }); + + it('leaves the other segmentations of a presentation alone', () => { + const { addSegmentationPresentationItem, setHydrationForSegmentation } = + useSegmentationPresentationStore.getState(); + + addSegmentationPresentationItem('volume-1', item('seg-1')); + addSegmentationPresentationItem('volume-1', item('seg-2')); + + setHydrationForSegmentation('seg-1', { hydrated: false }); + + expect(storeFor('volume-1')).toEqual([item('seg-1', { hydrated: false }), item('seg-2')]); + }); + + it('keeps the recorded type when the caller does not know it', () => { + const { addSegmentationPresentationItem, setHydrationForSegmentation } = + useSegmentationPresentationStore.getState(); + + addSegmentationPresentationItem('volume-1', item('seg-1', { type: 'Surface' as never })); + + setHydrationForSegmentation('seg-1', { hydrated: false }); + + expect(storeFor('volume-1')).toEqual([ + item('seg-1', { hydrated: false, type: 'Surface' as never }), + ]); + }); + + it('creates no entry for a segmentation nothing has recorded', () => { + const { setHydrationForSegmentation, segmentationPresentationStore } = + useSegmentationPresentationStore.getState(); + + setHydrationForSegmentation('seg-1', { hydrated: false }); + + expect(useSegmentationPresentationStore.getState().segmentationPresentationStore).toBe( + segmentationPresentationStore + ); + }); + }); + + describe('syncSegmentationPresentation', () => { + // The viewport is reporting what it renders, not what belongs in the + // standard view: a segmentation added from the viewport data overlay menu + // must not become hydrated everywhere the presentation id is shared. + it('keeps the recorded hydration for a segmentation the store knows', () => { + const { addSegmentationPresentationItem, syncSegmentationPresentation } = + useSegmentationPresentationStore.getState(); + + addSegmentationPresentationItem('volume-1', item('seg-1', { hydrated: false })); + syncSegmentationPresentation('volume-1', [item('seg-1', { hydrated: true })]); + + expect(storeFor('volume-1')).toEqual([item('seg-1', { hydrated: false })]); + }); + + it('refreshes the type of a segmentation the store knows', () => { + const { addSegmentationPresentationItem, syncSegmentationPresentation } = + useSegmentationPresentationStore.getState(); + + addSegmentationPresentationItem('volume-1', item('seg-1')); + syncSegmentationPresentation('volume-1', [ + item('seg-1', { hydrated: null, type: 'Surface' as never }), + ]); + + expect(storeFor('volume-1')).toEqual([item('seg-1', { type: 'Surface' as never })]); + }); + + it("takes the caller's hydration for a segmentation with no entry yet", () => { + const { syncSegmentationPresentation } = useSegmentationPresentationStore.getState(); + + syncSegmentationPresentation('volume-1', [item('seg-1', { hydrated: null })]); + + expect(storeFor('volume-1')).toEqual([item('seg-1', { hydrated: null })]); + }); + + // The presentation id is shared by every pane over the same background, so + // a pane that does not render a segmentation - gated out of automatic + // hydration, or one the user removed the overlay from - must not erase the + // record the others resolve against. + it('keeps entries the viewport is not rendering', () => { + const { addSegmentationPresentationItem, syncSegmentationPresentation } = + useSegmentationPresentationStore.getState(); + + addSegmentationPresentationItem('volume-1', item('seg-1')); + syncSegmentationPresentation('volume-1', []); + + expect(storeFor('volume-1')).toEqual([item('seg-1')]); + }); + + it('adds a segmentation alongside the ones already recorded', () => { + const { addSegmentationPresentationItem, syncSegmentationPresentation } = + useSegmentationPresentationStore.getState(); + + addSegmentationPresentationItem('volume-1', item('seg-1')); + syncSegmentationPresentation('volume-1', [item('seg-2', { hydrated: null })]); + + expect(storeFor('volume-1')).toEqual([item('seg-1'), item('seg-2', { hydrated: null })]); + }); + }); +}); diff --git a/extensions/cornerstone/src/stores/useSegmentationPresentationStore.ts b/extensions/cornerstone/src/stores/useSegmentationPresentationStore.ts index 1e81b2a9a70..c1968a4c664 100644 --- a/extensions/cornerstone/src/stores/useSegmentationPresentationStore.ts +++ b/extensions/cornerstone/src/stores/useSegmentationPresentationStore.ts @@ -56,17 +56,67 @@ type SegmentationPresentationStore = { ) => string | undefined; /** - * Adds a new segmentation presentation state. + * Adds or replaces a segmentation presentation item. + * + * Items are identified by their `segmentationId`: an existing entry for the + * same segmentation is replaced rather than appended. Appending would let + * hydrate -> remove -> hydrate accumulate duplicate entries for one + * segmentation, and the viewport applies every entry, so the same + * representation would be added more than once. * * @param presentationId - The presentation ID. - * @param segmentationPresentation - The `SegmentationPresentation` to add. - * @param servicesManager - The services manager instance. + * @param segmentationPresentationItem - The item to add or replace. */ addSegmentationPresentationItem: ( presentationId: string, segmentationPresentationItem: SegmentationPresentationItem ) => void; + /** + * Updates the recorded hydration of one segmentation in every presentation + * that already holds an entry for it, creating none. + * + * The store is keyed by the display set a segmentation is hydrated against, + * so a segmentation with no referenced display set - one drawn in the client - + * has no key of its own to be written under. It is still recorded, under the + * key of whatever viewport it was drawn in (see `_getInitialHydrationForSync` + * in CornerstoneViewportService), and nothing supersedes that record on its + * own - `syncSegmentationPresentation` only ever merges. So a removal + * converges the store by rewriting the entries that exist, rather than + * inventing a key that every such segmentation would collide under. + * + * @param segmentationId - The segmentation to restate. + * @param value.hydrated - The hydration to record for it. + * @param value.type - The representation type, if the caller knows it; + * otherwise each entry keeps the type it has. + */ + setHydrationForSegmentation: ( + segmentationId: string, + value: { hydrated: boolean | null; type?: SegmentationPresentationItem['type'] } + ) => void; + + /** + * Records the representations a viewport is currently rendering, without + * changing hydration. + * + * `hydrated` is display-set-global ("show this wherever it logically + * belongs") and is owned by the hydration paths, which write it through + * `addSegmentationPresentationItem`. A viewport's live representations are + * not: a segmentation added from the viewport data overlay menu is meant for + * that one pane, and the presentation id is shared by every pane over the + * same background. So for a segmentation the store already knows about this + * keeps the recorded `hydrated` and only refreshes `type`/`config`; the + * caller's `hydrated` applies only to a segmentation with no entry yet. + * + * Entries the viewport is not rendering are kept as they are - a pane that + * was gated out of automatic hydration, or one the user removed the overlay + * from, must not erase the record the other panes resolve against. + * + * @param presentationId - The presentation ID. + * @param items - One item per representation the viewport renders. + */ + syncSegmentationPresentation: (presentationId: string, items: SegmentationPresentation) => void; + /** * Gets the current segmentation presentation ID. * @@ -163,6 +213,11 @@ const _getSegmentationPresentationId = ({ const segmentationPresentationArr = []; + // Keyed by the exact display set UIDs. Two display sets can share a frame of + // reference while being unrelated series, so the frame of reference is not + // usable as a key - which viewports a hydrated segmentation belongs in is a + // relation, resolved at read time by getViewportPresentations rather than + // encoded here. segmentationPresentationArr.push(...nonOverlayUIDs); // Uncomment if unique indexing is needed @@ -193,7 +248,8 @@ const createSegmentationPresentationStore = set => ({ set({ segmentationPresentationStore: {} }, false, 'clearSegmentationPresentationStore'), /** - * Adds a new segmentation presentation item to the store. + * Adds a new segmentation presentation item to the store, replacing any + * existing item for the same segmentation. * * segmentationPresentationItem: { * segmentationId: string; @@ -207,19 +263,113 @@ const createSegmentationPresentationStore = set => ({ segmentationPresentationItem: SegmentationPresentationItem ) => set( - state => ({ - segmentationPresentationStore: { - ...state.segmentationPresentationStore, - [presentationId]: [ - ...(state.segmentationPresentationStore[presentationId] || []), - segmentationPresentationItem, - ], - }, - }), + state => { + const existingItems = state.segmentationPresentationStore[presentationId] || []; + + // Upsert by segmentationId: the store records the desired state of a + // segmentation for this presentation, so there is exactly one entry per + // segmentation and a later write (hydrate, or remove-from-viewport) + // supersedes an earlier one. + const otherItems = existingItems.filter( + item => item.segmentationId !== segmentationPresentationItem.segmentationId + ); + + return { + segmentationPresentationStore: { + ...state.segmentationPresentationStore, + [presentationId]: [...otherItems, segmentationPresentationItem], + }, + }; + }, false, 'addSegmentationPresentationItem' ), + /** + * Restates the hydration of a segmentation wherever it is already recorded. + * See the type declaration for why this updates in place instead of writing + * an entry of its own. + */ + setHydrationForSegmentation: ( + segmentationId: string, + { hydrated, type }: { hydrated: boolean | null; type?: SegmentationPresentationItem['type'] } + ) => + set( + state => { + const updated: Record = {}; + const entries = Object.entries(state.segmentationPresentationStore) as [ + string, + SegmentationPresentation, + ][]; + + for (const [presentationId, items] of entries) { + if (!items?.some(item => item.segmentationId === segmentationId)) { + continue; + } + + updated[presentationId] = items.map(item => + item.segmentationId === segmentationId + ? { ...item, hydrated, type: type ?? item.type } + : item + ); + } + + // Nothing recorded for this segmentation is not something to state, and + // returning the state unchanged leaves subscribers alone. + if (!Object.keys(updated).length) { + return state; + } + + return { + segmentationPresentationStore: { ...state.segmentationPresentationStore, ...updated }, + }; + }, + false, + 'setHydrationForSegmentation' + ), + + /** + * Records the representations a viewport is currently rendering, keeping the + * hydration already recorded for each of them. See the type declaration for + * why hydration is not the viewport's to state. + */ + syncSegmentationPresentation: (presentationId: string, items: SegmentationPresentation) => { + // Nothing rendered is not a statement that nothing belongs here, and + // storePresentation runs on every viewport teardown, so skip the write + // rather than notifying subscribers of an unchanged store. + if (!items?.length) { + return; + } + + set( + state => { + const merged = [...(state.segmentationPresentationStore[presentationId] || [])]; + + for (const item of items) { + const index = merged.findIndex( + existing => existing.segmentationId === item.segmentationId + ); + + if (index === -1) { + merged.push(item); + continue; + } + + merged[index] = { ...item, hydrated: merged[index].hydrated }; + } + + return { + segmentationPresentationStore: { + ...state.segmentationPresentationStore, + [presentationId]: merged, + }, + }; + }, + false, + 'syncSegmentationPresentation' + ); + }, + /** * Sets the segmentation presentation for a given presentation ID. A segmentation * presentation is an array of SegmentationPresentationItem. diff --git a/extensions/cornerstone/src/types/AppTypes.ts b/extensions/cornerstone/src/types/AppTypes.ts index 69bcdc4d94f..09152adedb6 100644 --- a/extensions/cornerstone/src/types/AppTypes.ts +++ b/extensions/cornerstone/src/types/AppTypes.ts @@ -43,6 +43,16 @@ declare global { export type SegmentationInfo = SegInfo; } + export interface Customizations { + /** + * Maximum number of undo/redo history items to keep. Segmentation memos + * hold full labelmap buffers, so a large history can cause out-of-memory + * or buffer allocation issues. Applied on mode entry; when unset, the + * size is 50. A value that is not a valid array length throws. + */ + 'cornerstone.maxUndoRedoCacheSize': number | undefined; + } + export interface PresentationIds { lutPresentationId: string; positionPresentationId: string; diff --git a/extensions/cornerstone/src/types/Presentation.ts b/extensions/cornerstone/src/types/Presentation.ts index 1d3d0a9da6e..eedf05fd94f 100644 --- a/extensions/cornerstone/src/types/Presentation.ts +++ b/extensions/cornerstone/src/types/Presentation.ts @@ -47,6 +47,13 @@ export interface LutPresentation { * currently in the viewport. It's false if the representation was in the viewport * but has been removed. * + * It mirrors `DisplaySet.isHydrated` for this presentation: true means show the + * segmentation in the standard viewports matching this presentation id, false + * means it was explicitly removed and must not be shown in them again. The + * viewport converges on this value, so false actively removes the + * representation rather than merely being the absence of an add - otherwise a + * stale true would silently restore a segmentation the user dismissed. + * * Config is the segmentation config, Todo: add stuff here */ export type SegmentationPresentationItem = { diff --git a/extensions/cornerstone/src/utils/ActiveViewportBehavior.tsx b/extensions/cornerstone/src/utils/ActiveViewportBehavior.tsx index 89a26000347..975c466c313 100644 --- a/extensions/cornerstone/src/utils/ActiveViewportBehavior.tsx +++ b/extensions/cornerstone/src/utils/ActiveViewportBehavior.tsx @@ -1,93 +1,80 @@ -import { useEffect, useState, memo, useCallback } from 'react'; - -const ActiveViewportBehavior = memo( - ({ servicesManager, viewportId }: withAppTypes<{ viewportId: string }>) => { - const { - displaySetService, - cineService, - viewportGridService, - customizationService, - cornerstoneViewportService, - } = servicesManager.services; - - const [activeViewportId, setActiveViewportId] = useState(viewportId); - - const handleCineEnable = useCallback(() => { - if (cineService.isViewportCineClosed(activeViewportId)) { - return; +import { useEffect, useState } from 'react'; + +const ActiveViewportBehavior = ({ + servicesManager, + viewportId, +}: withAppTypes<{ viewportId: string }>) => { + const { + displaySetService, + cineService, + viewportGridService, + customizationService, + cornerstoneViewportService, + } = servicesManager.services; + + const [activeViewportId, setActiveViewportId] = useState(viewportId); + + const handleCineEnable = () => { + if (cineService.isViewportCineClosed(activeViewportId)) { + return; + } + + const displaySetInstanceUIDs = + viewportGridService.getDisplaySetsUIDsForViewport(activeViewportId); + + if (!displaySetInstanceUIDs) { + return; + } + + const displaySets = displaySetInstanceUIDs.map(uid => + displaySetService.getDisplaySetByUID(uid) + ); + + if (!displaySets.length) { + return; + } + + const modalities = displaySets.map(displaySet => displaySet?.Modality); + const isDynamicVolume = displaySets.some(displaySet => displaySet?.isDynamicVolume); + + const sourceModalities = customizationService.getCustomization('autoCineModalities'); + + const requiresCine = modalities.some(modality => sourceModalities.includes(modality)); + + if ((requiresCine || isDynamicVolume) && !cineService.getState().isCineEnabled) { + cineService.setIsCineEnabled(true); + } + }; + + useEffect(() => { + const subscription = viewportGridService.subscribe( + viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED, + ({ viewportId }) => setActiveViewportId(viewportId) + ); + + return () => subscription.unsubscribe(); + }, [viewportId, viewportGridService]); + + useEffect(() => { + const subscription = cornerstoneViewportService.subscribe( + cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED, + () => { + const activeViewportId = viewportGridService.getActiveViewportId(); + setActiveViewportId(activeViewportId); + handleCineEnable(); } + ); - const displaySetInstanceUIDs = - viewportGridService.getDisplaySetsUIDsForViewport(activeViewportId); + return () => subscription.unsubscribe(); + }, [viewportId, cornerstoneViewportService, viewportGridService, handleCineEnable]); - if (!displaySetInstanceUIDs) { - return; - } - - const displaySets = displaySetInstanceUIDs.map(uid => - displaySetService.getDisplaySetByUID(uid) - ); - - if (!displaySets.length) { - return; - } - - const modalities = displaySets.map(displaySet => displaySet?.Modality); - const isDynamicVolume = displaySets.some(displaySet => displaySet?.isDynamicVolume); + useEffect(() => { + handleCineEnable(); + }, [handleCineEnable]); - const sourceModalities = customizationService.getCustomization('autoCineModalities'); - - const requiresCine = modalities.some(modality => sourceModalities.includes(modality)); - - if ((requiresCine || isDynamicVolume) && !cineService.getState().isCineEnabled) { - cineService.setIsCineEnabled(true); - } - }, [ - activeViewportId, - cineService, - viewportGridService, - displaySetService, - customizationService, - ]); - - useEffect(() => { - const subscription = viewportGridService.subscribe( - viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED, - ({ viewportId }) => setActiveViewportId(viewportId) - ); - - return () => subscription.unsubscribe(); - }, [viewportId, viewportGridService]); - - useEffect(() => { - const subscription = cornerstoneViewportService.subscribe( - cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED, - () => { - const activeViewportId = viewportGridService.getActiveViewportId(); - setActiveViewportId(activeViewportId); - handleCineEnable(); - } - ); - - return () => subscription.unsubscribe(); - }, [viewportId, cornerstoneViewportService, viewportGridService, handleCineEnable]); - - useEffect(() => { - handleCineEnable(); - }, [handleCineEnable]); - - return null; - }, - arePropsEqual -); + return null; +}; ActiveViewportBehavior.displayName = 'ActiveViewportBehavior'; -function arePropsEqual(prevProps, nextProps) { - return ( - prevProps.viewportId === nextProps.viewportId && - prevProps.servicesManager === nextProps.servicesManager - ); -} - export default ActiveViewportBehavior; diff --git a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx index f7a977efca2..5253c372b3f 100644 --- a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx +++ b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx @@ -29,6 +29,71 @@ type ViewportDownloadFormProps = { activeViewportId: string; }; +/** + * Mounts the source viewport's displayed content onto the capture viewport and + * re-applies its segmentation overlays, returning the clamped capture size. + * + * Module scope on purpose: the optional chaining inside this try/catch is a + * React Compiler limitation ("value blocks within a try/catch") that bails the + * whole component when inlined. Plain functions are never compiled. + */ +async function mountCaptureViewport( + viewport: any, + downloadViewport: any, + segmentationRepresentations: any[] | undefined, + width: number, + height: number +): Promise<{ width: number; height: number } | undefined> { + try { + // Capture current viewport state. The download (capture) viewport is created + // with the SAME type as the source (see handleEnableViewport), so source and + // capture are both legacy or both native, and the source's adapter can mount + // its displayed content (data + appearance + view state) onto the capture + // viewport directly. + await getViewportAdapter(viewport).copyDisplayedContentTo(downloadViewport); + + downloadViewport.render(); + + // Re-apply segmentation overlays to the download viewport + if (segmentationRepresentations?.length) { + segmentationRepresentations.forEach(segRepresentation => { + const { segmentationId, colorLUTIndex, type } = segRepresentation; + + if (type === Enums.SegmentationRepresentations.Labelmap) { + segmentation.addLabelmapRepresentationToViewportMap({ + [downloadViewport.id]: [ + { + segmentationId, + type: Enums.SegmentationRepresentations.Labelmap, + config: { colorLUTOrIndex: colorLUTIndex }, + }, + ], + }); + } + + if (type === Enums.SegmentationRepresentations.Contour) { + segmentation.addContourRepresentationToViewportMap({ + [downloadViewport.id]: [ + { + segmentationId, + type: Enums.SegmentationRepresentations.Contour, + config: { colorLUTOrIndex: colorLUTIndex }, + }, + ], + }); + } + }); + } + + return { + width: Math.min(width || DEFAULT_SIZE, MAX_TEXTURE_SIZE), + height: Math.min(height || DEFAULT_SIZE, MAX_TEXTURE_SIZE), + }; + } catch (error) { + console.error('Error loading image:', error); + } +} + const CornerstoneViewportDownloadForm = ({ hide, activeViewportId: activeViewportIdProp, @@ -118,54 +183,13 @@ const CornerstoneViewportDownloadForm = ({ const { viewport } = activeViewportEnabledElement; const downloadViewport = renderingEngine.getViewport(VIEWPORT_ID); - try { - // Capture current viewport state. The download (capture) viewport is created - // with the SAME type as the source (see handleEnableViewport), so source and - // capture are both legacy or both native, and the source's adapter can mount - // its displayed content (data + appearance + view state) onto the capture - // viewport directly. - await getViewportAdapter(viewport).copyDisplayedContentTo(downloadViewport); - - downloadViewport.render(); - - // Re-apply segmentation overlays to the download viewport - if (segmentationRepresentations?.length) { - segmentationRepresentations.forEach(segRepresentation => { - const { segmentationId, colorLUTIndex, type } = segRepresentation; - - if (type === Enums.SegmentationRepresentations.Labelmap) { - segmentation.addLabelmapRepresentationToViewportMap({ - [downloadViewport.id]: [ - { - segmentationId, - type: Enums.SegmentationRepresentations.Labelmap, - config: { colorLUTOrIndex: colorLUTIndex }, - }, - ], - }); - } - - if (type === Enums.SegmentationRepresentations.Contour) { - segmentation.addContourRepresentationToViewportMap({ - [downloadViewport.id]: [ - { - segmentationId, - type: Enums.SegmentationRepresentations.Contour, - config: { colorLUTOrIndex: colorLUTIndex }, - }, - ], - }); - } - }); - } - - return { - width: Math.min(width || DEFAULT_SIZE, MAX_TEXTURE_SIZE), - height: Math.min(height || DEFAULT_SIZE, MAX_TEXTURE_SIZE), - }; - } catch (error) { - console.error('Error loading image:', error); - } + return mountCaptureViewport( + viewport, + downloadViewport, + segmentationRepresentations, + width, + height + ); }; const handleToggleAnnotations = (show: boolean) => { diff --git a/extensions/cornerstone/src/utils/autoHydrateViewportTypes.ts b/extensions/cornerstone/src/utils/autoHydrateViewportTypes.ts new file mode 100644 index 00000000000..492c0c8fe81 --- /dev/null +++ b/extensions/cornerstone/src/utils/autoHydrateViewportTypes.ts @@ -0,0 +1,69 @@ +const CUSTOMIZATION_ID = 'cornerstone.segmentation.autoHydrateViewportTypes'; + +/** + * The customization is written in OHIF viewport types (the vocabulary of a + * hanging protocol's `viewportOptions.viewportType`: 'stack', 'volume', + * 'volume3d', ...), because that is the vocabulary a site configures in. + * + * Cornerstone's own enum is a different vocabulary and is not usable as the key: + * 'orthographic' is what a 'volume' viewport becomes, and under + * `useNextViewports` every planar viewport - stack and MPR alike - collapses to + * 'planarNext', which no configured list could name. So callers pass the OHIF + * type, and the few aliases that can still reach here are folded in. + * + * 'planarNext' deliberately maps to undefined rather than to a type: it is + * genuinely ambiguous (stack or volume), and guessing would gate viewports a + * site never meant to exclude. + */ +const TYPE_ALIASES: Record = { + orthographic: 'volume', + volume3dnext: 'volume3d', + videonext: 'video', + wholeslidenext: 'wholeslide', + ecgnext: 'ecg', + planarnext: undefined, +}; + +function normalizeViewportType(viewportType?: string): string | undefined { + if (!viewportType) { + return undefined; + } + + const lower = viewportType.toLowerCase(); + + return lower in TYPE_ALIASES ? TYPE_ALIASES[lower] : lower; +} + +/** + * Whether a hydrated segmentation may be shown automatically in a viewport of + * this type. + * + * Reads the `cornerstone.segmentation.autoHydrateViewportTypes` customization, + * which is `null` by default meaning "every type that can render it". A site + * that lists types narrows automatic hydration to those, which is how the + * expensive cases are opted out of (surface generation for a 3D viewport) + * without preventing a user from adding the segmentation there by hand. + */ +export function isAutoHydrateViewportType({ + viewportType, + customizationService, +}: { + viewportType?: string; + customizationService; +}): boolean { + const allowedTypes = customizationService?.getCustomization(CUSTOMIZATION_ID); + + if (!Array.isArray(allowedTypes)) { + return true; + } + + const normalized = normalizeViewportType(viewportType); + + // A viewport whose type we cannot determine is not excluded: the list is a + // narrowing of known types, not an allowlist to fail closed on. + if (!normalized) { + return true; + } + + return allowedTypes.some(allowed => normalizeViewportType(allowed) === normalized); +} diff --git a/extensions/cornerstone/src/utils/createSegmentationForViewport.ts b/extensions/cornerstone/src/utils/createSegmentationForViewport.ts index 24e40e867ab..a0bb977b31e 100644 --- a/extensions/cornerstone/src/utils/createSegmentationForViewport.ts +++ b/extensions/cornerstone/src/utils/createSegmentationForViewport.ts @@ -64,6 +64,7 @@ export async function createSegmentationForViewport( const segmentationCreationOptions = { label, segmentationId, + labelIsGenerated: !options.label, segments: _createDefaultSegments(options.createInitialSegment), }; diff --git a/extensions/cornerstone/src/utils/hydrationUtils.test.ts b/extensions/cornerstone/src/utils/hydrationUtils.test.ts index e10acc0089c..2a9ed2c2a0d 100644 --- a/extensions/cornerstone/src/utils/hydrationUtils.test.ts +++ b/extensions/cornerstone/src/utils/hydrationUtils.test.ts @@ -177,17 +177,23 @@ describe('getUpdatedViewportsForSegmentation', () => { expect(result).toEqual([]); }); - it('should handle viewport not found in viewports map', () => { + // Hydration is a statement about the display set, not about a viewport, so a + // missing or half-built target viewport must not throw. It also must not be + // matched against the hanging protocol - there is no viewport id to match + // with - so these fall through to frame-of-reference matching, which finds + // nothing here because no derived display set was supplied. + it('should not throw when the target viewport is not in the viewports map', () => { mockViewportGridService.getState.mockReturnValue({ isHangingProtocolLayout: true, viewports: new Map(), activeViewportId: 'non-existent-viewport', }); - expect(() => getUpdatedViewportsForSegmentation(defaultParameters)).toThrow(); + expect(getUpdatedViewportsForSegmentation(defaultParameters)).toEqual(null); + expect(mockHangingProtocolService.getViewportsRequireUpdate).not.toHaveBeenCalled(); }); - it('should handle viewport with missing viewportOptions', () => { + it('should not throw when the target viewport has no viewportOptions', () => { const viewportWithoutOptions = {}; const viewportsMap = new Map([['viewport-1', viewportWithoutOptions]]); @@ -197,10 +203,10 @@ describe('getUpdatedViewportsForSegmentation', () => { activeViewportId: 'active-viewport-id', }); - expect(() => getUpdatedViewportsForSegmentation(defaultParameters)).toThrow(); + expect(getUpdatedViewportsForSegmentation(defaultParameters)).toEqual(null); }); - it('should handle viewport with null viewportOptions', () => { + it('should not throw when the target viewport has null viewportOptions', () => { const viewportWithNullOptions = { viewportOptions: null, }; @@ -212,7 +218,7 @@ describe('getUpdatedViewportsForSegmentation', () => { activeViewportId: 'active-viewport-id', }); - expect(() => getUpdatedViewportsForSegmentation(defaultParameters)).toThrow(); + expect(getUpdatedViewportsForSegmentation(defaultParameters)).toEqual(null); }); it('should handle getViewportsRequireUpdate returning null', () => { @@ -313,6 +319,214 @@ describe('getUpdatedViewportsForSegmentation', () => { ]); }); + describe('eligibility matching', () => { + const displaySets = { + 'volume-1': { + displaySetInstanceUID: 'volume-1', + FrameOfReferenceUID: 'for-1', + isReconstructable: true, + }, + // A different series co-registered into the same frame of reference. A + // segmentation over a reconstructable volume is defined in that frame's + // world coordinates, so it is legitimate to draw it here too. + 'volume-2': { + displaySetInstanceUID: 'volume-2', + FrameOfReferenceUID: 'for-1', + isReconstructable: true, + }, + 'other-volume': { + displaySetInstanceUID: 'other-volume', + FrameOfReferenceUID: 'for-2', + isReconstructable: true, + }, + // Two unrelated non-reconstructable series that happen to share a frame + // of reference, which is common. Stack data is bound to specific images, + // so sharing the frame buys nothing here. + 'stack-1': { + displaySetInstanceUID: 'stack-1', + FrameOfReferenceUID: 'for-s', + isReconstructable: false, + }, + 'stack-2': { + displaySetInstanceUID: 'stack-2', + FrameOfReferenceUID: 'for-s', + isReconstructable: false, + }, + // A derived display set copies isReconstructable and FrameOfReferenceUID + // from the display set it references. + 'seg-1': { + displaySetInstanceUID: 'seg-1', + Modality: 'SEG', + FrameOfReferenceUID: 'for-1', + isReconstructable: true, + referencedDisplaySetInstanceUID: 'volume-1', + }, + 'rt-1': { + displaySetInstanceUID: 'rt-1', + Modality: 'RTSTRUCT', + FrameOfReferenceUID: 'for-s', + isReconstructable: false, + referencedDisplaySetInstanceUID: 'stack-1', + }, + }; + + const makeServices = (allowedViewportTypes = null) => + ({ + services: { + hangingProtocolService: mockHangingProtocolService, + viewportGridService: mockViewportGridService, + displaySetService: { + getDisplaySetByUID: (uid: string) => displaySets[uid], + }, + customizationService: { + getCustomization: jest.fn().mockReturnValue(allowedViewportTypes), + }, + }, + }) as unknown as AppTypes.ServicesManager; + + // The grid records OHIF viewport types ('stack' | 'volume' | 'volume3d'), + // which is the vocabulary the autoHydrateViewportTypes customization is + // written in. + const makeViewport = (viewportId: string, uids: string[], viewportType = 'volume') => [ + viewportId, + { + viewportId, + viewportOptions: { viewportId, viewportType }, + displaySetInstanceUIDs: uids, + }, + ]; + + const setViewports = (entries, activeViewportId) => { + mockViewportGridService.getState.mockReturnValue({ + isHangingProtocolLayout: true, + viewports: new Map(entries as never), + activeViewportId, + }); + }; + + it('should include co-registered volumes in the same frame of reference', () => { + setViewports( + [ + makeViewport('vp-axial', ['volume-1']), + makeViewport('vp-fusion', ['volume-2']), + makeViewport('vp-other', ['other-volume']), + ], + 'vp-axial' + ); + + mockHangingProtocolService.getViewportsRequireUpdate.mockReturnValue([ + { viewportId: 'vp-axial', displaySetInstanceUIDs: ['volume-1'] }, + ]); + + const result = getUpdatedViewportsForSegmentation({ + viewportId: 'vp-axial', + servicesManager: makeServices(), + displaySetInstanceUIDs: ['volume-1'], + derivedDisplaySetInstanceUID: 'seg-1', + }); + + // vp-other is a different frame of reference, so it is left alone. The + // co-registered pane keeps its own display set rather than having the + // referenced volume forced onto it. + expect(result).toEqual([ + { viewportId: 'vp-axial', displaySetInstanceUIDs: ['volume-1'] }, + { viewportId: 'vp-fusion', displaySetInstanceUIDs: ['volume-2'] }, + ]); + }); + + it('should not reach past its own display set when the reference is not reconstructable', () => { + setViewports( + [makeViewport('vp-stack-1', ['stack-1']), makeViewport('vp-stack-2', ['stack-2'])], + 'vp-stack-1' + ); + + mockHangingProtocolService.getViewportsRequireUpdate.mockReturnValue([ + { viewportId: 'vp-stack-1', displaySetInstanceUIDs: ['stack-1'] }, + ]); + + const result = getUpdatedViewportsForSegmentation({ + viewportId: 'vp-stack-1', + servicesManager: makeServices(), + displaySetInstanceUIDs: ['stack-1'], + derivedDisplaySetInstanceUID: 'rt-1', + }); + + expect(result).toEqual([{ viewportId: 'vp-stack-1', displaySetInstanceUIDs: ['stack-1'] }]); + }); + + it('should find targets with no viewport to match against', () => { + setViewports([makeViewport('vp-fusion', ['volume-2'])], 'gone-viewport'); + + const result = getUpdatedViewportsForSegmentation({ + viewportId: 'gone-viewport', + servicesManager: makeServices(), + displaySetInstanceUIDs: ['volume-1'], + derivedDisplaySetInstanceUID: 'seg-1', + }); + + expect(mockHangingProtocolService.getViewportsRequireUpdate).not.toHaveBeenCalled(); + expect(result).toEqual([{ viewportId: 'vp-fusion', displaySetInstanceUIDs: ['volume-2'] }]); + }); + + it('should exclude viewport types that automatic hydration is not allowed into', () => { + setViewports( + [makeViewport('vp-fusion', ['volume-2']), makeViewport('vp-3d', ['volume-2'], 'volume3d')], + 'gone-viewport' + ); + + const result = getUpdatedViewportsForSegmentation({ + viewportId: 'gone-viewport', + servicesManager: makeServices(['stack', 'volume']), + displaySetInstanceUIDs: ['volume-1'], + derivedDisplaySetInstanceUID: 'seg-1', + }); + + expect(result).toEqual([{ viewportId: 'vp-fusion', displaySetInstanceUIDs: ['volume-2'] }]); + }); + + it('should treat the cornerstone spelling of a viewport type as the OHIF one', () => { + setViewports( + [makeViewport('vp-fusion', ['volume-2']), makeViewport('vp-3d', ['volume-2'], 'volume3d')], + 'gone-viewport' + ); + + const result = getUpdatedViewportsForSegmentation({ + viewportId: 'gone-viewport', + servicesManager: makeServices(['stack', 'orthographic']), + displaySetInstanceUIDs: ['volume-1'], + derivedDisplaySetInstanceUID: 'seg-1', + }); + + expect(result).toEqual([{ viewportId: 'vp-fusion', displaySetInstanceUIDs: ['volume-2'] }]); + }); + + it('should keep the hanging protocol instruction for a pane that is also eligible', () => { + // The hydration target is showing a co-registered volume, so it matches on + // eligibility too - but the protocol is the one that knows the referenced + // volume has to be loaded into it. + setViewports( + [makeViewport('vp-axial', ['volume-2']), makeViewport('vp-fusion', ['volume-2'])], + 'vp-axial' + ); + + mockHangingProtocolService.getViewportsRequireUpdate.mockReturnValue([ + { viewportId: 'vp-axial', displaySetInstanceUIDs: ['volume-1'] }, + ]); + + const result = getUpdatedViewportsForSegmentation({ + viewportId: 'vp-axial', + servicesManager: makeServices(), + displaySetInstanceUIDs: ['volume-1'], + derivedDisplaySetInstanceUID: 'seg-1', + }); + + expect(result).toEqual([ + { viewportId: 'vp-axial', displaySetInstanceUIDs: ['volume-1'] }, + { viewportId: 'vp-fusion', displaySetInstanceUIDs: ['volume-2'] }, + ]); + }); + }); + it('should handle complex viewport structure', () => { const complexViewport = { viewportOptions: { diff --git a/extensions/cornerstone/src/utils/hydrationUtils.ts b/extensions/cornerstone/src/utils/hydrationUtils.ts index 19123ed11df..b6d71a158a6 100644 --- a/extensions/cornerstone/src/utils/hydrationUtils.ts +++ b/extensions/cornerstone/src/utils/hydrationUtils.ts @@ -1,29 +1,35 @@ import type { Types } from '@ohif/core'; +import { isDisplaySetOverlayable } from './isDisplaySetOverlayable'; +import { isAutoHydrateViewportType } from './autoHydrateViewportTypes'; /** * After SEG hydration we must refresh every viewport that shows the referenced volume so * presentations (including segmentation) apply to all MPR/3D tiles. Hanging-protocol matching * can return only the active viewport when protocol definitions omit viewportId (e.g. 3D four-up) * or when layout state diverges from the protocol; this merges in all grid panes that already - * list that volume in `displaySetInstanceUIDs`. + * list that volume in `displaySetInstanceUIDs`, plus every pane whose background the derived + * display set may be drawn over (`isDisplaySetOverlayable`, i.e. same frame of reference). * - * Only exact displaySetInstanceUID matches are used (no Frame-of-Reference inference): sibling - * MPR planes must already share the same referenced volume UID in grid state, or HP matching - * must list them; otherwise forcing a different UID onto a volume viewport can leave it blank. + * The frame-of-reference merge is what makes hydration mean "show it wherever it logically + * belongs" rather than "show it where the exact referenced UID happens to be hung": a pane built + * from a different display set of the same data (a multi-frame split, a reconstruction) is a + * legitimate place to show the segmentation, and the segmentation presentation store is keyed by + * frame of reference so the lookup in those panes resolves. */ -function mergeVolumeSharingViewports( +function mergeMatchingViewports( hangingProtocolUpdates: Types.HangingProtocol.ViewportUpdate[] | null | undefined, volumeUid: string | undefined, - viewports: AppTypes.ViewportGrid.GridViewports + viewports: AppTypes.ViewportGrid.Viewports, + isEligiblePane?: (viewport: AppTypes.ViewportGrid.Viewport) => boolean ): Types.HangingProtocol.ViewportUpdate[] { - if (!volumeUid) { + if (!volumeUid && !isEligiblePane) { return (hangingProtocolUpdates ?? []) as Types.HangingProtocol.ViewportUpdate[]; } const byId = new Map(); const add = (viewportId: string, uids: string[]) => { - if (!viewportId) { + if (!viewportId || !uids?.length) { return; } byId.set(viewportId, { viewportId, displaySetInstanceUIDs: uids }); @@ -39,9 +45,26 @@ function mergeVolumeSharingViewports( } viewports.forEach(vp => { + // The hanging-protocol pass ran first and is authoritative for the panes it names - it is the + // one that knows the referenced display set has to be *loaded* into the hydration target, + // whose current display sets are exactly what hydration is replacing. Overwriting its entry + // with the pane's existing UIDs would discard that instruction. + if (byId.has(vp.viewportId)) { + return; + } + const uids = vp.displaySetInstanceUIDs || []; - if (uids.includes(volumeUid)) { + + // Only exact displaySetInstanceUID matches force the referenced volume onto the pane; a pane + // that merely shares the frame of reference keeps the display sets it already has (forcing a + // different UID onto a volume viewport can leave it blank). + if (volumeUid && uids.includes(volumeUid)) { add(vp.viewportId, [volumeUid]); + return; + } + + if (isEligiblePane?.(vp)) { + add(vp.viewportId, uids); } }); @@ -58,29 +81,108 @@ function mergeVolumeSharingViewports( return merged; } +/** + * Builds a predicate saying whether a grid pane is a standard place to show the given derived + * display set: its background must be something the segmentation can be drawn over, and its + * viewport type must be one automatic hydration is allowed into. + * + * Returns undefined when there is no derived display set to reason about, so callers fall back to + * exact-UID matching alone. + */ +function makeEligiblePanePredicate({ + derivedDisplaySetInstanceUID, + servicesManager, +}: { + derivedDisplaySetInstanceUID?: string; + servicesManager: AppTypes.ServicesManager; +}) { + const { displaySetService, customizationService } = servicesManager.services; + + const derivedDisplaySet = derivedDisplaySetInstanceUID + ? displaySetService.getDisplaySetByUID(derivedDisplaySetInstanceUID) + : undefined; + + if (!derivedDisplaySet) { + return undefined; + } + + return (viewport: AppTypes.ViewportGrid.Viewport) => { + if ( + !isAutoHydrateViewportType({ + viewportType: viewport?.viewportOptions?.viewportType, + customizationService, + }) + ) { + return false; + } + + const backgroundUid = (viewport?.displaySetInstanceUIDs || []).find(uid => { + const ds = displaySetService.getDisplaySetByUID(uid); + return ds && !ds.isOverlayDisplaySet; + }); + + if (!backgroundUid) { + return false; + } + + return isDisplaySetOverlayable({ + displaySet: derivedDisplaySet, + backgroundDisplaySet: displaySetService.getDisplaySetByUID(backgroundUid), + }); + }; +} + function getUpdatedViewportsForSegmentation({ viewportId, servicesManager, displaySetInstanceUIDs, + derivedDisplaySetInstanceUID, }: withAppTypes) { const { hangingProtocolService, viewportGridService } = servicesManager.services; const { isHangingProtocolLayout, viewports } = viewportGridService.getState(); + const isEligiblePane = makeEligiblePanePredicate({ + derivedDisplaySetInstanceUID: derivedDisplaySetInstanceUID as string | undefined, + servicesManager, + }); + + // The target viewport may be gone (the layout changed while the hydration prompt was open, and + // promptHydrationDialog resolves after a user click and a setTimeout) or may never have existed + // (hydration driven from the study panel with nothing showing the referenced series). Hydration + // is a statement about the display set, not about a viewport, so a missing one is not fatal - + // fall through to eligibility matching instead of dereferencing it. const viewport = getTargetViewport({ viewportId, viewportGridService }); - const targetViewportId = viewport.viewportOptions.viewportId; + const targetViewportId = viewport?.viewportOptions?.viewportId; + + const updatedViewports = targetViewportId + ? hangingProtocolService.getViewportsRequireUpdate( + targetViewportId, + displaySetInstanceUIDs[0], + isHangingProtocolLayout + ) + : null; + + if (!isHangingProtocolLayout) { + // Outside a hanging protocol layout the protocol match is authoritative when there is one; + // eligibility matching only fills in when there was nothing to match against. + if (updatedViewports?.length) { + return updatedViewports; + } - const updatedViewports = hangingProtocolService.getViewportsRequireUpdate( - targetViewportId, - displaySetInstanceUIDs[0], - isHangingProtocolLayout - ); + return mergeMatchingViewports(null, undefined, viewports, isEligiblePane); + } - if (updatedViewports == null || !isHangingProtocolLayout) { + if (updatedViewports == null && !isEligiblePane) { return updatedViewports; } - return mergeVolumeSharingViewports(updatedViewports, displaySetInstanceUIDs[0], viewports); + return mergeMatchingViewports( + updatedViewports, + displaySetInstanceUIDs[0], + viewports, + isEligiblePane + ); } const getTargetViewport = ({ viewportId, viewportGridService }) => { diff --git a/extensions/cornerstone/src/utils/isDisplaySetOverlayable.ts b/extensions/cornerstone/src/utils/isDisplaySetOverlayable.ts new file mode 100644 index 00000000000..c79fd83429c --- /dev/null +++ b/extensions/cornerstone/src/utils/isDisplaySetOverlayable.ts @@ -0,0 +1,96 @@ +import { utilities as csUtils } from '@cornerstonejs/core'; + +/** + * Modalities that are drawn by a segmentation representation rather than being + * blended in as a second volume. + */ +export const DERIVED_OVERLAY_MODALITIES = ['SEG', 'RTSTRUCT']; + +/** + * Decides whether `displaySet` can be shown on top of `backgroundDisplaySet`. + * + * This is the single eligibility rule shared by the two places that need it: + * + * - the viewport data overlay menu, which asks it about one viewport's current + * background (see `getEnhancedDisplaySets`), and + * - hydration, which asks it about candidate backgrounds across the study and + * must be able to do so with no viewport in existence at all. + * + * It therefore takes display sets rather than a viewportId. Hydration is a + * statement about a display set ("show this wherever it logically belongs"), so + * anything it depends on has to be answerable from display sets alone. + */ +export function isDisplaySetOverlayable({ + displaySet, + backgroundDisplaySet, +}: { + displaySet; + backgroundDisplaySet; +}): boolean { + if (!displaySet || !backgroundDisplaySet) { + return false; + } + + if (displaySet.displaySetInstanceUID === backgroundDisplaySet.displaySetInstanceUID) { + return false; + } + + if (displaySet.unsupported) { + return false; + } + + // The frames of reference must agree when the candidate declares one. A + // candidate without one is not rejected here: not every overlayable display + // set carries a frame of reference. + if ( + displaySet.FrameOfReferenceUID && + displaySet.FrameOfReferenceUID !== backgroundDisplaySet.FrameOfReferenceUID + ) { + return false; + } + + if (DERIVED_OVERLAY_MODALITIES.includes(displaySet.Modality)) { + // The display set the overlay was made against always qualifies. Note it is + // not required to be reconstructable: an RTSTRUCT over a stack is exactly + // the case `getHydrationViewportTypeForModality` pins to 'stack' on + // hydrate, so the reconstructable gate below must not apply here. + if (displaySet.referencedDisplaySetInstanceUID === backgroundDisplaySet.displaySetInstanceUID) { + return true; + } + + // Beyond that display set, the frames of reference matching (checked above) + // is only meaningful when both sides are reconstructable volumes: the + // segmentation is then defined in world coordinates over that frame, so any + // co-registered volume in it is a legitimate place to draw it. Two display + // sets can share a frame of reference while being unrelated series, and a + // stack's data is bound to specific images, so a non-reconstructable side + // gets no reach past its own display set. + // + // A derived display set copies isReconstructable and FrameOfReferenceUID + // from the display set it references, so both are answers about the + // reference (see cornerstone-dicom-seg/getSopClassHandlerModule). + return Boolean( + displaySet.isReconstructable && + displaySet.FrameOfReferenceUID && + backgroundDisplaySet.isReconstructable + ); + } + + // A colormap overlay is blended in as a second volume, so both sides have to + // be valid volumes. + if (!backgroundDisplaySet.isReconstructable) { + return false; + } + + if (!csUtils.isValidVolume(backgroundDisplaySet.imageIds || [])) { + return false; + } + + const imageIds = displaySet.imageIds || displaySet.images?.map(image => image.imageId); + + if (!displaySet.isMultiFrame && imageIds?.length > 0 && !csUtils.isValidVolume(imageIds)) { + return false; + } + + return true; +} diff --git a/extensions/cornerstone/src/utils/loadDisplaySetData.ts b/extensions/cornerstone/src/utils/loadDisplaySetData.ts new file mode 100644 index 00000000000..0ee9c18ff79 --- /dev/null +++ b/extensions/cornerstone/src/utils/loadDisplaySetData.ts @@ -0,0 +1,49 @@ +import type { Types as OhifTypes } from '@ohif/core'; + +/** + * Loads a display set's data, for display sets that carry their own load + * operation (SEG, RTSTRUCT, PMAP, SR, PDF, video, ...). + * + * This is the viewport-independent half of getting a derived display set onto + * the screen. It fetches and decodes the data and registers the result - for a + * segmentation, that means the segmentation exists in the segmentation state - + * and it neither knows nor needs to know which viewport, if any, is going to + * display it. Choosing where the result is shown, and adding a representation + * to a viewport, is a separate viewport-scoped step. + * + * Keeping the two apart is what allows the decision about which overlays belong + * in which viewports to be made somewhere other than viewport assembly, without + * that decision having to drive loading as a side effect. + * + * The underlying `load` is memoized per display set and returns the same + * in-flight promise to every caller, so calling this repeatedly, concurrently, + * or earlier than the viewport that will show the result is safe and costs + * nothing after the first call. + * + * A load failure is reported to the user and otherwise swallowed: a display set + * that cannot be loaded must not stop whatever requested it from completing. + */ +export async function loadDisplaySetData( + displaySet: OhifTypes.DisplaySet, + servicesManager: AppTypes.ServicesManager +): Promise { + if (!(displaySet?.load instanceof Function)) { + return; + } + + const { userAuthenticationService, uiNotificationService } = servicesManager.services; + const headers = userAuthenticationService.getAuthorizationHeader(); + + try { + await displaySet.load({ headers }); + } catch (e) { + uiNotificationService.show({ + title: 'Error loading displaySet', + message: e.message, + type: 'error', + }); + console.error(e); + } +} + +export default loadDisplaySetData; diff --git a/extensions/cornerstone/src/utils/presentations/getViewportPresentations.test.ts b/extensions/cornerstone/src/utils/presentations/getViewportPresentations.test.ts new file mode 100644 index 00000000000..03a047737d0 --- /dev/null +++ b/extensions/cornerstone/src/utils/presentations/getViewportPresentations.test.ts @@ -0,0 +1,280 @@ +import { getViewportPresentations } from './getViewportPresentations'; +import { useSegmentationPresentationStore } from '../../stores/useSegmentationPresentationStore'; +import { usePositionPresentationStore } from '../../stores/usePositionPresentationStore'; +import { useLutPresentationStore } from '../../stores/useLutPresentationStore'; + +const displaySets = { + 'volume-1': { + displaySetInstanceUID: 'volume-1', + FrameOfReferenceUID: 'for-1', + isReconstructable: true, + }, + // A different series co-registered into the same frame of reference. + 'volume-2': { + displaySetInstanceUID: 'volume-2', + FrameOfReferenceUID: 'for-1', + isReconstructable: true, + }, + 'volume-3': { + displaySetInstanceUID: 'volume-3', + FrameOfReferenceUID: 'for-1', + isReconstructable: true, + }, + 'other-volume': { + displaySetInstanceUID: 'other-volume', + FrameOfReferenceUID: 'for-2', + isReconstructable: true, + }, + // Unrelated non-reconstructable series sharing a frame of reference. + 'stack-1': { + displaySetInstanceUID: 'stack-1', + FrameOfReferenceUID: 'for-s', + isReconstructable: false, + }, + 'stack-2': { + displaySetInstanceUID: 'stack-2', + FrameOfReferenceUID: 'for-s', + isReconstructable: false, + }, + 'seg-1': { + displaySetInstanceUID: 'seg-1', + Modality: 'SEG', + FrameOfReferenceUID: 'for-1', + isReconstructable: true, + referencedDisplaySetInstanceUID: 'volume-1', + isOverlayDisplaySet: true, + }, + 'rt-1': { + displaySetInstanceUID: 'rt-1', + Modality: 'RTSTRUCT', + FrameOfReferenceUID: 'for-s', + isReconstructable: false, + referencedDisplaySetInstanceUID: 'stack-1', + isOverlayDisplaySet: true, + }, +}; + +const displaySetService = { + getDisplaySetByUID: (uid: string) => displaySets[uid], +} as unknown as AppTypes.DisplaySetService; + +const viewportOptionsFor = (segmentationPresentationId: string) => + ({ + presentationIds: { + positionPresentationId: 'pos', + lutPresentationId: 'lut', + segmentationPresentationId, + }, + }) as unknown as AppTypes.ViewportGrid.GridViewportOptions; + +const segItem = (segmentationId: string) => ({ + segmentationId, + type: 'Labelmap', + hydrated: true, +}); + +describe('getViewportPresentations', () => { + beforeEach(() => { + useSegmentationPresentationStore.getState().clearSegmentationPresentationStore(); + usePositionPresentationStore.getState().clearPositionPresentationStore(); + useLutPresentationStore.getState().clearLutPresentationStore(); + }); + + it('returns nulls without presentationIds', () => { + const result = getViewportPresentations( + 'vp-1', + {} as unknown as AppTypes.ViewportGrid.GridViewportOptions + ); + + expect(result).toEqual({ + positionPresentation: null, + lutPresentation: null, + segmentationPresentation: null, + }); + }); + + it('returns the keyed entry for the display set the segmentation was hydrated against', () => { + const { addSegmentationPresentationItem } = useSegmentationPresentationStore.getState(); + addSegmentationPresentationItem('volume-1', segItem('seg-1')); + + const result = getViewportPresentations( + 'vp-1', + viewportOptionsFor('volume-1'), + [displaySets['volume-1']] as never, + displaySetService + ); + + expect(result.segmentationPresentation).toEqual([segItem('seg-1')]); + }); + + // The store cannot be keyed by frame of reference - unrelated series share + // one - so eligibility is resolved here instead, against this viewport's + // background display set. + it('picks up a segmentation hydrated against a co-registered volume', () => { + const { addSegmentationPresentationItem } = useSegmentationPresentationStore.getState(); + addSegmentationPresentationItem('volume-1', segItem('seg-1')); + + const result = getViewportPresentations( + 'vp-fusion', + viewportOptionsFor('volume-2'), + [displaySets['volume-2']] as never, + displaySetService + ); + + expect(result.segmentationPresentation).toEqual([segItem('seg-1')]); + }); + + it('does not pick up a segmentation from a different frame of reference', () => { + const { addSegmentationPresentationItem } = useSegmentationPresentationStore.getState(); + addSegmentationPresentationItem('volume-1', segItem('seg-1')); + + const result = getViewportPresentations( + 'vp-other', + viewportOptionsFor('other-volume'), + [displaySets['other-volume']] as never, + displaySetService + ); + + expect(result.segmentationPresentation).toEqual(null); + }); + + it('does not reach past its own display set when the reference is not reconstructable', () => { + const { addSegmentationPresentationItem } = useSegmentationPresentationStore.getState(); + addSegmentationPresentationItem('stack-1', segItem('rt-1')); + + const result = getViewportPresentations( + 'vp-stack-2', + viewportOptionsFor('stack-2'), + [displaySets['stack-2']] as never, + displaySetService + ); + + expect(result.segmentationPresentation).toEqual(null); + }); + + it('lets the keyed entry win over an eligible one for the same segmentation', () => { + const { addSegmentationPresentationItem } = useSegmentationPresentationStore.getState(); + // A stale eligible entry saying hydrated, and the authoritative keyed entry + // saying the user dismissed it. + addSegmentationPresentationItem('volume-1', segItem('seg-1')); + addSegmentationPresentationItem('volume-2', { ...segItem('seg-1'), hydrated: false }); + + const result = getViewportPresentations( + 'vp-fusion', + viewportOptionsFor('volume-2'), + [displaySets['volume-2']] as never, + displaySetService + ); + + expect(result.segmentationPresentation).toEqual([{ ...segItem('seg-1'), hydrated: false }]); + }); + + // storePresentation records `hydrated: null` for every pane a segmentation is + // merely rendered in, so a keyed entry of null must not be read as "dismissed" + // - otherwise one store/restore cycle would erase the relation that put the + // segmentation in this pane and it would silently vanish. + it('does not let a keyed no-statement entry override the hydration of its own display set', () => { + const { addSegmentationPresentationItem } = useSegmentationPresentationStore.getState(); + addSegmentationPresentationItem('volume-1', segItem('seg-1')); + addSegmentationPresentationItem('volume-2', { ...segItem('seg-1'), hydrated: null }); + + const result = getViewportPresentations( + 'vp-fusion', + viewportOptionsFor('volume-2'), + [displaySets['volume-2']] as never, + displaySetService + ); + + expect(result.segmentationPresentation).toEqual([segItem('seg-1')]); + }); + + it('keeps a keyed no-statement entry when nothing else states hydration', () => { + const { addSegmentationPresentationItem } = useSegmentationPresentationStore.getState(); + addSegmentationPresentationItem('volume-2', { ...segItem('seg-1'), hydrated: null }); + + const result = getViewportPresentations( + 'vp-fusion', + viewportOptionsFor('volume-2'), + [displaySets['volume-2']] as never, + displaySetService + ); + + expect(result.segmentationPresentation).toEqual([{ ...segItem('seg-1'), hydrated: null }]); + }); + + // With three co-registered panes, two keys hold an entry for the one + // segmentation: its own display set's, and the bookkeeping `hydrated: null` + // the second pane wrote on teardown. Neither is the third pane's own key, so + // both are reached by the scan and store order must not decide between them. + it('prefers the referenced display set entry over a no-statement one from another pane', () => { + const { addSegmentationPresentationItem } = useSegmentationPresentationStore.getState(); + addSegmentationPresentationItem('volume-1', segItem('seg-1')); + addSegmentationPresentationItem('volume-2', { ...segItem('seg-1'), hydrated: null }); + + const result = getViewportPresentations( + 'vp-third', + viewportOptionsFor('volume-3'), + [displaySets['volume-3']] as never, + displaySetService + ); + + expect(result.segmentationPresentation).toEqual([segItem('seg-1')]); + }); + + // Same pair of entries, written the other way round: the answer is the entry + // that states a hydration either way. + it('prefers the referenced display set entry whichever order the store holds them in', () => { + const { addSegmentationPresentationItem } = useSegmentationPresentationStore.getState(); + addSegmentationPresentationItem('volume-2', { ...segItem('seg-1'), hydrated: null }); + addSegmentationPresentationItem('volume-1', segItem('seg-1')); + + const result = getViewportPresentations( + 'vp-third', + viewportOptionsFor('volume-3'), + [displaySets['volume-3']] as never, + displaySetService + ); + + expect(result.segmentationPresentation).toEqual([segItem('seg-1')]); + }); + + // A dismissal recorded against the segmentation's own display set is what the + // third pane converges on, not the `hydrated: true` another pane still holds. + it('lets a dismissal on the referenced display set reach a third pane', () => { + const { addSegmentationPresentationItem } = useSegmentationPresentationStore.getState(); + addSegmentationPresentationItem('volume-2', segItem('seg-1')); + addSegmentationPresentationItem('volume-1', { ...segItem('seg-1'), hydrated: false }); + + const result = getViewportPresentations( + 'vp-third', + viewportOptionsFor('volume-3'), + [displaySets['volume-3']] as never, + displaySetService + ); + + expect(result.segmentationPresentation).toEqual([{ ...segItem('seg-1'), hydrated: false }]); + }); + + it('ignores overlay display sets when resolving the background', () => { + const { addSegmentationPresentationItem } = useSegmentationPresentationStore.getState(); + addSegmentationPresentationItem('volume-1', segItem('seg-1')); + + const result = getViewportPresentations( + 'vp-fusion', + viewportOptionsFor('volume-2'), + [displaySets['seg-1'], displaySets['volume-2']] as never, + displaySetService + ); + + expect(result.segmentationPresentation).toEqual([segItem('seg-1')]); + }); + + it('falls back to the keyed entry with no display sets supplied', () => { + const { addSegmentationPresentationItem } = useSegmentationPresentationStore.getState(); + addSegmentationPresentationItem('volume-1', segItem('seg-1')); + + const result = getViewportPresentations('vp-1', viewportOptionsFor('volume-1')); + + expect(result.segmentationPresentation).toEqual([segItem('seg-1')]); + }); +}); diff --git a/extensions/cornerstone/src/utils/presentations/getViewportPresentations.ts b/extensions/cornerstone/src/utils/presentations/getViewportPresentations.ts index bd55f5949ad..0ad0cba7b98 100644 --- a/extensions/cornerstone/src/utils/presentations/getViewportPresentations.ts +++ b/extensions/cornerstone/src/utils/presentations/getViewportPresentations.ts @@ -1,10 +1,119 @@ import { usePositionPresentationStore } from '../../stores/usePositionPresentationStore'; import { useLutPresentationStore } from '../../stores/useLutPresentationStore'; import { useSegmentationPresentationStore } from '../../stores/useSegmentationPresentationStore'; +import { isDisplaySetOverlayable } from '../isDisplaySetOverlayable'; +import type { SegmentationPresentation } from '../../types/Presentation'; + +/** + * Collects the segmentation presentation items that apply to this viewport. + * + * The store is keyed by the exact display set a segmentation was hydrated + * against, because two display sets can share a frame of reference while being + * unrelated series - so the frame of reference cannot be a key. But which + * viewports a hydrated segmentation belongs in is a relation, not a key: it is + * `isDisplaySetOverlayable`, which lets a segmentation over a reconstructable + * volume also apply to other co-registered volumes in the same frame of + * reference. So the keyed lookup is followed by a scan of the remaining entries + * for items that are overlayable on this viewport's background. + * + * The keyed entry wins on conflict: it is the segmentation's own display set, + * so its recorded type and hydration state are the authoritative ones. + * + * Provisional shape: this reads the store imperatively via getState() and + * rescans on every setViewportData, which is cheap today (one entry per + * referenced display set) but is not a hash lookup any more. The intended + * direction is a zustand store with selectors, so the relation is expressed as + * a memoized selector over the presentation state rather than a scan here. + */ +function getSegmentationPresentation({ + segmentationPresentationId, + segmentationPresentationStore, + displaySets, + displaySetService, +}): SegmentationPresentation | null { + const keyed = segmentationPresentationStore[segmentationPresentationId]; + + const backgroundDisplaySet = displaySets?.find(displaySet => !displaySet?.isOverlayDisplaySet); + + if (!backgroundDisplaySet || !displaySetService) { + return keyed ?? null; + } + + const bySegmentationId = new Map(); + // The segmentationIds bySegmentationId holds the referenced display set's own + // entry for. + const fromReferencedKey = new Set(); + + for (const [key, items] of Object.entries(segmentationPresentationStore)) { + if (key === segmentationPresentationId) { + continue; + } + + for (const item of (items as SegmentationPresentation) ?? []) { + const derivedDisplaySet = displaySetService.getDisplaySetByUID(item.segmentationId); + + if ( + !derivedDisplaySet || + !isDisplaySetOverlayable({ displaySet: derivedDisplaySet, backgroundDisplaySet }) + ) { + continue; + } + + // More than one key can carry an entry for the same segmentation once it + // reaches co-registered panes: the referenced display set's own key holds + // the hydration statement, while every other pane it is merely rendered + // in writes a bookkeeping `hydrated: null` through + // syncSegmentationPresentation. Object.entries order must not decide + // which of those a third pane resolves against - a null winning here + // would silently leave the segmentation out of that pane. So prefer the + // referenced display set's entry, then any entry that states a hydration + // at all. + const isReferencedKey = key === derivedDisplaySet.referencedDisplaySetInstanceUID; + const existing = bySegmentationId.get(item.segmentationId); + + if ( + existing && + !isReferencedKey && + (fromReferencedKey.has(item.segmentationId) || item.hydrated == null) + ) { + continue; + } + + if (isReferencedKey) { + fromReferencedKey.add(item.segmentationId); + } + + bySegmentationId.set(item.segmentationId, item); + } + } + + for (const item of keyed ?? []) { + const related = bySegmentationId.get(item.segmentationId); + + // The keyed entry wins on type/config, but `hydrated: null` is "no + // statement", not "not hydrated" - storePresentation writes exactly that + // for every pane a segmentation is merely *rendered* in, since hydration is + // owned by the referenced display set's own entry. Letting it overwrite an + // explicit hydration would erase the relation after one store/restore + // cycle, and the segmentation would silently vanish from the co-registered + // panes it reached through this scan. + const hydrated = item.hydrated == null && related ? related.hydrated : item.hydrated; + + bySegmentationId.set(item.segmentationId, { ...item, hydrated }); + } + + if (!bySegmentationId.size) { + return keyed ?? null; + } + + return Array.from(bySegmentationId.values()); +} export function getViewportPresentations( viewportId: string, - viewportOptions: AppTypes.ViewportGrid.GridViewportOptions + viewportOptions: AppTypes.ViewportGrid.GridViewportOptions, + displaySets?: AppTypes.DisplaySet[], + displaySetService?: AppTypes.DisplaySetService ) { const { lutPresentationStore } = useLutPresentationStore.getState(); const { positionPresentationStore } = usePositionPresentationStore.getState(); @@ -26,7 +135,13 @@ export function getViewportPresentations( const positionPresentation = positionPresentationStore[positionPresentationId]; const lutPresentation = lutPresentationStore[lutPresentationId]; - const segmentationPresentation = segmentationPresentationStore[segmentationPresentationId]; + + const segmentationPresentation = getSegmentationPresentation({ + segmentationPresentationId, + segmentationPresentationStore, + displaySets, + displaySetService, + }); return { positionPresentation, diff --git a/extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.ts b/extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.ts index 42a459869c1..d46aae7ad6e 100644 --- a/extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.ts +++ b/extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.ts @@ -65,6 +65,23 @@ export const setUpSegmentationEventHandlers = ({ servicesManager, commandsManage // Remove the display set layer from all viewports that have it if (displaySet) { + // This is the global removal path (the segmentation is gone from state, + // e.g. deleted from the segmentation panel), so clear the global + // "show this wherever it logically belongs" state. It must happen + // outside the loop below: that loop only visits viewports carrying the + // segmentation as an explicit layer, and a hydrated segmentation is + // never listed in a viewport's displaySetInstanceUIDs - only the + // referenced volume is. Recording `hydrated: false` rather than just + // dropping the entry keeps the store a statement of desired state, so a + // viewport created later converges on "not shown" instead of replaying + // an earlier `hydrated: true`. + displaySet.isHydrated = false; + + commandsManager.runCommand('updateStoredSegmentationPresentation', { + displaySet, + hydrated: false, + }); + const state = viewportGridService.getState(); const viewports = state.viewports; diff --git a/extensions/default/jest.config.js b/extensions/default/jest.config.js index 0411b1f8307..7e71418e1d5 100644 --- a/extensions/default/jest.config.js +++ b/extensions/default/jest.config.js @@ -4,6 +4,10 @@ module.exports = { ...base, moduleNameMapper: { ...base.moduleNameMapper, + // Deep imports already name `src`, so they must be matched before the + // catch-all below appends a second one (e.g. `@ohif/core/src/utils/x` + // would otherwise resolve to `platform/core/src/utils/x/src`). + '^@ohif/([^/]+)/src/(.*)$': '/../../platform/$1/src/$2', '@ohif/(.*)': '/../../platform/$1/src', }, // rootDir: "../.." diff --git a/extensions/default/package.json b/extensions/default/package.json index dc54607a264..79c5a728f6f 100644 --- a/extensions/default/package.json +++ b/extensions/default/package.json @@ -1,6 +1,6 @@ { "name": "@ohif/extension-default", - "version": "3.13.0-beta.135", + "version": "3.14.0-beta.44", "description": "Common/default features and functionality for basic image viewing", "author": "OHIF Core Team", "license": "MIT", @@ -33,9 +33,8 @@ "@ohif/i18n": "workspace:*", "dcmjs": "0.52.0", "dicomweb-client": "0.10.4", - "prop-types": "15.8.1", - "react": "18.3.1", - "react-dom": "18.3.1", + "react": "19.2.7", + "react-dom": "19.2.7", "react-i18next": "12.3.1", "react-window": "1.8.11", "webpack-merge": "5.10.0" diff --git a/extensions/default/src/Components/DataSourceConfigurationComponent.tsx b/extensions/default/src/Components/DataSourceConfigurationComponent.tsx index 7f26540a99c..910747fbead 100644 --- a/extensions/default/src/Components/DataSourceConfigurationComponent.tsx +++ b/extensions/default/src/Components/DataSourceConfigurationComponent.tsx @@ -18,7 +18,7 @@ import DataSourceConfigurationModalComponent from './DataSourceConfigurationModa function DataSourceConfigurationComponent({ servicesManager, extensionManager, -}: withAppTypes): ReactElement { +}: withAppTypes): ReactElement { const { t } = useTranslation('DataSourceConfiguration'); const { show, hide } = useModal(); @@ -87,7 +87,7 @@ function DataSourceConfigurationComponent({ onHide: hide, }, }); - }, [configurationAPI, configuredItems]); + }, [configurationAPI, configuredItems, show, t, hide]); useEffect(() => { if (!configurationAPI || !configuredItems) { diff --git a/extensions/default/src/Components/DataSourceConfigurationModalComponent.tsx b/extensions/default/src/Components/DataSourceConfigurationModalComponent.tsx index 4e2cb97707c..43b47303da4 100644 --- a/extensions/default/src/Components/DataSourceConfigurationModalComponent.tsx +++ b/extensions/default/src/Components/DataSourceConfigurationModalComponent.tsx @@ -115,7 +115,7 @@ function DataSourceConfigurationModalComponent({ const getSelectedItemTextClasses = itemIndex => itemIndex <= selectedItems.length ? 'text-highlight' : 'text-primary'; - const getErrorComponent = (): ReactElement => { + const getErrorComponent = (): ReactElement => { return (
@@ -126,7 +126,7 @@ function DataSourceConfigurationModalComponent({ ); }; - const getSelectedItemsComponent = (): ReactElement => { + const getSelectedItemsComponent = (): ReactElement => { return (
{itemLabels.map((itemLabel, itemLabelIndex) => { diff --git a/extensions/default/src/Components/ItemListComponent.tsx b/extensions/default/src/Components/ItemListComponent.tsx index ebc348b4889..ab30f3f6082 100644 --- a/extensions/default/src/Components/ItemListComponent.tsx +++ b/extensions/default/src/Components/ItemListComponent.tsx @@ -15,7 +15,7 @@ function ItemListComponent({ itemLabel, itemList, onItemClicked, -}: ItemListComponentProps): ReactElement { +}: ItemListComponentProps): ReactElement { const { servicesManager } = useSystem(); const { t } = useTranslation('DataSourceConfiguration'); const [filterValue, setFilterValue] = useState(''); diff --git a/extensions/default/src/Components/LineChartViewport/LineChartViewport.tsx b/extensions/default/src/Components/LineChartViewport/LineChartViewport.tsx index 23afb5f45e7..56e3cd133c9 100644 --- a/extensions/default/src/Components/LineChartViewport/LineChartViewport.tsx +++ b/extensions/default/src/Components/LineChartViewport/LineChartViewport.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { LineChart } from '@ohif/ui-next'; const LineChartViewport = ({ displaySets }) => { diff --git a/extensions/default/src/Components/ProgressDropdownWithService.tsx b/extensions/default/src/Components/ProgressDropdownWithService.tsx index 68eb7f73d13..1a9ccce8d00 100644 --- a/extensions/default/src/Components/ProgressDropdownWithService.tsx +++ b/extensions/default/src/Components/ProgressDropdownWithService.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback, ReactElement } from 'react'; +import React, { useEffect, useState, ReactElement } from 'react'; import { ProgressDropdown } from '@ohif/ui-next'; import { useSystem } from '@ohif/core'; @@ -11,7 +11,7 @@ const workflowStepsToDropdownOptions = (steps = []) => completed: false, })); -export function ProgressDropdownWithService(): ReactElement { +export function ProgressDropdownWithService(): ReactElement { const { servicesManager } = useSystem(); const { workflowStepsService } = servicesManager.services; const [activeStepId, setActiveStepId] = useState(workflowStepsService.activeWorkflowStep?.id); @@ -20,7 +20,7 @@ export function ProgressDropdownWithService(): ReactElement { workflowStepsToDropdownOptions(workflowStepsService.workflowSteps) ); - const setCurrentAndPreviousOptionsAsCompleted = useCallback(currentOption => { + const setCurrentAndPreviousOptionsAsCompleted = currentOption => { if (currentOption.completed) { return; } @@ -44,21 +44,18 @@ export function ProgressDropdownWithService(): ReactElement { return newOptionsState; }); - }, []); + }; - const handleDropdownChange = useCallback( - ({ selectedOption }) => { - if (!selectedOption) { - return; - } + const handleDropdownChange = ({ selectedOption }) => { + if (!selectedOption) { + return; + } - // TODO: Steps should be marked as completed after user has - // completed some action when required (not implemented) - setCurrentAndPreviousOptionsAsCompleted(selectedOption); - setActiveStepId(selectedOption.value); - }, - [setCurrentAndPreviousOptionsAsCompleted] - ); + // TODO: Steps should be marked as completed after user has + // completed some action when required (not implemented) + setCurrentAndPreviousOptionsAsCompleted(selectedOption); + setActiveStepId(selectedOption.value); + }; useEffect(() => { let timeoutId; diff --git a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx index 50e43d292b7..016ae43c0ae 100644 --- a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx +++ b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx @@ -45,7 +45,6 @@ const DicomTagBrowser = ({ const [selectedDisplaySetInstanceUID, setSelectedDisplaySetInstanceUID] = useState(displaySetInstanceUID); const [instanceNumber, setInstanceNumber] = useState(1); - const [shouldShowInstanceList, setShouldShowInstanceList] = useState(false); const [filterValue, setFilterValue] = useState(''); const onSelectChange = value => { @@ -92,11 +91,16 @@ const DicomTagBrowser = ({ [activeDisplaySet, instanceNumber] ); + // Derived, not state: it is a pure function of the active display set, and + // computing it inside the rows memo meant the JSX below read the previous + // render's value. + const shouldShowInstanceList = + activeDisplaySet instanceof ImageSet && activeDisplaySet.images.length > 1; + const rows = useMemo(() => { const isImageStack = activeDisplaySet instanceof ImageSet; const metadata = getMetadata(isImageStack); - setShouldShowInstanceList(isImageStack && activeDisplaySet.images.length > 1); const tags = getSortedTags(metadata); const rows = getFormattedRowsFromTags({ tags, metadata }); return rows; diff --git a/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx b/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx index 6da3b890343..ed2a80dc4d5 100644 --- a/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx +++ b/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx @@ -39,7 +39,7 @@ const RowComponent = ({
); } +/** + * Measures the tallest column of one row, in pixels, using the header column + * widths and a canvas for text metrics. + * + * Takes the header elements as arguments rather than closing over them: the + * compiler infers a callback's dependencies from its body, so reading + * `el.offsetWidth` inside a component-level callback gets hoisted into render, + * where these are still null. Passing the elements in means the emitted guard + * compares the references instead, and there is no property path to hoist. + */ +function measureRowHeight(row, headerElems, canvas) { + if (!canvas || headerElems.some(elem => !elem)) { + return 0; + } + + const headerWidths = headerElems.map(elem => elem.offsetWidth); + + const context = canvas.getContext('2d'); + context.font = getComputedStyle(canvas).font; + + const propertiesToCheck = ['tag', 'valueRepresentation', 'keyword', 'value']; + + return Object.entries(row) + .filter(([key]) => propertiesToCheck.includes(key)) + .map(([, colText], index) => { + const colOneLineWidth = context.measureText(colText).width; + const numLines = Math.ceil(colOneLineWidth / headerWidths[index]); + return numLines * lineHeightPx + 2 * rowVerticalPaddingPx + rowBottomBorderPx; + }) + .reduce((maxHeight, colHeight) => Math.max(maxHeight, colHeight), 0); +} + function DicomTagTable({ rows }: { rows: Row[] }) { - const listRef = useRef(); - const canvasRef = useRef(); + const listRef = useRef(null); + const canvasRef = useRef(null); const [tagHeaderElem, setTagHeaderElem] = useState(null); const [vrHeaderElem, setVrHeaderElem] = useState(null); @@ -180,44 +212,19 @@ function DicomTagTable({ rows }: { rows: Row[] }) { }; }, []); - const getOneRowHeight = useCallback( - row => { - const headerWidths = [ - tagHeaderElem.offsetWidth, - vrHeaderElem.offsetWidth, - keywordHeaderElem.offsetWidth, - valueHeaderElem.offsetWidth, - ]; - - const context = canvasRef.current.getContext('2d'); - context.font = getComputedStyle(canvasRef.current).font; - - const propertiesToCheck = ['tag', 'valueRepresentation', 'keyword', 'value']; - - return Object.entries(row) - .filter(([key]) => propertiesToCheck.includes(key)) - .map(([, colText], index) => { - const colOneLineWidth = context.measureText(colText).width; - const numLines = Math.ceil(colOneLineWidth / headerWidths[index]); - return numLines * lineHeightPx + 2 * rowVerticalPaddingPx + rowBottomBorderPx; - }) - .reduce((maxHeight, colHeight) => Math.max(maxHeight, colHeight), 0); - }, - [keywordHeaderElem, tagHeaderElem, valueHeaderElem, vrHeaderElem] - ); - /** * Get the item/row size. We use the header column widths to calculate the various row heights. * @param index the row index * @returns the row height */ const getItemSize = useCallback( - rows => index => { - const row = rows[index]; - const height = getOneRowHeight(row); - return height; - }, - [getOneRowHeight] + rows => index => + measureRowHeight( + rows[index], + [tagHeaderElem, vrHeaderElem, keywordHeaderElem, valueHeaderElem], + canvasRef.current + ), + [tagHeaderElem, vrHeaderElem, keywordHeaderElem, valueHeaderElem] ); const onToggle = useCallback( @@ -247,7 +254,7 @@ function DicomTagTable({ rows }: { rows: Row[] }) { const getRowComponent = useCallback( ({ rows }: { rows: Row[] }) => function RowList({ index, style }) { - const row = useMemo(() => rows[index], [index]); + const row = rows[index]; return (
{isHeaderRendered() && ( diff --git a/extensions/default/src/DicomWebDataSource/index.ts b/extensions/default/src/DicomWebDataSource/index.ts index 56fcb28e453..9417c528d63 100644 --- a/extensions/default/src/DicomWebDataSource/index.ts +++ b/extensions/default/src/DicomWebDataSource/index.ts @@ -43,6 +43,7 @@ export type DicomWebConfig = { /** Base URL to use for QIDO requests */ qidoRoot?: string; wadoRoot?: string; // - Base URL to use for WADO requests + stowRoot?: string; // - Base URL to use for STOW requests (defaults to wadoRoot) wadoUri?: string; // - Base URL to use for WADO URI requests qidoSupportsIncludeField?: boolean; // - Whether QIDO supports the "Include" option to request additional fields in response imageRendering?: string; // - wadors | ? (unsure of where/how this is used) @@ -137,13 +138,7 @@ export const excludeTransferSyntax: HeaderOptions = { includeTransferSyntax: fal */ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { const { userAuthenticationService } = servicesManager.services; - let dicomWebConfigCopy, - qidoConfig, - wadoConfig, - qidoDicomWebClient, - wadoDicomWebClient, - getAuthorizationHeader, - generateWadoHeader; + let dicomWebConfigCopy, clientConfig, dicomWebClient, getAuthorizationHeader, generateWadoHeader; // Default to enabling bulk data retrieves, with no other customization as // this is part of hte base standard. dicomWebConfig.bulkDataURI ||= { enabled: true }; @@ -173,7 +168,7 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { // handle the scenarios where bulkDataURI is relative path fixBulkDataURI(value, instance, dicomWebConfig); // Provide a method to fetch bulkdata - value.retrieveBulkData = retrieveBulkData.bind(qidoDicomWebClient, value); + value.retrieveBulkData = retrieveBulkData.bind(dicomWebClient, value); } } return naturalized; @@ -245,17 +240,14 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { } }; - qidoConfig = { - url: dicomWebConfig.qidoRoot, - staticWado: dicomWebConfig.staticWado, - singlepart: dicomWebConfig.singlepart, - headers: userAuthenticationService.getAuthorizationHeader(), - errorInterceptor: errorHandler.getHTTPErrorHandler(), - supportsFuzzyMatching: dicomWebConfig.supportsFuzzyMatching, - }; + // Each service falls back to the other configured roots so that no URL is + // left undefined when a deployment configures only one of them. + const qidoURL = dicomWebConfig.qidoRoot ?? dicomWebConfig.wadoRoot; + const wadoURL = dicomWebConfig.wadoRoot ?? dicomWebConfig.qidoRoot; + const stowURL = dicomWebConfig.stowRoot ?? wadoURL; - wadoConfig = { - url: dicomWebConfig.wadoRoot, + clientConfig = { + url: wadoURL, staticWado: dicomWebConfig.staticWado, singlepart: dicomWebConfig.singlepart, headers: userAuthenticationService.getAuthorizationHeader(), @@ -263,28 +255,32 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { supportsFuzzyMatching: dicomWebConfig.supportsFuzzyMatching, }; - // TODO -> Two clients sucks, but its better than 1000. - // TODO -> We'll need to merge auth later. - qidoDicomWebClient = dicomWebConfig.staticWado - ? new StaticWadoClient(qidoConfig) - : new api.DICOMwebClient(qidoConfig); - - wadoDicomWebClient = dicomWebConfig.staticWado - ? new StaticWadoClient(wadoConfig) - : new api.DICOMwebClient(wadoConfig); + dicomWebClient = dicomWebConfig.staticWado + ? new StaticWadoClient(clientConfig) + : new api.DICOMwebClient(clientConfig); + + // dicomweb-client reads `qidoURL`, `wadoURL` and `stowURL` fresh on every + // request, so a single client can serve all three services. Its + // constructor can only differentiate them via the `*URLPrefix` options, + // which are concatenated onto the single `baseURL` - OHIF's roots are + // independent absolute URLs that need not even share a host, so the + // prefixes cannot express them and the fields are assigned directly. + dicomWebClient.qidoURL = qidoURL; + dicomWebClient.wadoURL = wadoURL; + dicomWebClient.stowURL = stowURL; }, query: { studies: { mapParams: mapParams.bind(), search: async function (origParams) { - qidoDicomWebClient.headers = getAuthorizationHeader(); + dicomWebClient.headers = getAuthorizationHeader(); const { studyInstanceUid, seriesInstanceUid, ...mappedParams } = mapParams(origParams, { supportsFuzzyMatching: dicomWebConfig.supportsFuzzyMatching, supportsWildcard: dicomWebConfig.supportsWildcard, }) || {}; - const results = await qidoSearch(qidoDicomWebClient, undefined, undefined, mappedParams); + const results = await qidoSearch(dicomWebClient, undefined, undefined, mappedParams); return processResults(results); }, @@ -293,8 +289,8 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { series: { // mapParams: mapParams.bind(), search: async function (studyInstanceUid) { - qidoDicomWebClient.headers = getAuthorizationHeader(); - const results = await seriesInStudy(qidoDicomWebClient, studyInstanceUid); + dicomWebClient.headers = getAuthorizationHeader(); + const results = await seriesInStudy(dicomWebClient, studyInstanceUid); return processSeriesResults(results); }, @@ -302,10 +298,10 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { }, instances: { search: (studyInstanceUid, queryParameters) => { - qidoDicomWebClient.headers = getAuthorizationHeader(); + dicomWebClient.headers = getAuthorizationHeader(); return qidoSearch.call( undefined, - qidoDicomWebClient, + dicomWebClient, studyInstanceUid, null, queryParameters @@ -332,7 +328,7 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { imageId, config: dicomWebConfig, getAuthorizationHeader, - qidoDicomWebClient, + qidoDicomWebClient: dicomWebClient, retrieve: this, }); }, @@ -357,10 +353,10 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { * Provide direct access to the dicom web client for certain use cases * where the dicom web client is used by an external library such as the * microscopy viewer. - * Note this instance only needs to support the wado queries, and may not - * support any QIDO or STOW operations. + * The returned instance is configured for all three services, so QIDO and + * STOW operations are also routed to the correct root. */ - getWadoDicomWebClient: () => wadoDicomWebClient, + getWadoDicomWebClient: () => dicomWebClient, /** * Best-effort prefetch of a whole multiframe instance as a single Part 10 @@ -398,8 +394,8 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { // instance as an ArrayBuffer, unwrapping multipart/related transparently // (and returning the raw object for single-part responses). const resolvePart10 = async () => { - wadoDicomWebClient.headers = getAuthorizationHeader(); - const result = await wadoDicomWebClient.retrieveInstance({ + dicomWebClient.headers = getAuthorizationHeader(); + const result = await dicomWebClient.retrieveInstance({ studyInstanceUID: StudyInstanceUID, seriesInstanceUID: SeriesInstanceUID, sopInstanceUID: SOPInstanceUID, @@ -443,13 +439,13 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { }, bulkDataURI: async ({ StudyInstanceUID, BulkDataURI }) => { - qidoDicomWebClient.headers = getAuthorizationHeader(); + dicomWebClient.headers = getAuthorizationHeader(); const options = { multipart: false, BulkDataURI, StudyInstanceUID, }; - return qidoDicomWebClient.retrieveBulkData(options).then(val => { + return dicomWebClient.retrieveBulkData(options).then(val => { const ret = (val && val[0]) || undefined; return ret; }); @@ -491,13 +487,13 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { store: { dicom: async (dataset, request, dicomDict) => { - wadoDicomWebClient.headers = getAuthorizationHeader(); + dicomWebClient.headers = getAuthorizationHeader(); if (dataset instanceof ArrayBuffer) { const options = { datasets: [dataset], request, }; - await wadoDicomWebClient.storeInstances(options); + await dicomWebClient.storeInstances(options); } else { let effectiveDicomDict = dicomDict; @@ -525,7 +521,7 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { request, }; - await wadoDicomWebClient.storeInstances(options); + await dicomWebClient.storeInstances(options); } }, }, @@ -538,10 +534,10 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { madeInClient ) => { const enableStudyLazyLoad = false; - wadoDicomWebClient.headers = generateWadoHeader(excludeTransferSyntax); + dicomWebClient.headers = generateWadoHeader(excludeTransferSyntax); // data is all SOPInstanceUIDs const data = await retrieveStudyMetadata( - wadoDicomWebClient, + dicomWebClient, StudyInstanceUID, enableStudyLazyLoad, filters, @@ -556,9 +552,10 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { // Resolve the registered bulkdata tags (e.g. the Philips SUV Scale // Factor) delivered as bulkdata into plain numbers BEFORE - // INSTANCES_ADDED fires. retrieveBulkData is bound to qidoDicomWebClient, - // so refresh its auth headers first (matching every other qido op here). - qidoDicomWebClient.headers = getAuthorizationHeader(); + // INSTANCES_ADDED fires. retrieveBulkData is bound to the shared + // dicomWebClient, so refresh its auth headers first (matching every other + // qido op here). + dicomWebClient.headers = getAuthorizationHeader(); await utils.resolveBulkDataTags(naturalizedInstancesMetadata); const seriesSummaryMetadata = {}; @@ -620,11 +617,11 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { returnPromises = false ) => { const enableStudyLazyLoad = true; - wadoDicomWebClient.headers = generateWadoHeader(excludeTransferSyntax); + dicomWebClient.headers = generateWadoHeader(excludeTransferSyntax); // Get Series const { preLoadData: seriesSummaryMetadata, promises: seriesPromises } = await retrieveStudyMetadata( - wadoDicomWebClient, + dicomWebClient, StudyInstanceUID, enableStudyLazyLoad, filters, @@ -641,9 +638,10 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) { // Factor) that the server delivered as bulkdata into plain numbers // BEFORE INSTANCES_ADDED fires, so SUV scaling and every other // subscriber read a fully-resolved value rather than an unresolved - // { BulkDataURI }. retrieveBulkData is bound to qidoDicomWebClient, so - // refresh its auth headers first (matching every other qido op here). - qidoDicomWebClient.headers = getAuthorizationHeader(); + // { BulkDataURI }. retrieveBulkData is bound to the shared + // dicomWebClient, so refresh its auth headers first (matching every + // other qido op here). + dicomWebClient.headers = getAuthorizationHeader(); await utils.resolveBulkDataTags(naturalizedInstances); // Adding instanceMetadata to OHIF MetadataProvider diff --git a/extensions/default/src/DicomWebDataSource/qido.js b/extensions/default/src/DicomWebDataSource/qido.js index 38d3773b800..641d798e18e 100644 --- a/extensions/default/src/DicomWebDataSource/qido.js +++ b/extensions/default/src/DicomWebDataSource/qido.js @@ -83,7 +83,10 @@ export function processSeriesResults(qidoSeries) { seriesInstanceUid: getString(qidoSeries['0020000E']), modality: getString(qidoSeries['00080060']), seriesNumber: getString(qidoSeries['00200011']), - seriesDate: utils.formatDate(getString(qidoSeries['00080021'])), + // The raw DICOM DA value: `sortStudySeries` orders by it and cannot + // read a date already formatted for display, so it is formatted below, + // once the order is settled. + seriesDate: getString(qidoSeries['00080021']), numSeriesInstances: Number(getString(qidoSeries['00201209'])), description: getString(qidoSeries['0008103E']), }) @@ -92,7 +95,10 @@ export function processSeriesResults(qidoSeries) { sortStudySeries(series); - return series; + return series.map(result => ({ + ...result, + seriesDate: utils.formatDate(result.seriesDate), + })); } /** diff --git a/extensions/default/src/DicomWebDataSource/retrieveStudyMetadata.js b/extensions/default/src/DicomWebDataSource/retrieveStudyMetadata.js index 9c3c5901fdc..680d10ea42c 100644 --- a/extensions/default/src/DicomWebDataSource/retrieveStudyMetadata.js +++ b/extensions/default/src/DicomWebDataSource/retrieveStudyMetadata.js @@ -82,10 +82,27 @@ export function retrieveStudyMetadata( * Delete the cached study metadata retrieval promise to ensure that the browser will * re-retrieve the study metadata when it is next requested. * + * Callers know only the study, so this matches every + * `:` key rather than looking the bare UID + * up as one. + * * @param {String} StudyInstanceUID The UID of the Study to be removed from cache */ export function deleteStudyMetadataPromise(StudyInstanceUID) { - if (StudyMetaDataPromises.has(StudyInstanceUID)) { - StudyMetaDataPromises.delete(StudyInstanceUID); + if (!StudyInstanceUID) { + return; } + + const suffix = `:${StudyInstanceUID}`; + + for (const promiseId of [...StudyMetaDataPromises.keys()]) { + if (promiseId === StudyInstanceUID || promiseId.endsWith(suffix)) { + StudyMetaDataPromises.delete(promiseId); + } + } +} + +/** Test seam: the cached promises, so a test can assert what invalidation removed. */ +export function _getStudyMetadataPromiseCache() { + return StudyMetaDataPromises; } diff --git a/extensions/default/src/DicomWebDataSource/retrieveStudyMetadata.test.js b/extensions/default/src/DicomWebDataSource/retrieveStudyMetadata.test.js new file mode 100644 index 00000000000..c88724827c0 --- /dev/null +++ b/extensions/default/src/DicomWebDataSource/retrieveStudyMetadata.test.js @@ -0,0 +1,75 @@ +import { + deleteStudyMetadataPromise, + _getStudyMetadataPromiseCache, +} from './retrieveStudyMetadata.js'; + +const STUDY = '1.2.840.113619.2.55.3.1234'; +const OTHER_STUDY = '9.9.9'; + +describe('deleteStudyMetadataPromise', () => { + beforeEach(() => { + _getStudyMetadataPromiseCache().clear(); + }); + + // Promises are cached under `:`, but every + // caller holds only the study UID. Looking the bare UID up as a key matched + // nothing, so storing a derived artifact never invalidated anything and a + // re-retrieve returned the pre-save promise. + it('removes the promise cached under the data source qualified key', () => { + const cache = _getStudyMetadataPromiseCache(); + cache.set(`dicomweb:${STUDY}`, 'stale'); + + deleteStudyMetadataPromise(STUDY); + + expect(cache.has(`dicomweb:${STUDY}`)).toBe(false); + }); + + it('removes the study from every data source that cached it', () => { + const cache = _getStudyMetadataPromiseCache(); + cache.set(`dicomweb:${STUDY}`, 'stale'); + cache.set(`dicomwebproxy:${STUDY}`, 'stale'); + + deleteStudyMetadataPromise(STUDY); + + expect(cache.size).toBe(0); + }); + + it('leaves other studies cached', () => { + const cache = _getStudyMetadataPromiseCache(); + cache.set(`dicomweb:${STUDY}`, 'stale'); + cache.set(`dicomweb:${OTHER_STUDY}`, 'keep'); + + deleteStudyMetadataPromise(STUDY); + + expect(cache.has(`dicomweb:${OTHER_STUDY}`)).toBe(true); + }); + + // A study whose UID is a suffix of another must not be caught by the match. + it('does not remove a study whose UID merely ends with the same digits', () => { + const cache = _getStudyMetadataPromiseCache(); + cache.set(`dicomweb:${STUDY}`, 'stale'); + cache.set(`dicomweb:77${STUDY}`, 'keep'); + + deleteStudyMetadataPromise(STUDY); + + expect(cache.has(`dicomweb:77${STUDY}`)).toBe(true); + }); + + it('still removes an unqualified key, for any caller that cached one', () => { + const cache = _getStudyMetadataPromiseCache(); + cache.set(STUDY, 'stale'); + + deleteStudyMetadataPromise(STUDY); + + expect(cache.has(STUDY)).toBe(false); + }); + + it('does nothing without a study', () => { + const cache = _getStudyMetadataPromiseCache(); + cache.set(`dicomweb:${STUDY}`, 'keep'); + + deleteStudyMetadataPromise(undefined); + + expect(cache.size).toBe(1); + }); +}); diff --git a/extensions/default/src/DicomWebDataSource/serviceUrls.test.ts b/extensions/default/src/DicomWebDataSource/serviceUrls.test.ts new file mode 100644 index 00000000000..2a63b6ff2c0 --- /dev/null +++ b/extensions/default/src/DicomWebDataSource/serviceUrls.test.ts @@ -0,0 +1,143 @@ +import { createDicomWebApi } from './index'; + +// The data source imports the image loader for its multiframe Part 10 prefetch +// path, and that module's codec wasm entrypoints are not resolvable under jest. +// These tests only assert URLs, so a bare stub is enough. +jest.mock('@cornerstonejs/dicom-image-loader', () => ({ + __esModule: true, + default: { prefetchPart10Instance: jest.fn() }, +})); + +// Only the pieces the module graph destructures at import time, plus enough to +// let a QIDO search return an empty result set. These tests assert URLs, so no +// DICOM value parsing is exercised. +jest.mock('@ohif/core', () => ({ + DicomMetadataStore: { addSeriesMetadata: jest.fn(), addInstances: jest.fn() }, + // The data source wraps its implementation object; return it unchanged so the + // test can reach `retrieve.getWadoDicomWebClient()`. + IWebApiDataSource: { create: implementation => implementation }, + DICOMWeb: { + getString: element => element?.Value?.[0], + getName: element => element?.Value?.[0], + getModalities: (...elements) => elements.find(Boolean), + }, + utils: { + generateAcceptHeader: jest.fn(() => 'application/dicom+json'), + formatDate: date => date, + }, + errorHandler: { getHTTPErrorHandler: jest.fn(() => jest.fn()) }, + classes: { MetadataProvider: { addImageIdToUIDs: jest.fn() } }, +})); + +const servicesManager = { + services: { + userAuthenticationService: { getAuthorizationHeader: () => ({}) }, + }, +}; + +/** + * Builds an initialized data source and returns it alongside the single + * DICOMweb client it configured. + */ +function initDataSource(config) { + const dataSource = createDicomWebApi(config, servicesManager); + dataSource.initialize({}); + return { dataSource, client: dataSource.retrieve.getWadoDicomWebClient() }; +} + +describe('DicomWebDataSource service URLs', () => { + it('routes each service to its own root when qidoRoot and wadoRoot differ', () => { + const { client } = initDataSource({ + qidoRoot: 'https://server.com/qidors/org1', + wadoRoot: 'https://server.com/wadors/org1', + }); + + expect(client.qidoURL).toBe('https://server.com/qidors/org1'); + expect(client.wadoURL).toBe('https://server.com/wadors/org1'); + // No stowRoot configured, so STOW keeps the pre-existing wadoRoot behaviour. + expect(client.stowURL).toBe('https://server.com/wadors/org1'); + }); + + it('uses an explicit stowRoot for STOW only', () => { + const { client } = initDataSource({ + qidoRoot: 'https://server.com/qidors/org1', + wadoRoot: 'https://server.com/wadors/org1', + stowRoot: 'https://server.com/stowrs/org1', + }); + + expect(client.qidoURL).toBe('https://server.com/qidors/org1'); + expect(client.wadoURL).toBe('https://server.com/wadors/org1'); + expect(client.stowURL).toBe('https://server.com/stowrs/org1'); + }); + + it('points every service at the shared root when the roots are the same', () => { + const { client } = initDataSource({ + qidoRoot: 'https://server.com/dicomweb', + wadoRoot: 'https://server.com/dicomweb', + }); + + expect(client.qidoURL).toBe('https://server.com/dicomweb'); + expect(client.wadoURL).toBe('https://server.com/dicomweb'); + expect(client.stowURL).toBe('https://server.com/dicomweb'); + }); + + it('leaves no service URL undefined when only one root is configured', () => { + const { client: qidoOnly } = initDataSource({ qidoRoot: 'https://server.com/qidors' }); + expect(qidoOnly.qidoURL).toBe('https://server.com/qidors'); + expect(qidoOnly.wadoURL).toBe('https://server.com/qidors'); + expect(qidoOnly.stowURL).toBe('https://server.com/qidors'); + + const { client: wadoOnly } = initDataSource({ wadoRoot: 'https://server.com/wadors' }); + expect(wadoOnly.qidoURL).toBe('https://server.com/wadors'); + expect(wadoOnly.wadoURL).toBe('https://server.com/wadors'); + expect(wadoOnly.stowURL).toBe('https://server.com/wadors'); + }); + + /** + * Regression test for the reported failure: the series search issued while + * loading study metadata resolves against `qidoURL`, and previously ran on a + * client whose qidoURL was wadoRoot, producing + * `https://server.com/wadors/org1/studies/{uid}/series?...` -> 400. + */ + it('issues QIDO series searches against qidoRoot', async () => { + const { dataSource, client } = initDataSource({ + qidoRoot: 'https://server.com/qidors/org1', + wadoRoot: 'https://server.com/wadors/org1', + }); + + const requestedUrls: string[] = []; + client._httpGetApplicationJson = url => { + requestedUrls.push(url); + return Promise.resolve([]); + }; + + await dataSource.query.series.search('1.2.3'); + + expect(requestedUrls).toHaveLength(1); + // The loader appends `includefield` query params; only the endpoint matters here. + const [requestedPath] = requestedUrls[0].split('?'); + expect(requestedPath).toBe('https://server.com/qidors/org1/studies/1.2.3/series'); + }); + + it('retrieves WADO series metadata against wadoRoot', async () => { + const { client } = initDataSource({ + qidoRoot: 'https://server.com/qidors/org1', + wadoRoot: 'https://server.com/wadors/org1', + }); + + const requestedUrls: string[] = []; + client._httpGetApplicationJson = url => { + requestedUrls.push(url); + return Promise.resolve([]); + }; + + await client.retrieveSeriesMetadata({ + studyInstanceUID: '1.2.3', + seriesInstanceUID: '4.5.6', + }); + + expect(requestedUrls[0]).toBe( + 'https://server.com/wadors/org1/studies/1.2.3/series/4.5.6/metadata' + ); + }); +}); diff --git a/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx b/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx index 5a0d7a0e069..601f6c9dea6 100644 --- a/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx +++ b/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx @@ -1,3 +1,18 @@ +// 'use no memo' - this component triggers a runtime hook-order error when the +// React Compiler processes it: +// "React has detected a change in the order of Hooks called by PanelStudyBrowser" +// +// Ruled out, each by testing the built app: +// - hot-module reload (survives a hard reload and a fresh tab) +// - the hook count (occurs whether the double-click useCallback is kept or not) +// - the dependency contents (occurs with extensionManager.appConfig or useAppConfig) +// - a stale prebuilt dist/ copy of this extension (renaming it changes nothing) +// +// The emitted output is structurally sound - 24 hooks, all top level, one return +// at the end - and 46 other files compiled without this. The cause is unknown and +// narrowing it needs runtime bisection, not reading. +'use no memo'; + import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useImageViewer } from '@ohif/ui-next'; import { useSystem, utils } from '@ohif/core'; @@ -9,8 +24,9 @@ import MoreDropdownMenu from '../../Components/MoreDropdownMenu'; import { CallbackCustomization } from 'platform/core/src/types'; import { type TabsProps } from '@ohif/core/src/utils/createStudyBrowserTabs'; import { thumbnailNoImageModalities } from '@ohif/core/src/utils/thumbnailNoImageModalities'; +import { resolveThumbnailDetails } from './resolveThumbnailDetails'; -const { sortStudyInstances, formatDate, createStudyBrowserTabs } = utils; +const { sortStudyInstances, formatDate, formatTime, createStudyBrowserTabs } = utils; /** * Study Browser component that displays and manages studies and their display sets @@ -73,7 +89,39 @@ function PanelStudyBrowser({ setViewPresets(newViewPresets); }; - const mapDisplaySetsWithState = customMapDisplaySets || _mapDisplaySets; + const mapDisplaySets = customMapDisplaySets || _mapDisplaySets; + + // The detail line of each thumbnail is customizable, and is added to whatever + // the mapping produced so that a panel supplying its own mapping - the + // measurement tracking one does - gets it too. + const mapDisplaySetsWithState = useCallback( + (displaySetsToMap, ...args) => { + const mapped = mapDisplaySets(displaySetsToMap, ...args); + const items = customizationService.getCustomization('studyBrowser.thumbnailDetails'); + const sources = customizationService.getCustomization('studyBrowser.thumbnailDetailSources'); + const tests = customizationService.getCustomization('studyBrowser.thumbnailDetailTests'); + + return mapped.map(thumbnail => { + const displaySet = displaySetService.getDisplaySetByUID(thumbnail.displaySetInstanceUID); + if (!displaySet) { + // Leave `details` unset so the thumbnail keeps showing the series + // number and instance count it was given. + return thumbnail; + } + return { + ...thumbnail, + details: resolveThumbnailDetails({ + items, + displaySet, + sources, + tests, + formatters: { formatDate, formatTime }, + }), + }; + }); + }, + [mapDisplaySets, customizationService, displaySetService] + ); const onDoubleClickThumbnailHandler = useCallback( async displaySetInstanceUID => { @@ -102,6 +150,8 @@ function PanelStudyBrowser({ servicesManager, isHangingProtocolLayout, customizationService, + extensionManager.appConfig, + onDoubleClickThumbnailHandlerCallBack, ] ); @@ -235,6 +285,7 @@ function PanelStudyBrowser({ viewports, thumbnailImageSrcMap, customMapDisplaySets, + mapDisplaySetsWithState, ]); // ~~ subscriptions --> displaySets @@ -338,6 +389,7 @@ function PanelStudyBrowser({ viewports, displaySetService, customMapDisplaySets, + mapDisplaySetsWithState, ]); const tabs = createStudyBrowserTabs(StudyInstanceUIDs, studyDisplayList, displaySets); diff --git a/extensions/default/src/Panels/StudyBrowser/resolveThumbnailDetails.test.ts b/extensions/default/src/Panels/StudyBrowser/resolveThumbnailDetails.test.ts new file mode 100644 index 00000000000..e49bcdfd804 --- /dev/null +++ b/extensions/default/src/Panels/StudyBrowser/resolveThumbnailDetails.test.ts @@ -0,0 +1,197 @@ +import { utils } from '@ohif/core'; +import { resolveThumbnailDetails } from './resolveThumbnailDetails'; +import thumbnailDetailsCustomization, { + thumbnailDetailSources, + thumbnailDetailTests, +} from '../../customizations/thumbnailDetailsCustomization'; + +const { formatDate, formatTime } = utils; + +const defaultItems = thumbnailDetailsCustomization['studyBrowser.thumbnailDetails']; + +const resolve = (items, displaySet) => + resolveThumbnailDetails({ + items, + displaySet, + sources: thumbnailDetailSources, + tests: thumbnailDetailTests, + formatters: { formatDate, formatTime }, + }); + +const displaySet = (overrides = {}) => ({ + displaySetInstanceUID: 'ds1', + SeriesNumber: 5, + SeriesDate: '20260817', + SeriesTime: '090000', + instances: [{}, {}, {}], + instance: { SeriesDate: '20260817', SeriesTime: '090000' }, + ...overrides, +}); + +describe('resolveThumbnailDetails', () => { + // The thumbnails have always shown the series number and the instance count, + // so that is what the default items have to come to. + it('defaults to the series number and the instance count', () => { + expect(resolve(defaultItems, displaySet())).toEqual([ + { id: 'SeriesNumber', label: 'S:', title: '', value: '5', iconName: undefined }, + { id: 'InstanceCount', label: '', title: '', value: '3', iconName: 'InfoSeries' }, + ]); + }); + + it('uses the display set count icon when it has one', () => { + const [, count] = resolve(defaultItems, displaySet({ countIcon: 'icon-mpr' })); + + expect(count.iconName).toBe('icon-mpr'); + }); + + it('counts a multiframe display set by its frames', () => { + const [, count] = resolve(defaultItems, displaySet({ numImageFrames: 60 })); + + expect(count.value).toBe('60'); + }); + + it('takes a value from a named source', () => { + const items = [{ id: 'SeriesDate', label: '', source: 'seriesDate' }]; + + expect(resolve(items, displaySet())[0].value).toBe(formatDate('20260817')); + }); + + it('takes a value from a source function', () => { + const items = [{ id: 'Modality', contentF: ({ displaySet }) => displaySet.Modality }]; + + expect(resolve(items, displaySet({ Modality: 'SEG' }))[0].value).toBe('SEG'); + }); + + it('takes a value from an attribute of the instance the display set shows', () => { + const items = [{ id: 'SOPInstanceUID', attribute: 'SOPInstanceUID' }]; + const ds = displaySet({ instance: { SOPInstanceUID: '1.2.3.4' } }); + + expect(resolve(items, ds)[0].value).toBe('1.2.3.4'); + }); + + // With no items at all - no customization was resolved - the thumbnail has to + // be left showing the default detail line it stands alone with, which an + // empty array would replace with an empty line. + it('resolves to nothing when there are no items to resolve', () => { + expect(resolve(undefined, displaySet())).toBeUndefined(); + expect(resolve([], displaySet())).toEqual([]); + }); + + it('leaves out an item with no value', () => { + const items = [{ id: 'SeriesDate', source: 'seriesDate' }]; + + expect(resolve(items, displaySet({ SeriesDate: undefined, instance: {} }))).toEqual([]); + }); + + // A `studyBrowser.thumbnailDetailSources` override written with `$set` rather + // than `$merge` takes the sources the default items name away with it. That + // must not blank the series number and instance count on every thumbnail, so + // a line left empty only by names that could not be resolved is not honoured + // as an empty line - the thumbnail keeps the default one it stands alone with. + it('resolves to nothing when nothing was left after an unresolved name', () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + expect( + resolveThumbnailDetails({ + items: defaultItems, + displaySet: displaySet(), + sources: { somethingElse: () => 'x' }, + tests: thumbnailDetailTests, + formatters: { formatDate, formatTime }, + }) + ).toBeUndefined(); + expect(console.warn).toHaveBeenCalled(); + }); + + // The same broken customization is resolved for every item of every + // thumbnail, and again on every re-map, so a report on each of them buries + // the console under thousands of identical lines while a study loads. + it('reports an unresolved name once', () => { + const items = [{ id: 'ReportedOnce', source: 'noSuchSourceReportedOnce' }]; + jest.spyOn(console, 'warn').mockImplementation(() => {}); + (console.warn as jest.Mock).mockClear(); + + resolve(items, displaySet()); + resolve(items, displaySet()); + resolve(items, displaySet({ displaySetInstanceUID: 'ds2' })); + + expect(console.warn).toHaveBeenCalledTimes(1); + }); + + describe('condition', () => { + const items = [ + { id: 'InstanceDateTime', source: 'instanceDateTime', condition: 'isDerivedDisplaySet' }, + ]; + + it('includes the item when a named test passes', () => { + const ds = displaySet({ isDerivedDisplaySet: true }); + + expect(resolve(items, ds).map(detail => detail.id)).toEqual(['InstanceDateTime']); + }); + + it('leaves the item out when a named test fails', () => { + expect(resolve(items, displaySet({ isDerivedDisplaySet: false }))).toEqual([]); + }); + + it('leaves the item out when it names a test that is not registered', () => { + const unknown = [ + { id: 'X', source: 'seriesNumber', condition: 'noSuchTest' }, + { id: 'SeriesDate', source: 'seriesDate' }, + ]; + jest.spyOn(console, 'warn').mockImplementation(() => {}); + + expect(resolve(unknown, displaySet()).map(detail => detail.id)).toEqual(['SeriesDate']); + expect(console.warn).toHaveBeenCalled(); + }); + + it('accepts a test function', () => { + const withFunction = [ + { + id: 'X', + source: 'seriesNumber', + condition: ({ displaySet }) => displaySet.SeriesNumber > 9, + }, + ]; + + expect(resolve(withFunction, displaySet({ SeriesNumber: 10 }))).toHaveLength(1); + expect(resolve(withFunction, displaySet({ SeriesNumber: 5 }))).toHaveLength(0); + }); + }); + + describe('instanceDateTime source', () => { + const items = [{ id: 'InstanceDateTime', source: 'instanceDateTime' }]; + + // A report saved into an existing series keeps that series' date and time, + // so the date shown has to be the one of the instance the display set shows + // - which is also the one the series list is sorted by. + it('reports the creation date/time of the instance, not the series one', () => { + const ds = displaySet({ + instance: { + SeriesDate: '20260817', + SeriesTime: '090000', + ContentDate: '20260819', + ContentTime: '143012', + }, + }); + + expect(resolve(items, ds)[0].value).toBe(`${formatDate('20260819')} 14:30`); + }); + + // Nothing below minutes: the second a report was written is noise. + it('shows no seconds', () => { + expect(resolve(items, displaySet())[0].value).toBe(`${formatDate('20260817')} 09:00`); + }); + + it('shows the date alone when the instance has no time', () => { + const ds = displaySet({ instance: { SeriesDate: '20260817' } }); + + expect(resolve(items, ds)[0].value).toBe(formatDate('20260817')); + }); + + it('falls back to the display set when it has no instance', () => { + const ds = displaySet({ instance: undefined }); + + expect(resolve(items, ds)[0].value).toBe(`${formatDate('20260817')} 09:00`); + }); + }); +}); diff --git a/extensions/default/src/Panels/StudyBrowser/resolveThumbnailDetails.ts b/extensions/default/src/Panels/StudyBrowser/resolveThumbnailDetails.ts new file mode 100644 index 00000000000..e7624a210ff --- /dev/null +++ b/extensions/default/src/Panels/StudyBrowser/resolveThumbnailDetails.ts @@ -0,0 +1,132 @@ +import { utils } from '@ohif/core'; + +const { formatValue } = utils; + +export type ThumbnailDetail = { + id: string; + label: string; + title: string; + value: string; + iconName?: string; +}; + +type ResolveOptions = { + /** `studyBrowser.thumbnailDetails` - the items to include. */ + items; + displaySet; + /** `studyBrowser.thumbnailDetailSources` - named value sources. */ + sources?: Record unknown>; + /** `studyBrowser.thumbnailDetailTests` - named `condition` tests. */ + tests?: Record boolean>; + formatters; +}; + +/** + * The ` ""` names already reported by {@link resolveThumbnailDetails}. + * + * A broken customization is broken for every item of every thumbnail, and the + * details are re-resolved on each re-map, so the same name would otherwise be + * reported thousands of times while a large study loads. One report per name + * says everything the developer needs, so the rest are dropped. + */ +const reportedNames = new Set(); + +/** + * Builds the detail line of a study browser thumbnail from the + * `studyBrowser.thumbnailDetails` items, in the order they are declared. + * + * Each item contributes its value, taken from its own `contentF`, from a named + * `source`, or from an `attribute` of the instance the display set shows - the + * same three ways the viewport overlay items get theirs. An item whose + * `condition` says no, or which has no value to show, is left out. + * + * Returns `undefined` when there are no items to resolve at all, which leaves + * the thumbnail showing the default detail line it stands alone with. An empty + * `items` is a customization asking for an empty line, and is honoured as one. + * + * A line that came out empty only because the names it used could not be + * resolved is a broken customization rather than a request for an empty line - + * an override of `studyBrowser.thumbnailDetailSources` written with `$set` + * removes the sources the default items name - so that too is returned as + * `undefined`, leaving every thumbnail its default detail line instead of + * blanking the series number and instance count on all of them. + */ +export function resolveThumbnailDetails({ + items, + displaySet, + sources, + tests, + formatters, +}: ResolveOptions): ThumbnailDetail[] | undefined { + if (!Array.isArray(items)) { + return undefined; + } + + const props = { displaySet, instance: displaySet?.instance, formatters }; + const details: ThumbnailDetail[] = []; + let hasUnresolvedName = false; + + /** A `condition` / `source` that is a name resolves against a registry. */ + const resolveNamed = (value, registry, kind: string, id: string) => { + if (typeof value !== 'string') { + return value; + } + const named = registry?.[value]; + if (!named) { + const reportKey = `${id}|${kind}|${value}`; + if (!reportedNames.has(reportKey)) { + reportedNames.add(reportKey); + console.warn(`Thumbnail detail item "${id}" names an unknown ${kind} "${value}"`); + } + hasUnresolvedName = true; + } + return named; + }; + + for (const item of items) { + if (!item) { + continue; + } + const { id, condition, contentF, source, attribute, iconName } = item; + + if (condition !== undefined) { + const test = resolveNamed(condition, tests, 'condition', id); + if (typeof test !== 'function' || !test(props)) { + continue; + } + } + + let value; + if (typeof contentF === 'function') { + value = contentF(props); + } else if (source !== undefined) { + const sourceF = resolveNamed(source, sources, 'source', id); + value = typeof sourceF === 'function' ? sourceF(props) : undefined; + } else if (attribute) { + value = props.instance?.[attribute]; + } + + const displayValue = formatValue(value); + if (!displayValue) { + continue; + } + + const icon = typeof iconName === 'function' ? iconName(props) : iconName; + + details.push({ + id, + label: item.label ?? '', + title: item.title ?? '', + value: displayValue, + iconName: icon || undefined, + }); + } + + if (!details.length && hasUnresolvedName) { + return undefined; + } + + return details; +} + +export default resolveThumbnailDetails; diff --git a/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx b/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx index fb4c320347b..22f003fa9da 100644 --- a/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx +++ b/extensions/default/src/Panels/WrappedPanelStudyBrowser.tsx @@ -1,5 +1,3 @@ -import React, { useCallback } from 'react'; -// import PanelStudyBrowser from './StudyBrowser/PanelStudyBrowser'; import getImageSrcFromImageId from './getImageSrcFromImageId'; import getStudiesForPatientByMRN from './getStudiesForPatientByMRN'; @@ -19,10 +17,10 @@ function WrappedPanelStudyBrowser() { // already determined our datasource const [dataSource] = extensionManager.getActiveDataSource(); const _getStudiesForPatientByMRN = getStudiesForPatientByMRN.bind(null, dataSource); - const _getImageSrcFromImageId = useCallback( - _createGetImageSrcFromImageIdFn(extensionManager), - [] - ); + // Plain call: the React Compiler caches this keyed on extensionManager (a + // stable service), so it runs once and keeps a stable identity — what the + // old useCallback(fn(), []) wanted, but analyzable and correctly keyed. + const _getImageSrcFromImageId = _createGetImageSrcFromImageIdFn(extensionManager); const _requestDisplaySetCreationForStudy = requestDisplaySetCreationForStudy.bind( null, dataSource diff --git a/extensions/default/src/Panels/createReportDialogPrompt.tsx b/extensions/default/src/Panels/createReportDialogPrompt.tsx index daa18febfa1..c5b486650ab 100644 --- a/extensions/default/src/Panels/createReportDialogPrompt.tsx +++ b/extensions/default/src/Panels/createReportDialogPrompt.tsx @@ -8,32 +8,61 @@ import PROMPT_RESPONSES from '../utils/_shared/PROMPT_RESPONSES'; * - `minSeriesNumber` is the start of new series of this modality type. * Will get set to 4000 if not determined by the modality * - predecessorImageId is the image id that this series was currently loaded - * from. That allows defaulting the dialog to show the specified series instead - * of always creating a new series. + * from. That is the series the dialog offers to extend, and it defaults to + * extending it instead of creating a new series. Without one, the dialog + * only offers to create a new series. + * - `itemName` is the name the user chose, such as the segmentation name. A + * new series offers it first. A generated name belongs in + * `defaultSeriesDescription` instead. + * - `defaultSeriesDescription` is the name for an item with no chosen name, + * such as 'Contours' or 'Measurements'. A new series offers it last. + * - `itemType` is the type of item being stored, used as the key that the + * series descriptions used before are remembered under. Defaults to the + * modality, so that segmentations, contours and reports are remembered + * separately. + * - `rememberedDescriptionCount` is how many previously used series + * descriptions to remember and offer for this type of item, 0 to remember + * none of them. + * + * The dialog offers three destinations - `Save to current`, `Save as new` and + * `Replace existing` - and says which one is in effect. Each destination stores + * all of the current data as one object, and the dialog merges nothing. The + * behaviour doc describes the destinations, the series that the dialog offers, + * and the names for a new series: + * `platform/docs/docs/behaviours/report-dialog-save-destinations.md`. * * The response is: - * - `value`, the default name of the object/series being created + * - `value`, the series description of the object/series being created. When + * extending an existing series this is that series' existing description, + * as an existing series description is not editable. * - `dataSourceName`, where to store the object to * - `series`, is the series to store do, as referenced by a predecessorImageId value. - * - `priorSeriesNumber` is the previously lowest series number at least minSeriesNumber - * of all the seris of the given modality type. + * This is falsy for a new series. + * - `seriesNumber` is the series number to store the object as, which is the + * value shown (and possibly edited) in the dialog. + * - `priorSeriesNumber` is one less than `seriesNumber`, for callers that + * compute the series number to store as `1 + priorSeriesNumber`. * - * This should be provided to the DICOM encoder, which will get the predecessor - * sequence from the metaData provider so that the saved series will replace - * the existing instance in the same series. - * This will be falsy for a new series. + * The `series` value should be provided to the DICOM encoder, which will get the + * predecessor sequence from the metaData provider so that the saved instance + * goes into the same series, superseding the existing instance there. */ export default function CreateReportDialogPrompt({ - title = 'Create Report', + title = 'Save Measurements', modality = 'SR', minSeriesNumber = 0, predecessorImageId, + itemName = '', + defaultSeriesDescription = '', + itemType, + rememberedDescriptionCount = 5, extensionManager, servicesManager, enableDownload = false, }): Promise<{ value: string; dataSourceName: string; + seriesNumber?: number; priorSeriesNumber?: number; series: string; action: (typeof PROMPT_RESPONSES)[keyof typeof PROMPT_RESPONSES]; @@ -54,23 +83,32 @@ export default function CreateReportDialogPrompt({ uiDialogService.show({ id: 'report-dialog', title, + // The default dialog width (max-w-md) is narrower than the destination + // control, which would otherwise stick out past the dialog's edge. + containerClassName: 'max-w-lg', content: ReportDialog, contentProps: { dataSources: allowMultipleDataSources ? dataSources : undefined, predecessorImageId, minSeriesNumber, + itemName, + defaultSeriesDescription, + itemType, + rememberedDescriptionCount, modality, enableDownload, onSave: async ({ reportName, dataSource: selectedDataSource, series, + seriesNumber, priorSeriesNumber, }) => { resolve({ value: reportName, dataSourceName: selectedDataSource, series, + seriesNumber, priorSeriesNumber, action: PROMPT_RESPONSES.CREATE_REPORT, }); diff --git a/extensions/default/src/SOPClassHandlers/chartSOPClassHandler.test.ts b/extensions/default/src/SOPClassHandlers/chartSOPClassHandler.test.ts new file mode 100644 index 00000000000..c08436ce37b --- /dev/null +++ b/extensions/default/src/SOPClassHandlers/chartSOPClassHandler.test.ts @@ -0,0 +1,64 @@ +import { chartHandler } from './chartSOPClassHandler'; + +const chartInstance = (overrides = {}) => ({ + Modality: 'CHT', + SOPClassUID: '1.9.451.13215.7.3.2.7.6.1', + StudyInstanceUID: '1.2.3', + SeriesInstanceUID: '1.2.3.4', + SOPInstanceUID: '1.2.3.4.1', + SeriesDescription: 'Chart', + SeriesNumber: 99, + SeriesDate: '20260817', + SeriesTime: '133000', + ...overrides, +}); + +describe('chartHandler', () => { + // The series sort compares `SeriesDate SeriesTime` as a single string, so a + // display set without a time cannot be placed among the other series. + it('carries the series date and time of its instance', () => { + const [displaySet] = chartHandler.getDisplaySetsFromSeries([chartInstance()]); + + expect(displaySet.SeriesDate).toBe('20260817'); + expect(displaySet.SeriesTime).toBe('133000'); + }); + + it('reports an empty date and time rather than undefined', () => { + const [displaySet] = chartHandler.getDisplaySetsFromSeries([ + chartInstance({ SeriesDate: undefined, SeriesTime: undefined }), + ]); + + expect(displaySet.SeriesDate).toBe(''); + expect(displaySet.SeriesTime).toBe(''); + }); + + // The date/time of the display set is the latest one the instance carries, + // which for an instance added to an existing series is the instance level one + // rather than the series one. + it('takes the instance date and time over an older series one', () => { + const [displaySet] = chartHandler.getDisplaySetsFromSeries([ + chartInstance({ ContentDate: '20260819', ContentTime: '080000' }), + ]); + + expect(displaySet.SeriesDate).toBe('20260819'); + expect(displaySet.SeriesTime).toBe('080000'); + }); + + // `addInstances` advances the instance the display set shows, so the date and + // time shown for it - and summarized from it - have to advance with it rather + // than staying on those of the instance the series was created with. + it('moves the date and time on to the newly added instance', () => { + const [displaySet] = chartHandler.getDisplaySetsFromSeries([chartInstance()]); + + displaySet.addInstances([ + chartInstance({ + SOPInstanceUID: '1.2.3.4.2', + ContentDate: '20260819', + ContentTime: '080000', + }), + ]); + + expect(displaySet.SeriesDate).toBe('20260819'); + expect(displaySet.SeriesTime).toBe('080000'); + }); +}); diff --git a/extensions/default/src/SOPClassHandlers/chartSOPClassHandler.ts b/extensions/default/src/SOPClassHandlers/chartSOPClassHandler.ts index 58f561e99ce..65915705fd8 100644 --- a/extensions/default/src/SOPClassHandlers/chartSOPClassHandler.ts +++ b/extensions/default/src/SOPClassHandlers/chartSOPClassHandler.ts @@ -20,10 +20,13 @@ const makeChartDataDisplaySet = (instance, sopClassUids) => { SOPInstanceUID, SeriesDescription, SeriesNumber, - SeriesDate, SOPClassUID, } = instance; + // The date/time of a display set is the date/time of the instance it shows, + // chosen from all the attributes that instance carries. + const { SeriesDate, SeriesTime } = utils.getLatestInstanceDateTime(instance); + return { Modality: CHART_MODALITY, loading: false, @@ -32,6 +35,7 @@ const makeChartDataDisplaySet = (instance, sopClassUids) => { SeriesDescription, SeriesNumber, SeriesDate, + SeriesTime, SOPInstanceUID, SeriesInstanceUID, StudyInstanceUID, @@ -51,6 +55,12 @@ const makeChartDataDisplaySet = (instance, sopClassUids) => { addInstances: function (instances: InstanceMetadata[], _displaySetService: DisplaySetService) { this.instances.push(...instances); this.instance = this.instances[this.instances.length - 1]; + // The date/time shown and sorted by is that of the instance the display + // set shows, so it moves with that instance rather than staying on the + // one the chart was first created with. + const { SeriesDate, SeriesTime } = utils.getLatestInstanceDateTime(this.instance); + this.SeriesDate = SeriesDate; + this.SeriesTime = SeriesTime; return this; }, diff --git a/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx b/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx index d087ad4cf75..dbd6e01a975 100644 --- a/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx +++ b/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx @@ -1,6 +1,5 @@ // Updated ToolbarLayoutSelector.tsx -import React, { useCallback } from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; import { CommandsManager } from '@ohif/core'; import { LayoutSelector } from '@ohif/ui-next'; @@ -109,13 +108,12 @@ function ToolbarLayoutSelectorWithServices({ ]; // Unified selection handler that dispatches to the appropriate command - const handleSelectionChange = useCallback( - (commandOptions, isPreset) => { + const handleSelectionChange = (commandOptions, isPreset) => { if (isPreset) { - // Advanced preset selection + // Advanced preset selection: apply the stage layout, not a cached custom grid commandsManager.run({ commandName: 'setHangingProtocol', - commandOptions, + commandOptions: { ...commandOptions, restoreCachedLayout: false }, }); } else { // Common preset or custom grid selection @@ -124,9 +122,7 @@ function ToolbarLayoutSelectorWithServices({ commandOptions, }); } - }, - [commandsManager] - ); + }; return (
{ return str; }; -function HeaderPatientInfo({ servicesManager, appConfig }: withAppTypes) { +function PatientInfo({ showPatientInfo }) { const initialExpandedState = - appConfig.showPatientInfo === PatientInfoVisibility.VISIBLE || - appConfig.showPatientInfo === PatientInfoVisibility.VISIBLE_READONLY; + showPatientInfo === PatientInfoVisibility.VISIBLE || + showPatientInfo === PatientInfoVisibility.VISIBLE_READONLY; const [expanded, setExpanded] = useState(initialExpandedState); - const { patientInfo, isMixedPatients } = usePatientInfo(servicesManager); + const { patientInfo, isMixedPatients } = usePatientInfo(); useEffect(() => { if (isMixedPatients && expanded) { @@ -30,7 +31,7 @@ function HeaderPatientInfo({ servicesManager, appConfig }: withAppTypes) { }, [isMixedPatients, expanded]); const handleOnClick = () => { - if (!isMixedPatients && appConfig.showPatientInfo !== PatientInfoVisibility.VISIBLE_READONLY) { + if (!isMixedPatients && showPatientInfo !== PatientInfoVisibility.VISIBLE_READONLY) { setExpanded(!expanded); } }; @@ -71,4 +72,25 @@ function HeaderPatientInfo({ servicesManager, appConfig }: withAppTypes) { ); } +/** + * Patient name/ID/sex/DOB, shipped as one of the `ohif.headerRightSide` items. + * Like every item in that list it takes no props, and it renders nothing when + * `showPatientInfo` is `disabled` — the header slot collapses with it. + * + * The visibility check lives out here rather than inside `PatientInfo` so that + * `disabled` never mounts the body: `usePatientInfo` subscribes to display set + * events and recomputes on every batch, which is wasted work when the result is + * always `null`. + */ +function HeaderPatientInfo() { + const { extensionManager } = useSystem(); + const { showPatientInfo } = extensionManager.appConfig; + + if (showPatientInfo === PatientInfoVisibility.DISABLED) { + return null; + } + + return ; +} + export default HeaderPatientInfo; diff --git a/extensions/default/src/ViewerLayout/HeaderUndoRedo.tsx b/extensions/default/src/ViewerLayout/HeaderUndoRedo.tsx new file mode 100644 index 00000000000..c180a090402 --- /dev/null +++ b/extensions/default/src/ViewerLayout/HeaderUndoRedo.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Icons } from '@ohif/ui-next'; +import { useSystem } from '@ohif/core'; +import { useUndoRedoState } from './useUndoRedoState'; + +/** + * Undo/redo buttons, shipped as one of the `ohif.headerRightSide` items. Like + * every item in that list it takes no props and gets what it needs from + * `useSystem()`, so a site can reorder or drop it without touching the header. + */ +function HeaderUndoRedo() { + const { commandsManager } = useSystem(); + const { t } = useTranslation(); + const { canUndo, canRedo } = useUndoRedoState(); + + return ( +
+ + +
+ ); +} + +export default HeaderUndoRedo; diff --git a/extensions/default/src/ViewerLayout/ResizablePanelsHook.tsx b/extensions/default/src/ViewerLayout/ResizablePanelsHook.tsx index 0e3dce22206..b6192042126 100644 --- a/extensions/default/src/ViewerLayout/ResizablePanelsHook.tsx +++ b/extensions/default/src/ViewerLayout/ResizablePanelsHook.tsx @@ -66,6 +66,27 @@ const useResizablePanels = ( // The total width of both handles. const resizableHandlesWidth = useRef(null); + /** + * Gets the percentage size corresponding to the given pixel size. + * Note that the width attributed to the handles must be taken into account. + */ + const getPercentageSize = pixelSize => { + const { width: panelGroupWidth } = resizablePanelGroupElemRef.current?.getBoundingClientRect(); + return (pixelSize / (panelGroupWidth - resizableHandlesWidth.current)) * 100; + }; + + /** + * Gets the width in pixels for an expanded panel given its percentage size/width. + * Note that the width attributed to the handles must be taken into account. + */ + const getExpandedPixelWidth = percentageSize => { + const { width: panelGroupWidth } = resizablePanelGroupElemRef.current?.getBoundingClientRect(); + const expandedWidth = + (percentageSize / 100) * (panelGroupWidth - resizableHandlesWidth.current) - + panelGroupDefinition.shared.expandedInsideBorderSize; + return expandedWidth; + }; + // This useLayoutEffect is used to... // - Grab a reference to the various resizable panel elements needed for // converting between percentages and pixels in various callbacks. @@ -178,33 +199,30 @@ const useResizablePanels = ( /** * Handles dragging of either side panel resize handle. */ - const onHandleDragging = useCallback( - isStartDrag => { - if (isStartDrag) { - isResizableHandleDraggingRef.current = true; - - setMinMaxWidth(resizableLeftPanelElemRef.current); - setMinMaxWidth(resizableRightPanelElemRef.current); - } else { - isResizableHandleDraggingRef.current = false; - - if (resizableLeftPanelAPIRef?.current?.isExpanded()) { - setMinMaxWidth( - resizableLeftPanelElemRef.current, - leftPanelExpandedWidth + panelGroupDefinition.shared.expandedInsideBorderSize - ); - } - - if (resizableRightPanelAPIRef?.current?.isExpanded()) { - setMinMaxWidth( - resizableRightPanelElemRef.current, - rightPanelExpandedWidth + panelGroupDefinition.shared.expandedInsideBorderSize - ); - } + const onHandleDragging = isStartDrag => { + if (isStartDrag) { + isResizableHandleDraggingRef.current = true; + + setMinMaxWidth(resizableLeftPanelElemRef.current); + setMinMaxWidth(resizableRightPanelElemRef.current); + } else { + isResizableHandleDraggingRef.current = false; + + if (resizableLeftPanelAPIRef?.current?.isExpanded()) { + setMinMaxWidth( + resizableLeftPanelElemRef.current, + leftPanelExpandedWidth + panelGroupDefinition.shared.expandedInsideBorderSize + ); } - }, - [leftPanelExpandedWidth, rightPanelExpandedWidth] - ); + + if (resizableRightPanelAPIRef?.current?.isExpanded()) { + setMinMaxWidth( + resizableRightPanelElemRef.current, + rightPanelExpandedWidth + panelGroupDefinition.shared.expandedInsideBorderSize + ); + } + } + }; const onLeftPanelClose = useCallback(() => { setLeftPanelClosed(true); @@ -212,14 +230,14 @@ const useResizablePanels = ( resizableLeftPanelAPIRef?.current?.collapse(); }, [setLeftPanelClosed]); - const onLeftPanelOpen = useCallback(() => { + const onLeftPanelOpen = () => { resizableLeftPanelAPIRef?.current?.expand( getPercentageSize(panelGroupDefinition.left.initialExpandedOffsetWidth) ); setLeftPanelClosed(false); - }, [setLeftPanelClosed]); + }; - const onLeftPanelResize = useCallback(size => { + const onLeftPanelResize = size => { if (!resizablePanelGroupElemRef?.current || resizableLeftPanelAPIRef.current?.isCollapsed()) { return; } @@ -233,7 +251,7 @@ const useResizablePanels = ( // because here we know the size of the expanded panel. setMinMaxWidth(resizableLeftPanelElemRef.current, newExpandedWidth); } - }, []); + }; const onRightPanelClose = useCallback(() => { setRightPanelClosed(true); @@ -241,14 +259,14 @@ const useResizablePanels = ( resizableRightPanelAPIRef?.current?.collapse(); }, [setRightPanelClosed]); - const onRightPanelOpen = useCallback(() => { + const onRightPanelOpen = () => { resizableRightPanelAPIRef?.current?.expand( getPercentageSize(panelGroupDefinition.right.initialExpandedOffsetWidth) ); setRightPanelClosed(false); - }, [setRightPanelClosed]); + }; - const onRightPanelResize = useCallback(size => { + const onRightPanelResize = size => { if (!resizablePanelGroupElemRef?.current || resizableRightPanelAPIRef?.current?.isCollapsed()) { return; } @@ -262,27 +280,6 @@ const useResizablePanels = ( // because here we know the size of the expanded panel. setMinMaxWidth(resizableRightPanelElemRef.current, newExpandedWidth); } - }, []); - - /** - * Gets the percentage size corresponding to the given pixel size. - * Note that the width attributed to the handles must be taken into account. - */ - const getPercentageSize = pixelSize => { - const { width: panelGroupWidth } = resizablePanelGroupElemRef.current?.getBoundingClientRect(); - return (pixelSize / (panelGroupWidth - resizableHandlesWidth.current)) * 100; - }; - - /** - * Gets the width in pixels for an expanded panel given its percentage size/width. - * Note that the width attributed to the handles must be taken into account. - */ - const getExpandedPixelWidth = percentageSize => { - const { width: panelGroupWidth } = resizablePanelGroupElemRef.current?.getBoundingClientRect(); - const expandedWidth = - (percentageSize / 100) * (panelGroupWidth - resizableHandlesWidth.current) - - panelGroupDefinition.shared.expandedInsideBorderSize; - return expandedWidth; }; return [ diff --git a/extensions/default/src/ViewerLayout/ViewerHeader.tsx b/extensions/default/src/ViewerLayout/ViewerHeader.tsx index da9f98936dc..62dd12eae5c 100644 --- a/extensions/default/src/ViewerLayout/ViewerHeader.tsx +++ b/extensions/default/src/ViewerLayout/ViewerHeader.tsx @@ -2,16 +2,14 @@ import React from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Button, Header, Icons, useModal } from '@ohif/ui-next'; +import { Header, useModal } from '@ohif/ui-next'; import { useSystem } from '@ohif/core'; import { Toolbar } from '../Toolbar/Toolbar'; -import HeaderPatientInfo from './HeaderPatientInfo'; -import { PatientInfoVisibility } from './HeaderPatientInfo/HeaderPatientInfo'; import { preserveQueryParameters } from '@ohif/app'; import { Types } from '@ohif/core'; function ViewerHeader({ appConfig }: withAppTypes<{ appConfig: AppTypes.Config }>) { - const { servicesManager, extensionManager, commandsManager } = useSystem(); + const { servicesManager, extensionManager } = useSystem(); const { customizationService } = servicesManager.services; const navigate = useNavigate(); @@ -51,6 +49,12 @@ function ViewerHeader({ appConfig }: withAppTypes<{ appConfig: AppTypes.Config } 'ohif.userPreferencesModal' ) as Types.MenuComponentCustomization; + // Whatever fills the right side of the menu bar, in order: undo/redo then + // patient info by default. Each item is rendered as a component, so it can + // bring its own hooks, and reordering the list reorders the header. + const rightSideItems = + customizationService.getCustomization('ohif.headerRightSide')?.items ?? []; + const menuOptions = [ { title: AboutModal?.menuTitle ?? t('Header:About'), @@ -105,38 +109,10 @@ function ViewerHeader({ appConfig }: withAppTypes<{ appConfig: AppTypes.Config } onClickReturnButton={onClickReturnButton} WhiteLabeling={appConfig.whiteLabeling} Secondary={} - PatientInfo={ - appConfig.showPatientInfo !== PatientInfoVisibility.DISABLED && ( - - ) - } - UndoRedo={ -
- - -
- } + RightSide={rightSideItems.map((Item, index) => ( + // The list is static per configuration, so the index is a stable key. + + ))} >
diff --git a/extensions/default/src/ViewerLayout/index.tsx b/extensions/default/src/ViewerLayout/index.tsx index 50533ff9eba..bdb39c39e94 100644 --- a/extensions/default/src/ViewerLayout/index.tsx +++ b/extensions/default/src/ViewerLayout/index.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState, useCallback } from 'react'; -import PropTypes from 'prop-types'; import { InvestigationalUseDialog } from '@ohif/ui-next'; import { HangingProtocolService, CommandsManager } from '@ohif/core'; @@ -223,21 +222,6 @@ function ViewerLayout({ ); } -ViewerLayout.propTypes = { - // From extension module params - extensionManager: PropTypes.shape({ - getModuleEntry: PropTypes.func.isRequired, - }).isRequired, - commandsManager: PropTypes.instanceOf(CommandsManager), - servicesManager: PropTypes.object.isRequired, - // From modes - leftPanels: PropTypes.array, - rightPanels: PropTypes.array, - leftPanelClosed: PropTypes.bool.isRequired, - rightPanelClosed: PropTypes.bool.isRequired, - /** Responsible for rendering our grid of viewports; provided by consuming application */ - children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired, - viewports: PropTypes.array, -}; + export default ViewerLayout; diff --git a/extensions/default/src/ViewerLayout/useUndoRedoState.ts b/extensions/default/src/ViewerLayout/useUndoRedoState.ts new file mode 100644 index 00000000000..a0095009170 --- /dev/null +++ b/extensions/default/src/ViewerLayout/useUndoRedoState.ts @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react'; +import { eventTarget, utilities as csUtilities } from '@cornerstonejs/core'; +import { Enums as csToolsEnums } from '@cornerstonejs/tools'; + +const { DefaultHistoryMemo } = csUtilities.HistoryMemo; + +// Cornerstone events after which the undo/redo history may have changed. +// `undo`/`redo` emit the HISTORY_* events, but any tool action that records a +// memo (drawing an annotation, editing a labelmap, deleting an annotation) +// pushes onto the history WITHOUT emitting a history event, so we also listen +// for the tool events that produce those memos in order to keep the enabled +// state of the buttons in sync. +const HISTORY_CHANGING_EVENTS = [ + csToolsEnums.Events.HISTORY_UNDO, + csToolsEnums.Events.HISTORY_REDO, + csToolsEnums.Events.ANNOTATION_COMPLETED, + csToolsEnums.Events.ANNOTATION_MODIFIED, + csToolsEnums.Events.ANNOTATION_REMOVED, + csToolsEnums.Events.SEGMENTATION_DATA_MODIFIED, +]; + +// A drag of an existing annotation fires ANNOTATION_MODIFIED only while the +// pointer moves; the tool pushes the memo on mouse up and fires no event for +// it. So also re-read the state after each mouse up / touch end. +const POINTER_END_EVENTS = ['mouseup', 'touchend']; + +/** + * Tracks whether an undo/redo is currently available so the header buttons can + * be disabled (greyed out) when there is nothing to undo/redo. + */ +export function useUndoRedoState(): { canUndo: boolean; canRedo: boolean } { + const [state, setState] = useState(() => ({ + canUndo: DefaultHistoryMemo.canUndo, + canRedo: DefaultHistoryMemo.canRedo, + })); + + useEffect(() => { + // A memo is often pushed synchronously *after* the triggering event is + // dispatched, so defer the read until the current call stack unwinds to + // make sure we observe the up-to-date availability. + let scheduled: ReturnType | null = null; + + const readState = () => { + scheduled = null; + setState(prev => { + const { canUndo, canRedo } = DefaultHistoryMemo; + return prev.canUndo === canUndo && prev.canRedo === canRedo ? prev : { canUndo, canRedo }; + }); + }; + + const schedule = () => { + if (scheduled === null) { + scheduled = setTimeout(readState, 0); + } + }; + + HISTORY_CHANGING_EVENTS.forEach(evt => eventTarget.addEventListener(evt, schedule)); + POINTER_END_EVENTS.forEach(evt => window.addEventListener(evt, schedule, true)); + + // Sync once on mount in case the history already has content. + readState(); + + return () => { + HISTORY_CHANGING_EVENTS.forEach(evt => eventTarget.removeEventListener(evt, schedule)); + POINTER_END_EVENTS.forEach(evt => window.removeEventListener(evt, schedule, true)); + if (scheduled !== null) { + clearTimeout(scheduled); + } + }; + }, []); + + return state; +} + +export default useUndoRedoState; diff --git a/extensions/default/src/__tests__/commandsModuleToggleOneUp.test.ts b/extensions/default/src/__tests__/commandsModuleToggleOneUp.test.ts new file mode 100644 index 00000000000..6106fdf7653 --- /dev/null +++ b/extensions/default/src/__tests__/commandsModuleToggleOneUp.test.ts @@ -0,0 +1,147 @@ +/** + * The one-up toggle store (`useToggleOneUpViewportGridStore`) must only ever + * hold a one-up that is currently active and can be toggled back. Any explicit + * layout change that actually proceeds — a common grid (`setViewportGridLayout`) + * or a protocol/preset (`setHangingProtocol`) — abandons a pending one-up, so + * those commands clear the store. Without this, reaching a 1x1 preset (e.g. + * "3D only") and double-clicking it restores a stale grid left over from an + * earlier one-up. + * + * A layout change that does NOT proceed must leave the store untouched: if the + * protocol's `onLayoutChange` callback vetoes the grid change, we are still in + * the pending one-up and the toggle-back state must survive. + * + * The command module pulls a large dependency graph (OHIF core/app, cornerstone, + * the context-menu controller, …), so the heavy imports are mocked out; the real + * store is kept so we can assert the clearing behaviour, and the services are + * stubbed enough for each command to run its happy path to completion. + */ + +// --- Mock the heavy / resolution-breaking imports (keep the real store) ------ +jest.mock('@ohif/core', () => ({ Types: {}, DicomMetadataStore: {}, utils: {} }), { + virtual: true, +}); +jest.mock('@ohif/app', () => ({ history: {} }), { virtual: true }); +jest.mock('../utils/dicomWriter', () => ({ + datasetToDicomBlob: jest.fn(), + setNonEnumerableInstanceProperty: jest.fn(), +})); +jest.mock('../utils/registerNaturalizedDatasetForLocalWadouri', () => ({ + registerNaturalizedDatasetsForLocalWadouri: jest.fn(), +})); +jest.mock('../utils/registerStoredInstanceImageId', () => ({ + registerStoredInstanceImageId: jest.fn(), + registerStoredInstanceImageIds: jest.fn(), +})); +jest.mock('../CustomizableContextMenu', () => ({ ContextMenuController: class {} })); +jest.mock('../DicomTagBrowser/DicomTagBrowser', () => ({ __esModule: true, default: class {} })); +jest.mock('../utils/reuseCachedLayouts', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('../utils/layerConfigurationUtils', () => ({ + configureViewportForLayerAddition: jest.fn(), + configureViewportForLayerRemoval: jest.fn(), + canAddDisplaySetToViewport: jest.fn(), + DERIVED_OVERLAY_MODALITIES: [], +})); +jest.mock('../findViewportsByPosition', () => ({ + __esModule: true, + default: jest.fn(), + findOrCreateViewport: jest.fn(), +})); +jest.mock('../Panels/requestDisplaySetCreationForStudy', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mock('../utils/promptSaveReport', () => ({ __esModule: true, default: jest.fn() })); + +import commandsModule from '../commandsModule'; +import { useToggleOneUpViewportGridStore } from '../stores/useToggleOneUpViewportGridStore'; + +/** + * Build the commands module with services stubbed enough that the two layout + * commands complete their happy path. `onLayoutChange` lets a test install a + * grid-change veto callback on the active protocol. + */ +function makeCommandsModule({ onLayoutChange }: { onLayoutChange?: () => unknown } = {}) { + const noop = () => undefined; + const services = { + customizationService: { getCustomization: noop }, + measurementService: {}, + hangingProtocolService: { + // setViewportGridLayout reads the active protocol's onLayoutChange callback. + getActiveProtocol: () => ({ protocol: { callbacks: { onLayoutChange } } }), + getState: () => ({ protocolId: 'someProtocol', stageIndex: 0, activeStudyUID: 'study' }), + getStageIndex: () => 0, + setActiveStudyUID: () => false, + setProtocol: noop, + run: noop, + }, + uiNotificationService: { show: noop }, + viewportGridService: { + getState: () => ({ layout: { numRows: 1, numCols: 1 }, viewports: new Map() }), + setLayout: noop, + set: noop, + getLayoutOptionsFromState: () => [], + }, + displaySetService: { getActiveDisplaySets: () => [] }, + multiMonitorService: {}, + }; + // Run function-valued commands (the onLayoutChange callback) so a veto is + // observable; ignore string command ids used elsewhere. + const commandsManager = { + run: (cmd: unknown, options: unknown) => (typeof cmd === 'function' ? cmd(options) : undefined), + runCommand: noop, + }; + const extensionManager = { getActiveDataSource: () => [] }; + return commandsModule({ + servicesManager: { services }, + commandsManager, + extensionManager, + } as any); +} + +const store = () => useToggleOneUpViewportGridStore.getState(); +const seedPendingOneUp = () => + store().setToggleOneUpViewportGridStore({ + activeViewportId: 'v', + layout: { numRows: 2, numCols: 3 }, + viewports: new Map(), + }); + +describe('commandsModule — one-up store is cleared only on layout changes that proceed', () => { + beforeEach(() => { + store().clearToggleOneUpViewportGridStore(); + }); + + it('setViewportGridLayout clears a pending one-up when the change proceeds', () => { + const { actions } = makeCommandsModule(); + seedPendingOneUp(); + expect(store().toggleOneUpViewportGridStore).not.toBeNull(); + + actions.setViewportGridLayout({ numRows: 1, numCols: 1 }); + + expect(store().toggleOneUpViewportGridStore).toBeNull(); + }); + + it('setViewportGridLayout keeps the one-up when onLayoutChange vetoes the change', () => { + const { actions } = makeCommandsModule({ onLayoutChange: () => false }); + seedPendingOneUp(); + expect(store().toggleOneUpViewportGridStore).not.toBeNull(); + + actions.setViewportGridLayout({ numRows: 1, numCols: 1 }); + + // Change was rejected — the toggle-back state must survive so the next + // double-click still restores. + expect(store().toggleOneUpViewportGridStore).not.toBeNull(); + }); + + it('setHangingProtocol clears a pending one-up (advanced presets like "3D only")', () => { + const { actions } = makeCommandsModule(); + seedPendingOneUp(); + expect(store().toggleOneUpViewportGridStore).not.toBeNull(); + + const applied = actions.setHangingProtocol({ protocolId: 'someProtocol' }); + + expect(applied).toBe(true); + expect(store().toggleOneUpViewportGridStore).toBeNull(); + }); +}); diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index 573830c426a..e2cff2533e8 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -1,6 +1,7 @@ import { Types, DicomMetadataStore, utils } from '@ohif/core'; import { datasetToDicomBlob, setNonEnumerableInstanceProperty } from './utils/dicomWriter'; import { registerNaturalizedDatasetsForLocalWadouri } from './utils/registerNaturalizedDatasetForLocalWadouri'; +import { registerStoredInstanceImageIds } from './utils/registerStoredInstanceImageId'; const { downloadBlob } = utils; @@ -35,6 +36,7 @@ export type HangingProtocolParams = { activeStudyUID?: string; stageId?: string; reset?: false; + restoreCachedLayout?: boolean; }; export type UpdateViewportDisplaySetParams = { @@ -102,6 +104,17 @@ const commandsModule = ({ return; } + // Adding a layer is a per-viewport action: it shows the display set in the + // one viewport it is given, and leaves `isHydrated` and the segmentation + // presentation store alone. Those two are display-set-global ("show this + // wherever it logically belongs"), so a viewport re-created after a + // per-viewport add re-applies whatever hydration says rather than + // inheriting the add. The global statement is made by the hydration + // commands (hydrateSecondaryDisplaySet / loadSegmentationDisplaySetsForViewport + // in extensions/cornerstone), which record it themselves; the removal + // counterpart here is `removeDisplaySetLayer`'s `unhydrate` flag, which + // exists because removal has callers that are themselves global. + // // Add the display set to the viewport const updatedViewports = hangingProtocolService.getViewportsRequireUpdate( viewportId, @@ -135,8 +148,10 @@ const commandsModule = ({ * * @param options.viewportId - The ID of the viewport to remove the layer from * @param options.displaySetInstanceUID - The UID of the display set to remove + * @param options.unhydrate - Whether this removal also means "stop showing + * this display set anywhere" (see the note below). */ - removeDisplaySetLayer: ({ viewportId, displaySetInstanceUID }) => { + removeDisplaySetLayer: ({ viewportId, displaySetInstanceUID, unhydrate = false }) => { if (!viewportId || !displaySetInstanceUID) { console.warn('Missing required parameters for removeDisplaySetLayer command'); return; @@ -166,6 +181,33 @@ const commandsModule = ({ segmentationService.removeRepresentationsFromViewport(viewportId, { segmentationId: displaySetInstanceUID, }); + + // By default this command is the single-viewport primitive: it removes + // the layer from the one viewport it is given and leaves + // `displaySet.isHydrated` and the segmentation presentation store alone, + // both of which are display-set-global ("show this wherever it + // logically belongs"). That is what the viewport overlay menu wants - + // its Remove hides the overlay here, and a viewport re-created later + // re-applies whatever hydration says. Clearing them unconditionally + // would also let a layer *replacement* (remove + add) silently + // un-hydrate the display set everywhere. + // + // `unhydrate: true` is the global statement, used by the removal paths + // that are themselves global - the segmentation panel's Remove from + // Viewport, and the SEGMENTATION_REMOVED handler in + // extensions/cornerstone/src/utils/setUpSegmentationEventHandlers.ts + // (which records it itself, before this loop over viewports). Recording + // `hydrated: false` rather than dropping the entry keeps the store a + // statement of desired state, so a viewport created later converges on + // "not shown" instead of replaying an earlier `hydrated: true`. + if (unhydrate) { + displaySet.isHydrated = false; + + commandsManager.runCommand('updateStoredSegmentationPresentation', { + displaySet, + hydrated: false, + }); + } } // Get current display sets for the viewport @@ -344,6 +386,7 @@ const commandsModule = ({ stageId, stageIndex, reset = false, + restoreCachedLayout = true, }: HangingProtocolParams): boolean => { const toUseStudyInstanceUID = activeStudyUID || StudyInstanceUID; try { @@ -382,7 +425,8 @@ const commandsModule = ({ }`; const { viewportGridState } = useViewportGridStore.getState(); - const restoreProtocol = !reset && viewportGridState[storedHanging]; + // An explicit preset selection passes restoreCachedLayout: false so the stage layout wins over a stale cached grid + const restoreProtocol = !reset && restoreCachedLayout && viewportGridState[storedHanging]; if ( reset || @@ -428,6 +472,9 @@ const commandsModule = ({ `${toUseStudyInstanceUID || hpInfo.activeStudyUID}:activeDisplaySet:0`, null ); + + // An applied protocol is an explicit layout change, so abandon any pending one-up toggle + useToggleOneUpViewportGridStore.getState().clearToggleOneUpViewportGridStore(); return true; } catch (e) { console.error(e); @@ -506,6 +553,9 @@ const commandsModule = ({ return; } + // An explicit grid selection abandons any pending one-up toggle; clear only past the onLayoutChange veto + useToggleOneUpViewportGridStore.getState().clearToggleOneUpViewportGridStore(); + const completeLayout = () => { const state = viewportGridService.getState(); findViewportsByPosition(state, { numRows, numCols }); @@ -600,6 +650,9 @@ const commandsModule = ({ isHangingProtocolLayout: true, }); + // Toggled back, so drop the stored layout; the store only holds a currently active one-up + useToggleOneUpViewportGridStore.getState().clearToggleOneUpViewportGridStore(); + // Reset crosshairs after restoring the layout setTimeout(() => { commandsManager.runCommand('resetCrosshairs'); @@ -805,9 +858,7 @@ const commandsModule = ({ } const reportBlob = datasetToDicomBlob(instances[0]); const type = defaultContentType || 'application/dicom'; - await navigator.clipboard.write([ - new ClipboardItem({ [type]: reportBlob }), - ]); + await navigator.clipboard.write([new ClipboardItem({ [type]: reportBlob })]); }; } @@ -832,6 +883,11 @@ const commandsModule = ({ } } + // Identify the stored instances before they reach the metadata store, so + // that the display sets made from them know which instance they came + // from, and a later save of the same data can extend this series. + registerStoredInstanceImageIds(instances, resolvedDataSource); + DicomMetadataStore.addInstances(instances, true); for (const instance of instances) { await resolvedDataSource.store.dicom(instance, null, dicomDict); diff --git a/extensions/default/src/customizations/headerRightSideCustomization.ts b/extensions/default/src/customizations/headerRightSideCustomization.ts new file mode 100644 index 00000000000..62959723a35 --- /dev/null +++ b/extensions/default/src/customizations/headerRightSideCustomization.ts @@ -0,0 +1,19 @@ +import HeaderUndoRedo from '../ViewerLayout/HeaderUndoRedo'; +import HeaderPatientInfo from '../ViewerLayout/HeaderPatientInfo'; + +/** + * The right side of the viewer header's menu bar, ahead of the settings menu. + * The key is named for the area, not its contents: `items` is an ordered list + * of components, so reordering the array reorders the header (patient info + * ahead of undo/redo, say), adding to it adds a slot, and removing an entry + * removes one — see `hideHeaderUndoRedoCustomization` for that last case. + * + * `ViewerHeader` renders each entry as a component, so an item is a normal + * component that may use hooks (these two use `useSystem()`), and each gets its + * own separator. An item that renders `null` collapses its slot and separator. + */ +export default { + 'ohif.headerRightSide': { + items: [HeaderUndoRedo, HeaderPatientInfo], + }, +}; diff --git a/extensions/default/src/customizations/hideHeaderUndoRedoCustomization.ts b/extensions/default/src/customizations/hideHeaderUndoRedoCustomization.ts new file mode 100644 index 00000000000..cd9e16bf4b1 --- /dev/null +++ b/extensions/default/src/customizations/hideHeaderUndoRedoCustomization.ts @@ -0,0 +1,22 @@ +import HeaderUndoRedo from '../ViewerLayout/HeaderUndoRedo'; + +/** + * Opt-in customization that drops the undo/redo buttons from the right side of + * the viewer header's menu bar, leaving every other item there (patient info, + * plus anything a site added) untouched. Add it to the app config: + * + * ```js + * window.config = { + * customizationService: ['@ohif/extension-default.customizationModule.hideHeaderUndoRedo'], + * }; + * ``` + * + * `$filter` keeps the items its predicate returns true for, so this removes one + * entry from the shipped `ohif.headerRightSide` list rather than replacing the + * list — a site that added its own items keeps them. + */ +export default { + 'ohif.headerRightSide': { + items: { $filter: (item: unknown) => item !== HeaderUndoRedo }, + }, +}; diff --git a/extensions/default/src/customizations/reportDialogCustomization.test.ts b/extensions/default/src/customizations/reportDialogCustomization.test.ts new file mode 100644 index 00000000000..4225e0e477c --- /dev/null +++ b/extensions/default/src/customizations/reportDialogCustomization.test.ts @@ -0,0 +1,701 @@ +import React from 'react'; +import { configure, fireEvent, render, screen } from '@testing-library/react'; + +const mockDisplaySetCache = new Map(); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: key => key }), +})); + +jest.mock('@ohif/core', () => ({ + useSystem: () => ({ + servicesManager: { + services: { + displaySetService: { + getDisplaySetCache: () => mockDisplaySetCache, + }, + }, + }, + }), +})); + +// Lightweight stand ins for the design system components - the dialog is being +// tested for what it says about the destination series, not for how ui-next +// renders tabs, a select or an input. +jest.mock('@ohif/ui-next', () => { + const ReactMock = require('react'); + const TabsContext = ReactMock.createContext(null); + const SelectContext = ReactMock.createContext(null); + + const FooterAction = ({ children }) => ReactMock.createElement('div', null, children); + FooterAction.Left = ({ children }) => ReactMock.createElement('div', null, children); + FooterAction.Right = ({ children }) => ReactMock.createElement('div', null, children); + const footerButton = ({ dataCY, onClick, disabled, children }) => + ReactMock.createElement('button', { 'data-cy': dataCY, onClick, disabled }, children); + FooterAction.Primary = footerButton; + FooterAction.Secondary = footerButton; + + return { + cn: (...classes) => classes.filter(Boolean).join(' '), + Icons: { ChevronOpen: () => null }, + Label: ({ children, htmlFor }) => ReactMock.createElement('label', { htmlFor }, children), + Input: ({ className, ...props }) => ReactMock.createElement('input', props), + FooterAction, + Tabs: ({ value, onValueChange, children }) => + ReactMock.createElement(TabsContext.Provider, { value: { value, onValueChange } }, children), + TabsList: ({ children }) => ReactMock.createElement('div', null, children), + TabsTrigger: ({ value, children, ...props }) => { + const context = ReactMock.useContext(TabsContext); + return ReactMock.createElement( + 'button', + { + ...props, + 'aria-selected': context.value === value, + onClick: () => context.onValueChange(value), + }, + children + ); + }, + Select: ({ value, onValueChange, children }) => + ReactMock.createElement( + SelectContext.Provider, + { value: { value, onValueChange } }, + children + ), + SelectTrigger: ({ children, ...props }) => ReactMock.createElement('div', props, children), + SelectContent: ({ children }) => ReactMock.createElement('div', null, children), + // Radix shows the placeholder until an item is chosen, then the item's text. + SelectValue: ({ placeholder }) => { + const context = ReactMock.useContext(SelectContext); + return context?.value ? null : placeholder; + }, + SelectItem: ({ value, children }) => { + const context = ReactMock.useContext(SelectContext); + return ReactMock.createElement( + 'button', + { onClick: () => context.onValueChange(value) }, + children + ); + }, + }; +}); + +import { ReportDialog } from './reportDialogCustomization'; + +configure({ testIdAttribute: 'data-cy' }); + +const CURRENT_SERIES_IMAGE_ID = 'wadors:/seg-instance-1'; +const OTHER_SERIES_IMAGE_ID = 'wadors:/seg-instance-2'; + +const CURRENT_SERIES = { + displaySetInstanceUID: 'ds-current', + Modality: 'SEG', + SeriesInstanceUID: '1.2.3', + SeriesNumber: 3105, + SeriesDescription: 'Liver', + predecessorImageId: CURRENT_SERIES_IMAGE_ID, +}; + +const OTHER_SERIES = { + displaySetInstanceUID: 'ds-other', + Modality: 'SEG', + SeriesInstanceUID: '1.2.5', + SeriesNumber: 3103, + SeriesDescription: 'Spleen', + predecessorImageId: OTHER_SERIES_IMAGE_ID, +}; + +// Another modality, so it is never a destination for a SEG. +const UNRELATED_SERIES = { + displaySetInstanceUID: 'ds-ct', + Modality: 'CT', + SeriesInstanceUID: '1.2.4', + SeriesNumber: 1, + SeriesDescription: 'Axial', +}; + +// An uploaded instance carries a local id, which the provider resolves. +const LOCAL_SERIES = { + displaySetInstanceUID: 'ds-local', + Modality: 'SEG', + SeriesInstanceUID: '1.2.6', + SeriesNumber: 3107, + SeriesDescription: 'Kidney', + predecessorImageId: 'dicomfile:3', +}; + +// The viewer never stored this series, so the series has no predecessor image id. +const DOWNLOADED_SERIES = { + displaySetInstanceUID: 'ds-downloaded', + Modality: 'SEG', + SeriesInstanceUID: '1.2.7', + SeriesNumber: 3108, + SeriesDescription: 'Pancreas', +}; + +const HISTORY_STORAGE_KEY = 'ohif.seriesDescriptionHistory'; + +function setDisplaySets(displaySets) { + mockDisplaySetCache.clear(); + displaySets.forEach(ds => mockDisplaySetCache.set(ds.displaySetInstanceUID, ds)); +} + +function renderDialog(props = {}) { + const onSave = jest.fn(); + const onCancel = jest.fn(); + render( + React.createElement(ReportDialog as any, { + dataSources: [], + modality: 'SEG', + minSeriesNumber: 3100, + defaultSeriesDescription: 'Segmentation 1', + enableDownload: true, + hide: jest.fn(), + onSave, + onCancel, + ...props, + }) + ); + return { onSave, onCancel }; +} + +const tab = (destination: string) => screen.getByTestId(`report-destination-${destination}`); +const selectedTab = () => + ['current', 'new', 'replace'].find( + destination => tab(destination).getAttribute('aria-selected') === 'true' + ); +const helpText = () => screen.getByTestId('report-destination-help').textContent; +const seriesNumberField = () => screen.getByTestId('report-series-number') as HTMLInputElement; +const descriptionField = () => screen.getByTestId('dialog-input') as HTMLInputElement; +const saveButton = () => screen.getByTestId('input-dialog-save-button') as HTMLButtonElement; +const isDisabled = (element: HTMLElement) => (element as HTMLButtonElement).disabled; +const shownDescriptions = () => + Array.from(screen.getByTestId('report-series-description-list').querySelectorAll('button')).map( + option => option.textContent + ); +const storedHistory = () => JSON.parse(window.localStorage.getItem(HISTORY_STORAGE_KEY) || '{}'); +const setStoredHistory = (history: Record) => + window.localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(history)); + +describe('ReportDialog', () => { + beforeEach(() => { + window.localStorage.clear(); + setDisplaySets([CURRENT_SERIES, OTHER_SERIES, UNRELATED_SERIES]); + }); + + describe('destinations', () => { + it('saves to the series the data was loaded from by default', () => { + renderDialog({ predecessorImageId: CURRENT_SERIES_IMAGE_ID }); + + expect(selectedTab()).toBe('current'); + expect(helpText()).toContain('Adds a new version to this series'); + expect(saveButton().textContent).toBe('Save to current'); + }); + + it('creates a series by default when the data has never been saved', () => { + renderDialog(); + + expect(selectedTab()).toBe('new'); + expect(helpText()).toBe('Creates a separate series.'); + expect(saveButton().textContent).toBe('Save as new'); + // There is no series it was loaded from to save to. + expect(isDisabled(tab('current'))).toBe(true); + expect(tab('current').getAttribute('title')).toBe( + 'This data has not been saved to a series yet' + ); + }); + + it('cannot replace a series when no other series of the type is loaded', () => { + setDisplaySets([CURRENT_SERIES, UNRELATED_SERIES]); + renderDialog({ predecessorImageId: CURRENT_SERIES_IMAGE_ID }); + + expect(isDisabled(tab('replace'))).toBe(true); + expect(tab('replace').getAttribute('title')).toBe('No other series of this type is loaded'); + }); + }); + + describe('save to current', () => { + it('shows the series number and description, uneditable, and stores into it', () => { + const { onSave } = renderDialog({ predecessorImageId: CURRENT_SERIES_IMAGE_ID }); + + expect(screen.getByTestId('report-series-description').textContent).toBe('Liver'); + expect(seriesNumberField().textContent).toBe('3105'); + expect(screen.queryByTestId('dialog-input')).toBeNull(); + + fireEvent.click(saveButton()); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + reportName: 'Liver', + series: CURRENT_SERIES_IMAGE_ID, + seriesNumber: 3105, + }) + ); + }); + }); + + describe('save as new', () => { + it('offers the next series number and the default description', () => { + const { onSave } = renderDialog(); + + // One past the highest existing series number of this modality. + expect(seriesNumberField().value).toBe('3106'); + expect(descriptionField().value).toBe('Segmentation 1'); + + fireEvent.click(saveButton()); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + reportName: 'Segmentation 1', + series: null, + seriesNumber: 3106, + priorSeriesNumber: 3105, + }) + ); + }); + + it('uses the minimum series number when no series of the modality exists', () => { + setDisplaySets([UNRELATED_SERIES]); + renderDialog(); + + expect(seriesNumberField().value).toBe('3101'); + }); + + it('counts a series that it does not offer as a destination', () => { + // The lists do not offer series 3108, because that series has no + // predecessor image id. A new series must still take a number past 3108. + setDisplaySets([CURRENT_SERIES, DOWNLOADED_SERIES]); + renderDialog(); + + expect(seriesNumberField().value).toBe('3109'); + }); + + it('saves an edited series number and description', () => { + const { onSave } = renderDialog(); + + fireEvent.change(seriesNumberField(), { target: { value: '4321' } }); + fireEvent.change(descriptionField(), { target: { value: 'Left kidney' } }); + fireEvent.click(saveButton()); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + reportName: 'Left kidney', + series: null, + seriesNumber: 4321, + priorSeriesNumber: 4320, + }) + ); + }); + + it('falls back to the offered values when the fields are emptied', () => { + const { onSave } = renderDialog(); + + fireEvent.change(seriesNumberField(), { target: { value: '' } }); + fireEvent.change(descriptionField(), { target: { value: ' ' } }); + fireEvent.click(saveButton()); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ reportName: 'Segmentation 1', seriesNumber: 3106 }) + ); + }); + }); + + describe('replace existing', () => { + it('waits for a series to be chosen, then stores into it', () => { + const { onSave } = renderDialog({ predecessorImageId: CURRENT_SERIES_IMAGE_ID }); + + fireEvent.click(tab('replace')); + + expect(helpText()).toContain('Choose a series to replace'); + expect(isDisabled(saveButton())).toBe(true); + expect(isDisabled(screen.getByTestId('report-download-button'))).toBe(true); + expect(seriesNumberField().textContent).toBe(''); + // The row is labelled `Series Description`, so the control asks for the + // choice it needs rather than repeating the label. + expect(screen.getByTestId('report-replaced-series-select').textContent).toBe( + 'Select a series' + ); + + // The series the data was loaded from is not offered again here. + fireEvent.click(screen.getByText('Spleen')); + + expect(screen.queryByText('Liver')).toBeNull(); + expect(seriesNumberField().textContent).toBe('3103'); + expect(isDisabled(saveButton())).toBe(false); + + fireEvent.click(saveButton()); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + reportName: 'Spleen', + series: OTHER_SERIES_IMAGE_ID, + seriesNumber: 3103, + }) + ); + }); + + it('names a series without a description by its number', () => { + setDisplaySets([{ ...OTHER_SERIES, SeriesDescription: undefined }]); + renderDialog(); + + fireEvent.click(tab('replace')); + + expect(screen.getByText('Series 3103')).toBeTruthy(); + }); + + it('does not offer a series that no predecessor image id names', () => { + setDisplaySets([CURRENT_SERIES, OTHER_SERIES, LOCAL_SERIES, DOWNLOADED_SERIES]); + renderDialog({ predecessorImageId: CURRENT_SERIES_IMAGE_ID }); + + fireEvent.click(tab('replace')); + + expect(screen.getByText('Spleen')).toBeTruthy(); + // An uploaded instance carries a local id, which the provider resolves. + expect(screen.getByText('Kidney')).toBeTruthy(); + // The list gave the `SeriesInstanceUID` of this display set before, which + // is not an image id, and the provider then threw a `TypeError`. + expect(screen.queryByText('Pancreas')).toBeNull(); + }); + + it('cannot replace when the only other series has no predecessor image id', () => { + setDisplaySets([CURRENT_SERIES, DOWNLOADED_SERIES]); + renderDialog({ predecessorImageId: CURRENT_SERIES_IMAGE_ID }); + + expect(isDisabled(tab('replace'))).toBe(true); + }); + + it('stores into an uploaded series through its local image id', () => { + // A user must be able to save against an uploaded instance more than once. + setDisplaySets([LOCAL_SERIES]); + const { onSave } = renderDialog(); + + fireEvent.click(tab('replace')); + fireEvent.click(screen.getByText('Kidney')); + fireEvent.click(saveButton()); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + reportName: 'Kidney', + series: 'dicomfile:3', + seriesNumber: 3107, + }) + ); + }); + }); + + describe('remembered series descriptions', () => { + it('remembers the description that was used, per type of item', () => { + renderDialog(); + + fireEvent.change(descriptionField(), { target: { value: 'Right kidney' } }); + fireEvent.click(saveButton()); + + expect(storedHistory()).toEqual({ SEG: ['Right kidney'] }); + }); + + it('remembers nothing for the name that the caller provides', () => { + // `Segmentation 1` is the generated name of this segmentation. A later + // segmentation carries `Segmentation 2`, and the history would otherwise + // offer `Segmentation 1` as the name of that unrelated segmentation. + const { onSave } = renderDialog(); + + fireEvent.click(saveButton()); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ reportName: 'Segmentation 1' }) + ); + expect(storedHistory()).toEqual({}); + }); + + it('remembers nothing when the typed name is the provided one', () => { + renderDialog({ defaultSeriesDescription: 'Contours' }); + + fireEvent.change(descriptionField(), { target: { value: ' contours ' } }); + fireEvent.click(saveButton()); + + expect(storedHistory()).toEqual({}); + }); + + it('remembers nothing when an existing series is stored into', () => { + renderDialog({ predecessorImageId: CURRENT_SERIES_IMAGE_ID }); + + fireEvent.click(saveButton()); + + expect(storedHistory()).toEqual({}); + }); + + it('offers the last used description, and keeps the older ones behind it', () => { + setStoredHistory({ SEG: ['Right kidney', 'Left kidney'] }); + renderDialog(); + + // The last used one is the most likely to be wanted again. + expect(descriptionField().value).toBe('Right kidney'); + + fireEvent.click(screen.getByTestId('report-series-description-options')); + // The ones used before, most recent first, then the generic name. + expect(shownDescriptions()).toEqual(['Right kidney', 'Left kidney', 'Segmentation 1']); + }); + + it('offers the item name before every other description', () => { + // The user renamed `Liver` to `Liver + tumor` before the save. + setStoredHistory({ SEG: ['Right kidney', 'Left kidney'] }); + renderDialog({ predecessorImageId: CURRENT_SERIES_IMAGE_ID, itemName: 'Liver + tumor' }); + + fireEvent.click(tab('new')); + + expect(descriptionField().value).toBe('Liver + tumor'); + + fireEvent.click(screen.getByTestId('report-series-description-options')); + expect(shownDescriptions()).toEqual([ + 'Liver + tumor', + 'Liver', + 'Right kidney', + 'Left kidney', + 'Segmentation 1', + ]); + }); + + it('ignores an item name of spaces', () => { + // A blank name must not hide the description of the loaded series. + setStoredHistory({ SEG: ['Right kidney'] }); + renderDialog({ predecessorImageId: CURRENT_SERIES_IMAGE_ID, itemName: ' ' }); + + fireEvent.click(tab('new')); + + expect(descriptionField().value).toBe('Liver'); + }); + + it('offers the description of the loaded series when there is no item name', () => { + // The name of the last save comes before the remembered names. + setStoredHistory({ SEG: ['Right kidney', 'Left kidney'] }); + renderDialog({ predecessorImageId: CURRENT_SERIES_IMAGE_ID }); + + fireEvent.click(tab('new')); + + expect(descriptionField().value).toBe('Liver'); + + fireEvent.click(screen.getByTestId('report-series-description-options')); + expect(shownDescriptions()).toEqual([ + 'Liver', + 'Right kidney', + 'Left kidney', + 'Segmentation 1', + ]); + }); + + it('offers the last used description when the caller provides none', () => { + // A caller such as the findings flow of a fork provides no description. + setStoredHistory({ SEG: ['Right kidney', 'Left kidney'] }); + renderDialog({ defaultSeriesDescription: '' }); + + expect(descriptionField().value).toBe('Right kidney'); + }); + + it('saves when the caller provides a null description', () => { + // A mode context, or a customization, can give an explicit null, and the + // default of the prop replaces `undefined` alone. A save read `.trim()` + // off that null and threw, and the save then stored nothing. + setStoredHistory({ SEG: ['Right kidney'] }); + const { onSave } = renderDialog({ defaultSeriesDescription: null }); + + fireEvent.click(saveButton()); + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ reportName: 'Right kidney' })); + expect(storedHistory()).toEqual({ SEG: ['Right kidney'] }); + }); + + it('drops the outer spaces of an offered name', () => { + // The dialog shows the trimmed name, so the save stores the trimmed name + // as well, and the history holds the same name as the series. + const { onSave } = renderDialog({ itemName: ' Right kidney ' }); + + expect(descriptionField().value).toBe('Right kidney'); + + fireEvent.click(saveButton()); + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ reportName: 'Right kidney' })); + expect(storedHistory()).toEqual({ SEG: ['Right kidney'] }); + }); + + it('drops the outer spaces of the name an emptied field falls back to', () => { + const { onSave } = renderDialog({ itemName: ' Right kidney ' }); + + fireEvent.change(descriptionField(), { target: { value: ' ' } }); + fireEvent.click(saveButton()); + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ reportName: 'Right kidney' })); + }); + + it('drops the second copy of a description that two sources hold', () => { + // The history holds the generic name, so the list holds that name once. + setStoredHistory({ SEG: ['Segmentation 1', 'Left kidney'] }); + renderDialog(); + + expect(descriptionField().value).toBe('Segmentation 1'); + + fireEvent.click(screen.getByTestId('report-series-description-options')); + expect(shownDescriptions()).toEqual(['Segmentation 1', 'Left kidney']); + }); + + it('falls back to the offered description when the field is emptied', () => { + setStoredHistory({ SEG: ['Right kidney'] }); + const { onSave } = renderDialog(); + + fireEvent.change(descriptionField(), { target: { value: ' ' } }); + fireEvent.click(saveButton()); + + // The name the field offered, and not the generic name behind it. + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ reportName: 'Right kidney' })); + }); + + it('moves a reused description back to the front, without duplicating it', () => { + setStoredHistory({ SEG: ['Right kidney', 'Left kidney'] }); + renderDialog(); + + fireEvent.change(descriptionField(), { target: { value: 'left kidney' } }); + fireEvent.click(saveButton()); + + expect(storedHistory()).toEqual({ SEG: ['left kidney', 'Right kidney'] }); + }); + + it('remembers no more than the requested number of them', () => { + setStoredHistory({ SEG: ['4', '3', '2', '1'] }); + renderDialog({ rememberedDescriptionCount: 3 }); + + fireEvent.change(descriptionField(), { target: { value: '5' } }); + fireEvent.click(saveButton()); + + expect(storedHistory()).toEqual({ SEG: ['5', '4', '3'] }); + }); + + it('remembers nothing, and offers nothing, for a count of 0', () => { + setStoredHistory({ SEG: ['Right kidney'] }); + renderDialog({ rememberedDescriptionCount: 0 }); + + expect(descriptionField().value).toBe('Segmentation 1'); + expect(screen.queryByTestId('report-series-description-options')).toBeNull(); + + fireEvent.click(saveButton()); + expect(storedHistory()).toEqual({ SEG: ['Right kidney'] }); + }); + + it('still starts from the item name for a count of 0', () => { + setStoredHistory({ SEG: ['Right kidney'] }); + renderDialog({ rememberedDescriptionCount: 0, itemName: 'Liver + tumor' }); + + expect(descriptionField().value).toBe('Liver + tumor'); + expect(screen.queryByTestId('report-series-description-options')).toBeNull(); + }); + + it('ignores an item name of spaces for a count of 0', () => { + // A blank name must not empty the field, and must not hide `Liver`. + renderDialog({ + rememberedDescriptionCount: 0, + itemName: ' ', + predecessorImageId: CURRENT_SERIES_IMAGE_ID, + }); + + fireEvent.click(tab('new')); + + expect(descriptionField().value).toBe('Liver'); + expect(screen.queryByTestId('report-series-description-options')).toBeNull(); + }); + + it('offers the default when the current description is blank, for a count of 0', () => { + // A blank loaded description must fall through to the generic name. + setDisplaySets([{ ...CURRENT_SERIES, SeriesDescription: ' ' }]); + const { onSave } = renderDialog({ + rememberedDescriptionCount: 0, + predecessorImageId: CURRENT_SERIES_IMAGE_ID, + }); + + fireEvent.click(tab('new')); + expect(descriptionField().value).toBe('Segmentation 1'); + + fireEvent.change(descriptionField(), { target: { value: ' ' } }); + fireEvent.click(saveButton()); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ reportName: 'Segmentation 1' }) + ); + }); + + it('keeps each type of item separate', () => { + setStoredHistory({ SEG: ['Right kidney'] }); + renderDialog({ modality: 'RTSTRUCT', defaultSeriesDescription: 'Contours' }); + + expect(descriptionField().value).toBe('Contours'); + + fireEvent.change(descriptionField(), { target: { value: 'Left lung' } }); + fireEvent.click(saveButton()); + expect(storedHistory()).toEqual({ SEG: ['Right kidney'], RTSTRUCT: ['Left lung'] }); + }); + + it('narrows the offered descriptions to what is being typed', () => { + setStoredHistory({ SEG: ['Right kidney', 'Left kidney', 'Liver'] }); + renderDialog(); + + fireEvent.change(descriptionField(), { target: { value: 'Li' } }); + + expect(shownDescriptions()).toEqual(['Liver']); + }); + + it('completes the typed prefix on tab', () => { + setStoredHistory({ SEG: ['Right kidney', 'Left kidney'] }); + renderDialog(); + + fireEvent.change(descriptionField(), { target: { value: 'le' } }); + fireEvent.keyDown(descriptionField(), { key: 'Tab' }); + + expect(descriptionField().value).toBe('Left kidney'); + expect(screen.queryByTestId('report-series-description-list')).toBeNull(); + }); + + it('leaves the typed description alone when nothing completes it', () => { + setStoredHistory({ SEG: ['Right kidney'] }); + renderDialog(); + + fireEvent.change(descriptionField(), { target: { value: 'Spleen' } }); + fireEvent.keyDown(descriptionField(), { key: 'Tab' }); + + expect(descriptionField().value).toBe('Spleen'); + }); + + it('accepts a highlighted description with enter, and saves with the next one', () => { + setStoredHistory({ SEG: ['Right kidney', 'Left kidney'] }); + const { onSave } = renderDialog(); + + fireEvent.click(screen.getByTestId('report-series-description-options')); + fireEvent.keyDown(descriptionField(), { key: 'ArrowDown' }); + fireEvent.keyDown(descriptionField(), { key: 'ArrowDown' }); + fireEvent.keyDown(descriptionField(), { key: 'Enter' }); + + // The field starts from the first entry, so the arrow key picks the second. + expect(descriptionField().value).toBe('Left kidney'); + expect(onSave).not.toHaveBeenCalled(); + + fireEvent.keyDown(descriptionField(), { key: 'Enter' }); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ reportName: 'Left kidney' })); + }); + }); + + describe('actions', () => { + it('downloads through the same destination', () => { + const { onSave } = renderDialog(); + + fireEvent.click(screen.getByTestId('report-download-button')); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ dataSource: 'download', series: null }) + ); + }); + + it('cancels without saving', () => { + const { onSave, onCancel } = renderDialog(); + + fireEvent.click(screen.getByTestId('input-dialog-cancel-button')); + + expect(onCancel).toHaveBeenCalled(); + expect(onSave).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/extensions/default/src/customizations/reportDialogCustomization.tsx b/extensions/default/src/customizations/reportDialogCustomization.tsx index fc193a7345b..af93a79aa28 100644 --- a/extensions/default/src/customizations/reportDialogCustomization.tsx +++ b/extensions/default/src/customizations/reportDialogCustomization.tsx @@ -1,36 +1,125 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { InputDialog } from '@ohif/ui-next'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ohif/ui-next'; +import { + cn, + FooterAction, + Icons, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Tabs, + TabsList, + TabsTrigger, +} from '@ohif/ui-next'; import { useSystem } from '@ohif/core'; +import { + getSeriesDescriptionHistory, + rememberSeriesDescription, +} from '../utils/seriesDescriptionHistory'; + +/** + * The dialog that stores a segmentation, a contour set or a measurement report. + * See `platform/docs/docs/behaviours/report-dialog-save-destinations.md`. + */ + type DataSource = { value: string; label: string; placeHolder: string; }; -/** Radix Select item value for "Create new series" (state remains null). */ -const NEW_SERIES_SELECT_VALUE = '__new_series_id__'; - -type SeriesOption = { - optionKey: string; - selectValue: string; - value: string | null; +/** A series of the stored modality that this save could be written into. */ +type ExistingSeries = { + /** Identifies the series to store into, as a predecessorImageId value. */ + value: string; + /** Numeric series number, used to compute the next available series number. */ seriesNumber: number; + /** Series number as shown to the user, or a placeholder when there isn't one. */ + seriesNumberLabel: string; description: string | null; + /** What the series is called in the list of series to replace. */ label: string; }; +/** + * Which series the save goes into: + * - `current` the series the data was loaded from + * - `new` a series created for it + * - `replace` another loaded series of this modality + * + * All three store the same object, and the dialog merges nothing. + */ +type Destination = 'current' | 'new' | 'replace'; + +const DESTINATIONS: { + value: Destination; + label: string; + help: string; + /** Why the destination is not available, shown on the disabled tab. */ + unavailable: string; +}[] = [ + { + value: 'current', + label: 'Save to current', + help: 'Adds a new version to this series as the new default. Earlier versions are kept.', + unavailable: 'This data has not been saved to a series yet', + }, + { + value: 'new', + label: 'Save as new', + help: 'Creates a separate series.', + unavailable: '', + }, + { + value: 'replace', + label: 'Replace existing', + help: 'Choose a series to replace. The current data becomes the default and earlier versions are kept.', + unavailable: 'No other series of this type is loaded', + }, +]; + type ReportDialogProps = { dataSources: DataSource[]; modality?: string; + /** + * The image id the data being saved was loaded from. When it belongs to a + * loaded series, that series is the one `Save to current` writes into. + */ predecessorImageId?: string; + /** Lowest series number to use for a newly created series of this modality. */ + minSeriesNumber?: number; + /** + * The name that the user chose for the item - the segmentation name, for + * example. A new series offers this name first. A generated name is not such + * a name, and belongs in `defaultSeriesDescription`. + */ + itemName?: string; + /** + * The name for an item that has no other name, such as 'Contours' or + * 'Measurements'. A new series offers this name last. + */ + defaultSeriesDescription?: string; + /** + * The type of item being stored, used as the key the series descriptions used + * before are remembered under. Defaults to the modality. + */ + itemType?: string; + /** + * How many previously used series descriptions to remember and offer for this + * type of item. 0 remembers nothing. + */ + rememberedDescriptionCount?: number; hide: () => void; onSave: (data: { reportName: string; dataSource: string | null; series: string | null; + seriesNumber: number; priorSeriesNumber: number; }) => void; onCancel: () => void; @@ -42,6 +131,10 @@ function ReportDialog({ modality = 'SR', predecessorImageId, minSeriesNumber = 3000, + itemName = '', + defaultSeriesDescription = '', + itemType, + rememberedDescriptionCount = 5, hide, onSave, onCancel, @@ -50,76 +143,220 @@ function ReportDialog({ const { t } = useTranslation('Buttons'); const { servicesManager } = useSystem(); const actionTakenRef = useRef(false); + const descriptionInputRef = useRef(null); const [selectedDataSource, setSelectedDataSource] = useState( dataSources?.[0]?.value ?? null ); const { displaySetService } = servicesManager.services; - const [selectedSeries, setSelectedSeries] = useState(predecessorImageId || null); - const [reportName, setReportName] = useState(''); + /** Every loaded display set of the stored modality. */ + const modalityDisplaySets = useMemo( + () => + Array.from(displaySetService.getDisplaySetCache().values()).filter( + ds => ds.Modality === modality + ), + [displaySetService, modality] + ); + + const existingSeries = useMemo((): ExistingSeries[] => { + const seen = new Set(); - const seriesOptions = useMemo((): SeriesOption[] => { - const displaySetsMap = displaySetService.getDisplaySetCache(); - const displaySets = Array.from(displaySetsMap.values()); - const options = displaySets - .filter(ds => ds.Modality === modality) + return modalityDisplaySets .map(ds => { - const value = ds.predecessorImageId || ds.SeriesInstanceUID; - const selectValue = value || ds.displaySetInstanceUID; + const hasSeriesNumber = isFinite(ds.SeriesNumber); + const seriesNumberLabel = hasSeriesNumber ? `${ds.SeriesNumber}` : 'Not specified'; return { - optionKey: `series-${ds.displaySetInstanceUID}`, - selectValue, - value: value || null, - seriesNumber: isFinite(ds.SeriesNumber) ? ds.SeriesNumber : minSeriesNumber, - description: ds.SeriesDescription, - label: `${ds.SeriesDescription} ${ds.SeriesDate}/${ds.SeriesTime} ${ds.SeriesNumber}`, + value: ds.predecessorImageId, + seriesNumber: hasSeriesNumber ? Number(ds.SeriesNumber) : minSeriesNumber, + seriesNumberLabel, + description: ds.SeriesDescription || null, + label: ds.SeriesDescription || `Series ${seriesNumberLabel}`, }; }) - .filter( - option => - option.selectValue && option.selectValue !== NEW_SERIES_SELECT_VALUE - ); - - return [ - { - optionKey: NEW_SERIES_SELECT_VALUE, - selectValue: NEW_SERIES_SELECT_VALUE, - value: null, - description: null, - seriesNumber: minSeriesNumber, - label: 'Create new series', - }, - ...options, - ]; - }, [displaySetService, modality, minSeriesNumber]); - - const handleSeriesChange = useCallback( - (selectValue: string) => { - const option = seriesOptions.find(o => o.selectValue === selectValue); - setSelectedSeries( - selectValue === NEW_SERIES_SELECT_VALUE ? null : (option?.value ?? selectValue) - ); - }, - [seriesOptions] + .filter(series => { + // The value names the superseded instance, so a display set without one + // cannot be a target. Two display sets can share a series. + if (!series.value || seen.has(series.value)) { + return false; + } + seen.add(series.value); + return true; + }); + }, [modalityDisplaySets, minSeriesNumber]); + + /** + * The series the data was loaded from, which `Save to current` writes into. + * There isn't one for data that has never been stored. + */ + const currentSeries = useMemo( + () => existingSeries.find(series => series.value === predecessorImageId) ?? null, + [existingSeries, predecessorImageId] ); - useEffect(() => { - const seriesOption = seriesOptions.find(s => s.value === selectedSeries); - const newReportName = - selectedSeries && seriesOption?.description ? seriesOption.description : ''; - setReportName(newReportName); - }, [selectedSeries, seriesOptions]); + /** The other series of this modality, which `Replace existing` chooses from. */ + const replaceableSeries = useMemo( + () => existingSeries.filter(series => series !== currentSeries), + [existingSeries, currentSeries] + ); - const handleSave = useCallback(() => { - actionTakenRef.current = true; - onSave({ - reportName, - dataSource: selectedDataSource, - priorSeriesNumber: Math.max(...seriesOptions.map(it => it.seriesNumber)), - series: selectedSeries, - }); - hide(); - }, [selectedDataSource, selectedSeries, reportName, hide, onSave]); + /** One past every loaded series of the modality, not only the offered ones. */ + const defaultNewSeriesNumber = useMemo( + () => + 1 + + modalityDisplaySets.reduce( + (highest, ds) => + isFinite(ds.SeriesNumber) ? Math.max(highest, Number(ds.SeriesNumber)) : highest, + minSeriesNumber + ), + [modalityDisplaySets, minSeriesNumber] + ); + + /** + * The series descriptions to offer for a new series: `itemName`, then the + * description of `currentSeries`, then the ones used before for this type of + * item, then `defaultSeriesDescription`. + */ + const descriptionOptions = useMemo(() => { + // A count of 0 turns the history off, and the list then holds one name. + const remembered = + rememberedDescriptionCount > 0 + ? getSeriesDescriptionHistory(itemType || modality, rememberedDescriptionCount) + : []; + + // Trimmed here, so the stored, shown and remembered names all match. + const offered = [itemName, currentSeries?.description, ...remembered, defaultSeriesDescription] + .map(option => option?.trim()) + .filter((option): option is string => !!option); + + const options = offered.filter( + (option, index) => + offered.findIndex(other => other.toLowerCase() === option.toLowerCase()) === index + ); + + return rememberedDescriptionCount > 0 ? options : options.slice(0, 1); + }, [ + currentSeries, + itemName, + defaultSeriesDescription, + itemType, + modality, + rememberedDescriptionCount, + ]); + + /** The name a new series starts from, and the fallback for an emptied field. */ + const baseSeriesDescription = descriptionOptions[0] ?? ''; + + const [destination, setDestination] = useState(currentSeries ? 'current' : 'new'); + const [newSeriesNumber, setNewSeriesNumber] = useState(String(defaultNewSeriesNumber)); + const [newSeriesDescription, setNewSeriesDescription] = useState(baseSeriesDescription); + const [replacedSeriesValue, setReplacedSeriesValue] = useState(null); + const [descriptionsOpen, setDescriptionsOpen] = useState(false); + // Typing narrows the list to what it can complete; opening the list from its + // button shows everything on offer. + const [descriptionsFiltered, setDescriptionsFiltered] = useState(false); + const [highlightedDescription, setHighlightedDescription] = useState(-1); + + const replacedSeries = + replaceableSeries.find(series => series.value === replacedSeriesValue) ?? null; + + /** The series being written into - null when a series is being created. */ + const targetSeries: ExistingSeries | null = + destination === 'current' ? currentSeries : destination === 'replace' ? replacedSeries : null; + + /** Nothing can be produced until the series to replace has been chosen. */ + const isIncomplete = destination === 'replace' && !replacedSeries; + + const isAvailable = useCallback( + (value: Destination) => + (value === 'current' && !!currentSeries) || + value === 'new' || + (value === 'replace' && replaceableSeries.length > 0), + [currentSeries, replaceableSeries] + ); + + // An emptied or otherwise unusable series number falls back to the offered one + // rather than storing something invalid. + const parsedSeriesNumber = Number.parseInt(newSeriesNumber, 10); + const seriesNumber = Number.isFinite(parsedSeriesNumber) + ? parsedSeriesNumber + : defaultNewSeriesNumber; + + /** The options completing what has been typed so far, in offered order. */ + const descriptionCompletions = useMemo(() => { + const typed = newSeriesDescription.trim().toLowerCase(); + return descriptionOptions.filter(option => option.toLowerCase().startsWith(typed)); + }, [descriptionOptions, newSeriesDescription]); + + const shownDescriptions = descriptionsFiltered ? descriptionCompletions : descriptionOptions; + + const acceptDescription = useCallback((description: string) => { + setNewSeriesDescription(description); + setDescriptionsOpen(false); + setHighlightedDescription(-1); + }, []); + + const handleDescriptionChange = useCallback((description: string) => { + setNewSeriesDescription(description); + setHighlightedDescription(-1); + setDescriptionsFiltered(true); + setDescriptionsOpen(true); + }, []); + + const submit = useCallback( + (dataSource: string | null) => { + if (isIncomplete) { + return; + } + + actionTakenRef.current = true; + const storedSeriesNumber = targetSeries ? targetSeries.seriesNumber : seriesNumber; + // An existing series keeps its own description, so there is nothing for + // the user to have changed there. + const storedDescription = targetSeries + ? (targetSeries.description ?? '') + : newSeriesDescription.trim() || baseSeriesDescription; + + // The history keeps only a name the user chose, so the offered default + // does not consume a slot. + const isProvidedName = + storedDescription.toLowerCase() === defaultSeriesDescription?.trim().toLowerCase(); + + if (!targetSeries && !isProvidedName) { + rememberSeriesDescription( + itemType || modality, + storedDescription, + rememberedDescriptionCount + ); + } + + onSave({ + reportName: storedDescription, + dataSource, + series: targetSeries ? targetSeries.value : null, + seriesNumber: storedSeriesNumber, + // Kept for callers that compute the new series number as + // `1 + priorSeriesNumber`, including when it has been edited here. + priorSeriesNumber: storedSeriesNumber - 1, + }); + hide(); + }, + [ + isIncomplete, + targetSeries, + seriesNumber, + newSeriesDescription, + baseSeriesDescription, + defaultSeriesDescription, + itemType, + modality, + rememberedDescriptionCount, + hide, + onSave, + ] + ); + + const handleSave = useCallback(() => submit(selectedDataSource), [submit, selectedDataSource]); + const handleDownload = useCallback(() => submit('download'), [submit]); const handleCancel = useCallback(() => { actionTakenRef.current = true; @@ -127,16 +364,62 @@ function ReportDialog({ hide(); }, [onCancel, hide]); - const handleDownload = useCallback(() => { - actionTakenRef.current = true; - onSave({ - reportName, - dataSource: 'download', - priorSeriesNumber: Math.max(...seriesOptions.map(it => it.seriesNumber)), - series: selectedSeries, - }); - hide(); - }, [selectedDataSource, selectedSeries, reportName, hide, onSave]); + const handleDescriptionKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const highlighted = + highlightedDescription >= 0 + ? shownDescriptions[highlightedDescription] + : descriptionCompletions[0]; + + switch (event.key) { + case 'Tab': + // Completes what has been typed, and only moves on when there is + // nothing left to complete. + if (!event.shiftKey && highlighted && highlighted !== newSeriesDescription) { + event.preventDefault(); + acceptDescription(highlighted); + } else { + setDescriptionsOpen(false); + } + break; + case 'ArrowDown': + event.preventDefault(); + setDescriptionsOpen(true); + setHighlightedDescription(index => Math.min(index + 1, shownDescriptions.length - 1)); + break; + case 'ArrowUp': + event.preventDefault(); + setHighlightedDescription(index => Math.max(index - 1, -1)); + break; + case 'Escape': + if (descriptionsOpen) { + event.preventDefault(); + setDescriptionsOpen(false); + setHighlightedDescription(-1); + } + break; + case 'Enter': + event.preventDefault(); + if (descriptionsOpen && highlightedDescription >= 0 && highlighted) { + acceptDescription(highlighted); + } else { + handleSave(); + } + break; + default: + break; + } + }, + [ + descriptionCompletions, + shownDescriptions, + descriptionsOpen, + highlightedDescription, + newSeriesDescription, + acceptDescription, + handleSave, + ] + ); // Handles the close dialog button/external close as a cancel useEffect(() => { @@ -147,117 +430,227 @@ function ReportDialog({ }; }, [onCancel]); + // The dialog focuses the first control in its content when it opens, which is + // the destination tabs. Put the caret in the description instead, so that a + // new series can be named by typing straight away. + useEffect(() => { + if (destination !== 'new') { + return; + } + const focusDescription = window.setTimeout(() => descriptionInputRef.current?.focus(), 0); + return () => window.clearTimeout(focusDescription); + }, [destination]); + + const selected = DESTINATIONS.find(option => option.value === destination); const showDataSourceSelect = dataSources?.length > 1; - const showDownloadButton = enableDownload; - const selectedSeriesSelectValue = - selectedSeries == null - ? NEW_SERIES_SELECT_VALUE - : (seriesOptions.find(o => o.value === selectedSeries)?.selectValue ?? - selectedSeries); return ( -
-
-
- {showDataSourceSelect && ( - <> -
-
Data source
- -
-
-
Series
- + + + + + {dataSources.map(source => ( + + {source.label} + + ))} + + + + )} + + +
+ setDestination(value as Destination)} + > + + {DESTINATIONS.map(option => ( + - - - - - {seriesOptions.map(series => ( - - {series.label} - - ))} - - -
- - )} + {option.label} + + ))} + + +

+ {selected?.help} +

-
- {!showDataSourceSelect && ( -
-
Series
- handleDescriptionChange(event.target.value)} + onKeyDown={handleDescriptionKeyDown} + onBlur={() => setDescriptionsOpen(false)} + /> + {descriptionOptions.length > 1 && ( + + )} + {descriptionsOpen && shownDescriptions.length > 0 && ( +
    - - - - - {seriesOptions.map(series => ( - ( +
  • +
- )} - + + ))} + + )} +
+ ) : destination === 'replace' ? ( + + ) : ( + + {currentSeries?.description ?? 'No description'} + + )} -
- - - {showDownloadButton && ( - - {t('Download')} - - )} - Save - - -
+ + {destination === 'new' ? ( + setNewSeriesNumber(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSave(); + } + }} + /> + ) : ( + + {targetSeries ? targetSeries.seriesNumberLabel : ''} + + )}
+ + + {enableDownload && ( + + + {t('Download')} + + + )} + + + {t('Cancel')} + + + {selected?.label} + + +
); } diff --git a/extensions/default/src/customizations/thumbnailDetailsCustomization.ts b/extensions/default/src/customizations/thumbnailDetailsCustomization.ts new file mode 100644 index 00000000000..0a648dd87e9 --- /dev/null +++ b/extensions/default/src/customizations/thumbnailDetailsCustomization.ts @@ -0,0 +1,91 @@ +import { utils } from '@ohif/core'; + +const { getLatestInstanceDateTime } = utils; + +/** + * Named sources for the study browser thumbnail detail items, so an item can + * say where to get its value from without supplying a function - which is what + * lets the whole thing be declared as data in a `?customization=` JSONC file. + * + * An item may name one of these as `source`, or supply its own `contentF`. + * + * Add to this with `$merge`: a `$set` replaces the registry, which takes away + * the sources the default items name. + */ +export const thumbnailDetailSources = { + seriesNumber: ({ displaySet }) => displaySet?.SeriesNumber, + + numInstances: ({ displaySet }) => + (displaySet?.numImageFrames ?? displaySet?.instances?.length) || 1, + + seriesDate: ({ displaySet, formatters }) => formatters.formatDate(displaySet?.SeriesDate), + + /** + * When the display set was created, which is the date/time the series list is + * sorted by - see `getLatestInstanceDateTime`. Without this on the thumbnail, several + * reports or segmentations saved on the same day all read as the same date and + * their order looks arbitrary. + * + * Nothing below minutes is shown: the second a report was written says nothing + * a reader can use, and it is not reliably recorded either. + */ + instanceDateTime: ({ displaySet, instance, formatters }) => { + const { SeriesDate, SeriesTime } = getLatestInstanceDateTime(instance ?? displaySet); + if (!SeriesDate) { + return ''; + } + const date = formatters.formatDate(SeriesDate); + return SeriesTime ? `${date} ${formatters.formatTime(SeriesTime, 'HH:mm')}` : date; + }, +}; + +/** + * Named tests for the study browser thumbnail detail items, so an item can say + * whether to include itself without supplying a function. + * + * An item may name one of these as `condition`, or supply its own function. + */ +export const thumbnailDetailTests = { + /** SR, SEG, RTSTRUCT, PMAP and the other derived display sets. */ + isDerivedDisplaySet: ({ displaySet }) => !!displaySet?.isDerivedDisplaySet, +}; + +export default { + 'studyBrowser.thumbnailDetailSources': thumbnailDetailSources, + 'studyBrowser.thumbnailDetailTests': thumbnailDetailTests, + + /** + * The items shown on the detail line of a study browser thumbnail, under the + * modality and series description. Declared the same way as the viewport + * overlay items (`viewportOverlay.topLeft` and friends): + * + * - `id` names the item, and is what an override replaces or removes. + * - `condition` decides whether to include it, either as a function of + * `{ displaySet, instance, formatters }` or the name of a + * `studyBrowser.thumbnailDetailTests` entry. Omitted means always. + * - the value comes from `contentF` (a function of the same properties), + * `source` (the name of a `studyBrowser.thumbnailDetailSources` entry) or + * `attribute` (an attribute of the instance the display set shows). An item + * with no value, or naming a source or test that is not registered, is left + * out; if that leaves no items at all the thumbnail keeps its default detail + * line rather than showing an empty one. + * - `label` prefixes the value, `title` is its tooltip, and `iconName` puts an + * icon before it - a name, or a function returning one. + * + * The default is the series number and the instance count, as the thumbnails + * have always shown. `platform/app/public/customizations/studyBrowser/ + * derivedDateTime.jsonc` is an example of adding to it. + */ + 'studyBrowser.thumbnailDetails': [ + { + id: 'SeriesNumber', + label: 'S:', + source: 'seriesNumber', + }, + { + id: 'InstanceCount', + source: 'numInstances', + iconName: ({ displaySet }) => displaySet?.countIcon || 'InfoSeries', + }, + ], +}; diff --git a/extensions/default/src/customizations/userPreferencesCustomization.tsx b/extensions/default/src/customizations/userPreferencesCustomization.tsx index f139ce2cb3a..dc057bf3b2e 100644 --- a/extensions/default/src/customizations/userPreferencesCustomization.tsx +++ b/extensions/default/src/customizations/userPreferencesCustomization.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { useSystem, hotkeys as hotkeysModule } from '@ohif/core'; import { UserPreferencesModal, FooterAction } from '@ohif/ui-next'; import { useTranslation } from 'react-i18next'; @@ -67,6 +67,30 @@ function getModifierFromBindings( return modifierBinding?.modifierKey != null ? String(modifierBinding.modifierKey) : null; } +/** + * Resolves a localized language name, or null when Intl cannot produce one that + * differs from the raw locale code. + * + * Module scope on purpose: the conditional inside this try/catch is a React + * Compiler limitation ("value blocks within a try/catch") that bails the whole + * component when inlined. Plain functions are never compiled. + */ +function resolveLocalizedLanguageName( + displayNames: Intl.DisplayNames, + languageValue: string +): string | null { + try { + const localized = displayNames.of(languageValue); + if (localized && localized.toLowerCase() !== languageValue.toLowerCase()) { + return localized.charAt(0).toUpperCase() + localized.slice(1); + } + } catch (error) { + console.debug(`Unable to resolve display name for ${languageValue}`, error); + } + + return null; +} + function UserPreferencesModalDefault({ hide }: { hide: () => void }) { const { hotkeysManager, servicesManager } = useSystem(); const { t, i18n: i18nextInstance } = useTranslation('UserPreferencesModal'); @@ -74,13 +98,9 @@ function UserPreferencesModalDefault({ hide }: { hide: () => void }) { const { hotkeyDefinitions = {}, hotkeyDefaults = {} } = hotkeysManager; - const fallbackHotkeyDefinitions = useMemo( - () => - hotkeysManager.getValidHotkeyDefinitions( - hotkeysModule.defaults.hotkeyBindings - ) as HotkeyDefinitions, - [hotkeysManager] - ); + const fallbackHotkeyDefinitions = hotkeysManager.getValidHotkeyDefinitions( + hotkeysModule.defaults.hotkeyBindings + ) as HotkeyDefinitions; useEffect(() => { if (!Object.keys(hotkeyDefaults).length) { @@ -102,13 +122,10 @@ function UserPreferencesModalDefault({ hide }: { hide: () => void }) { const currentLanguage = currentLanguageFn(); - const initialCrosshairModifier = useMemo( - () => getToolModifier(toolGroupService, 'mpr', 'Crosshairs', 1), - [toolGroupService] - ); - const defaultCrosshairBindings = useMemo( - () => toolGroupService?.getDefaultToolBindings?.('mpr', 'Crosshairs'), - [toolGroupService] + const initialCrosshairModifier = getToolModifier(toolGroupService, 'mpr', 'Crosshairs', 1); + const defaultCrosshairBindings = toolGroupService?.getDefaultToolBindings?.( + 'mpr', + 'Crosshairs' ); const [state, setState] = useState({ @@ -177,13 +194,9 @@ function UserPreferencesModalDefault({ hide }: { hide: () => void }) { } if (displayNames) { - try { - const localized = displayNames.of(languageValue); - if (localized && localized.toLowerCase() !== languageValue.toLowerCase()) { - return localized.charAt(0).toUpperCase() + localized.slice(1); - } - } catch (error) { - console.debug(`Unable to resolve display name for ${languageValue}`, error); + const localized = resolveLocalizedLanguageName(displayNames, languageValue); + if (localized) { + return localized; } } diff --git a/extensions/default/src/customizations/workListCustomization.ts b/extensions/default/src/customizations/workListCustomization.ts index c4e85eb6383..1e847b757ce 100644 --- a/extensions/default/src/customizations/workListCustomization.ts +++ b/extensions/default/src/customizations/workListCustomization.ts @@ -7,7 +7,8 @@ import { StudyList } from '@ohif/ui-next'; * Selects which study-list route is mounted at `/`. * - `'default'`: the new ui-next WorkList. * - `'legacy'`: the pre-3.13 WorkList (now `LegacyWorkList`). Useful as an - * opt-out while integrators migrate to the new study list. + * opt-out while integrators migrate to the new study list. Deprecated; a + * future release removes it along with this id. * * - `workList.previewSeriesView`: `'all' | 'thumbnails' | 'list'` (default: `'all'`) * Controls which series views are available in the preview panel. @@ -17,7 +18,6 @@ import { StudyList } from '@ohif/ui-next'; * Note: the preview is forced to `'list'` when the active data source either: * - declares `thumbnailRendering` as `'wadors'` or `'thumbnailDirect'`, or * - declares `thumbnailRequestStrategy` as `'bulkDataRetrieve'` (default value). - * Currently only applies when `workList.variant` is `'default'`. * * - `workList.columns`: `ColumnDef[]` (default: `StudyList.defaultColumns`) * The column set for the WorkList table, as a value (not a function). Because @@ -33,7 +33,7 @@ import { StudyList } from '@ohif/ui-next'; * column without writing the accessor/header/cell wiring. * * Gotchas / limitations: - * - A `ColumnDef`'s `accessorFn` / `cell` / `header` / `filterFn` / `sortingFn` + * - A `ColumnDef`'s `accessorFn` / `cell` / `header` / `filterFn` / `sortFn` * are functions: `$set`/`$push` accept them, but they are not serializable, * so columns that render anything beyond plain text still need code. * - The trailing `actions` column should stay last for correct layout (its @@ -43,7 +43,6 @@ import { StudyList } from '@ohif/ui-next'; * - Index-based commands (e.g. `{ 2: { meta: { label: { $set: '…' } } } }`) * are position-fragile; prefer `$apply` for id-based edits. * If the merged value is not an array, WorkList falls back to the defaults. - * Currently only applies when `workList.variant` is `'default'`. * * - `workList.renderPreviewContent`: `(React, props) => ReactNode` (default: undefined) * Render function for the preview panel content. Receives the host React and @@ -65,7 +64,6 @@ import { StudyList } from '@ohif/ui-next'; * Use this to change the preview layout while keeping the fetch/abort/thumbnail * logic intact. When unset (or not a function), the built-in * `` layout is used. - * Currently only applies when `workList.variant` is `'default'`. * * - `workList.onStudyDoubleClick`: command run input (default: * `{ commandName: 'launchDefaultMode' }`) @@ -88,7 +86,6 @@ import { StudyList } from '@ohif/ui-next'; * export on the mode definition, registered at app init in the 'WORKLIST' * context — before any mode route is entered. When set to a falsy value the * built-in StudyList.Table double-click behavior applies. - * Currently only applies when `workList.variant` is `'default'`. * * - `workList.settingsMenuItems`: `(defaults) => SettingsMenuItem[]` (default: identity) * Builds the items in the WorkList settings popover. Receives the default @@ -97,7 +94,6 @@ import { StudyList } from '@ohif/ui-next'; * `SettingsMenuItem[]`. Each item is `{ id, label, onClick }`. Use this to * reorder, remove, or insert items without rebuilding the popover shell. If * the returned value is not an array, WorkList falls back to the defaults. - * Currently only applies when `workList.variant` is `'default'`. */ export default function getWorkListCustomization() { return { diff --git a/extensions/default/src/getCustomizationModule.tsx b/extensions/default/src/getCustomizationModule.tsx index 7279dc1081e..cc8c6ef1b62 100644 --- a/extensions/default/src/getCustomizationModule.tsx +++ b/extensions/default/src/getCustomizationModule.tsx @@ -4,6 +4,7 @@ import datasourcesCustomization from './customizations/datasourcesCustomization' import multimonitorCustomization from './customizations/multimonitorCustomization'; import customRoutesCustomization from './customizations/customRoutesCustomization'; import studyBrowserCustomization from './customizations/studyBrowserCustomization'; +import thumbnailDetailsCustomization from './customizations/thumbnailDetailsCustomization'; import overlayItemCustomization from './customizations/overlayItemCustomization'; import contextMenuCustomization from './customizations/contextMenuCustomization'; import contextMenuUICustomization from './customizations/contextMenuUICustomization'; @@ -25,6 +26,8 @@ import hotkeyBindingsCustomization from './customizations/hotkeyBindingsCustomiz import onboardingCustomization from './customizations/onboardingCustomization'; import instanceSortingCriteriaCustomization from './customizations/instanceSortingCriteriaCustomization'; import getWorkListCustomization from './customizations/workListCustomization'; +import headerRightSideCustomization from './customizations/headerRightSideCustomization'; +import hideHeaderUndoRedoCustomization from './customizations/hideHeaderUndoRedoCustomization'; /** * * Note: this is an example of how the customization module can be used @@ -52,11 +55,18 @@ export default function getCustomizationModule({ servicesManager, extensionManag name: 'multimonitor', value: multimonitorCustomization, }, + { + // Opt-in: drops the undo/redo buttons from the right side of the + // header's menu bar, leaving the rest of that list in place. + name: 'hideHeaderUndoRedo', + value: hideHeaderUndoRedoCustomization, + }, { name: 'default', value: { ...customRoutesCustomization, ...studyBrowserCustomization, + ...thumbnailDetailsCustomization, ...overlayItemCustomization, ...contextMenuCustomization, ...menuContentCustomization, @@ -78,6 +88,7 @@ export default function getCustomizationModule({ servicesManager, extensionManag ...onboardingCustomization, ...instanceSortingCriteriaCustomization, ...getWorkListCustomization(), + ...headerRightSideCustomization, }, }, ]; diff --git a/extensions/default/src/types/AppTypes.ts b/extensions/default/src/types/AppTypes.ts new file mode 100644 index 00000000000..b9ccb18516e --- /dev/null +++ b/extensions/default/src/types/AppTypes.ts @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-namespace */ +import type { ComponentType } from 'react'; + +declare global { + namespace AppTypes { + interface Customizations { + /** + * The right side of the viewer header's menu bar, ahead of the settings + * menu. `items` is an ordered list of components — undo/redo and patient + * info by default — each rendered by `ViewerHeader` in its own separated + * slot, so reordering the array reorders the header and dropping an entry + * removes it (the + * `@ohif/extension-default.customizationModule.hideHeaderUndoRedo` module + * does exactly that). Items take no props and may use hooks; one that + * renders `null` collapses its slot. + */ + 'ohif.headerRightSide': { items: ComponentType[] }; + } + } +} diff --git a/extensions/default/src/utils/getCurrentDicomDateTime.ts b/extensions/default/src/utils/getCurrentDicomDateTime.ts deleted file mode 100644 index 2266998eb28..00000000000 --- a/extensions/default/src/utils/getCurrentDicomDateTime.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const getSeriesDateTime = (jsDate: Date = new Date()) => { - const dicomDateTime = getDicomDateTime(jsDate); - return { - SeriesDate: dicomDateTime.date, - SeriesTime: dicomDateTime.time, - }; -}; - -export const getDicomDateTime = (jsDate: Date = new Date()) => { - const month = String(jsDate.getUTCMonth() + 1).padStart(2, '0'); - const day = String(jsDate.getUTCDate()).padStart(2, '0'); - const year = String(jsDate.getUTCFullYear()).padStart(4, '0'); - const date = `${year}${month}${day}`; - const hours = String(jsDate.getUTCHours()).padStart(2, '0'); - const minutes = String(jsDate.getUTCMinutes()).padStart(2, '0'); - const seconds = String(jsDate.getUTCSeconds()).padStart(2, '0'); - const time = `${hours}${minutes}${seconds}`; - - return { date, time }; -}; diff --git a/extensions/default/src/utils/promptSaveReport.test.ts b/extensions/default/src/utils/promptSaveReport.test.ts new file mode 100644 index 00000000000..b64d9df5a54 --- /dev/null +++ b/extensions/default/src/utils/promptSaveReport.test.ts @@ -0,0 +1,106 @@ +import PROMPT_RESPONSES from './_shared/PROMPT_RESPONSES'; + +const mockCreateReportDialogPrompt = jest.fn(); +const mockCreateReportAsync = jest.fn(); + +jest.mock('../Panels', () => ({ + createReportDialogPrompt: (...args) => mockCreateReportDialogPrompt(...args), +})); + +jest.mock('../Actions/createReportAsync', () => ({ + __esModule: true, + default: (...args) => mockCreateReportAsync(...args), +})); + +import promptSaveReport from './promptSaveReport'; + +const MEASUREMENTS = [{ uid: 'measurement-1' }, { uid: 'measurement-2' }]; + +function setup({ storedDisplaySet = { predecessorImageId: 'wadors:/stored-sr' } } = {}) { + const runCommand = jest.fn(); + const servicesManager = { + services: { + measurementService: { getMeasurements: () => MEASUREMENTS }, + displaySetService: { getDisplaySetByUID: jest.fn(() => storedDisplaySet) }, + }, + }; + + mockCreateReportDialogPrompt.mockResolvedValue({ + action: PROMPT_RESPONSES.CREATE_REPORT, + value: 'Measurements', + dataSourceName: 'dicomweb', + series: null, + seriesNumber: 3001, + }); + // Stands in for the store, which is what the real one drives through getReport. + mockCreateReportAsync.mockImplementation(async ({ getReport }) => { + await getReport(); + return ['created-display-set']; + }); + + const run = () => + promptSaveReport( + { servicesManager, commandsManager: { runCommand }, extensionManager: {} } as any, + { trackedStudy: '1.2.3', trackedSeries: ['1.2.3.4'], measurementFilter: () => true }, + { data: { StudyInstanceUID: '1.2.3', viewportId: 'viewport-1', isBackupSave: false } } + ); + + return { run, runCommand, servicesManager }; +} + +const commandCall = (runCommand: jest.Mock, name: string) => + runCommand.mock.calls.find(call => call[0] === name); + +describe('promptSaveReport', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('stores the measurements with the series the dialog resolved', async () => { + const { run, runCommand } = setup(); + + await run(); + + expect(commandCall(runCommand, 'storeMeasurements')[1]).toMatchObject({ + dataSource: 'dicomweb', + options: { + SeriesDescription: 'Measurements', + SeriesNumber: 3001, + predecessorImageId: null, + }, + }); + }); + + it('records the report just written as the predecessor of the measurements', async () => { + const { run, runCommand, servicesManager } = setup(); + + await run(); + + expect(servicesManager.services.displaySetService.getDisplaySetByUID).toHaveBeenCalledWith( + 'created-display-set' + ); + expect(commandCall(runCommand, 'recordMeasurementsPredecessor')[1]).toEqual({ + measurements: MEASUREMENTS, + predecessorImageId: 'wadors:/stored-sr', + }); + }); + + it('records nothing when the stored report cannot be identified', async () => { + const { run, runCommand } = setup({ storedDisplaySet: {} }); + + await run(); + + expect(commandCall(runCommand, 'recordMeasurementsPredecessor')).toBeUndefined(); + }); + + it('does not store anything when the dialog is cancelled', async () => { + const { run, runCommand } = setup(); + mockCreateReportDialogPrompt.mockResolvedValue({ action: PROMPT_RESPONSES.CANCEL }); + + const result = await run(); + + expect(mockCreateReportAsync).not.toHaveBeenCalled(); + expect(runCommand).not.toHaveBeenCalled(); + expect(result.userResponse).toBe(PROMPT_RESPONSES.CANCEL); + }); +}); diff --git a/extensions/default/src/utils/promptSaveReport.tsx b/extensions/default/src/utils/promptSaveReport.tsx index 59cb5e8bc08..e2ca2d0998b 100644 --- a/extensions/default/src/utils/promptSaveReport.tsx +++ b/extensions/default/src/utils/promptSaveReport.tsx @@ -21,7 +21,8 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan filterMeasurementsByStudyUID(StudyInstanceUID), filterMeasurementsBySeriesUID(trackedSeries) ), - defaultSaveTitle = 'Create Report', + defaultSaveTitle = 'Save Measurements', + defaultSeriesDescription = 'Measurements', } = ctx; let displaySetInstanceUIDs; @@ -32,6 +33,7 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan const promptResult = await createReportDialogPrompt({ title: defaultSaveTitle, predecessorImageId, + defaultSeriesDescription, minSeriesNumber: 3000, extensionManager, servicesManager, @@ -39,8 +41,8 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan }); if (promptResult.action === PROMPT_RESPONSES.CREATE_REPORT) { - const { series, priorSeriesNumber, value: reportName, dataSourceName } = promptResult; - const SeriesDescription = reportName || defaultSaveTitle; + const { series, seriesNumber, value: reportName, dataSourceName } = promptResult; + const SeriesDescription = reportName || defaultSeriesDescription; const getReport = async () => commandsManager.runCommand( @@ -51,7 +53,7 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan additionalFindingTypes: ['ArrowAnnotate'], options: { SeriesDescription, - SeriesNumber: 1 + priorSeriesNumber, + SeriesNumber: seriesNumber, predecessorImageId: series, }, }, @@ -62,6 +64,21 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan servicesManager, getReport, }); + + // The report just written is what these measurements are now stored as, so + // saving them again offers to extend that series rather than making + // another one. Measurements are not reloaded from the report they were + // stored into, so this is recorded on them directly. + const storedDisplaySet = displaySetInstanceUIDs?.length + ? displaySetService.getDisplaySetByUID(displaySetInstanceUIDs[0]) + : undefined; + + if (storedDisplaySet?.predecessorImageId) { + commandsManager.runCommand('recordMeasurementsPredecessor', { + measurements: measurementData, + predecessorImageId: storedDisplaySet.predecessorImageId, + }); + } } else if (promptResult.action === PROMPT_RESPONSES.CANCEL) { // Do nothing } diff --git a/extensions/default/src/utils/registerStoredInstanceImageId.test.ts b/extensions/default/src/utils/registerStoredInstanceImageId.test.ts new file mode 100644 index 00000000000..78261743051 --- /dev/null +++ b/extensions/default/src/utils/registerStoredInstanceImageId.test.ts @@ -0,0 +1,73 @@ +import OHIF from '@ohif/core'; + +import { registerStoredInstanceImageId } from './registerStoredInstanceImageId'; + +const metadataProvider = OHIF.classes.MetadataProvider; + +const UIDS = { + StudyInstanceUID: '1.2.3', + SeriesInstanceUID: '1.2.3.4', + SOPInstanceUID: '1.2.3.4.5', +}; + +describe('registerStoredInstanceImageId', () => { + it('gives the instance the imageId the data source would load it with', () => { + const instance = { ...UIDS }; + const dataSource = { + getImageIdsForInstance: jest.fn( + () => 'wadors:/studies/1.2.3/series/1.2.3.4/instances/1.2.3.4.5/frames/1' + ), + }; + + const imageId = registerStoredInstanceImageId(instance, dataSource); + + expect(imageId).toBe('wadors:/studies/1.2.3/series/1.2.3.4/instances/1.2.3.4.5/frames/1'); + expect(instance.imageId).toBe(imageId); + expect(dataSource.getImageIdsForInstance).toHaveBeenCalledWith({ instance }); + // Which is what lets the predecessor of a later save be resolved from it. + expect(metadataProvider.getUIDsFromImageID(imageId)).toMatchObject(UIDS); + }); + + it('does not add the imageId as a stored attribute', () => { + const instance = { ...UIDS }; + registerStoredInstanceImageId(instance, { getImageIdsForInstance: () => 'wadors:/an-image' }); + + expect(Object.keys(instance)).not.toContain('imageId'); + expect(JSON.parse(JSON.stringify(instance)).imageId).toBeUndefined(); + }); + + it('falls back to the locally registered instance', () => { + // Instances with pixel data are registered with the wadouri file manager, + // which puts that imageId on `url`. + const instance = { ...UIDS, url: 'dicomfile:3' }; + + expect(registerStoredInstanceImageId(instance, {})).toBe('dicomfile:3'); + expect(instance.imageId).toBe('dicomfile:3'); + }); + + it('keeps an imageId the instance already has', () => { + const instance = { ...UIDS, imageId: 'wadors:/already-known', url: 'dicomfile:4' }; + const dataSource = { getImageIdsForInstance: jest.fn() }; + + expect(registerStoredInstanceImageId(instance, dataSource)).toBe('wadors:/already-known'); + expect(dataSource.getImageIdsForInstance).not.toHaveBeenCalled(); + }); + + it('does nothing when there is no imageId to be had', () => { + const instance = { ...UIDS }; + + expect(registerStoredInstanceImageId(instance, undefined)).toBeUndefined(); + expect(instance.imageId).toBeUndefined(); + }); + + it('survives a data source that cannot make an imageId for the instance', () => { + const instance = { ...UIDS, url: 'dicomfile:5' }; + const dataSource = { + getImageIdsForInstance: () => { + throw new Error('not an image instance'); + }, + }; + + expect(registerStoredInstanceImageId(instance, dataSource)).toBe('dicomfile:5'); + }); +}); diff --git a/extensions/default/src/utils/registerStoredInstanceImageId.ts b/extensions/default/src/utils/registerStoredInstanceImageId.ts new file mode 100644 index 00000000000..9e586b0efa8 --- /dev/null +++ b/extensions/default/src/utils/registerStoredInstanceImageId.ts @@ -0,0 +1,66 @@ +import OHIF from '@ohif/core'; + +import { setNonEnumerableInstanceProperty } from './dicomWriter'; + +const metadataProvider = OHIF.classes.MetadataProvider; + +/** + * Gives a just stored instance the imageId that loading it back would use, and + * maps that imageId to the instance's UIDs. + * + * Instances that arrive through a data source's metadata request are given this + * as part of that request. An instance stored from the viewer is added to the + * metadata store directly, so it has to be done here - and until it is, the + * display set made from the stored instance does not know which instance it came + * from. That is what makes a just stored object the predecessor of the next save + * of the same data, so that the next save can offer to extend the series just + * written instead of creating another one. + * + * @param instance - naturalized instance that has just been stored + * @param dataSource - the data source it was stored to, when there is one + * @returns the imageId of the instance, or undefined when none can be determined + */ +export function registerStoredInstanceImageId(instance, dataSource?): string | undefined { + if (!instance) { + return undefined; + } + + if (instance.imageId) { + return instance.imageId; + } + + let imageId: string | undefined; + try { + imageId = dataSource?.getImageIdsForInstance?.({ instance }); + } catch (error) { + OHIF.log.debug('Unable to derive the imageId of a stored instance', error); + } + + // Instances with pixel data have already been registered with the local + // wadouri file manager, which puts that imageId on `url` and maps it. + imageId ||= instance.url; + + if (!imageId || typeof imageId !== 'string') { + return undefined; + } + + setNonEnumerableInstanceProperty(instance, 'imageId', imageId); + + const { StudyInstanceUID, SeriesInstanceUID } = instance; + const SOPInstanceUID = instance.SOPInstanceUID || instance.SopInstanceUID; + + if (StudyInstanceUID && SeriesInstanceUID && SOPInstanceUID) { + metadataProvider.addImageIdToUIDs(imageId, { + StudyInstanceUID, + SeriesInstanceUID, + SOPInstanceUID, + }); + } + + return imageId; +} + +export function registerStoredInstanceImageIds(instances, dataSource?): void { + const list = Array.isArray(instances) ? instances : [instances]; + list.forEach(instance => registerStoredInstanceImageId(instance, dataSource)); +} diff --git a/extensions/default/src/utils/seriesDescriptionHistory.ts b/extensions/default/src/utils/seriesDescriptionHistory.ts new file mode 100644 index 00000000000..5b05670d732 --- /dev/null +++ b/extensions/default/src/utils/seriesDescriptionHistory.ts @@ -0,0 +1,83 @@ +/** + * Remembers the series descriptions that were last used to store something, so + * that storing the next one can offer them again. The descriptions are kept per + * type of item being stored (`SEG`, `RTSTRUCT`, `SR`, ...), most recently used + * first, in local storage so that they survive a reload. + * + * A `maxCount` of 0 disables this entirely - nothing is remembered and nothing + * is offered. + */ + +const STORAGE_KEY = 'ohif.seriesDescriptionHistory'; + +type History = Record; + +/** + * Local storage is unavailable in some browser configurations, and can contain + * anything at all, so every read is defensive and a failure just means there is + * no history. + */ +function readHistory(): History { + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + const parsed = stored ? JSON.parse(stored) : null; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch (error) { + console.debug('Unable to read the series description history', error); + return {}; + } +} + +function isSameDescription(a: string, b: string) { + return a.toLowerCase() === b.toLowerCase(); +} + +/** + * The descriptions last used for `itemType`, most recent first, at most + * `maxCount` of them. + */ +export function getSeriesDescriptionHistory(itemType: string, maxCount: number): string[] { + if (!itemType || !(maxCount > 0)) { + return []; + } + + const descriptions = readHistory()[itemType]; + if (!Array.isArray(descriptions)) { + return []; + } + + return descriptions + .filter(description => typeof description === 'string' && !!description.trim()) + .slice(0, maxCount); +} + +/** + * Records `description` as the most recently used one for `itemType`, dropping + * any earlier use of it and any entry past `maxCount`. + */ +export function rememberSeriesDescription( + itemType: string, + description: string, + maxCount: number +): void { + const trimmed = description?.trim(); + if (!itemType || !trimmed || !(maxCount > 0)) { + return; + } + + const history = readHistory(); + const previous = Array.isArray(history[itemType]) ? history[itemType] : []; + + history[itemType] = [ + trimmed, + ...previous.filter( + entry => typeof entry === 'string' && !!entry.trim() && !isSameDescription(entry, trimmed) + ), + ].slice(0, maxCount); + + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(history)); + } catch (error) { + console.debug('Unable to store the series description history', error); + } +} diff --git a/extensions/dicom-microscopy/babel.config.js b/extensions/dicom-microscopy/babel.config.js index 24adaea8d29..325ca2a8ee7 100644 --- a/extensions/dicom-microscopy/babel.config.js +++ b/extensions/dicom-microscopy/babel.config.js @@ -1,43 +1 @@ -module.exports = { - plugins: ['@babel/plugin-transform-class-properties'], - env: { - test: { - presets: [ - [ - // TODO: https://babeljs.io/blog/2019/03/19/7.4.0#migration-from-core-js-2 - '@babel/preset-env', - { - modules: 'commonjs', - debug: false, - }, - '@babel/preset-typescript', - ], - '@babel/preset-react', - ], - plugins: [ - '@babel/plugin-transform-object-rest-spread', - '@babel/plugin-syntax-dynamic-import', - '@babel/plugin-transform-regenerator', - '@babel/plugin-transform-runtime', - ], - }, - production: { - presets: [ - // WebPack handles ES6 --> Target Syntax - ['@babel/preset-env', { modules: false }], - '@babel/preset-react', - '@babel/preset-typescript', - ], - ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], - }, - development: { - presets: [ - // WebPack handles ES6 --> Target Syntax - ['@babel/preset-env', { modules: false }], - '@babel/preset-react', - '@babel/preset-typescript', - ], - ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], - }, - }, -}; +module.exports = require('../../babel.config.js'); diff --git a/extensions/dicom-microscopy/package.json b/extensions/dicom-microscopy/package.json index 236ae7148af..4f3a602d4b6 100644 --- a/extensions/dicom-microscopy/package.json +++ b/extensions/dicom-microscopy/package.json @@ -1,6 +1,6 @@ { "name": "@ohif/extension-dicom-microscopy", - "version": "3.13.0-beta.135", + "version": "3.14.0-beta.44", "description": "OHIF extension for DICOM microscopy", "author": "Bill Wallace, md-prog", "license": "MIT", @@ -28,19 +28,18 @@ "@ohif/core": "workspace:*", "@ohif/extension-default": "workspace:*", "@ohif/i18n": "workspace:*", - "@ohif/ui": "workspace:*", - "prop-types": "15.8.1", - "react": "18.3.1", - "react-dom": "18.3.1", + "react": "19.2.7", + "react-dom": "19.2.7", "react-i18next": "12.3.1", + "react-resize-detector": "12.3.0", "react-router": "6.30.3", "react-router-dom": "6.30.3" }, "dependencies": { "@babel/runtime": "7.29.7", - "@cornerstonejs/codec-charls": "1.2.3", - "@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2", - "@cornerstonejs/codec-openjpeg": "1.3.0", + "@cornerstonejs/codec-charls": "1.2.5", + "@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.4", + "@cornerstonejs/codec-openjpeg": "1.3.2", "colormap": "2.3.2", "dicom-microscopy-viewer": "0.48.6", "lodash.debounce": "4.0.8", diff --git a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx index 2f3e36af67a..b97f1d03828 100644 --- a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx +++ b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx @@ -32,8 +32,8 @@ const DicomMicroscopyViewport = React.memo( const [isLoaded, setIsLoaded] = useState(false); const [viewer, setViewer] = useState(null); const [managedViewer, setManagedViewer] = useState(null); - const overlayElement = useRef(); - const container = useRef(); + const overlayElement = useRef(undefined); + const container = useRef(undefined); const { microscopyService, customizationService } = servicesManager.services; const overlayData = customizationService.getCustomization('microscopyViewport.overlay'); diff --git a/extensions/dicom-microscopy/src/components/MicroscopyPanel/MicroscopyPanel.tsx b/extensions/dicom-microscopy/src/components/MicroscopyPanel/MicroscopyPanel.tsx index ecb79493268..6cde60bda43 100644 --- a/extensions/dicom-microscopy/src/components/MicroscopyPanel/MicroscopyPanel.tsx +++ b/extensions/dicom-microscopy/src/components/MicroscopyPanel/MicroscopyPanel.tsx @@ -1,5 +1,4 @@ import React, { useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; import { callInputDialog } from '@ohif/extension-default'; import { ExtensionManager, CommandsManager, DicomMetadataStore, utils } from '@ohif/core'; import { DataRow } from '@ohif/ui-next'; @@ -11,6 +10,51 @@ import constructSR from '../../utils/constructSR'; const { downloadDicom } = utils; let saving = false; + +/** + * Writes the constructed SR dataset out - to a file when the data source is the + * download pseudo-source, otherwise to the data source itself - and reports the + * outcome. Always clears the `saving` re-entrancy guard. + * + * Lives at module scope so it can keep a real `finally`: the compiler cannot yet + * lower a `try` with a finalizer, and moving the reset after the `try`/`catch` + * instead would leave the guard stuck if the `catch` itself threw. + */ +async function storeDataset(dataset, dataSource, onSaveComplete) { + try { + if (!dataSource) { + console.error('Server unspecified'); + return; + } + + if (dataSource.wadoRoot == 'saveDicom') { + // download as DICOM file + const part10Buffer = datasetToBuffer(dataset); + downloadDicom(part10Buffer, { filename: `sr-microscopy.dcm` }); + } else { + // Save into Web Data source + const { StudyInstanceUID } = dataset; + await dataSource.store.dicom(dataset); + if (StudyInstanceUID) { + dataSource.deleteStudyMetadataPromise(StudyInstanceUID); + } + } + + onSaveComplete({ + title: 'SR Saved', + message: 'Measurements downloaded successfully', + type: 'success', + }); + } catch (error) { + onSaveComplete({ + title: 'SR Save Failed', + message: error.message || error.toString(), + type: 'error', + }); + } finally { + saving = false; + } +} const { datasetToBuffer } = dcmjs.data; const formatArea = area => { @@ -46,12 +90,12 @@ const formatLength = (length, unit) => { }; interface IMicroscopyPanelProps extends WithTranslation { - viewports: PropTypes.array; - activeViewportId: PropTypes.string; + viewports: unknown[]; + activeViewportId: string; // - onSaveComplete?: PropTypes.func; // callback when successfully saved annotations - onRejectComplete?: PropTypes.func; // callback when rejected annotations + onSaveComplete?: (...args: unknown[]) => void; // callback when successfully saved annotations + onRejectComplete?: (...args: unknown[]) => void; // callback when rejected annotations // servicesManager: AppTypes.ServicesManager; @@ -192,38 +236,7 @@ function MicroscopyPanel(props: IMicroscopyPanelProps) { // construct SR dataset const dataset = constructSR(metadata, { SeriesDescription, SeriesNumber }, annotations); - // Save in DICOM format - try { - if (dataSource) { - if (dataSource.wadoRoot == 'saveDicom') { - // download as DICOM file - const part10Buffer = datasetToBuffer(dataset); - downloadDicom(part10Buffer, { filename: `sr-microscopy.dcm` }); - } else { - // Save into Web Data source - const { StudyInstanceUID } = dataset; - await dataSource.store.dicom(dataset); - if (StudyInstanceUID) { - dataSource.deleteStudyMetadataPromise(StudyInstanceUID); - } - } - onSaveComplete({ - title: 'SR Saved', - message: 'Measurements downloaded successfully', - type: 'success', - }); - } else { - console.error('Server unspecified'); - } - } catch (error) { - onSaveComplete({ - title: 'SR Save Failed', - message: error.message || error.toString(), - type: 'error', - }); - } finally { - saving = false; - } + await storeDataset(dataset, dataSource, onSaveComplete); }; /** diff --git a/extensions/dicom-microscopy/src/index.tsx b/extensions/dicom-microscopy/src/index.tsx index 8f68813689b..405c2797eeb 100644 --- a/extensions/dicom-microscopy/src/index.tsx +++ b/extensions/dicom-microscopy/src/index.tsx @@ -1,5 +1,5 @@ import { id } from './id'; -import React, { Suspense, useCallback, useMemo } from 'react'; +import React, { Suspense } from 'react'; import getPanelModule from './getPanelModule'; import getCommandsModule from './getCommandsModule'; import getCustomizationModule from './getCustomizationModule'; @@ -56,24 +56,27 @@ const extension: Types.Extensions.Extension = { * @param props.displaySetOptions * @returns */ + // Declared here rather than inside the component. It closes over + // `servicesManager`, which belongs to this factory and not to render, and the + // compiler will hoist a render-scope callback it believes captures nothing + // all the way to module scope - where `servicesManager` does not resolve. + // It also addresses every managed viewer, so one shared debounce is right. + const onResize = debounce(() => { + const { microscopyService } = servicesManager.services; + const managedViewer = microscopyService.getAllManagedViewers(); + + if (managedViewer && managedViewer.length > 0) { + managedViewer[0].viewer.resize(); + } + }, 100); + const ExtendedMicroscopyViewport = props => { const { viewportOptions } = props; const [viewportGrid, viewportGridService] = useViewportGrid(); const { activeViewportId } = viewportGrid; - const displaySetsKey = useMemo(() => { - return props.displaySets.map(ds => ds.displaySetInstanceUID).join('-'); - }, [props.displaySets]); - - const onResize = debounce(() => { - const { microscopyService } = servicesManager.services; - const managedViewer = microscopyService.getAllManagedViewers(); - - if (managedViewer && managedViewer.length > 0) { - managedViewer[0].viewer.resize(); - } - }, 100); + const displaySetsKey = props.displaySets.map(ds => ds.displaySetInstanceUID).join('-'); const { ref: resizeRef } = useResizeDetector({ onResize, @@ -81,12 +84,9 @@ const extension: Types.Extensions.Extension = { handleWidth: true, }); - const setViewportActive = useCallback( - (viewportId: string) => { - viewportGridService.setActiveViewportId(viewportId); - }, - [viewportGridService] - ); + const setViewportActive = (viewportId: string) => { + viewportGridService.setActiveViewportId(viewportId); + }; return ( /../../platform/$1/src/$2', + '@ohif/(.*)': '/../../platform/$1/src', + }, +}; diff --git a/extensions/dicom-pdf/package.json b/extensions/dicom-pdf/package.json index e3181bc1427..b562c6ff9af 100644 --- a/extensions/dicom-pdf/package.json +++ b/extensions/dicom-pdf/package.json @@ -1,6 +1,6 @@ { "name": "@ohif/extension-dicom-pdf", - "version": "3.13.0-beta.135", + "version": "3.14.0-beta.44", "description": "OHIF extension for PDF display", "author": "OHIF", "license": "MIT", @@ -29,12 +29,11 @@ }, "peerDependencies": { "@ohif/core": "workspace:*", - "@ohif/ui": "workspace:*", "dcmjs": "0.52.0", "dicom-parser": "1.8.21", "hammerjs": "2.0.8", - "prop-types": "15.8.1", - "react": "18.3.1" + "react": "19.2.7", + "react-i18next": "12.3.1" }, "dependencies": { "@babel/runtime": "7.29.7", diff --git a/extensions/dicom-pdf/src/getSopClassHandlerModule.js b/extensions/dicom-pdf/src/getSopClassHandlerModule.js index 83c3890a9ce..c1e1ccb4091 100644 --- a/extensions/dicom-pdf/src/getSopClassHandlerModule.js +++ b/extensions/dicom-pdf/src/getSopClassHandlerModule.js @@ -1,6 +1,8 @@ import { SOPClassHandlerId } from './id'; import { utils, Types as OhifTypes } from '@ohif/core'; import i18n from '@ohif/i18n'; +import { normalizeDocumentMimeType } from './utils/displayableDocumentTypes'; +import { loadDisplayableDocument } from './utils/loadDisplayableDocument'; const SOP_CLASS_UIDS = { ENCAPSULATED_PDF: '1.2.840.10008.5.1.4.1.1.104.1', @@ -9,22 +11,24 @@ const SOP_CLASS_UIDS = { const sopClassUids = Object.values(SOP_CLASS_UIDS); const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager) => { - const dataSource = extensionManager.getActiveDataSource()[0]; return instances.map(instance => { const { Modality, SOPInstanceUID } = instance; const { SeriesDescription = 'PDF', MIMETypeOfEncapsulatedDocument } = instance; - const { SeriesNumber, SeriesDate, SeriesInstanceUID, StudyInstanceUID, SOPClassUID } = instance; - const renderedUrlParams = { + const { SeriesNumber, SeriesInstanceUID, StudyInstanceUID, SOPClassUID } = instance; + // The date/time of a display set is the date/time of the instance it shows, + // chosen from all the attributes that instance carries. + const { SeriesDate, SeriesTime } = utils.getLatestInstanceDateTime(instance); + // The declared type is only a claim. It is resolved against the displayable + // type allowlist, and the payload is re-wrapped in a Blob of the canonical + // type, so the instance cannot steer how the browser parses the document. + const mimeType = normalizeDocumentMimeType(MIMETypeOfEncapsulatedDocument) || 'application/pdf'; + + const documentParams = { instance, tag: 'EncapsulatedDocument', - defaultType: MIMETypeOfEncapsulatedDocument || 'application/pdf', - singlepart: 'pdf', + mimeType, }; - const renderedUrl = dataSource.retrieve.directURL(renderedUrlParams); - const getRenderedUrl = dataSource.retrieve.renderedURL - ? options => - dataSource.retrieve.renderedURL({ ...renderedUrlParams, url: renderedUrl }, options) - : undefined; + const getDocument = options => loadDisplayableDocument(documentParams, options); const displaySet = { //plugin: id, @@ -33,6 +37,7 @@ const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager) SeriesDescription, SeriesNumber, SeriesDate, + SeriesTime, SOPInstanceUID, SeriesInstanceUID, StudyInstanceUID, @@ -40,8 +45,8 @@ const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager) SOPClassUID, referencedImages: null, measurements: null, - renderedUrl: renderedUrl, - getRenderedUrl, + getDocument, + mimeType, instances: [instance], thumbnailSrc: null, isDerivedDisplaySet: true, diff --git a/extensions/dicom-pdf/src/utils/displayableDocumentTypes.test.ts b/extensions/dicom-pdf/src/utils/displayableDocumentTypes.test.ts new file mode 100644 index 00000000000..fe337962819 --- /dev/null +++ b/extensions/dicom-pdf/src/utils/displayableDocumentTypes.test.ts @@ -0,0 +1,129 @@ +import { + getDisplayableDocumentType, + matchesDocumentSignature, + normalizeDocumentMimeType, +} from './displayableDocumentTypes'; + +const bufferFrom = (bytes: number[]) => new Uint8Array(bytes).buffer; + +describe('normalizeDocumentMimeType', () => { + it('lower-cases and strips parameters', () => { + expect(normalizeDocumentMimeType('Text/HTML; charset=UTF-8')).toBe('text/html'); + }); + + it('trims surrounding whitespace', () => { + expect(normalizeDocumentMimeType(' application/pdf ')).toBe('application/pdf'); + }); + + it('returns undefined for missing or empty values', () => { + expect(normalizeDocumentMimeType(undefined)).toBeUndefined(); + expect(normalizeDocumentMimeType('')).toBeUndefined(); + expect(normalizeDocumentMimeType(' ')).toBeUndefined(); + expect(normalizeDocumentMimeType(42 as unknown as string)).toBeUndefined(); + }); +}); + +describe('getDisplayableDocumentType', () => { + it('renders pdf through , which cannot be sandboxed', () => { + const documentType = getDisplayableDocumentType('application/pdf'); + + expect(documentType).toMatchObject({ mimeType: 'application/pdf', strategy: 'object' }); + expect(documentType?.sandbox).toBeUndefined(); + }); + + it('renders html through a fully restricted sandboxed iframe', () => { + expect(getDisplayableDocumentType('text/html')).toMatchObject({ + mimeType: 'text/html', + strategy: 'iframe', + sandbox: '', + }); + }); + + it('folds application/html onto text/html', () => { + expect(getDisplayableDocumentType('application/html')).toMatchObject({ + mimeType: 'text/html', + strategy: 'iframe', + }); + }); + + it('folds non-standard pdf spellings onto application/pdf', () => { + for (const alias of ['application/x-pdf', 'application/acrobat', 'text/pdf']) { + expect(getDisplayableDocumentType(alias)).toMatchObject({ + mimeType: 'application/pdf', + strategy: 'object', + }); + } + }); + + it('resolves types that carry parameters', () => { + expect(getDisplayableDocumentType('text/html;charset=iso-8859-1')).toMatchObject({ + mimeType: 'text/html', + }); + }); + + it('rejects types that are not on the allowlist', () => { + for (const mimeType of [ + 'application/octet-stream', + 'image/svg+xml', + 'application/javascript', + 'text/rtf', + 'application/msword', + undefined, + '', + ]) { + expect(getDisplayableDocumentType(mimeType)).toBeUndefined(); + } + }); +}); + +describe('matchesDocumentSignature', () => { + const pdf = getDisplayableDocumentType('application/pdf'); + const html = getDisplayableDocumentType('text/html'); + + it('accepts a payload that starts with the type magic number', () => { + // "%PDF-1.4" + const payload = bufferFrom([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34]); + + expect(matchesDocumentSignature(pdf, payload)).toBe(true); + }); + + // "%PDF-1.4" + const pdfHeader = [0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34]; + + it('accepts a pdf whose header follows a UTF-8 BOM', () => { + const payload = bufferFrom([0xef, 0xbb, 0xbf, ...pdfHeader]); + + expect(matchesDocumentSignature(pdf, payload)).toBe(true); + }); + + it('accepts a pdf whose header starts at the last searched offset', () => { + // Mainstream readers scan roughly the first 1024 bytes for "%PDF-", so a + // header this far in is still a file they open. + const payload = bufferFrom([...new Array(1024).fill(0x20), ...pdfHeader]); + + expect(matchesDocumentSignature(pdf, payload)).toBe(true); + }); + + it('rejects a pdf whose header starts past the search window', () => { + const payload = bufferFrom([...new Array(1025).fill(0x20), ...pdfHeader]); + + expect(matchesDocumentSignature(pdf, payload)).toBe(false); + }); + + it('rejects a payload declared as pdf that is actually html', () => { + // "" + const payload = bufferFrom([0x3c, 0x68, 0x74, 0x6d, 0x6c, 0x3e]); + + expect(matchesDocumentSignature(pdf, payload)).toBe(false); + }); + + it('rejects a payload shorter than the signature', () => { + expect(matchesDocumentSignature(pdf, bufferFrom([0x25, 0x50]))).toBe(false); + expect(matchesDocumentSignature(pdf, bufferFrom([]))).toBe(false); + }); + + it('accepts any payload for types with no reliable magic number', () => { + expect(matchesDocumentSignature(html, bufferFrom([0x3c, 0x68]))).toBe(true); + expect(matchesDocumentSignature(html, bufferFrom([]))).toBe(true); + }); +}); diff --git a/extensions/dicom-pdf/src/utils/displayableDocumentTypes.ts b/extensions/dicom-pdf/src/utils/displayableDocumentTypes.ts new file mode 100644 index 00000000000..214ab64aca2 --- /dev/null +++ b/extensions/dicom-pdf/src/utils/displayableDocumentTypes.ts @@ -0,0 +1,170 @@ +/** + * The set of encapsulated-document MIME types this extension is willing to put + * in front of a user, and how each one is embedded. + * + * MIMETypeOfEncapsulatedDocument is supplied by whoever produced the instance, + * so it is treated here as a claim to be checked rather than an instruction to + * be followed. A type that is not on this list is not rendered at all. + */ + +/** + * How a document is embedded in the viewport. + * + * 'object' - . The browsers' built-in PDF viewers refuse to run inside + * a sandboxed browsing context: in Chrome a PDF renders under no sandbox at + * all and under none of the token combinations, including the fully + * permissive `allow-scripts allow-same-origin`. PDFs therefore cannot be + * sandboxed, and their safety rests entirely on the type guarantee that + * loadDisplayableDocument applies (canonical Blob type + signature check). + * + * 'iframe' - - +- Assert exact expected values: `toHaveText`, exact `toHaveCount`, + `toHaveAttribute`. Avoid `toBeTruthy`, `toContainText`, and `>=`-style + loose counts. +- `expect(locator).not.toBeNull()` is always a no-op, because a locator is + never null. Use `toBeVisible()`. +- When a value can only come through a method, use + `await expect.poll(() => ...)`. +- Reserve capture-then-compare (e.g. saving an SVG `d` to diff before/after) + for whole-SVG show/hide checks; anything updated incrementally should use + the retrying `toHaveAttribute`. When you do capture, first assert the + captured value is not null (`null === null` passes vacuously), and capture + only after the render settles. A too-early capture snapshots a transient + preview path that never matches the persisted one. +- Verify the outcome, not a proxy. After deleting a segment, assert the + deleted name is gone from the list, not merely that the count dropped. + After activating a tool, drag on the viewport and confirm the image + responded. Asserting that the toolbar button looks active proves only that + the button changed, not that the tool does anything to the canvas. + Assert the side-panel state too (e.g. the clicked segment is highlighted, + not only that the viewport navigated). +- A drawn annotation must be visible, not merely present. `toHaveCount(n)` + plus a non-null `d` still passes when a regression hides the path, so add + `toBeVisible()` after drawing and after navigating back. +- Cover the granular case, not only the bulk one (toggle a single segment, + not just "toggle all"), and interact out of listed order where order + shouldn't matter. +- Assert preconditions before relying on hardcoded indices (e.g. expect the + initial count before addressing "row 4"). +- `await` every locator action and assertion. A floating `.click()` or + un-awaited `toHaveText` lets a broken test pass silently. Custom failure + messages must describe the check they guard. + +## Page objects + +Specs must read as intent, not as a pile of selectors. If the control you need +isn't exposed by a page object, extend one (or add a new one) rather than +inlining `page.*` calls: + +| What you need | Where it belongs | +| --- | --- | +| A toolbar button or tool | `MainToolbarPageObject` | +| A menu, prompt, or small dialog | `DOMOverlayPageObject` | +| A substantial dialog with its own fields | Its own page object, reached through `DOMOverlayPageObject` (see `DicomTagBrowserPageObject` for an example) | +| A side-panel control | `LeftPanelPageObject` / `RightPanelPageObject` | +| Anything inside a viewport | `ViewportPageObject` | + +Conventions that come up in every review: + +- Page-object methods return Locators, not extracted values, so specs can + use web-first assertions. Absence checks go through the page object too + (e.g. filter a titles locator and expect count 0). +- Put actions on the object that owns the element. A row click belongs on + the row object, and it should target a safe spot (the title, not wherever a + visibility toggle might sit). +- Scope child locators to their parent locator, not to `page`. This also + avoids needing globally unique `data-cy` values. +- Expose one locator per control, anchored on the element that receives + clicks and carries the interactive state. +- If a control has no `data-cy`, add one to the source component (the config + maps `getByTestId` to `data-cy`) and call it out in your PR. Follow existing + `data-cy` naming patterns, include the segmentation representation type + where relevant, and spell-check the value; a typo passes CI and pollutes + production code. Don't add new props or attributes to production components + when an existing hook works. +- When you replace one raw `page.*` call, sweep the spec for every similar + call and replace them all. +- Remove the `page` fixture from the test signature once nothing uses it. + +## Utilities + +- Logic repeated across specs belongs in `tests/utils/`. Before adding a + utility, check whether one already covers it. Don't add wrappers that + contribute nothing; a function whose only body is a wait shouldn't exist. +- New utilities take a single object parameter with defaults, not positional + arguments. Some existing helpers (e.g. `visitStudy`) predate this — check + each utility's signature before calling it. +- Import utilities through the `./utils` barrel. A few helpers are + intentionally not re-exported; only then import from the deeper path. +- Hoist literals repeated within a spec (e.g. a click-coordinate array) into + a file-level constant. +- Shared expectations belong in `tests/utils/assertions.ts`. +- Specs never call `page.evaluate` directly. The app exposes `services`, + `commandsManager`, `extensionManager`, `config`, and the cornerstone + libraries on `window` (typed as `AppTypes.Test`); read them through a + utility such as `getSUV` or `clearAllAnnotations`. Use this for setup and + for values the UI does not show, never as a stand-in for a render + assertion (see Screenshots below). + + ```ts + // tests/utils/getSUV.ts + const getSUV = async page => + page.evaluate( + ({ services }: AppTypes.Test) => + services.measurementService.getMeasurements()[0].displayText[2], + await page.evaluateHandle('window') + ); + ``` + +## Screenshots (visual regression) + +Reach for the cheapest faithful signal, in this order: + +1. If the result has a DOM or SVG signal, assert on it. Panel counts, dialog + and overlay text, enabled state, and SVG annotation paths are all DOM; no + screenshot needed. +2. If the result exists only as pixels on the WebGL canvas, a + viewport-scoped screenshot is the correct tool. +3. Never substitute a `window.services` state read for a render assertion. + It passes even when rendering is broken. + +Rules for new screenshot assertions: + +- Capture a viewport with `checkForViewportScreenshot({ page, viewport, screenshotPath })`; + it hides viewport text before the shot. Keep text out of baselines; dates, + series descriptions, and W/L values drift and make them fragile. +- Never screenshot the full app. Use `checkForScreenshot` (object form, + with a locator) only for non-viewport locators such as panels and dialogs. +- Name baselines with `screenShotPaths..` from + `tests/utils/screenShotPaths.ts`, not hand-typed strings. +- Pair the screenshot with the DOM assertions that exist for the same outcome + (e.g. the exact SVG path count). The count verifies topology; the pixels + verify appearance. +- Don't add `normalizedClip` unless you're targeting a sub-region of a + locator, and never tune `maxDiffPixelRatio` or `threshold` to make a test + pass. If a baseline mismatches, fix the flake or regenerate the baseline + with `--update-snapshots` and review the diff by eye before committing. +- A missing baseline is written on the spot by Playwright. Because + `checkForScreenshot` retries, the first run then compares against the file it + just wrote and usually **passes** — an unreviewed baseline can slip in + silently. Always open the new PNG under `tests/screenshots/chromium//` + and confirm it shows what you expect before committing it. + +## Naming + +- Spec file names must be unambiguous. Does `ContourSegRename` rename a + segment or a segmentation? Say which. +- Test titles are precise and non-repetitive; method and parameter names are + self-explanatory (`newName`, not `text`). Check for an existing naming + precedent before inventing one, add a doc comment when a method's purpose + isn't clear from its name, and fix typos before pushing; automated + reviewers flag every one. +- Remember there are two segmentation representations, contour and labelmap. + Don't give a helper a generic name if it only handles one, and encode the + representation type in signatures and `data-cy` values where it matters. + Conversely, a helper that works for both representations shouldn't name + one, and a feature that isn't segmentation-specific shouldn't carry + segmentation in its name. + +## Submitting your PR + +- Keep the scope small: one feature or behavior per PR. Small, decoupled PRs + get reviewed much faster (see the general + [contributing guidelines](./contributing.md)). +- Title the PR in [Conventional Commits](https://www.conventionalcommits.org/) + form — `(): `, e.g. + `test(contour): add segment rename interactions` — not the auto-filled branch + name. The repo's release tooling parses these to derive the semantic version, + so the type matters. See this + [quick reference](https://gist.github.com/joshbuchea/6f47e86d2510bce28f8e7f42ae84c716). + +## Agentic development + +- The in-repo agent skill at + [`.agents/skills/ohif-test-agent/`](https://github.com/OHIF/Viewers/tree/master/.agents/skills/ohif-test-agent) + mirrors these + conventions and adds per-feature seed-spec pointers + (`references/patterns-by-feature.md`) and a failure-triage guide + (`references/failure-triage.md`). It's useful reading for humans too, and + an AI coding agent working in this repo follows the same rules. diff --git a/platform/docs/docs/migration-guide/3p12-to-3p13/customization-url.md b/platform/docs/docs/migration-guide/3p12-to-3p13/customization-url.md index 91ac7231b9b..51257f5cf0b 100644 --- a/platform/docs/docs/migration-guide/3p12-to-3p13/customization-url.md +++ b/platform/docs/docs/migration-guide/3p12-to-3p13/customization-url.md @@ -119,6 +119,12 @@ window.config = { }; ``` +:::warning Deprecated +The example uses `workList.variant`, which is still a valid id in 3.14 but will +be removed in a future release. The phases and the syntax do not change. See the +[3.13 to 3.14 WorkList guide](../3p13-to-3p14/work-list.md). +::: + How the phases map onto the boot sequence: | Phase | When | Scope | diff --git a/platform/docs/docs/migration-guide/3p12-to-3p13/data-source-paging.md b/platform/docs/docs/migration-guide/3p12-to-3p13/data-source-paging.md index 8b3802924d4..409568a1273 100644 --- a/platform/docs/docs/migration-guide/3p12-to-3p13/data-source-paging.md +++ b/platform/docs/docs/migration-guide/3p12-to-3p13/data-source-paging.md @@ -8,6 +8,12 @@ title: Study list paging and the query limit The study-list data fetch in `DataSourceWrapper` was simplified. This affects **both** the new `WorkList` and the `LegacyWorkList`, since both receive their studies from `DataSourceWrapper`. +:::warning Deprecated +`LegacyWorkList` still exists in 3.14 but will be removed in a future release, +after which this page applies to `WorkList` only. See the +[3.13 to 3.14 WorkList guide](../3p13-to-3p14/work-list.md). +::: + ## What changed Previously, for data sources that support `offset`/`limit`, the wrapper derived a server-side `offset` from the current page and re-queried as you paged — a "rolling window" that let you page past the first result window. diff --git a/platform/docs/docs/migration-guide/3p12-to-3p13/work-list.md b/platform/docs/docs/migration-guide/3p12-to-3p13/work-list.md index fc8e54f0e6b..efee7137844 100644 --- a/platform/docs/docs/migration-guide/3p12-to-3p13/work-list.md +++ b/platform/docs/docs/migration-guide/3p12-to-3p13/work-list.md @@ -24,6 +24,12 @@ import LegacyWorkList from 'path/to/routes/LegacyWorkList/LegacyWorkList'; ## Opting back into the legacy study list +:::warning Deprecated +The opt-out below still works in 3.14, but `LegacyWorkList` and `workList.variant` +will be removed in a future release. See the +[3.13 to 3.14 WorkList guide](../3p13-to-3p14/work-list.md). +::: + If you need more time to migrate, set the new `workList.variant` customization to `'legacy'` to mount `LegacyWorkList` at `/`: ```js diff --git a/platform/docs/docs/migration-guide/3p13-to-3p14/create-context.md b/platform/docs/docs/migration-guide/3p13-to-3p14/create-context.md new file mode 100644 index 00000000000..c7867408bc3 --- /dev/null +++ b/platform/docs/docs/migration-guide/3p13-to-3p14/create-context.md @@ -0,0 +1,149 @@ +--- +sidebar_position: 9 +sidebar_label: createContext helper +title: The createContext helper is removed +--- + +# The `createContext` helper is removed + +`platform/ui-next/src/lib/createContext.tsx` is deleted. It was an internal helper +that took a component name and an optional default, and returned a provider +component and a reader hook as a pair. + +It was never exported from `@ohif/ui-next`, and `MeasurementTable` was its only +caller, so most projects cannot have been using it. If you built OHIF from source +and imported it directly, replace it with React's own context. + +## Replacement + +The examples below are the real change made to `MeasurementTable`, so you can read +the finished version in +`platform/ui-next/src/components/MeasurementTable/MeasurementTable.tsx`. Substitute +your own component and fields. + +**Before:** + +```tsx +import { createContext } from '../../lib/createContext'; + +const [MeasurementTableProvider, useMeasurementTableContext] = createContext< + MeasurementTableContext +>('MeasurementTable', { data: [], isExpanded: true }); + +const MeasurementTable = ({ data = [], onAction, isExpanded = true, disableEditing = false, children }) => ( + + {children} + +); + +const Row = () => { + const { onAction, isExpanded, disableEditing } = + useMeasurementTableContext('MeasurementTable.Row'); + // ... +}; +``` + +**After:** + +```tsx +const MeasurementTableContextValue = React.createContext({ + data: [], + isExpanded: true, +}); + +const MeasurementTable = ({ data = [], onAction, isExpanded = true, disableEditing = false, children }) => ( + + {children} + +); + +const Row = () => { + const { onAction, isExpanded, disableEditing } = React.useContext( + MeasurementTableContextValue + ); + // ... +}; +``` + +## Why there is no useMemo in the replacement + +The helper existed to solve one problem. A context value written inline is a new +object on every render, so every consumer re-renders whenever the provider does. +The usual fix is `useMemo`, but a **generic** helper cannot name the dependencies +of props it has never seen, so it used a computed list: + +```ts +const value = React.useMemo( + () => context, + // eslint-disable-next-line react-hooks/exhaustive-deps + Object.values(context) +); +``` + +That compares each context value individually rather than the props object, which +is the behaviour you want. It also requires suppressing `exhaustive-deps`, because a +dependency list is meant to be a fixed-length array literal. + +Two separate things change when the context is written out at a concrete call site. + +**The suppression goes away because the field names are known.** `MeasurementTable` +can write a real dependency array where the generic helper could not. This has +nothing to do with the compiler — without it, the honest replacement is: + +```ts +const contextValue = React.useMemo( + () => ({ data, onAction, isExpanded, disableEditing }), + [data, onAction, isExpanded, disableEditing] +); +``` + +**The `useMemo` goes away because of the React Compiler.** It memoizes the object +literal on exactly those four values, so the wrapper is redundant and can be +dropped. That comparison is equivalent to what `Object.values(context)` achieved, +not better — the gain is that it costs no code and needs no suppression. + +So if you are porting this pattern into a project that does **not** run the +compiler, keep the `useMemo` and pass `value={contextValue}`. The provider JSX is +otherwise the same; without it the value object is rebuilt every render and +consumers re-render with it. + +## Behaviour to check when porting + +The helper's reader hook took a component name and threw when no provider was +found: + +```ts +if (context) return context; +if (defaultContext) return defaultContext; +throw Error(`${callerComponentName} must be rendered inside of a ${rootComponentName}...`); +``` + +Note the order: **a supplied default wins over the error.** `MeasurementTable` passed +`{ data: [], isExpanded: true }`, so its throw was unreachable and the component name +went unused — which is why the replacement has no wrapper hook. React's `useContext` +returns the default in exactly the same case, so dropping it changes nothing. + +If you passed **no** default, the throw was live. To keep it, wrap `useContext`: + +```ts +function useMeasurementTableContext(callerComponentName: string): MeasurementTableContext { + const context = React.useContext(MeasurementTableContextValue); + + if (!context) { + throw new Error( + `${callerComponentName} must be rendered inside of a MeasurementTable component.` + ); + } + + return context; +} +``` + +and create the context with no default — +`React.createContext(undefined)` — so the guard +can actually fire. diff --git a/platform/docs/docs/migration-guide/3p13-to-3p14/data-table.md b/platform/docs/docs/migration-guide/3p13-to-3p14/data-table.md new file mode 100644 index 00000000000..80db0e1bfbd --- /dev/null +++ b/platform/docs/docs/migration-guide/3p13-to-3p14/data-table.md @@ -0,0 +1,98 @@ +--- +sidebar_position: 5 +sidebar_label: DataTable / TanStack Table v9 +title: DataTable moves to TanStack Table v9 +--- + +# DataTable moves to TanStack Table v9 + +`@ohif/ui-next` upgrades `@tanstack/react-table` from v8 to **v9**. If you consume +the `DataTable` compound component — directly, or by supplying columns to the +WorkList through the `workList.tableColumns` customization — you must update your +column definitions. If you never touch `DataTable` or its columns, nothing here +applies to you. + +## `ColumnDef` gains a features generic + +v9 threads the table's feature set through every type. Column definitions you +pass to `DataTable` must use the exported `DataTableFeatures` as the first +generic argument: + +```diff +-import type { ColumnDef } from '@tanstack/react-table'; ++import type { ColumnDef } from '@tanstack/react-table'; ++import type { DataTableFeatures } from '@ohif/ui-next'; + +-const columns: ColumnDef[] = [ ... ]; ++const columns: ColumnDef[] = [ ... ]; +``` + +The feature set itself (`dataTableFeatures`) is defined once by `DataTable` and +registers filtering, visibility, pagination, selection, and sorting. You do not +configure row models yourself. + +## `sortingFn` is now `sortFn` + +v9 renames the custom sort function on a column definition. A stale `sortingFn` +is **silently ignored** — the column falls back to default sorting with no +warning — so search your column definitions for it: + +```diff + { + id: 'instances', + accessorKey: 'instances', +- sortingFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number), ++ sortFn: (a, b, colId) => (a.getValue(colId) as number) - (b.getValue(colId) as number), + } +``` + +The signature is unchanged: `(rowA, rowB, columnId) => number`. + +## `VisibilityState` is now `ColumnVisibilityState` + +If you type the `initialVisibility` prop, the type was renamed: + +```diff +-import type { VisibilityState } from '@tanstack/react-table'; ++import type { ColumnVisibilityState } from '@tanstack/react-table'; +``` + +## Toggling column visibility programmatically + +`DataTable` distinguishes deliberate visibility choices from its own +responsive-layout changes (narrow tables drop low-priority columns +automatically). Use the exported hook for any toggle you initiate: + +```tsx +import { useToggleColumnVisibility } from '@ohif/ui-next'; + +const toggleColumnVisibility = useToggleColumnVisibility(); +toggleColumnVisibility('modalities', false); +``` + +A hide made through this hook is sticky — the responsive layout will not +auto-restore the column when the table grows. Calling +`column.toggleVisibility()` directly still works, but the layout treats the +change as its own and may revert it on the next width change. + +## Do not spread or destructure table objects + +v9 places row, cell, column, and header methods on prototypes. Spreading or +destructuring one of these objects (`{ ...row }`, `const { getValue } = row`) +silently drops its methods. Always call methods through the object. + +## If you compile your extension with the React Compiler + +TanStack exposes state through methods on identity-stable objects — +`row.getIsSelected()` and `column.getIsSorted()` are examples, not the full +list. The rule: any method whose answer changes as the user interacts +(selection, sorting, visibility, filters, expansion, pinning) belongs to this +category. A compiled component that calls one of these during render caches +the answer against an object that never changes, so the value freezes at +mount. If your build enables +`babel-plugin-react-compiler` and you render custom cells or headers, read +state reactively instead: `table.state.` where you have the table +instance, or a `Subscribe` boundary from `@tanstack/react-table` where you +only have a row, cell, or column. Reads of row data (`row.getValue`, +`row.original`) and of static column config are safe, as is any call made +inside an event handler. diff --git a/platform/docs/docs/migration-guide/3p13-to-3p14/display-set-ordering.md b/platform/docs/docs/migration-guide/3p13-to-3p14/display-set-ordering.md new file mode 100644 index 00000000000..bf5d67c788a --- /dev/null +++ b/platform/docs/docs/migration-guide/3p13-to-3p14/display-set-ordering.md @@ -0,0 +1,198 @@ +--- +sidebar_position: 3 +sidebar_label: Display set date/time ordering +title: Display set date/time ordering +--- + +# Display set date/time ordering + +Derived series - reports, segmentations, structure sets - are ordered by when +each one was created, so the most recently created is the one nearest the images. +Getting that order right needed two changes with behaviour you may be relying on. + +See [display set date and time](../../development/notes-requirements.md) for how +the date/time of a display set is chosen and what it is used for. + +## A display set's `SeriesDate`/`SeriesTime` are the display set's own date/time + +`compareSeriesDateTime` still compares the `SeriesDate`/`SeriesTime` of the two +sides, and `dateTimeSortKey` still reads them from the display set. What changed +is the value the SOP class handler puts there. The handler now writes +`getLatestInstanceDateTime` of the instance the display set shows, chosen from every +creation attribute that instance carries - `InstanceCreationDate`/`Time`, +`ContentDate`/`Time`, `AcquisitionDate`/`Time` (or `AcquisitionDateTime`), +`StructureSetDate`/`Time`, `PresentationCreationDate`/`Time` and +`SeriesDate`/`Time`. + +So the two fields hold the date/time **of the display set**, which is not always +the date/time of the series the instances belong to. The series' own +`SeriesDate`/`SeriesTime`, in the instance metadata and in the archive, stay as +they are. Every instance of a series carries that series' `SeriesDate`/ +`SeriesTime`, so a second report saved into an existing SR series used to be +indistinguishable from the first. + +| Display set | Value written | +| --- | --- | +| image (CT, MR, MG, CR, DX, ECG, multi-frame) | the instance's `SeriesDate`/`SeriesTime`, identical on every instance of the series | +| derived (SEG, RTSTRUCT, SR, PMAP, PDF, video, chart) | the creation date/time of the instance the display set shows | + +**What changes for you:** display sets of derived series whose instance level +date/time differ from their series date/time change position. Series themselves +are unaffected: sorting a list of series rather than display sets is the plain +series date/time sort it always was. + +**If you write a SOP class handler,** write both fields, and write them again +whenever `addInstances` moves the instance the display set shows. A handler that +writes neither gets the series date/time and orders as it did in 3.13. See the +`SeriesDate` field of the `DisplaySet` type for the whole contract. + +**If you split a series into several display sets,** give all of them one value. +The sort reads the display set and never `displaySet.instance`, for two reasons. +A key read from the instance differs between the display sets of a split series, +so it would order them by the instance each one happens to show and the +`addSameSeriesCompare` comparison, which runs only when the key ties, would +never run. A key read from the instance would also make the comparator +inconsistent: with one key inside a series and another between series, a series +A whose two display sets straddle a display set B of another series gives +A1 < B, B < A2 and A2 < A1, and `Array.prototype.sort` then returns a +different list for each input order. + +Two display sets of one series that hold different values order by those values, +and a display set of another series can come between them. That is what a second +segmentation saved into an existing SEG series needs: the SEG, RTSTRUCT and PMAP +handlers have no `addInstances`, so `DisplaySetService` gives the new instance +its own display set, and that display set takes the position of the save. To +keep a split together instead, give every display set of the split the value of +the series and register an `addSameSeriesCompare` comparison to order them. + +## A DT that declares a UTC offset is read in the viewer's own offset + +`AcquisitionDateTime` is a DICOM DT, and a DT may end with the `&ZZXX` UTC +offset the rest of it is written in. `getLatestInstanceDateTime` used to take the first +8 characters as the date and everything after as the time, which read the offset +digits as time digits: `20260819+0500` became five in the morning, and the `05` +of `202608191030-0500` became the seconds. + +`expandDicomDateTime` now splits the offset off and moves the value to the +offset of the viewer, so the date and the time are the local wall clock reading +of the same instant - noon at `-0400` is 16:00 UTC, and 16:00 UTC is 10:00 at +`-0600`. That is the value a DT carrying no offset would have to hold to name +the same instant, because a value with no offset is read as local. The offset of +the viewer *at that instant* is used, so daylight saving is right for an +acquisition made in another season. + +- A DT that declares no offset is read exactly as it is. Nothing says what zone + it was written in, and every bare DA and TM is read with the same silence. +- A DT holding a date alone names the start of that day, which is the reading + the move needs, and it comes back with the time of that instant here. Around + midnight it changes day as well. +- A DT that declares an offset always comes back with a time, including when + the offset it declares is the viewer's own. Two DT values naming one instant + have to give one answer, and a date returned alone would order before the + same instant written out in another offset. + +**What changes for you:** a display set whose date/time came from an +offset-bearing `AcquisitionDateTime` changes position, and the `SeriesTime` +stored on it is now a valid DICOM TM. It used to be the raw remainder of the DT, +offset included - `100000.000000-0500` - which the thumbnail detail line then +passed to `formatTime`. + +## `addSameSeriesCompare` comparators now run + +A comparator registered with `addSameSeriesCompare` orders two display sets of +the same series. `compareSameSeriesDisplaySet` returned the comparator's answer +only when that answer was `0`, and discarded it whenever it actually ordered the +two sides, falling through to the instance compare instead - so a registered +comparator had no effect on the resulting order. + +It is now applied as documented: a non-zero answer decides, and only a tie falls +through to the instance compare. + +**What changes for you:** if you registered a comparator, it now takes effect, +which may reorder display sets within a series that were previously ordered by +instance number alone. If the old order is the one you want, remove the +registration by passing `null` as the compare function: + +```js +addSameSeriesCompare(name, null, priority); +``` + +## Instances tie-break by creation date/time + +`sortByInstanceNumber` ordered by instance number and then by SOP instance UID. +It now falls back to the creation date/time before the SOP instance UID. + +The instance number remains the primary key, and image series give every instance +a unique one, so their ordering is unchanged. The fallback only runs when the +instance numbers tie or neither instance has one - which is where the SOP +instance UID, an arbitrary identifier, was deciding which instance of a series is +the most recent. Two frames of the same instance are excluded from it: they share +the one date/time their instance has, so only the frame number orders them. + +## New instances are stamped on save + +`updateNewInstanceMetadata` stamps every report, segmentation and structure set +OHIF saves with `InstanceCreationDate`/`Time`, with the creation date/time pair +the modality's IOD defines, and with an instance number one higher than every +instance already in the series. + +`InstanceCreationDate`/`Time` are in the SOP Common module, so every IOD has +them. The creation date/time of the object itself depends on the modality: + +| Modality | Attribute pair | Module that defines it | +| --- | --- | --- | +| `RTSTRUCT` | `StructureSetDate`/`Time` | Structure Set | +| `PR` | `PresentationCreationDate`/`Time` | Presentation State Identification | +| every other modality | `ContentDate`/`Time` | Multi-frame Functional Groups (SEG), SR Document General (SR), General Image (an image series) | + +The RTSTRUCT and PR IODs define no content date/time at all, so a +`ContentDate`/`Time` on one of them is an attribute a strict validator or +archive can reject the instance for. `getLatestInstanceDateTime` reads all three pairs, +so the ordering is the same whichever pair the modality gets. + +The date/time are read as wall clock values in the dataset's own timezone - +`TimezoneOffsetFromUTC` when it declares one, the local zone otherwise - since +that is how a viewer displays them. + +The series level date/time cannot be stamped afterwards, since an object added to +an existing series has to keep that series' own. So the store commands generate +the object with the series date/time it should have: `SeriesDate`/`Time` for a +new series, and `StructureSetDate`/`Time` for every structure set, in the local +zone rather than the UTC values dcmjs and the adapters default to. + +**What changes for you:** stored objects carry these attributes where they may +not have before. If you post-process saved instances and relied on the instance +number coming from a single predecessor instance, note that it is now derived +from the highest instance number in the whole series. + +## The exported name is `getLatestInstanceDateTime` + +`platform/core` exported this function as `getSeriesDateTime` in +3.14.0-beta.25. The name says that the function gives the date and the time of +the series, and the function does not do that. The function gives the latest +date of the attributes that the instance carries, together with the latest time +that carries the same date. The export is `getLatestInstanceDateTime` now, and +the sort key export is `getLatestInstanceDateTimeSortKey`. + +**What changes for you:** change the name at every call. The behaviour of both +functions is exactly the same as before. + +| Before | Now | +| --- | --- | +| `utils.getSeriesDateTime` | `utils.getLatestInstanceDateTime` | +| `utils.getSeriesDateTimeSortKey` | `utils.getLatestInstanceDateTimeSortKey` | +| the type `SeriesDateTime` | the type `LatestInstanceDateTime` | +| the module `platform/core/src/utils/seriesDateTime` | the module `platform/core/src/utils/latestInstanceDateTime` | + +The two fields of the type keep the names `SeriesDate` and `SeriesTime`, +because a handler assigns the two fields to a display set, and the display set +holds the two fields under those names. + +`getSeriesDateTime` gets no alias, because the name was in no stable release of +OHIF. The name was in the 3.14.0-beta.25 line only. + +`extensions/default/src/utils/getCurrentDicomDateTime.ts` also exported a +`getSeriesDateTime`, and that function gave the current date and time. No module +imports that file, and this release deletes the file. Use +`utils.getCurrentDicomDateTime` of `platform/core` for the current date and +time. diff --git a/platform/docs/docs/migration-guide/3p13-to-3p14/index.md b/platform/docs/docs/migration-guide/3p13-to-3p14/index.md new file mode 100644 index 00000000000..3a2e5d51d33 --- /dev/null +++ b/platform/docs/docs/migration-guide/3p13-to-3p14/index.md @@ -0,0 +1,77 @@ +--- +id: index +sidebar_position: 1 +sidebar_label: 3.13 -> 3.14 +title: 3.13 to 3.14 Migration Guide +--- + +import DocCardList from '@theme/DocCardList'; + +# 3.13 to 3.14 Migration Guide + +This guide covers changes when upgrading from OHIF version 3.13 to version 3.14. + +- **[React 19](./react-19.md)** — the monorepo moves to **React 19.2.7**. + `@ohif/ui-next` now declares react/react-dom as `peerDependencies`, so + consuming applications must supply React 19, and extensions and modes require + it too. +- **[SmartScrollbar](./smart-scrollbar.md)** — `SmartScrollbarFill` and + `SmartScrollbarEndpoints` now require the `marked` array to change identity when + its contents change. The `version` prop is deprecated and no longer invalidates. +- **[WorkList](./work-list.md)** — `LegacyWorkList` and the `workList.variant` + customization still work in 3.14 and are deprecated. A future release removes + both; migrate to the `workList.*` customizations on the ui-next `WorkList`. +- **[DataTable / TanStack Table v9](./data-table.md)** — `@ohif/ui-next` upgrades + `@tanstack/react-table` to v9. Column definitions need the `DataTableFeatures` + generic and the `sortFn` rename; programmatic visibility toggles should go + through `useToggleColumnVisibility`. + +- **[useSegmentationExpanded](./segmentation-expanded-context.md)** — the hook + returns `undefined` outside a `SegmentationExpandedProvider` instead of + throwing, and no longer takes a component-name argument. Callers that wrapped + it in `try/catch` should check for `undefined`; callers that require the + provider should assert for themselves. + +- **[Viewport element hooks](./use-viewport-refs.md)** — `useViewportRef` and + `useViewportRefs` are replaced by `useViewportElement` for readers and + `useViewportElementRegistration` for the viewport that owns the element, and + `ViewportRefsProvider` is renamed `ViewportElementsProvider`. The element is now + a subscribed value rather than a snapshot read during render. + +- **[Save/report dialog](./report-dialog.md)** — the dialog for storing + measurements, segmentations and contours replaces its series drop down with an + explicit choice of three destinations: **Save to current**, **Save as new** and + **Replace existing**. The series number of a new series is editable, and the + series descriptions used before are remembered per type of item and offered as + completions. `createReportDialogPrompt` takes `defaultSeriesDescription`, + `itemType` and `rememberedDescriptionCount`, and returns a `seriesNumber`. A + stored instance is now identified like a loaded one, so the object just saved + becomes the predecessor of the next save of the same data. +- **[Display set date/time ordering](./display-set-ordering.md)** — derived + display sets are ordered by the creation date/time of the instance they show + rather than by their series date/time, comparators registered with + `addSameSeriesCompare` now actually run, instances tie-break by creation + date/time before the SOP instance UID, and saved reports and segmentations are + stamped with a creation date/time and an instance number. + +- **[useCustomization](./use-customization.md)** — `@ohif/core` adds a + `useCustomization` hook. A component that calls + `customizationService.getCustomization` during render can now freeze at a stale + value, because the React Compiler caches the call on the service reference, + which never changes. Move such reads to the hook. Event handlers and non-React + code keep calling the service directly. + +- **[InputNumber](./input-number.md)** — `InputNumber.HorizontalControls` and + `InputNumber.VerticalControls` now share `min`, `max`, `step` and `disabled` + through a nested provider rather than by writing onto the root context object. + That fixes constraints being discarded on every keystroke, but it means only + **descendants** of the controls receive them; an `InputNumber.Input` rendered as + a sibling silently falls back to `min=0, max=100, step=1`. + +- **[createContext helper](./create-context.md)** — the internal + `ui-next/lib/createContext` helper is removed. It was never exported from + `@ohif/ui-next`; if you imported it from source, use React's own + `createContext` and `useContext` — the compiler now provides the memoization it + was hand-rolling. + + diff --git a/platform/docs/docs/migration-guide/3p13-to-3p14/input-number.md b/platform/docs/docs/migration-guide/3p13-to-3p14/input-number.md new file mode 100644 index 00000000000..4e68e6e8bf5 --- /dev/null +++ b/platform/docs/docs/migration-guide/3p13-to-3p14/input-number.md @@ -0,0 +1,79 @@ +--- +sidebar_position: 10 +sidebar_label: InputNumber +title: InputNumber constraints reach descendants only +--- + +# `InputNumber` constraints reach descendants only + +`InputNumber.HorizontalControls` and `InputNumber.VerticalControls` accept `min`, +`max`, `step` and `disabled`, and they share those constraints with +`InputNumber.Input` through the `InputNumber` context. + +3.14 changes **how** they share the constraints. The change fixes a defect, and it +narrows the contract at the same time. + +## What changed + +**Before (3.13):** the controls wrote the constraints onto the context object that +the root had already created. + +**After (3.14):** the controls render their own `InputNumberContext.Provider`, with +a new context value that carries the constraints. + +## The defect this fixes + +The root re-creates its context value whenever the input value changes. That +discarded the constraints that the controls had written onto the previous object, +on every keystroke, until the effect ran again. Children that had already rendered +never saw the constraints at all. + +After the change, a child has the constraints on its first render, and no +keystroke can discard them. + +## What this narrows + +A nested provider only reaches the children of the controls. Before the change, +the mutation reached every consumer of the root context, wherever it sat in the +tree. + +**This keeps working:** + +```tsx + + + {/* a descendant: gets min=0, max=10, step=0.5 */} + + +``` + +**This silently stops working:** + +```tsx + + + {/* a sibling: falls back to min=0, max=100, step=1 */} + +``` + +In the second layout, `InputNumber.Input` is not a descendant of the controls, so +it does not see the constraints. It falls back to the defaults of +`InputNumber.Input`, which are `min=0`, `max=100` and `step=1`. Nothing errors and +nothing warns. The input simply accepts a wider range than you intended. + +## What to do + +Check every place that renders `InputNumber.Input` as a **sibling** of +`InputNumber.HorizontalControls` or `InputNumber.VerticalControls`. Choose one of +two fixes: + +1. Move the `InputNumber.Input` inside the controls, as in the first example. +2. Pass `min`, `max` and `step` to `InputNumber.Input` directly. An explicit prop + on the input wins over the context in both 3.13 and 3.14: + +```tsx + +``` + +No component inside this repository uses the sibling layout, so OHIF itself needs +no change. This note is for consumers who compose `InputNumber` themselves. diff --git a/platform/docs/docs/migration-guide/3p13-to-3p14/react-19.md b/platform/docs/docs/migration-guide/3p13-to-3p14/react-19.md new file mode 100644 index 00000000000..1a445f6f8bf --- /dev/null +++ b/platform/docs/docs/migration-guide/3p13-to-3p14/react-19.md @@ -0,0 +1,152 @@ +--- +sidebar_position: 2 +sidebar_label: React 19 +title: React 19 required +--- + +# React 19 required + +3.14 upgrades the monorepo to **React 19.2.7**. + +If you deploy the shipped OHIF viewer as-is, this is invisible — the app bundles its +own React. It matters in two cases: you consume OHIF's published packages from npm, +or you maintain a third-party extension or mode. + +## `@ohif/ui-next` now declares React as a peer dependency + +**Before (3.13):** + +```json +"dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" +} +``` + +**After (3.14):** + +```json +"peerDependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7" +} +``` + +With OHIF 3.14, your application must supply React itself, because `@ohif/ui-next` no +longer installs a copy of its own. A peer dependency states a requirement on your +project — it does not deliver the package to you. + +This is what keeps a **single React instance** on the page. Two copies of React have +separate internal state: hooks throw `Invalid hook call`, and context created by one +copy is invisible to components rendered by the other. + +**What to do:** upgrade your application to React 19.2.7 or newer. Any 19.x at or above +that version satisfies the range. + +## Extensions and modes require React 19 + +Every OHIF extension and mode declares `react` and `react-dom` 19.2.7 as peer +dependencies. A third-party extension built against React 18 must be upgraded before it +will work with 3.14. + +## UMD builds require a React 19 host at runtime + +OHIF's UMD artifacts that externalize React (notably `@ohif/ui-next`) are compiled with +the React Compiler, and the compiled output reads `useMemoCache` off the host's React. +That API exists only in React 19. + +This failure surfaces at **runtime, not install time** — a React 18 host loads the +bundle and breaks when a compiled component first renders, with no package-manager +warning beforehand. If you load OHIF UMD bundles against a global `React`, confirm that +global is React 19 before upgrading. + +## React 19 removals that affect extension code + +React 19 removed a number of long-deprecated APIs. The ones most likely to appear in +extension or mode code: + +| Removed in React 19 | Use instead | +| --- | --- | +| `ReactDOM.render`, `ReactDOM.hydrate` | `createRoot`, `hydrateRoot` | +| `ReactDOM.unmountComponentAtNode` | `root.unmount()` | +| `findDOMNode` | refs | +| runtime `propTypes` (silently ignored) | TypeScript types | +| `defaultProps` on function components | default parameter values | +| string refs, legacy context | callback or object refs, `createContext` | + +`forwardRef` is **not** removed, but it is no longer necessary: React 19 function +components accept `ref` as an ordinary prop. OHIF's own components were converted, and +extension code can be converted at your convenience. + +See React's [version 19 upgrade guide](https://react.dev/blog/2024/04/25/react-19-upgrade-guide) +for the complete list. + +## TypeScript types + +OHIF moves to `@types/react` 19.2.17 and `@types/react-dom` 19.2.3. If you maintain +TypeScript extension code, `types-react-codemod`'s `preset-19` handles the mechanical +changes — chiefly that `useRef()` now requires an argument, and `ReactElement` generic +defaults changed: + +```bash +npx types-react-codemod@latest preset-19 ./src +``` + +## The React Compiler, and what it means for your extension + +3.14 enables the React Compiler across OHIF's own source. What that means for you +depends on how you build. + +**You consume OHIF from npm and build your extension yourself.** The compiler applies +to OHIF's code, not yours, and nothing is required. If you want the same automatic +memoization, enable `babel-plugin-react-compiler` in your own build. + +**You fork the repository and add your extension under `extensions/` or `modes/`.** +Your code becomes part of OHIF's build and CI, so the same tooling that covers OHIF's +own components covers yours: + +1. **It is compiled.** The compiler's scope is every `src/` directory under + `platform/`, `extensions/` and `modes/`. Most components are fine. A component that + reads or mutates state outside React during render — a cornerstone3D viewport or + overlay is the typical case — can be memoized in a way that stops it updating; the + opt-outs below are for that. +2. **The React 19 lint rules apply.** `pnpm lint:compiler` flags `forwardRef` and + `prop-types`, which React 19 no longer needs. +3. **The coverage gate includes it.** `pnpm run compiler:coverage:ci` reports any + function the compiler declines to memoize. Fix it, or record it in + `.react-compiler-budget.json`. +4. **The lint budget includes it.** `pnpm run lint:compiler:ci` compares the + `react-hooks/*` error and warning counts against `.react-compiler-lint-budget.json`. + Adjust the budget when your code moves them. + +Both gates fail CI until their budget file matches, and both print exactly which file +and which entry is involved, so the fix is never a guess. + +### Opting a file out + +Put `'use no memo';` as the first statement in the file, with a comment above it +saying what broke, and add the file's path to `fileOptOuts` in +`.react-compiler-budget.json`. The compiler skips the whole file, and the gate checks +that the entry exists. Prefer this when one or two components are the problem. + +### Opting a directory out + +Add it to `excluded` in `react-compiler.scope.cjs` at the repository root: + +```js +const excluded = [ + 'platform/ui', + 'extensions/my-extension', +]; +``` + +That one list drives the compiler in both build pipelines, the compiler lint rules +and the coverage gate, so they cannot disagree. The gate names every directory it +skips: + +``` +excluded by react-compiler.scope.cjs: 12 file(s) under extensions/my-extension +``` + +An excluded directory is outside the compiler's world entirely — not compiled, not +linted by the compiler rules, not gated — the way `platform/ui` is. diff --git a/platform/docs/docs/migration-guide/3p13-to-3p14/report-dialog.md b/platform/docs/docs/migration-guide/3p13-to-3p14/report-dialog.md new file mode 100644 index 00000000000..ede8b51045c --- /dev/null +++ b/platform/docs/docs/migration-guide/3p13-to-3p14/report-dialog.md @@ -0,0 +1,249 @@ +--- +sidebar_position: 8 +sidebar_label: Save/report dialog +title: Save/report dialog destination +--- + +# Save/report dialog destination + +The dialog shown when storing measurements, segmentations or contours +(`createReportDialogPrompt`, and the `ohif.createReportDialog` customization it +renders) contained a single series drop down that mixed together two quite +different operations - creating a new series, and storing into a series that +already exists. Which one was about to happen, and what it would do to the +data already stored, was not visible. + +The destination is now an explicit choice of three, made with a segmented control +on a `Series` row, with a line of help under it and the same words repeated on the +button that commits the save: + +- **Save to current** adds a version to the series the data was loaded from - the + series identified by `predecessorImageId`. The stored instance becomes the one + loaded by default for that series; the data already there is kept, but is no + longer the default. It is the default choice when there is such a series, and + is unavailable otherwise. +- **Save as new** creates a separate series, with no predecessor. The series + number and the series description are both editable: the number is offered as + one past the existing series of this modality (at least `minSeriesNumber`), and + the description as the first of four names - `itemName`, the description the + data was loaded from, the one last used for this type of item, then + `defaultSeriesDescription` - see + [remembered series descriptions](#remembered-series-descriptions). It is the + default choice when the data has not been stored before, and is always + available. +- **Replace existing** adds a version to another loaded series of this modality, + chosen from a select of their descriptions. It behaves like `Save to current` + otherwise, and is unavailable when no such series is loaded. This is what the + old drop down could do, as its own destination rather than an entry mixed in + among the series. + +A series that already exists keeps its own series number and description, so both +are read-only for `Save to current` and `Replace existing`; the number shown +follows the series picked. + +All three store the same object: all of the current data, as selected from the +service holding it - the measurements in the measurement service, the segments in +the segmentation service. The destination only decides which series that object +belongs to, and so which instance it supersedes. In particular, `Replace +existing` does not merge: it neither loads what is already stored in the chosen +series to add to it, nor leaves any of the current data out. The instance it +supersedes is kept, and stops being the one loaded by default. + +Storing into a series makes that series the one the data was last stored as, so a +save after a `Replace existing` defaults to `Save to current` on the series that +was replaced. + +The dialogs are titled **Save Segmentation**, **Save Contours** and **Save +Measurements**, having been `Store Segmentation`, `Store Contours` and `Create +Report`, to match the `Save` action in them. A caller that passes its own `title`, +or a mode that sets `defaultSaveTitle`, is unaffected. + +The footer is now a `FooterAction` with `Download` on the left and `Cancel` and +the primary action on the right, rather than the right-aligned cluster +`InputDialog.Actions` gives. + +## `createReportDialogPrompt` input + +`itemName` and `defaultSeriesDescription` are new, and both are optional: + +```ts +// `labelIsGenerated` says that the service invented the label, so the user has +// chosen no name for this segmentation. A generated name goes to +// `defaultSeriesDescription`, and a name that the user chose goes to `itemName`. +const { label, labelIsGenerated } = segmentation; + +const { value, series, seriesNumber, dataSourceName, action } = + await createReportDialogPrompt({ + servicesManager, + extensionManager, + title: 'Save Segmentation', + modality: 'SEG', + predecessorImageId, + // New: the name that the user chose for the item, offered first for a new + // series + itemName: labelIsGenerated ? '' : label, + // New: the name for an item that has no other, offered last + defaultSeriesDescription: (labelIsGenerated && label) || 'Segmentation', + enableDownload: true, + }); +``` + +`itemName` is the name that the user chose for the item being stored. A new +series offers `itemName` first, so a rename that the user makes before the save +reaches the description field. A caller with no such name passes nothing: the +measurement report passes no `itemName`, and `storeSegmentation` passes no +`itemName` while the label is still the name that the service invented. A +generated name belongs in `defaultSeriesDescription`, so that the name does not +outrank the remembered descriptions - see +[the behaviour doc](../../behaviours/report-dialog-save-destinations.md). + +`Segmentation.labelIsGenerated` is new in `@cornerstonejs/tools`, and +`SegmentationPublicInput.config` carries the flag. A creator that invents a +label passes `labelIsGenerated: true` beside the label. A creator that gives no +label at all gets the flag anyway, because such a creator gives no name that the +user chose. An update that carries a `label` and no `labelIsGenerated` clears +the flag, so a rename gives a name that the user chose. The viewer wrote a +private `generatedLabel` attribute onto the segmentation state before, and a +consumer compared the two strings; a consumer reads the flag now. + +`defaultSeriesDescription` is the name for an item that has no other name, and a +new series offers `defaultSeriesDescription` last. The in-tree callers pass +`Segmentation` for a SEG, `Contours` for an RTSTRUCT export, and `Measurements` +for a measurement report. A user therefore gets a meaningful series description +rather than an empty field. Previously an unedited description stored the *title +of the dialog* as the series description, so a measurement report saved without +typing a name was stored as `Create Report`. + +`itemType` and `rememberedDescriptionCount` are also new and optional - see +[remembered series descriptions](#remembered-series-descriptions). + +The meaning of `predecessorImageId` is unchanged, but it now decides whether +extending an existing series is possible at all, not just what the drop down +defaults to. A `predecessorImageId` that does not belong to a loaded series of +the given modality falls back to creating a new series, rather than claiming to +update a series that cannot be described. + +The dialog offers a loaded series as a destination only when the series holds the +modality being stored, and when the series has a `predecessorImageId` value. The +dialog does not fall back to the `SeriesInstanceUID` value of the display set, +because the `PredecessorSequence` provider throws on a UID. For the whole rule, +and for the local ids that an uploaded instance carries, see +[the behaviour doc](../../behaviours/report-dialog-save-destinations.md). + +## `createReportDialogPrompt` output + +`seriesNumber` is new: + +- `seriesNumber` is the series number to store the instance as - the value shown + in the dialog, including any edit the user made to it. **Prefer this over + `1 + priorSeriesNumber`**, which cannot see the user's edit. +- `priorSeriesNumber` is now `seriesNumber - 1`, so existing callers computing + `1 + priorSeriesNumber` keep working and pick up an edited series number. It + is no longer the highest existing series number of the modality. +- `value` is the series description. When an existing series is being extended + this is that series' existing description, since an existing series keeps it. +- `series` is unchanged - the series being extended, as a `predecessorImageId` + value, and falsy when a new series is created. + +**Before (3.13):** + +```ts +options: { + SeriesDescription, + SeriesNumber: 1 + priorSeriesNumber, + predecessorImageId: series, +} +``` + +**After (3.14):** + +```ts +options: { + SeriesDescription, + SeriesNumber: seriesNumber, + predecessorImageId: series, +} +``` + +Note that `SeriesDescription` and `SeriesNumber` are ignored when +`predecessorImageId` is set, because the predecessor's series data is applied to +the generated instance - which is why the dialog shows them as read-only when +extending a series. + +## After the save, the stored object is the predecessor + +Saving to the current series relies on the data knowing which instance it was +last stored as - its `predecessorImageId`. That was only ever known for data +loaded from a store, so the first save of a segmentation or a report created a new +series, and so did the save after it, and the one after that. + +An instance stored from the viewer is now identified the way one loaded from a +data source is: `registerStoredInstanceImageId` gives it the imageId that loading +it back would use, and maps that imageId to its UIDs, before it is added to the +metadata store. The display set made from the stored instance therefore has a +`predecessorImageId`, which is what the two save types then record: + +- **segmentations and contours** are reloaded from the series just written (the + save removes the in-memory segmentation and displays the stored one), so the + reloaded segmentation picks the predecessor up from that display set through + the normal load path; +- **measurements** are not reloaded from the report they were stored into, so the + new `recordMeasurementsPredecessor` command (`CORNERSTONE`) records it on them + directly - on the measurement and on the annotation it is derived from, so that + editing a measurement afterwards does not lose it. + +The effect is that saving the same data twice defaults to **Save to current** the +second time, pointing at the series the first save created. + +## Remembered series descriptions + +The series descriptions used to store something are remembered, so that storing +the next one of the same kind can offer them again. They are kept in local +storage under `ohif.seriesDescriptionHistory`, most recently used first, keyed by +the **type of item** being stored, so that segmentations, contours and reports +each have their own list. + +Two new optional inputs control this: + +- `itemType` is the key the descriptions are remembered under. It defaults to + the `modality`, which is already distinct for the in-tree callers (`SEG`, + `RTSTRUCT`, `SR`), so it only needs passing when one modality covers several + kinds of item that deserve their own list. +- `rememberedDescriptionCount` is how many to remember, and defaults to **5**. + **0 disables the feature**: nothing is remembered, nothing is offered, and the + pull-down is not shown, leaving a plain description field. + +In the dialog, the `Save as new` description field: + +- starts from the first of four names: `itemName`, then the description the data + was loaded from, then the one last used for this type of item, then + `defaultSeriesDescription`. An emptied field falls back to that same name; +- offers a pull-down that holds those names in that order, with the remembered + ones most recent first; +- narrows that list to the entries the typing can complete, with **Tab** + completing to the first of them, and the arrow keys plus Enter picking one. + +A description is remembered when it is used to create a new series, including a +download. Storing into a series that already exists records nothing, since that +series keeps its own description. A save that uses `defaultSeriesDescription` +also records nothing, because the caller supplies that name at every save, and +the dialog offers the name in any case; a generated segmentation label therefore +stays out of the history. Reusing a description moves it back to the front of the +list rather than duplicating it, matched without regard to case. + +Deployments that must not persist anything into local storage should pass +`rememberedDescriptionCount: 0`. + +## Replacing the dialog + +A custom `ohif.createReportDialog` component receives the new +`defaultSeriesDescription`, `itemType` and `rememberedDescriptionCount` props, and +its `onSave` payload gained `seriesNumber` alongside the existing `reportName`, +`dataSource`, `series` and `priorSeriesNumber`. A dialog that only ever creates +new series can pass `series: null` and its own `seriesNumber`. + +Remembering descriptions is the dialog's own doing, via +`getSeriesDescriptionHistory` and `rememberSeriesDescription` in +`extensions/default/src/utils/seriesDescriptionHistory.ts`. A replacement dialog +that wants the same behavior should call those, and one that does not simply +ignores `itemType` and `rememberedDescriptionCount`. diff --git a/platform/docs/docs/migration-guide/3p13-to-3p14/segmentation-expanded-context.md b/platform/docs/docs/migration-guide/3p13-to-3p14/segmentation-expanded-context.md new file mode 100644 index 00000000000..e4f0e014c2f --- /dev/null +++ b/platform/docs/docs/migration-guide/3p13-to-3p14/segmentation-expanded-context.md @@ -0,0 +1,109 @@ +--- +sidebar_position: 6 +sidebar_label: useSegmentationExpanded +title: useSegmentationExpanded no longer throws +--- + +# `useSegmentationExpanded` no longer throws + +`useSegmentationExpanded` used to throw when a component was rendered outside a +`SegmentationExpandedProvider`. In 3.14 it returns `undefined` instead. + +**Before (3.13):** + +```ts +const useSegmentationExpanded = (componentName?: string) => { + const context = useContext(SegmentationExpandedContext); + + if (context === undefined) { + throw new Error( + `useSegmentationExpanded must be used within a SegmentationExpandedProvider` + + (componentName ? ` (called from ${componentName})` : '') + ); + } + + return context; +}; +``` + +**After (3.14):** + +```ts +const useSegmentationExpanded = (): SegmentationExpandedContextType | undefined => + useContext(SegmentationExpandedContext); +``` + +The `componentName` argument is gone. It only existed to name the component in the +error message. Remove it from your calls: TypeScript rejects it (`TS2554: Expected 0 +arguments, but got 1`), and at runtime it is ignored. + +## Why + +Most consumers render both inside and outside the provider, and treat "no context" +as a normal fallback rather than a fault. Because the hook threw, every one of them +wrapped it like this: + +```ts +let segmentation; + +try { + const context = useSegmentationExpanded(); + segmentation = context.segmentation; +} catch (e) { + segmentation = activeSegmentation; +} +``` + +A hook call inside a `try` block violates the rules of hooks: if the call order can +change between renders, React's hook state can be read against the wrong slot. It +also makes the React Compiler refuse to optimize the entire component, so these +files got no automatic memoization at all. + +That pattern had already been copied into three components. Removing the throw +removes the reason to write it. + +## What you need to change + +**If you catch the throw, stop catching it and check for `undefined`:** + +```ts +// Before +let segmentation; +try { + segmentation = useSegmentationExpanded().segmentation; +} catch (e) { + segmentation = activeSegmentation; +} + +// After +const expandedContext = useSegmentationExpanded(); +const segmentation = expandedContext ? expandedContext.segmentation : activeSegmentation; +``` + +**If you rely on the throw to catch a missing provider, assert in your component:** + +```ts +const expandedContext = useSegmentationExpanded(); + +if (!expandedContext) { + throw new Error('MyComponent must be rendered inside a SegmentationExpandedProvider'); +} + +const { segmentation } = expandedContext; +``` + +This is what `SegmentationCollapsedSelector` now does. Asserting at the call site +means the error names the component that actually has the requirement, instead of +naming the hook. + +:::note TypeScript callers get a compile error; JavaScript callers do not +The return type is now `SegmentationExpandedContextType | undefined`, so TypeScript +flags any code that uses the result without handling the absent case. + +Plain JavaScript gets no such warning. Code like +`const { segmentation } = useSegmentationExpanded()` used to fail with a clear +"must be used within a SegmentationExpandedProvider" message and will now fail with +`TypeError: Cannot destructure property 'segmentation' of undefined`, reported +wherever the value is first used rather than where the provider is missing. If you +consume this hook from JavaScript, add one of the two patterns above. +::: diff --git a/platform/docs/docs/migration-guide/3p13-to-3p14/smart-scrollbar.md b/platform/docs/docs/migration-guide/3p13-to-3p14/smart-scrollbar.md new file mode 100644 index 00000000000..69c73fa83ac --- /dev/null +++ b/platform/docs/docs/migration-guide/3p13-to-3p14/smart-scrollbar.md @@ -0,0 +1,46 @@ +--- +sidebar_position: 4 +sidebar_label: SmartScrollbar +title: SmartScrollbar marked identity +--- + +# SmartScrollbar: `marked` must change identity + +In 3.13, `SmartScrollbarFill` and `SmartScrollbarEndpoints` accepted a `marked` array +that was mutated in place, with a `version` prop bumped to signal the change. + +That contract no longer holds. With the React Compiler enabled in 3.14, both components +memoize on `marked`, so a stable array identity means the rendered fill stops updating — +it refreshes only when unrelated geometry changes, such as a window resize. + +**In 3.14, `marked` must change identity whenever its contents change.** The `version` +prop is removed from both components, and `ByteArrayHandle` no longer exposes it. Passing +it is a compile error — deliberately, since a silently-ignored prop would leave code on +the old contract rendering stale with nothing to signal it. + +## If you use `useByteArray()` + +Nothing to do. The hook now publishes a new view of the same buffer on every change, so +identity moves with content. Writes are still in place and still batched; the new view +shares the underlying memory and costs nothing. + +## If you manage the array yourself + +Publish a new identity whenever the contents change. `subarray()` returns a new view over +the same memory with no copy: + +**Before (3.13):** + +```ts +bytes[index] = 1; +setVersion(v => v + 1); // no longer sufficient on its own +``` + +**After (3.14):** + +```ts +bytes[index] = 1; +setBytes(bytes.subarray()); // new identity, same buffer, no copy +``` + +Remove any `version` prop you were passing — it no longer exists on either component. diff --git a/platform/docs/docs/migration-guide/3p13-to-3p14/use-customization.md b/platform/docs/docs/migration-guide/3p13-to-3p14/use-customization.md new file mode 100644 index 00000000000..681a4c3c373 --- /dev/null +++ b/platform/docs/docs/migration-guide/3p13-to-3p14/use-customization.md @@ -0,0 +1,89 @@ +--- +sidebar_position: 9 +sidebar_label: useCustomization +title: Read customizations with useCustomization +--- + +# Read customizations with `useCustomization` + +3.14 adds `useCustomization` to `@ohif/core`. If a component of yours reads a +customization during render by calling `customizationService.getCustomization` +directly, you should move that read to the hook. + +This is not only a tidier API. Under the React Compiler, the direct call can +freeze at a stale value. + +## Why the direct call is no longer safe + +`customizationService.getCustomization` returns whatever is registered at the +moment you call it. Registration order is not guaranteed: + +- Mode-scope customizations are registered in `mode.onModeEnter`, which can run + **after** panels have already rendered. +- Global-scope customizations can be written at runtime by a `?customization=` + URL override, or by any consumer that calls `setCustomizations`. + +Before 3.14 a component that read too early got the pre-registration value on its +first render, and then got the registered value on any later render. + +3.14 turns on the React Compiler, which caches the call on the service reference. +The service reference never changes, so the compiler can serve the first result +for the life of the component. The component then keeps the pre-registration value +and never converges. + +## What to change + +**Before:** + +```tsx +function MyPanel() { + const { servicesManager } = useSystem(); + const { customizationService } = servicesManager.services; + const config = customizationService.getCustomization('myExtension.myPanel'); + + return
{config.title}
; +} +``` + +**After:** + +```tsx +import { useCustomization } from '@ohif/core'; + +function MyPanel() { + const config = useCustomization('myExtension.myPanel'); + + return
{config.title}
; +} +``` + +The hook takes the customization id and returns the current value. The generic +parameter types the return value, and it defaults to `unknown`. + +## What the hook does + +- It reads the value once for the first render. +- It reads again in an effect, which catches any registration that happened + between the render and the effect. +- It subscribes to `MODE_CUSTOMIZATION_MODIFIED`, + `GLOBAL_CUSTOMIZATION_MODIFIED` and `DEFAULT_CUSTOMIZATION_MODIFIED`, so a write + to any of the three scopes re-renders the component with the new value. + +`getCustomization` caches the transformed value, so an unchanged customization +returns the same reference and `setState` bails out. The extra events are +therefore cheap. + +## When you do not need the hook + +An event handler may keep calling `customizationService.getCustomization` +directly. A handler runs at event time, not during render, so the compiler does +not cache its result and the value is always current. + +Code outside a React component — a command, a service, a mode lifecycle hook — +also keeps calling the service directly. + +## Related + +The same rule about values that freeze under the compiler applies to any read of +mutable state during render. See the +[React 19 guide](./react-19.md). diff --git a/platform/docs/docs/migration-guide/3p13-to-3p14/use-viewport-refs.md b/platform/docs/docs/migration-guide/3p13-to-3p14/use-viewport-refs.md new file mode 100644 index 00000000000..13a08663d88 --- /dev/null +++ b/platform/docs/docs/migration-guide/3p13-to-3p14/use-viewport-refs.md @@ -0,0 +1,120 @@ +--- +sidebar_position: 7 +sidebar_label: Viewport element hooks +title: useViewportRef is replaced by two hooks +--- + +# `useViewportRef` is replaced by two hooks + +The viewport element registry lets a component reach a viewport's DOM element by +viewport ID when it is not that viewport's child. Its API has changed shape. + +| 3.13 | 3.14 | +|---|---| +| `useViewportRef(viewportId)` → `{ current, register, unregister }` | `useViewportElement(viewportId)` → `HTMLElement | null` | +| | `useViewportElementRegistration(viewportId)` → `{ register, unregister }` | +| `useViewportRefs()` | removed | +| `ViewportRefsProvider` | `ViewportElementsProvider` | + +## If you read a viewport's element + +**Before (3.13):** + +```ts +const viewportRef = useViewportRef(viewportId); + +useEffect(() => { + const element = viewportRef.current; + if (!element) { + return; + } + // observe or measure it +}, [viewportRef]); +``` + +**After (3.14):** + +```ts +const element = useViewportElement(viewportId); + +useEffect(() => { + if (!element) { + return; + } + // observe or measure it +}, [element]); +``` + +The element type is the caller's assertion, as with `useRef`: + +```ts +const element = useViewportElement(viewportId); +``` + +## If you register a viewport's element + +**Before (3.13):** + +```ts +const viewportRef = useViewportRef(viewportId); + +
{ if (el) { viewportRef.register(el); } }} /> + +// in your teardown +viewportRef.unregister(); +``` + +**After (3.14):** + +```ts +const { register, unregister } = useViewportElementRegistration(viewportId); + +
{ if (el) { register(el); } }} /> + +// in your teardown, unchanged +unregister(); +``` + +`unregister` is a plain function rather than something handled by a ref cleanup, so +you keep control of when it runs relative to the rest of your teardown. If you were +unregistering after disabling a cornerstone element, that ordering is preserved. + +## Why + +The old hook returned one object serving two different callers, shaped like a React +ref. Three problems followed from that. + +**`.current` was a snapshot, not a live box.** It was read during render and only +re-read if the hook's own inputs changed. Registering an element is not a render, +so it did not refresh — a component that ran the hook before its viewport attached +its element would hold `null` for as long as it stayed mounted. In 3.13 this was +masked: nothing was memoized, so the snapshot was retaken on every render and +happened to stay correct. Under React 19 with the React Compiler it no longer is. + +`useViewportElement` reads through `useSyncExternalStore`, so the element arriving +is a state change. Notification is registry-wide, but `useSyncExternalStore` +compares the value it reads back, so watching one viewport does not re-render you +when another registers. + +**The registrar could not use the field it populated.** A viewport calls the hook +during render, before any ref callback has run, so its own `.current` was always +`null`. Both viewports in this repository worked around it by keeping a private +`useRef` beside the registry one. `useViewportElementRegistration` gives the owner +only what it can use. + +**Asking to register meant subscribing.** With one hook, a viewport that only +wanted `register` would also be subscribed to every registration in the app — +re-rendering the heaviest component in the viewer for a value it never reads. +`useViewportElementRegistration` subscribes to nothing. + +## Also removed + +The registry no longer exposes its `Map`. `useViewportRefs()` returned a +`viewportRefs` field holding the live `Map` of IDs to elements; a caller holding it +could add or remove entries without going through `register`/`unregister`, and could +read entries for viewports that had already unmounted. + +If you were iterating it to find every mounted viewport, get the viewport IDs from +`ViewportGridService` and resolve each through `useViewportElement`. The grid service +is the authority on which viewports exist; the registry only ever knew which ones +had reported a DOM element. diff --git a/platform/docs/docs/migration-guide/3p13-to-3p14/work-list.md b/platform/docs/docs/migration-guide/3p13-to-3p14/work-list.md new file mode 100644 index 00000000000..59877314be6 --- /dev/null +++ b/platform/docs/docs/migration-guide/3p13-to-3p14/work-list.md @@ -0,0 +1,79 @@ +--- +sidebar_position: 3 +sidebar_label: WorkList +title: LegacyWorkList deprecated +--- + +# `LegacyWorkList` deprecated + +3.13 shipped the ui-next `WorkList` at `/` and kept the previous study list as +`LegacyWorkList`, selectable through the `workList.variant` customization. + +3.14 keeps that opt-out. It is deprecated. + +:::warning `LegacyWorkList` will be removed in a future release +`workList.variant: 'legacy'` still mounts the 3.13 study list in 3.14. A later +release removes the route and the customization id together. Treat the opt-out +as time to finish migrating, not as a setting to keep. +::: + +## What is unchanged in 3.14 + +- `workList.variant` accepts `'default'` and `'legacy'`, and defaults to + `'default'`. +- `'legacy'` mounts the same `LegacyWorkList` code as 3.13. The file is opted + out of the React Compiler, so it keeps its own memoization. +- The other `workList.*` customizations apply only when the variant is + `'default'`, as before. + +## What changed around the legacy list + +`DataSourceWrapper` was rewritten for the new study list. `DataSourceWrapper` +now issues one query and pages on the client. `LegacyWorkList` still contains +the server-paged rolling-window arithmetic of 3.13, and it hard-codes its sort +threshold at 100 results. On a result set larger than one page, the page +controls and the column sorting of the legacy list can behave incorrectly. Use +a small result set when you compare the two study lists. + +## How to turn the opt-out on + +The viewer ships a URL customization file that sets the variant, at +`platform/app/public/customizations/worklist/legacyWorkList.jsonc`. Load it with +`?customization=worklist/legacyWorkList`, or add `worklist/legacyWorkList` to +`appConfig.customizationService.requires`. The `?customization=` parameter needs +`customizationUrlPrefixes` in the app config; `dev.js`, `e2e.js`, `netlify.js` +and `customization.js` set that property. + +The legacy study list was verified in 3.14 through this customization file. See +the [Work List customization docs](../../platform/services/customization-service/WorkList.md#turning-on-the-legacy-list) +for the full note. + +## What to do now + +Move whatever the legacy list is kept for onto the new one. The `workList.*` +namespace covers the study-list table and its preview panel: + +| Customization | Purpose | +| --- | --- | +| `workList.columns` | the study-list table's column set | +| `workList.previewSeriesView` | thumbnails, list, or both in the preview panel | +| `workList.renderPreviewContent` | replace the preview panel's contents | +| `workList.settingsMenuItems` | entries in the study-list settings menu | + +See the [Work List customization docs](../../platform/services/customization-service/WorkList.md) +for the full reference and examples. Once your customizations are in place, +remove `workList.variant` from your config. + +If you import `LegacyWorkList` directly, plan on that import path disappearing +with the route. There will be no drop-in replacement; customize `WorkList` or +mount your own route. + +## Related: `@ohif/ui` is frozen + +`LegacyWorkList` is the last consumer of the legacy `@ohif/ui` package inside the +viewer. 3.14 drops the `@ohif/ui` workspace dependency from the extensions and +modes that declared it without importing it. The package still builds and +publishes, and it leaves the app graph when `LegacyWorkList` does. + +If your extension or mode imports components from `@ohif/ui`, migrate those +imports to `@ohif/ui-next`. diff --git a/platform/docs/docs/platform/modes/lifecycle.md b/platform/docs/docs/platform/modes/lifecycle.md index c3bd0e70766..df2c477c429 100644 --- a/platform/docs/docs/platform/modes/lifecycle.md +++ b/platform/docs/docs/platform/modes/lifecycle.md @@ -2,17 +2,18 @@ sidebar_position: 2 sidebar_label: Lifecycle Hooks title: Mode Lifecycle Hooks -summary: Documentation for OHIF Mode's lifecycle hooks (onModeInit, onModeEnter, and onModeExit), which allow customization of initialization, resource setup, and cleanup when entering or exiting viewer modes. +summary: Documentation for OHIF Mode's lifecycle hooks (onModeInit, onModeEnter, onModeExit, and validateModeEntry), which allow customization of initialization, resource setup, validation, and cleanup when entering or exiting viewer modes. --- # Modes: Lifecycle Hooks ## Overview -Currently, there are two hooks that are called for modes: +The mode route calls these hooks for modes: - onModeInit - onModeEnter +- validateModeEntry - onModeExit ## onModeInit @@ -76,6 +77,75 @@ function modeFactory() { } ``` +## validateModeEntry + +This hook checks that the mode can run with the data that the URL asks for. The +mode route calls this hook after `onModeInit`, after the `onModeEnter` of the +extensions, after the `onModeEnter` of the mode, and after `route.init`. A mode +that sets a custom authentication token in one of those hooks has set that token +before this hook runs. + +The mode route does not wait for this hook. The hook runs at the same time as +the retrieve of the metadata, so the hook adds no delay to the retrieve. + +The hook can be an async function. The hook receives `navigate`, and the hook +navigates away on its own when the data is not valid. A `navigate` call after +the user leaves the route does nothing. + +The mode route calls this hook with these properties: + +| Property | Description | +| --- | --- | +| `studyInstanceUIDs` | The studies that the URL asks for. | +| `dataSource` | The active data source. | +| `navigate` | Navigates to another route. | +| `servicesManager` | The services manager. | +| `extensionManager` | The extension manager. | +| `commandsManager` | The commands manager. | +| `appConfig` | The application configuration. | +| `query` | The URL query parameters. | + +A mode that does not declare this hook gets the default hook. The default hook +queries the data source for each StudyInstanceUID in the URL, and the default +hook navigates to `/notfoundstudy` when a study is absent or when the query +fails. + +A mode that does not load its data by StudyInstanceUID must declare this hook. +Such a mode either runs its own check, or the mode gives an empty function to +skip the check. + +```js +function modeFactory() { + return { + id: '', + displayName: '', + // This mode loads the data from a URL parameter, and not from a study, + // so the default study check does not apply. + validateModeEntry: async ({ query, navigate }) => { + if (!query.get('datasetId')) { + navigate('/notfoundstudy'); + } + }, + /* + ... + */ + }; +} +``` + +The retrieve of the metadata starts before this hook completes, and the mode +route does not wait for this hook before the mode route calls +`onSetupRouteComplete`. Put the work that must complete before the viewer +renders in `onModeEnter` instead. + +:::note + +`validateModeEntry` runs when the user enters the mode. `isValidMode` runs in +the work list, when the work list decides which modes to offer for a study. The +two hooks are separate. See [Validity](./validity.md). + +::: + ## onModeExit This hook is called when the viewer navigates away from the route in the url. diff --git a/platform/docs/docs/platform/modes/validity.md b/platform/docs/docs/platform/modes/validity.md index 3c5c650992e..d0210531d96 100644 --- a/platform/docs/docs/platform/modes/validity.md +++ b/platform/docs/docs/platform/modes/validity.md @@ -15,6 +15,15 @@ There are two mechanism for checking the validity of a mode for a study. validity based on the modalities in the study. - `validTags` +:::note + +Both mechanisms run in the work list, before the user enters the mode. The mode +route runs a separate check when the user enters the mode. That check is the +`validateModeEntry` hook - see +[Lifecycle Hooks](./lifecycle.md#validatemodeentry). + +::: + ## isValidMode diff --git a/platform/docs/docs/platform/services/customization-service/WorkList.md b/platform/docs/docs/platform/services/customization-service/WorkList.md index 7f5adaa4e9d..aa713e48f35 100644 --- a/platform/docs/docs/platform/services/customization-service/WorkList.md +++ b/platform/docs/docs/platform/services/customization-service/WorkList.md @@ -20,6 +20,51 @@ Selects which study-list route is mounted at `/`. The customization is read once during route registration, so changing it requires a reload. +### Turning on the legacy list + +The viewer ships a URL customization file that sets this value, at +`platform/app/public/customizations/worklist/legacyWorkList.jsonc`: + +```jsonc +{ + "global": { + "workList.variant": { "$set": "legacy" } + } +} +``` + +Load it in either of two ways: + +- Append `?customization=worklist/legacyWorkList` to the viewer URL. +- Add `worklist/legacyWorkList` to `appConfig.customizationService.requires`. + +The `?customization=` parameter is off unless the app config sets +`customizationUrlPrefixes`. The configs `dev.js`, `e2e.js`, `netlify.js` and +`customization.js` set that property; `default.js` does not. `pnpm run dev` +selects `dev.js`, so `http://localhost:3000/?customization=worklist/legacyWorkList` +works with no config change. + +The file applies in the `global` phase. That phase is applied before the app +renders, and therefore before route registration reads `workList.variant`. + +The legacy study list was verified in 3.14 through this customization file. + +:::warning The legacy list expects 3.13 paging +`LegacyWorkList` still contains the server-paged rolling-window arithmetic of +3.13, and it hard-codes its sort threshold at 100 results. The 3.14 +`DataSourceWrapper` issues one query and pages on the client. On a result set +larger than one page, the page controls and the column sorting of the legacy +list can behave incorrectly. Use a small result set when you compare the two +study lists. +::: + +:::warning Deprecated +`'legacy'` and `LegacyWorkList` are deprecated and will be removed in a future +release. Use the opt-out to finish migrating to the `workList.*` +customizations below, then remove it. See the +[3.13 to 3.14 WorkList guide](../../../migration-guide/3p13-to-3p14/work-list.md). +::: + ## `workList.previewSeriesView` Controls which series views are available in the preview panel that opens to the right of the study list. @@ -113,7 +158,7 @@ Two caveats: the server must actually return the tag (it has to support `include ### Gotchas and limitations -- **Renderers aren't serializable.** A column's `accessorFn`, `cell`, `header`, `filterFn`, and `sortingFn` are functions. `$set`/`$push` accept them, but a column that renders anything beyond plain text still requires code — you can't express it as pure JSON config. `StudyList.textColumn` covers the simple text case. +- **Renderers aren't serializable.** A column's `accessorFn`, `cell`, `header`, `filterFn`, and `sortFn` are functions. `$set`/`$push` accept them, but a column that renders anything beyond plain text still requires code — you can't express it as pure JSON config. `StudyList.textColumn` covers the simple text case. - **The `actions` column should stay last (cosmetic).** Its hover menu is right-aligned to anchor the row end, so placing it mid-row just looks wrong — it's not a functional requirement. Insert new columns *before* it (e.g. `$splice` at its index, or the `$apply` pattern above); a bare `$push` lands *after* it, leaving the actions menu mid-row. - **Index-based commands are position-fragile.** `{ 2: { … } }` targets whatever is at index 2, which shifts if earlier columns are added/removed. Prefer `$apply` with a `findIndex`/`id` lookup for edits that should survive reordering. - If the merged value is not an array, WorkList falls back to `StudyList.defaultColumns`. diff --git a/platform/docs/docs/platform/services/customization-service/advanced.md b/platform/docs/docs/platform/services/customization-service/advanced.md index fa751b4994b..df891bc5d53 100644 --- a/platform/docs/docs/platform/services/customization-service/advanced.md +++ b/platform/docs/docs/platform/services/customization-service/advanced.md @@ -94,6 +94,12 @@ In this snippet, the `transform` function: --- +**See also** +- [Typing Customizations](./typing.md): declared types describe the value *after* + `inheritsFrom` and `$transform` have resolved, so a key can be typed as its + final shape while still being written with `inheritsFrom` / `$transform` / + `$reference`. + **Key Points** - `inheritsFrom` is a reference to another customization’s ID. - If `transform` is defined, it always runs after inheritance is resolved. diff --git a/platform/docs/docs/platform/services/customization-service/customizationService.md b/platform/docs/docs/platform/services/customization-service/customizationService.md index b459bfda289..f0504ee69a9 100644 --- a/platform/docs/docs/platform/services/customization-service/customizationService.md +++ b/platform/docs/docs/platform/services/customization-service/customizationService.md @@ -249,6 +249,7 @@ Use this introduction page for the core model (scope, priority, and syntax), the - [Measurements](./Measurements.md): Measurement-related customization surface. - [Segmentation](./Segmentation.md): Segmentation-specific customization options. - [Advanced Customization](./advanced.md): `inheritsFrom`, `$transform`, and compositional patterns. +- [Typing Customizations](./typing.md): Declaring a customization id in `AppTypes.Customizations` so reads are typed, writes are checked, and call-site casts can be deleted. ## Customization Syntax @@ -488,6 +489,8 @@ customizationService.setCustomizations({ | `$filter` | Find and update specific items in arrays | Target nested structures | | `$transform`| Apply a function to transform the customization | Apply a function to transform values | +Extensions can register additional commands with `registerCustomUpdateCommand`. To have specs using them type-checked, declare them as described in [Typing Customizations](./typing.md#declaring-a-custom-update-command). + ## Building Customizations Across Multiple Extensions Sometimes it is useful to build customizations across multiple extensions. For example, you may want to build a default list of tools inside a vieweport. But then each extension may want to add their own tools to the list. diff --git a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx index c42c486ae48..979531fa91b 100644 --- a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx +++ b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx @@ -357,31 +357,6 @@ window.config = { ]; export const workListCustomizations = [ - { - id: 'workList.variant', - description: ( - <> - Selects which study-list route is mounted at /. Use 'default'{' '} - (default customization value) for the new ui-next WorkList introduced in 3.13. Use{' '} - 'legacy' to mount the pre-3.13 WorkList (internally{' '} - LegacyWorkList) as an opt-out while migrating. The customization is read once - during route registration, so changing it requires a reload. - - ), - default: 'default', - configuration: ` -window.config = { - // rest of window config - customizationService: [ - { - 'workList.variant': { - $set: 'legacy', - }, - }, - ], -}; - `, - }, { id: 'workList.previewSeriesView', description: ( @@ -393,8 +368,7 @@ window.config = { 'list' when the active data source declares thumbnailRendering as{' '} 'wadors' or 'thumbnailDirect', or declares{' '} thumbnailRequestStrategy as 'bulkDataRetrieve' (its default - value), regardless of this setting. Currently only applies when workList.variant is{' '} - 'default'. + value), regardless of this setting. ), default: 'all', @@ -429,8 +403,7 @@ window.config = { before it with $splice rather than $push; and index-based edits are position-fragile (prefer $apply{' '} for id-based changes). If the merged value is not an array, WorkList falls back to the - defaults. Currently only applies when workList.variant is{' '} - 'default'. + defaults. ), default: 'StudyList.defaultColumns', @@ -488,8 +461,7 @@ window.config = { Use this to change the preview layout while keeping the fetch, abort, and thumbnail worker-pool logic intact. When unset, the built-in{' '} - {''} layout is used. Currently only applies when{' '} - workList.variant is 'default'. + {''} layout is used. ), default: 'undefined', @@ -551,7 +523,6 @@ window.config = { back to the first applicable one. Modes can contribute their own commands via a{' '} getCommandsModule export on the mode definition — these are registered at app init in the WORKLIST context, before any mode route is entered. - Currently only applies when workList.variant is 'default'. ), default: "{ commandName: 'launchDefaultMode' }", @@ -582,8 +553,7 @@ window.config = { defaults are about, userPreferences, and (when{' '} appConfig.oidc is configured) logout. Use it to reorder, remove, or insert items without rebuilding the popover shell. If the customization returns a - non-array value, WorkList falls back to the defaults. Currently only applies when{' '} - workList.variant is 'default'. + non-array value, WorkList falls back to the defaults. ), default: '(defaults) => defaults', @@ -833,6 +803,22 @@ window.config = { }, }, ], +}; + `, + }, + { + id: 'cornerstone.maxUndoRedoCacheSize', + description: + 'The maximum number of undo/redo history items to keep. Segmentation edits record undo memos that hold full labelmap buffers, so a large history can cause memory pressure or out-of-memory errors with large data. The viewer reads the value once, when a mode opens, and that clears the undo/redo history. A later change of the customization has no effect until the next mode opens. A value that is not a positive integer makes the mode entry throw a RangeError.', + default: '50', + configuration: ` +window.config = { + // rest of window config + customizationService: { + global: { + 'cornerstone.maxUndoRedoCacheSize': { $set: 10 }, + }, + }, }; `, }, @@ -1319,6 +1305,39 @@ window.config = { customizationService: [ { 'ohif.aboutModal': { $set: MyAboutModal } }, ], +}; + `, + }, + { + id: 'ohif.headerRightSide', + description: ( + <> + The ordered list of components filling the right of the header's menu bar, ahead of the + settings menu. Each entry is rendered as a component in its own separated slot, so + reordering the array reorders the header, and an item that renders null{' '} + collapses its slot. Items take no props and may use hooks; see{' '} + extensions/default/src/customizations/headerRightSideCustomization.ts for the + default. + + ), + default: '{ items: [HeaderUndoRedo, HeaderPatientInfo] }', + configurationIntro: ( +

+ Reorder or extend the list with $set / $push. To drop just the + undo/redo buttons, extension-default ships that as a named module:{' '} + '@ohif/extension-default.customizationModule.hideHeaderUndoRedo'. +

+ ), + configuration: ` +import HeaderPatientInfo from '@ohif/extension-default/src/ViewerLayout/HeaderPatientInfo'; +import HeaderUndoRedo from '@ohif/extension-default/src/ViewerLayout/HeaderUndoRedo'; + +window.config = { + // rest of window config + customizationService: [ + // Patient info first, then undo/redo. + { 'ohif.headerRightSide': { items: { $set: [HeaderPatientInfo, HeaderUndoRedo] } } }, + ], }; `, }, @@ -2156,6 +2175,117 @@ window.config = { }, }, ], +}; + `, + }, + { + id: 'studyBrowser.thumbnailDetails', + description: + 'The items on the detail line of a study browser thumbnail, under the modality and ' + + 'series description. Declared the same way as the viewport overlay items: each has an ' + + '`id`, an optional `condition` deciding whether to include it, and a value taken from ' + + 'its `contentF`, from a named `source`, or from an `attribute` of the instance the ' + + 'display set shows. `label` prefixes the value, `title` is its tooltip, and `iconName` ' + + 'puts an icon before it. An item with no value is left out. `condition` and `iconName` ' + + 'may each be a function or a name, so the whole line can be declared as data - see ' + + '`studyBrowser.thumbnailDetailSources` and `studyBrowser.thumbnailDetailTests` for the ' + + 'names, and `?customization=studyBrowser/derivedDateTime` for an example of adding the ' + + 'creation date/time that derived series are sorted by. An item naming a source or test ' + + 'that is not registered is left out with a warning; if that leaves no items at all, the ' + + 'thumbnail keeps its default detail line rather than showing an empty one.', + default: [ + { + id: 'SeriesNumber', + label: 'S:', + source: 'seriesNumber', + }, + { + id: 'InstanceCount', + source: 'numInstances', + iconName: ({ displaySet }) => displaySet?.countIcon || 'InfoSeries', + }, + ], + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'studyBrowser.thumbnailDetails': { + $push: [ + { + id: 'InstanceDateTime', + // Named source and test, so this can also be written in a + // ?customization= JSONC file, which is data and never executed. + source: 'instanceDateTime', + condition: 'isDerivedDisplaySet', + title: 'Created', + }, + { + // Or supply the functions directly. + id: 'BodyPart', + label: 'Part:', + attribute: 'BodyPartExamined', + condition: ({ displaySet }) => displaySet.Modality === 'CT', + }, + ], + }, + }, + ], +}; + `, + }, + { + id: 'studyBrowser.thumbnailDetailSources', + description: + 'Named value sources a `studyBrowser.thumbnailDetails` item can point at with `source`, ' + + 'instead of supplying a `contentF` function. Each is called with ' + + '`{ displaySet, instance, formatters }`, where `instance` is the instance the display ' + + 'set shows. `instanceDateTime` is the creation date/time that the series list is sorted ' + + 'by, formatted to the minute. Add to it with `$merge`, as a `$set` replaces the whole ' + + 'registry and so takes away the sources the default items name.', + default: { + seriesNumber: ({ displaySet }) => displaySet?.SeriesNumber, + numInstances: ({ displaySet }) => + (displaySet?.numImageFrames ?? displaySet?.instances?.length) || 1, + seriesDate: ({ displaySet, formatters }) => formatters.formatDate(displaySet?.SeriesDate), + instanceDateTime: '(the creation date/time, see getLatestInstanceDateTime)', + }, + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'studyBrowser.thumbnailDetailSources': { + $merge: { + seriesDescription: ({ displaySet }) => displaySet.SeriesDescription, + }, + }, + }, + ], +}; + `, + }, + { + id: 'studyBrowser.thumbnailDetailTests', + description: + 'Named tests a `studyBrowser.thumbnailDetails` item can point at with `condition`, ' + + 'instead of supplying a function. Each is called with the same properties as a source ' + + 'and returns whether to include the item.', + default: { + isDerivedDisplaySet: ({ displaySet }) => !!displaySet?.isDerivedDisplaySet, + }, + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'studyBrowser.thumbnailDetailTests': { + $merge: { + isMultiframe: ({ displaySet }) => displaySet.isMultiFrame, + }, + }, + }, + ], }; `, }, diff --git a/platform/docs/docs/platform/services/customization-service/specificCustomizations.md b/platform/docs/docs/platform/services/customization-service/specificCustomizations.md index f096ba98d0a..86107909c64 100644 --- a/platform/docs/docs/platform/services/customization-service/specificCustomizations.md +++ b/platform/docs/docs/platform/services/customization-service/specificCustomizations.md @@ -35,6 +35,45 @@ window.config = { With this example, navigation preserves the default keys plus `customizationAlt` and `experimentFlag`. +## `ohif.headerRightSide` + +- **Purpose**: Fills the right side of the viewer header's menu bar, ahead of the + settings menu. The key is named for the area, not its contents — the shipped + default is the undo/redo buttons followed by the patient info, but the list is + yours to reorder, extend or trim. +- **Value**: `{ items: ComponentType[] }` — an ordered list of components. +- **How it is applied**: `ViewerHeader` renders each entry as a component + (``) in array order, each in its own slot with a separator after it. An + item takes no props and may use hooks (both defaults use `useSystem()`), and an + item that renders `null` collapses its slot and separator — that is how patient + info disappears under `showPatientInfo: 'disabled'`. +- **Default**: `extensions/default/src/customizations/headerRightSideCustomization.ts`. + +Reordering the list reorders the header — putting patient info ahead of undo/redo +is just a different array: + +```js +import HeaderPatientInfo from '@ohif/extension-default/src/ViewerLayout/HeaderPatientInfo'; +import HeaderUndoRedo from '@ohif/extension-default/src/ViewerLayout/HeaderUndoRedo'; + +window.config = { + customizationService: [ + { 'ohif.headerRightSide': { items: { $set: [HeaderPatientInfo, HeaderUndoRedo] } } }, + ], +}; +``` + +Adding your own component is a `$push`, and removing one is a `$filter`. +`extension-default` ships the undo/redo removal as a named customization module, +so hiding those buttons — and only those, leaving the rest of the list alone — is +a one-line config change: + +```js +window.config = { + customizationService: ['@ohif/extension-default.customizationModule.hideHeaderUndoRedo'], +}; +``` + ## `customizationUrlPrefixes` (app config) - **Purpose**: Allowlist of prefixes that `?customization=` values may resolve against. The `?customization=` feature is **off until this is configured**. diff --git a/platform/docs/docs/platform/services/customization-service/typing.md b/platform/docs/docs/platform/services/customization-service/typing.md new file mode 100644 index 00000000000..a4a32389ec0 --- /dev/null +++ b/platform/docs/docs/platform/services/customization-service/typing.md @@ -0,0 +1,245 @@ +--- +sidebar_label: Typing Customizations +title: Typing Customizations +summary: How to declare the TypeScript type of a customization id in the AppTypes.Customizations registry so that reads are typed, writes are checked, and casts can be deleted. +sidebar_position: 12 +--- + +By default `getCustomization(id)` accepts any string and returns the loose +`Customization` union, so call sites cast (`as string`, `as unknown as +ColorbarCustomization`, ...) and `setCustomizations` payloads are not checked at +all. + +You can opt a customization id into real typing by declaring it in the +`AppTypes.Customizations` registry. Declaring an id gives you: + +- **autocomplete** for the id itself, +- a **precise return type** from `getCustomization` / `getValue`, so the cast at + the call site can be deleted, +- **checked writes** — `setCustomizations` validates the value against the + declared type, including `$set` / `$push` / `$merge` specs. + +Ids you do not declare keep working exactly as before. Nothing here changes +runtime behavior. + +## Declaring a key + +The registry is a global interface extended by declaration merging, the same +pattern already used for `AppTypes.Services`. Add a `types/AppTypes.ts` to your +package (or extend the existing one) and merge in the ids you own: + +```ts +// platform/ui-next/src/types/AppTypes.ts +declare global { + namespace AppTypes { + interface Customizations { + /** + * Sort options offered by the study browser's sort dropdown. Read by + * `StudyBrowserSort`, which indexes `[0]` for its initial selection, so a + * default with at least one entry is expected. + */ + 'studyBrowser.sortFunctions': Array<{ + label: string; + sortFunction: (a: AppTypes.DisplaySet, b: AppTypes.DisplaySet) => number; + }>; + } + } +} + +export {}; +``` + +That is the whole mechanism. `StudyBrowserSort` now reads the value without a +cast and without `?.`: + +```tsx +const sortFunctions = customizationService.getCustomization('studyBrowser.sortFunctions'); + +const [selectedSort, setSelectedSort] = useState(sortFunctions[0]); +// ... +{sortFunctions.map(sort => {sort.label})} +``` + +For a value type of any real size, export it from the file that produces the +default and reference it here rather than inlining the shape. + +## Where to declare it + +**The package that consumes a key declares its type**, next to the consumer. + +That is often also the package registering the default, but not always, and the +difference matters. `studyBrowser.sortFunctions` above is declared in +`platform/ui-next` because that is where it is read — but its default is +registered by `extension-default` +(`src/customizations/studyBrowserCustomization.ts`). Declaring at the consumer is +what lets the provider's default be checked against the contract the consumer +relies on. + +Keys read by `platform/core` are declared in core, so the dependency direction +stays extension-free: + +```ts +// platform/core/src/types/AppTypes.ts, inside the existing +// `declare global { namespace AppTypes { ... } }` block +export interface Customizations { + /** + * Orders display sets within a study. Consumed by `createStudyBrowserTabs` + * here in core and by the viewer's `defaultRouteInit`; the default is + * registered by `extension-default`. + */ + sortingCriteria: (a: DisplaySet, b: DisplaySet) => number; + + /** Orders the instances of an image set. Consumed by `ImageSet.sort`. */ + instanceSortingCriteria: { + defaultSortFunctionName?: string; + sortFunctions?: Record number>; + }; +} +``` + +Declaring `sortingCriteria` is what let this cast be deleted in *two* packages +(`platform/core/src/utils/createStudyBrowserTabs.ts` and +`platform/app/src/routes/Mode/defaultRouteInit.ts`), which had the same one: + +```diff +- const sortCriteria = customizationService.getCustomization('sortingCriteria') as (a, b) => number; ++ const sortCriteria = customizationService.getCustomization('sortingCriteria'); +``` + +Third-party extensions declare their own ids the same way, from outside the +repo. Nothing needs to be registered centrally. + +## Declare the *resolved* value + +The declared type describes what `getCustomization` hands back — the value +**after** `inheritsFrom` merging, `$transform`, and `$reference` expansion have +run. It is not the shape you write. + +This distinction is what lets composition keys be typed at all. `toolbarButtons` +resolves to a list of buttons, so that is what you declare: + +```ts +interface Customizations { + toolbarButtons: Button[]; +} +``` + +...even though it is almost always *written* with `$reference` markers: + +```js +// modes/basic/src/index.tsx +toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }], +``` + +Both are accepted. `setCustomizations` allows the read-time markers wherever the +resolver walks — as a whole value, as an array item, or as a plain object's +property value — while reads stay clean: + +```ts +customizationService.setCustomizations({ + // whole value + toolbarButtons: { $reference: 'cornerstone.toolbarButtons' }, + // appended to the existing list + toolbarSections: { $push: [{ $reference: 'cornerstone.toolbarSections' }] }, + // mixed with literal entries + toolGroupAdditions: { default: [{ $reference: 'x' }], mpr: [] }, + // computed at read time from its siblings (must be a `function`, not an + // arrow -- `$transform` reads the sibling properties off `this`) + measurementsContextMenu: { + inheritsFrom: 'ohif.contextMenu', + $transform: function (customizationService) { + return { ...this, menus: this.menus.map(menu => ({ ...menu })) }; + }, + }, +}); +``` + +Do **not** put markers into the declared type. `toolbarButtons: (Button | +ReferenceMarker)[]` would force every read site to handle a marker that +`getCustomization` never returns. + +## Nullability + +**A declaration without `| undefined` is a promise that a default is +registered.** Consumers may then use the value directly, which is how existing +consumers are already written — `StudyBrowserSort` indexes `sortFunctions[0]` +with no guard. + +Add `| undefined` only for ids that genuinely ship no default: + +```ts +interface Customizations { + // has a default registered by extension-default + 'studyBrowser.sortFunctions': SortFunction[]; + // no default; every consumer must handle absence + 'studyBrowser.onDoubleClick': DoubleClickHandler | undefined; +} +``` + +Note the repo's own `tsconfig.json` does not enable `strictNullChecks`, so +`| undefined` is erased in-tree; it is a contract for downstream consumers that +do compile strictly. + +## Declaring a custom update command + +`$filter` is registered by the service itself. If your extension registers more +through `registerCustomUpdateCommand`, declare them in the parallel +`AppTypes.CustomizationUpdateCommands` registry so specs using them type-check +without a cast: + +```ts +declare global { + namespace AppTypes { + interface CustomizationUpdateCommands { + /** Reorders toolbar entries by weight. */ + $reweight: { id: string; weight: number }; + } + } +} +``` + +```ts +customizationService.registerCustomUpdateCommand('reweight', (query, original) => ...); +customizationService.setCustomizations({ + toolbarButtons: { $reweight: { id: 'Zoom', weight: 3 } }, +}); +``` + +## Gotchas + +**A key of type `any` disables all checking.** If the id you pass is `any` — for +example destructured out of an untyped props bag — the call returns `any`, which +is *weaker* than the `Customization | undefined` an undeclared id gets. +Annotate the key as `string`: + +```ts +// items: any -- no checking at all +export default function MoreDropdownMenu(bindProps) { + const { menuItemsKey } = bindProps; + const items = customizationService.getCustomization(menuItemsKey); + +// items: Customization | undefined -- correct fallback +export default function MoreDropdownMenu(bindProps) { + const { menuItemsKey }: { menuItemsKey: string } = bindProps; + const items = customizationService.getCustomization(menuItemsKey); +``` + +**Typos in a declared id are not caught.** Because undeclared ids must keep +working, `setCustomizations` accepts any string key, so +`'panelSegmentation.disabledEditing'` is silently treated as an unregistered +dynamic key rather than reported as a misspelling of a declared one. + +**`$transform`'s return type is not checked** — it is validated as a function, +but not against the declared value type. + +**`getValue`'s fallback is not checked.** Passing a `fallbackValue` whose type +does not match the declaration does not error; the call resolves to the loose +signature and returns the fallback's type. Prefer `getCustomization` for +declared ids. + +## See also + +- [Advanced Customization](./advanced.md) — `inheritsFrom` and `$transform` + semantics. +- [Customization Service](./customizationService.md) — the `$set` / `$push` / + `$filter` command syntax these types check. diff --git a/platform/docs/package.json b/platform/docs/package.json index d19dc45f252..f902de0ac1e 100644 --- a/platform/docs/package.json +++ b/platform/docs/package.json @@ -1,6 +1,6 @@ { "name": "ohif-docs", - "version": "3.13.0-beta.135", + "version": "3.14.0-beta.44", "private": true, "scripts": { "docusaurus": "docusaurus", @@ -54,7 +54,7 @@ "@radix-ui/react-toggle": "1.1.9", "@radix-ui/react-tooltip": "1.2.7", "@svgr/webpack": "8.1.0", - "@types/react": "18.3.23", + "@types/react": "19.2.17", "autoprefixer": "10.4.21", "class-variance-authority": "0.7.1", "classnames": "2.5.1", @@ -63,19 +63,18 @@ "date-fns": "3.6.0", "docusaurus-plugin-image-zoom": "1.0.1", "file-loader": "6.2.0", - "framer-motion": "6.2.4", "glob": "10.5.0", - "lucide-react": "0.379.0", - "next-themes": "0.3.0", + "lucide-react": "0.577.0", + "next-themes": "0.4.6", "postcss": "8.5.6", "postcss-import": "14.1.0", "postcss-preset-env": "7.8.3", "prism-react-renderer": "2.1.0", - "react": "18.3.1", - "react-day-picker": "8.10.1", - "react-dom": "18.3.1", + "react": "19.2.7", + "react-day-picker": "9.12.0", + "react-dom": "19.2.7", "react-outside-click-handler": "1.3.0", - "shepherd.js": "15.2.2", + "shepherd.js": "15.3.0", "sonner": "1.7.4", "tailwind-merge": "2.6.0", "tailwindcss": "3.2.4", diff --git a/platform/docs/src/pages/components/_layout/CodeBlock.tsx b/platform/docs/src/pages/components/_layout/CodeBlock.tsx index 2286de9e049..5942e7af3eb 100644 --- a/platform/docs/src/pages/components/_layout/CodeBlock.tsx +++ b/platform/docs/src/pages/components/_layout/CodeBlock.tsx @@ -6,7 +6,7 @@ interface CodeBlockProps { export default function CodeBlock({ code }: CodeBlockProps) { const [copied, setCopied] = useState(false); - const timerRef = useRef>(); + const timerRef = useRef>(undefined); useEffect(() => () => clearTimeout(timerRef.current), []); diff --git a/platform/docs/src/pages/components/_layout/TableOfContents.tsx b/platform/docs/src/pages/components/_layout/TableOfContents.tsx index be4637bc94e..f995fd44d6a 100644 --- a/platform/docs/src/pages/components/_layout/TableOfContents.tsx +++ b/platform/docs/src/pages/components/_layout/TableOfContents.tsx @@ -6,7 +6,7 @@ interface TocItem { } interface TableOfContentsProps { - contentRef: React.RefObject; + contentRef: React.RefObject; } export default function TableOfContents({ contentRef }: TableOfContentsProps) { diff --git a/platform/docs/src/pages/components/data-table.tsx b/platform/docs/src/pages/components/data-table.tsx index d419dc59e63..21a65978120 100644 --- a/platform/docs/src/pages/components/data-table.tsx +++ b/platform/docs/src/pages/components/data-table.tsx @@ -1,5 +1,7 @@ import React, { useState } from 'react'; import BrowserOnly from '@docusaurus/BrowserOnly'; +import type { ColumnDef } from '@tanstack/react-table'; +import type { DataTableFeatures } from '../../../../ui-next/src/components/DataTable'; function DataTablePageContent() { const { DataTable } = require('../../../../ui-next/src/components/DataTable'); @@ -33,8 +35,9 @@ function DataTablePageContent() { { studyInstanceUid: '1.2.840.7', patientName: 'Chen, Wei', mrn: '78901234', modality: 'US', date: 'Mar 10, 2024', description: 'US Abdomen Complete', instances: 48 }, { studyInstanceUid: '1.2.840.8', patientName: 'Johnson, Sarah', mrn: '89012345', modality: 'MR', date: 'Mar 09, 2024', description: 'MR Lumbar Spine', instances: 220 }, ]; + type DemoStudy = (typeof studies)[number]; - const columns = [ + const columns: ColumnDef[] = [ { id: 'patientName', accessorFn: row => row.patientName, @@ -68,7 +71,7 @@ function DataTablePageContent() { accessorFn: row => row.description, header: ({ column }) => , cell: ({ row }) => { - const desc = row.getValue('description'); + const desc = row.getValue('description'); return (
{desc || 'No Description'} @@ -82,7 +85,7 @@ function DataTablePageContent() { accessorFn: row => Number(row.instances), header: ({ column }) => , cell: ({ row }) =>
{row.getValue('instances')}
, - sortingFn: (a, b) => a.getValue('instances') - b.getValue('instances'), + sortFn: (a, b) => (a.getValue('instances') as number) - (b.getValue('instances') as number), meta: { label: 'Instances', align: 'right', minWidth: 80, priority: 50 }, }, ]; diff --git a/platform/docs/src/pages/components/numeric.tsx b/platform/docs/src/pages/components/numeric.tsx index ac28660a424..36c6c3644d7 100644 --- a/platform/docs/src/pages/components/numeric.tsx +++ b/platform/docs/src/pages/components/numeric.tsx @@ -29,8 +29,9 @@ function NumericPageContent() { { name: 'values', type: '[number, number]', default: '—', description: 'Controlled range values (doubleRange mode)' }, { name: 'defaultValues', type: '[number, number]', default: '[30%, 70%]', description: 'Initial uncontrolled range values' }, { name: 'onChange', type: '(val: number | [number, number]) => void', default: '—', description: 'Called when any value changes' }, - { name: 'min', type: 'number', default: '0', description: 'Minimum allowed value' }, - { name: 'max', type: 'number', default: '100', description: 'Maximum allowed value' }, + { name: 'min', type: 'number', default: '0', description: 'Minimum value and default lower bound for typed doubleRange values' }, + { name: 'max', type: 'number', default: '100', description: 'Maximum value and default upper bound for typed doubleRange values' }, + { name: 'allowTypedExpansion', type: 'boolean | [number, number]', default: 'false', description: 'Allows typed doubleRange values beyond min/max, optionally within explicit bounds' }, { name: 'step', type: 'number', default: '1', description: 'Step increment' }, ]; @@ -64,6 +65,13 @@ function NumericPageContent() { mode prop on the Container determines which input type renders.

+

+ Editable number fields preserve intermediate text such as an empty value, a minus + sign, or a decimal point, and commit on Enter or blur. In double-range mode, typed + values are limited by min and max by default. Set allowTypedExpansion to true to accept + any finite typed value, or provide [minimum, maximum] to constrain the expansion. A + committed out-of-range value expands the slider domain. +

@@ -205,7 +213,7 @@ function NumericPageContent() { step={1} className="space-y-1" > - Window Width/Level + Default bounded range @@ -213,11 +221,12 @@ function NumericPageContent() { mode="doubleRange" min={0} max={100} + allowTypedExpansion step={1} defaultValues={[30, 70]} className="space-y-1" > - With number inputs + Expandable typed range
@@ -276,6 +285,17 @@ function NumericPageContent() { CT Window + + +// Allow typed values to expand beyond the slider's initial limits + + Expandable range + `} /> diff --git a/platform/docs/src/pages/components/smart-scrollbar.tsx b/platform/docs/src/pages/components/smart-scrollbar.tsx index 4311fcf9779..fcc659184d5 100644 --- a/platform/docs/src/pages/components/smart-scrollbar.tsx +++ b/platform/docs/src/pages/components/smart-scrollbar.tsx @@ -200,18 +200,16 @@ function SmartScrollbarDemo({ - + ); @@ -257,19 +255,16 @@ function SmartScrollbarPageContent() { const fillProps = [ { name: 'marked', type: 'Uint8Array', default: '—', description: 'Byte array where 1 = marked position, 0 = unmarked' }, - { name: 'version', type: 'number', default: '—', description: 'Change token — bump when the array mutates in-place' }, { name: 'className', type: 'string', default: 'bg-neutral/25', description: 'Fill color class for normal state' }, { name: 'loadingClassName', type: 'string', default: 'bg-neutral/50', description: 'Fill color class while parent isLoading is true' }, ]; const endpointsProps = [ { name: 'marked', type: 'Uint8Array', default: '—', description: 'Byte array marking loaded positions' }, - { name: 'version', type: 'number', default: '—', description: 'Change token — bump when the array mutates in-place' }, ]; const byteArrayFields = [ - { name: 'bytes', type: 'Uint8Array', default: '—', description: 'Mutable array — safe for in-place writes' }, - { name: 'version', type: 'number', default: '—', description: 'Invalidation token for React memo dependencies' }, + { name: 'bytes', type: 'Uint8Array', default: '—', description: 'Safe for in-place writes; new identity published on each change' }, { name: 'isFull', type: 'boolean', default: '—', description: 'True when all bytes are set to 1' }, { name: 'setByte(index)', type: 'function', default: '—', description: 'Mark a position as loaded or viewed' }, { name: 'clearByte(index)', type: 'function', default: '—', description: 'Unmark a position' }, @@ -418,21 +413,16 @@ viewed.setByte(currentIndex); - + `} /> diff --git a/platform/docs/src/theme/Footer/index.tsx b/platform/docs/src/theme/Footer/index.tsx index f8230d4a407..d1e21dc32dd 100644 --- a/platform/docs/src/theme/Footer/index.tsx +++ b/platform/docs/src/theme/Footer/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import Link from '@docusaurus/Link'; function FooterLink({ diff --git a/platform/i18n/package.json b/platform/i18n/package.json index a3f69e7b75e..b6c4d65ae18 100644 --- a/platform/i18n/package.json +++ b/platform/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@ohif/i18n", - "version": "3.13.0-beta.135", + "version": "3.14.0-beta.44", "description": "Internationalization library for The OHIF Viewer", "author": "OHIF", "license": "MIT", @@ -29,10 +29,10 @@ "test:unit:ci": "echo 'platform/i18n: missing unit tests'" }, "peerDependencies": { - "i18next": "17.3.1", + "i18next": "19.9.2", "i18next-browser-languagedetector": "3.1.1", - "react": "18.3.1", - "react-dom": "18.3.1", + "react": "19.2.7", + "react-dom": "19.2.7", "react-i18next": "12.3.1" }, "dependencies": { @@ -43,10 +43,10 @@ }, "devDependencies": { "cross-env": "7.0.3", - "i18next": "17.3.1", + "i18next": "19.9.2", "i18next-browser-languagedetector": "3.1.1", - "react": "18.3.1", - "react-dom": "18.3.1", + "react": "19.2.7", + "react-dom": "19.2.7", "react-i18next": "12.3.1", "webpack-merge": "5.10.0" } diff --git a/platform/i18n/src/debugger.js b/platform/i18n/src/debugger.js index 9b6881ee52d..9d6594a3fad 100644 --- a/platform/i18n/src/debugger.js +++ b/platform/i18n/src/debugger.js @@ -2,7 +2,6 @@ import { debugMode } from './config'; export default (message, level = 'log') => { if (debugMode) { - // eslint-disable-next-line console[level]('@ohif/i18n: ', message); } }; diff --git a/platform/i18n/src/locales/en-US/EncapsulatedDocument.json b/platform/i18n/src/locales/en-US/EncapsulatedDocument.json new file mode 100644 index 00000000000..e4da185bd8d --- /dev/null +++ b/platform/i18n/src/locales/en-US/EncapsulatedDocument.json @@ -0,0 +1,8 @@ +{ + "Loading document...": "Loading document...", + "This document type cannot be displayed": "This document type cannot be displayed", + "Document content does not match its declared type": "Document content does not match its declared type", + "Unable to retrieve this document": "Unable to retrieve this document", + "No viewer installed for {{mimeType}}": "No viewer installed for {{mimeType}}", + "Encapsulated document": "Encapsulated document" +} diff --git a/platform/i18n/src/locales/en-US/Header.json b/platform/i18n/src/locales/en-US/Header.json index cfc956f0486..1ec88be9c93 100644 --- a/platform/i18n/src/locales/en-US/Header.json +++ b/platform/i18n/src/locales/en-US/Header.json @@ -5,6 +5,8 @@ "INVESTIGATIONAL USE ONLY": "INVESTIGATIONAL USE ONLY", "Options": "Options", "Preferences": "Preferences", + "Redo": "Redo", "Study list": "Study list", + "Undo": "Undo", "Logout": "Logout" } diff --git a/platform/i18n/src/locales/en-US/MeasurementTable.json b/platform/i18n/src/locales/en-US/MeasurementTable.json index 7fd54073e87..d005216e447 100644 --- a/platform/i18n/src/locales/en-US/MeasurementTable.json +++ b/platform/i18n/src/locales/en-US/MeasurementTable.json @@ -16,6 +16,7 @@ "Hide": "Hide", "Show": "Show", "Create SR": "Create SR", + "Save": "Save", "empty": "(empty)", "Track measurements for this series?": "Track measurements for this series?", "Do you want to add this measurement to the existing report?": "Do you want to add this measurement to the existing report?", diff --git a/platform/i18n/src/locales/en-US/Messages.json b/platform/i18n/src/locales/en-US/Messages.json index f656dccd9eb..a9bdfc171b3 100644 --- a/platform/i18n/src/locales/en-US/Messages.json +++ b/platform/i18n/src/locales/en-US/Messages.json @@ -14,5 +14,6 @@ "12": "Display set has inconsistent position information.", "13": "Unsupported display set.", "14": "SOP Class UID {{ sopClassUid }} is not supported.", - "15": "Display Set is missing a SOP Class UID. Please check the file." + "15": "Display Set is missing a SOP Class UID. Please check the file.", + "16": "Report has no content to display." } diff --git a/platform/i18n/src/locales/en-US/SegmentationPanel.json b/platform/i18n/src/locales/en-US/SegmentationPanel.json index 682e73b5f94..3413fd37a6a 100644 --- a/platform/i18n/src/locales/en-US/SegmentationPanel.json +++ b/platform/i18n/src/locales/en-US/SegmentationPanel.json @@ -40,6 +40,7 @@ "Download & Export": "Download & Export", "Download": "Download", "Export": "Export", + "Save": "Save", "CSV Report": "CSV Report", "DICOM SEG": "DICOM SEG", "DICOM RTSS": "DICOM RTSS", diff --git a/platform/i18n/src/locales/en-US/index.js b/platform/i18n/src/locales/en-US/index.js index 0081c650084..4902e2e19a2 100644 --- a/platform/i18n/src/locales/en-US/index.js +++ b/platform/i18n/src/locales/en-US/index.js @@ -33,6 +33,7 @@ import Colormaps from './Colormaps.json'; import PanelSUV from './PanelSUV.json'; import ROIThresholdConfiguration from './ROIThresholdConfiguration.json'; import USAnnotationPanel from './USAnnotationPanel.json'; +import EncapsulatedDocument from './EncapsulatedDocument.json'; export default { 'en-US': { @@ -71,5 +72,6 @@ export default { PanelSUV, ROIThresholdConfiguration, USAnnotationPanel, + EncapsulatedDocument, }, }; diff --git a/platform/i18n/src/locales/test-LNG/EncapsulatedDocument.json b/platform/i18n/src/locales/test-LNG/EncapsulatedDocument.json new file mode 100644 index 00000000000..47804a886da --- /dev/null +++ b/platform/i18n/src/locales/test-LNG/EncapsulatedDocument.json @@ -0,0 +1,8 @@ +{ + "Loading document...": "Test Loading document...", + "This document type cannot be displayed": "Test This document type cannot be displayed", + "Document content does not match its declared type": "Test Document content does not match its declared type", + "Unable to retrieve this document": "Test Unable to retrieve this document", + "No viewer installed for {{mimeType}}": "Test No viewer installed for {{mimeType}}", + "Encapsulated document": "Test Encapsulated document" +} diff --git a/platform/i18n/src/locales/test-LNG/MeasurementTable.json b/platform/i18n/src/locales/test-LNG/MeasurementTable.json index f65da4815e1..f119d44a45f 100644 --- a/platform/i18n/src/locales/test-LNG/MeasurementTable.json +++ b/platform/i18n/src/locales/test-LNG/MeasurementTable.json @@ -3,6 +3,7 @@ "No tracked measurements": "Test No tracked measurements", "Create Report": "Test Create Report", "Export": "Test Export", + "Save": "Test Save", "Delete": "Delete", "Description": "Description", "MAX": "MAX", diff --git a/platform/i18n/src/locales/test-LNG/SegmentationPanel.json b/platform/i18n/src/locales/test-LNG/SegmentationPanel.json index 687c9055a94..d712bf12ce5 100644 --- a/platform/i18n/src/locales/test-LNG/SegmentationPanel.json +++ b/platform/i18n/src/locales/test-LNG/SegmentationPanel.json @@ -40,6 +40,7 @@ "Download & Export": "Test Download & Export", "Download": "Test Download", "Export": "Test Export", + "Save": "Test Save", "CSV Report": "Test CSV Report", "DICOM SEG": "Test DICOM SEG", "DICOM RTSS": "Test DICOM RTSS", diff --git a/platform/i18n/src/locales/test-LNG/index.js b/platform/i18n/src/locales/test-LNG/index.js index 7822a3f4ff3..9964bdf49a3 100644 --- a/platform/i18n/src/locales/test-LNG/index.js +++ b/platform/i18n/src/locales/test-LNG/index.js @@ -35,6 +35,7 @@ import Tools from './Tools.json'; import Hps from './Hps.json'; import ToolbarLayoutSelector from './ToolbarLayoutSelector.json'; import USAnnotationPanel from './USAnnotationPanel.json'; +import EncapsulatedDocument from './EncapsulatedDocument.json'; export default { 'test-LNG': { @@ -75,5 +76,6 @@ export default { Hps, ToolbarLayoutSelector, USAnnotationPanel, + EncapsulatedDocument, }, }; diff --git a/platform/ui-next/31fb9346313fc3740d7b.woff2 b/platform/ui-next/31fb9346313fc3740d7b.woff2 deleted file mode 100644 index 0d91b7ab500..00000000000 Binary files a/platform/ui-next/31fb9346313fc3740d7b.woff2 and /dev/null differ diff --git a/platform/ui-next/babel.config.js b/platform/ui-next/babel.config.js index 90e8d49cad4..325ca2a8ee7 100644 --- a/platform/ui-next/babel.config.js +++ b/platform/ui-next/babel.config.js @@ -1,56 +1 @@ -// https://babeljs.io/docs/en/options#babelrcroots -const { extendDefaultPlugins } = require('svgo'); - -module.exports = { - babelrcRoots: ['./platform/*', './extensions/*', './modes/*'], - presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'], - plugins: [ - ['@babel/plugin-transform-class-properties', { loose: true }], - '@babel/plugin-transform-typescript', - ['@babel/plugin-transform-private-methods', { loose: true }], - '@babel/plugin-transform-class-static-block', - ], - env: { - test: { - presets: [ - [ - // TODO: https://babeljs.io/blog/2019/03/19/7.4.0#migration-from-core-js-2 - '@babel/preset-env', - { - modules: 'commonjs', - debug: false, - }, - ], - '@babel/preset-react', - '@babel/preset-typescript', - ], - plugins: [ - '@babel/plugin-transform-object-rest-spread', - '@babel/plugin-syntax-dynamic-import', - '@babel/plugin-transform-regenerator', - '@babel/transform-destructuring', - '@babel/plugin-transform-runtime', - '@babel/plugin-transform-typescript', - '@babel/plugin-transform-class-static-block', - ], - }, - production: { - presets: [ - // WebPack handles ES6 --> Target Syntax - ['@babel/preset-env', { modules: false }], - '@babel/preset-react', - '@babel/preset-typescript', - ], - ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], - }, - development: { - presets: [ - // WebPack handles ES6 --> Target Syntax - ['@babel/preset-env', { modules: false }], - '@babel/preset-react', - '@babel/preset-typescript', - ], - ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], - }, - }, -}; +module.exports = require('../../babel.config.js'); diff --git a/platform/ui-next/package.json b/platform/ui-next/package.json index 5ed11810cd0..bdda2cf0c65 100644 --- a/platform/ui-next/package.json +++ b/platform/ui-next/package.json @@ -1,6 +1,6 @@ { "name": "@ohif/ui-next", - "version": "3.13.0-beta.135", + "version": "3.14.0-beta.44", "description": "Next version of OHIF Viewers UI, more customizable using shadcn/ui", "author": "OHIF", "license": "MIT", @@ -48,18 +48,16 @@ "@radix-ui/react-toggle": "1.1.9", "@radix-ui/react-toggle-group": "1.1.10", "@radix-ui/react-tooltip": "1.2.7", - "@tanstack/react-table": "8.21.3", + "@tanstack/react-table": "9.1.2", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", "date-fns": "4.1.0", - "framer-motion": "6.2.4", - "lucide-react": "0.379.0", - "next-themes": "0.3.0", - "react": "18.3.1", + "lucide-react": "0.577.0", + "next-themes": "0.4.6", "react-day-picker": "9.12.0", "react-resizable-panels": "2.1.9", - "shepherd.js": "15.2.2", + "shepherd.js": "15.3.0", "sonner": "1.7.4", "tailwind-merge": "2.6.0", "tailwindcss": "3.2.4", @@ -67,7 +65,15 @@ }, "devDependencies": { "@babel/plugin-transform-private-property-in-object": "7.29.7", - "cross-env": "7.0.3" + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", + "cross-env": "7.0.3", + "react": "19.2.7", + "react-dom": "19.2.7" }, - "keywords": [] + "keywords": [], + "peerDependencies": { + "react": "^19.2.7", + "react-dom": "^19.2.7" + } } diff --git a/platform/ui-next/src/__mocks__/fileMock.js b/platform/ui-next/src/__mocks__/fileMock.js new file mode 100644 index 00000000000..51ed12d1f32 --- /dev/null +++ b/platform/ui-next/src/__mocks__/fileMock.js @@ -0,0 +1,7 @@ +// https://jestjs.io/docs/en/webpack#handling-static-assets +// +// The shared jest config maps every static asset here. This package had no copy +// of it, so any test that reached a component importing an image - the Icons +// barrel imports the window level preset thumbnails - failed to resolve. + +module.exports = 'test-file-stub'; diff --git a/platform/ui-next/src/components/Accordion/Accordion.tsx b/platform/ui-next/src/components/Accordion/Accordion.tsx index 9e20e200d86..55197f27e47 100644 --- a/platform/ui-next/src/components/Accordion/Accordion.tsx +++ b/platform/ui-next/src/components/Accordion/Accordion.tsx @@ -8,22 +8,25 @@ import { cn } from '../../lib/utils'; const Accordion = AccordionPrimitive.Root; -const AccordionItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +const AccordionItem = ({ + className, + ref, + ...props +}: React.ComponentProps) => ( -)); +); AccordionItem.displayName = 'AccordionItem'; -const AccordionTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( +const AccordionTrigger = ({ + className, + children, + ref, + ...props +}: React.ComponentProps) => ( -)); +); AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; -const AccordionContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( +const AccordionContent = ({ + className, + children, + ref, + ...props +}: React.ComponentProps) => (
{children}
-)); +); AccordionContent.displayName = AccordionPrimitive.Content.displayName; export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/platform/ui-next/src/components/AllInOneMenu/IconMenu.tsx b/platform/ui-next/src/components/AllInOneMenu/IconMenu.tsx index b1662394a8a..2739f367b24 100644 --- a/platform/ui-next/src/components/AllInOneMenu/IconMenu.tsx +++ b/platform/ui-next/src/components/AllInOneMenu/IconMenu.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useState } from 'react'; import OutsideClickHandler from 'react-outside-click-handler'; import { MenuProps } from './Menu'; import classNames from 'classnames'; @@ -40,7 +40,7 @@ export default function IconMenu({ }: IconMenuProps) { const [isMenuVisible, setIsMenuVisible] = useState(false); - const toggleMenuVisibility = useCallback(() => setIsMenuVisible(isVisible => !isVisible), []); + const toggleMenuVisibility = () => setIsMenuVisible(isVisible => !isVisible); return ( { const { hideMenu } = useContext(MenuContext); - const onClickHandler = useCallback(() => { + const onClickHandler = () => { hideMenu(); onClick?.(); - }, [hideMenu, onClick]); + }; return (
{ const { showSubMenu } = useContext(MenuContext); - const onClickHandler = useCallback(() => { + const onClickHandler = () => { showSubMenu(props); props.onClick?.(); - }, [showSubMenu, props]); + }; return (
{ asChild?: boolean; dataCY?: string; + ref?: React.Ref; } -const Button = React.forwardRef( - ({ className, variant, size, asChild = false, dataCY, ...props }, forwardRef) => { - const Comp = asChild ? Slot : 'button'; - const testId = dataCY || `${props.name}-btn`; +const Button = ({ + className, + variant, + size, + asChild = false, + dataCY, + ref, + ...props +}: ButtonProps) => { + const Comp = asChild ? Slot : 'button'; + const testId = dataCY || `${props.name}-btn`; - return ( - - ); - } -); + return ( + + ); +}; Button.displayName = 'Button'; export { Button, buttonVariants }; diff --git a/platform/ui-next/src/components/Calendar/Calendar.tsx b/platform/ui-next/src/components/Calendar/Calendar.tsx index 69af38bacfd..71a55f74a78 100644 --- a/platform/ui-next/src/components/Calendar/Calendar.tsx +++ b/platform/ui-next/src/components/Calendar/Calendar.tsx @@ -1,5 +1,4 @@ import * as React from 'react'; -import { useMemo } from 'react'; import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'; import type { Locale } from 'react-day-picker'; @@ -45,6 +44,13 @@ const LOCALE_MAP: Record = { 'test-LNG': enUS, }; +// Tailwind arbitrary variants targeting react-day-picker's internal class names. +// `String.raw` keeps the backslash that escapes the underscore, and the two live +// at module scope because the compiler cannot lower a tagged template whose +// cooked value differs from its raw value. +const RTL_NEXT_ARROW = String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`; +const RTL_PREVIOUS_ARROW = String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`; + function Calendar({ className, classNames, @@ -61,13 +67,8 @@ function Calendar({ const { i18n } = useTranslation('DatePicker'); const defaultClassNames = getDefaultClassNames(); - const locale = useMemo(() => { - if (localeProp) { - return localeProp; - } - const lang = i18n.language || 'en'; - return LOCALE_MAP[lang] ?? enUS; - }, [i18n.language, localeProp]); + const lang = i18n.language || 'en'; + const locale = localeProp || (LOCALE_MAP[lang] ?? enUS); return ( svg]:rotate-180`, - String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + RTL_NEXT_ARROW, + RTL_PREVIOUS_ARROW, className )} captionLayout={captionLayout} @@ -114,7 +115,14 @@ function Calendar({ 'relative rounded-md border border-input', defaultClassNames.dropdown_root ), - dropdown: cn('absolute inset-0 opacity-0', defaultClassNames.dropdown), + // The native + setInputValues(currentValues => [event.target.value, currentValues[1]]) } - setValue(clampedValue); - onValueChange?.(clampedValue); - } - }, - [value, min, max, onValueChange, step] - ); - - const formatValue = (val: number) => { - return isInteger ? Math.round(val) : val; - }; - - return ( -
handleInputKeyDown(0, event)} + onBlur={() => handleInputBlur(0)} + className="w-14" + /> + )} + - {showNumberInputs && ( - handleInputChange(0, e.target.value)} - onBlur={() => handleInputChange(0, value[0].toString())} - className="w-14" - min={min} - max={max} - step={step} - /> - )} - - - - - - - - {showNumberInputs && ( - handleInputChange(1, e.target.value)} - onBlur={() => handleInputChange(1, value[1].toString())} - className="w-14" - min={min} - max={max} - step={step} - /> - )} -
- ); - } -); + + + + + + + {showNumberInputs && ( + + setInputValues(currentValues => [currentValues[0], event.target.value]) + } + onKeyDown={event => handleInputKeyDown(1, event)} + onBlur={() => handleInputBlur(1)} + className="w-14" + /> + )} +
+ ); +}; DoubleSlider.displayName = 'DoubleSlider'; export { DoubleSlider }; diff --git a/platform/ui-next/src/components/DropdownMenu/DropdownMenu.tsx b/platform/ui-next/src/components/DropdownMenu/DropdownMenu.tsx index 3d420d333f7..c393393fea5 100644 --- a/platform/ui-next/src/components/DropdownMenu/DropdownMenu.tsx +++ b/platform/ui-next/src/components/DropdownMenu/DropdownMenu.tsx @@ -16,13 +16,17 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - disabled?: boolean; - } ->(({ className, inset, children, disabled, ...props }, ref) => ( +const DropdownMenuSubTrigger = ({ + className, + inset, + children, + disabled, + ref, + ...props +}: React.ComponentProps & { + inset?: boolean; + disabled?: boolean; +}) => ( -)); +); DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +const DropdownMenuSubContent = ({ + className, + ref, + ...props +}: React.ComponentProps) => ( -)); +); DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; -const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( +const DropdownMenuContent = ({ + className, + sideOffset = 4, + ref, + ...props +}: React.ComponentProps) => ( -)); +); DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; -const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( +const DropdownMenuItem = ({ + className, + inset, + ref, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) => ( -)); +); DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( +const DropdownMenuCheckboxItem = ({ + className, + children, + checked, + ref, + ...props +}: React.ComponentProps) => ( {children} -)); +); DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( +const DropdownMenuRadioItem = ({ + className, + children, + ref, + ...props +}: React.ComponentProps) => ( {children} -)); +); DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; -const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( +const DropdownMenuLabel = ({ + className, + inset, + ref, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) => ( -)); +); DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +const DropdownMenuSeparator = ({ + className, + ref, + ...props +}: React.ComponentProps) => ( -)); +); DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { diff --git a/platform/ui-next/src/components/Errorboundary/ErrorBoundary.tsx b/platform/ui-next/src/components/Errorboundary/ErrorBoundary.tsx index 78b689cadea..2068f624ed4 100644 --- a/platform/ui-next/src/components/Errorboundary/ErrorBoundary.tsx +++ b/platform/ui-next/src/components/Errorboundary/ErrorBoundary.tsx @@ -272,6 +272,17 @@ const ErrorBoundary = ({ onReset(); }; + // Declared above the effect that calls it: the effect body only runs after + // render, but a reference that textually precedes its declaration is something + // the compiler refuses to reason about. + const onErrorHandler = ( + error: ErrorBoundaryError | ErrorEvent, + componentStack: string | null + ) => { + console.debug(`${context} Error Boundary`, error, componentStack, context); + onError(error, componentStack || '', context); + }; + // Add error event listener to window useEffect(() => { let errorTimeout: NodeJS.Timeout; @@ -303,13 +314,6 @@ const ErrorBoundary = ({ }; }, []); - const onErrorHandler = ( - error: ErrorBoundaryError | ErrorEvent, - componentStack: string | null - ) => { - console.debug(`${context} Error Boundary`, error, componentStack, context); - onError(error, componentStack || '', context); - }; return ( ReactNode; }; - PatientInfo?: ReactNode; Secondary?: ReactNode; - UndoRedo?: ReactNode; + /** + * Ordered slots filling the right of the menu bar, ahead of the settings + * menu — patient info, undo/redo, whatever a site puts there. Each is + * followed by a separator, and a slot whose content renders nothing takes + * its separator with it. + */ + RightSide?: ReactNode[]; } function Header({ @@ -40,8 +45,7 @@ function Header({ onClickReturnButton, isSticky = false, WhiteLabeling, - PatientInfo, - UndoRedo, + RightSide = [], Secondary, ...props }: HeaderProps): ReactNode { @@ -81,10 +85,17 @@ function Header({
{children}
- {UndoRedo} -
- {PatientInfo} -
+ {RightSide.map((item, index) => ( + // The separator is an `::after` so that `empty:hidden` can drop + // the whole slot — separator included — when the item rendered + // nothing (e.g. patient info with `showPatientInfo: 'disabled'`). +
+ {item} +
+ ))}
diff --git a/platform/ui-next/src/components/HoverCard/HoverCard.tsx b/platform/ui-next/src/components/HoverCard/HoverCard.tsx index 1898d6c4e19..a09c397c266 100644 --- a/platform/ui-next/src/components/HoverCard/HoverCard.tsx +++ b/platform/ui-next/src/components/HoverCard/HoverCard.tsx @@ -7,10 +7,13 @@ const HoverCard = HoverCardPrimitive.Root; const HoverCardTrigger = HoverCardPrimitive.Trigger; -const HoverCardContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( +const HoverCardContent = ({ + className, + align = 'center', + sideOffset = 4, + ref, + ...props +}: React.ComponentProps) => ( -)); +); HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; export { HoverCard, HoverCardTrigger, HoverCardContent }; diff --git a/platform/ui-next/src/components/Input/Input.tsx b/platform/ui-next/src/components/Input/Input.tsx index 9b4da766f19..fee7b32f863 100644 --- a/platform/ui-next/src/components/Input/Input.tsx +++ b/platform/ui-next/src/components/Input/Input.tsx @@ -2,23 +2,23 @@ import * as React from 'react'; import { cn } from '../../lib/utils'; -export interface InputProps extends React.InputHTMLAttributes {} +export interface InputProps extends React.InputHTMLAttributes { + ref?: React.Ref; +} -const Input = React.forwardRef( - ({ className, type, ...props }, ref) => { - return ( - - ); - } -); +const Input = ({ className, type, ref, ...props }: InputProps) => { + return ( + + ); +}; Input.displayName = 'Input'; diff --git a/platform/ui-next/src/components/InputFilter/InputFilter.tsx b/platform/ui-next/src/components/InputFilter/InputFilter.tsx index f5350555095..6221d75a939 100644 --- a/platform/ui-next/src/components/InputFilter/InputFilter.tsx +++ b/platform/ui-next/src/components/InputFilter/InputFilter.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react'; +import { createContext, useContext, useState, useEffect, useMemo } from 'react'; import debounce from 'lodash.debounce'; import { cn } from '../../lib/utils'; import { Input } from '../Input'; @@ -11,7 +11,7 @@ type InputFilterContextType = { setValue: (value: string) => void; handleChange: (event: React.ChangeEvent) => void; clearValue: () => void; - inputRef: React.RefObject; + inputRef: React.RefObject; }; const InputFilterContext = createContext(undefined); @@ -57,30 +57,24 @@ function Root({ return () => debouncedOnChange?.cancel(); }, [debouncedOnChange]); - const setValue = useCallback( - (newValue: string) => { - if (!isControlled) { - setUncontrolledValue(newValue); - } - - if (debouncedOnChange) { - debouncedOnChange(newValue); - } - }, - [isControlled, debouncedOnChange] - ); + const setValue = (newValue: string) => { + if (!isControlled) { + setUncontrolledValue(newValue); + } - const handleChange = useCallback( - (event: React.ChangeEvent) => { - setValue(event.target.value); - }, - [setValue] - ); + if (debouncedOnChange) { + debouncedOnChange(newValue); + } + }; + + const handleChange = (event: React.ChangeEvent) => { + setValue(event.target.value); + }; - const clearValue = useCallback(() => { + const clearValue = () => { setValue(''); inputRef.current?.focus(); - }, [setValue]); + }; return ( diff --git a/platform/ui-next/src/components/InputMultiSelect/InputMultiSelect.tsx b/platform/ui-next/src/components/InputMultiSelect/InputMultiSelect.tsx index e7daff1e870..eb5eb2e5afb 100644 --- a/platform/ui-next/src/components/InputMultiSelect/InputMultiSelect.tsx +++ b/platform/ui-next/src/components/InputMultiSelect/InputMultiSelect.tsx @@ -72,11 +72,13 @@ function InputMultiSelectRoot({ options, value, onChange, children }: InputMulti const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(''); - const selectedSet = React.useMemo(() => new Set(value), [value]); - const normalized = React.useMemo(() => options.map(normalizeOption), [options]); - const filtered = React.useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return normalized; + const selectedSet = new Set(value); + const normalized = options.map(normalizeOption); + const q = query.trim().toLowerCase(); + let filtered: NormalizedOption[]; + if (!q) { + filtered = normalized; + } else { // Prefix matches rank above substring matches (typing "PT" lists PT before CTPT). const prefixMatches: NormalizedOption[] = []; const substringMatches: NormalizedOption[] = []; @@ -89,8 +91,8 @@ function InputMultiSelectRoot({ options, value, onChange, children }: InputMulti substringMatches.push(opt); } } - return [...prefixMatches, ...substringMatches]; - }, [normalized, query]); + filtered = [...prefixMatches, ...substringMatches]; + } React.useEffect(() => { function handleDoc(event: MouseEvent) { @@ -107,7 +109,7 @@ function InputMultiSelectRoot({ options, value, onChange, children }: InputMulti const [coords, setCoords] = React.useState(null); // Prefer placing the overlay below the field; flip above if there's more space upward. - const measure = React.useCallback(() => { + const measure = () => { const anchor = fieldRef.current; if (!anchor) return; const rect = anchor.getBoundingClientRect(); @@ -119,7 +121,7 @@ function InputMultiSelectRoot({ options, value, onChange, children }: InputMulti const maxHeight = Math.max(120, Math.min(300, available - gutter)); const top = preferBelow ? rect.bottom : Math.max(0, rect.top - maxHeight); setCoords({ left: rect.left, top, width: rect.width, maxHeight }); - }, []); + }; React.useLayoutEffect(() => { if (open) measure(); @@ -137,21 +139,15 @@ function InputMultiSelectRoot({ options, value, onChange, children }: InputMulti }; }, [open, measure]); - const remove = React.useCallback( - (val: string) => onChange(value.filter(v => v !== val)), - [value, onChange] - ); + const remove = (val: string) => onChange(value.filter(v => v !== val)); - const toggle = React.useCallback( - (val: string) => { - const next = selectedSet.has(val) ? value.filter(v => v !== val) : [...value, val]; - onChange(next); - setQuery(''); - }, - [selectedSet, value, onChange] - ); + const toggle = (val: string) => { + const next = selectedSet.has(val) ? value.filter(v => v !== val) : [...value, val]; + onChange(next); + setQuery(''); + }; - const clear = React.useCallback(() => onChange([]), [onChange]); + const clear = () => onChange([]); const ctx: IMSContext = { value, diff --git a/platform/ui-next/src/components/InputNumber/InputNumber.tsx b/platform/ui-next/src/components/InputNumber/InputNumber.tsx index 5775f96b4bd..81d34f50a9e 100644 --- a/platform/ui-next/src/components/InputNumber/InputNumber.tsx +++ b/platform/ui-next/src/components/InputNumber/InputNumber.tsx @@ -51,131 +51,134 @@ export interface InputNumberProps { } // The single top-level InputNumber component - much simpler now -const InputNumber = React.forwardRef( - ({ value, onChange, className, children, ...props }, ref) => { - const [inputValue, setInputValue] = React.useState(value); - - // Update internal state when prop changes - React.useEffect(() => { - setInputValue(value); - }, [value]); - - // Context value - only core state - const contextValue = React.useMemo( - () => ({ - value: inputValue, - setValue: setInputValue, - onChange, - }), - [inputValue, onChange] - ); - - return ( - -
- {children} -
-
- ); - } -); +const InputNumber = ({ + value, + onChange, + className, + children, + ref, + ...props +}: InputNumberProps & { ref?: React.Ref }) => { + const [inputValue, setInputValue] = React.useState(value); + + // Update internal state when prop changes + React.useEffect(() => { + setInputValue(value); + }, [value]); + + // Context value - only core state + const contextValue = React.useMemo( + () => ({ + value: inputValue, + setValue: setInputValue, + onChange, + }), + [inputValue, onChange] + ); + + return ( + +
+ {children} +
+
+ ); +}; InputNumber.displayName = 'InputNumber'; // Input component with its own constraints export interface InputNumberInputProps - extends Omit, 'onChange'> { + extends Omit, 'onChange'> { min?: number; max?: number; step?: number; } -const InputNumberInput = React.forwardRef( - ({ className, min, max, step, ...props }, ref) => { - // Get context which may include constraints from parent - const { value, setValue, onChange, constraints = {} } = useInputNumber(); - - // Use provided props or fall back to parent constraints or defaults - const effectiveMin = min ?? constraints.min ?? 0; - const effectiveMax = max ?? constraints.max ?? 100; - const effectiveStep = step ?? constraints.step ?? 1; - const decimalPlaces = getDecimalPlaces(effectiveStep); - - // Format displayed value with proper decimal places - const displayValue = React.useMemo(() => { - if (typeof value === 'string') { - return value; +const InputNumberInput = ({ className, min, max, step, ref, ...props }: InputNumberInputProps) => { + // Get context which may include constraints from parent + const { value, setValue, onChange, constraints = {} } = useInputNumber(); + + // Use provided props or fall back to parent constraints or defaults + const effectiveMin = min ?? constraints.min ?? 0; + const effectiveMax = max ?? constraints.max ?? 100; + const effectiveStep = step ?? constraints.step ?? 1; + const decimalPlaces = getDecimalPlaces(effectiveStep); + + // Format displayed value with proper decimal places + const displayValue = React.useMemo(() => { + if (typeof value === 'string') { + return value; + } + return decimalPlaces > 0 ? value.toFixed(decimalPlaces) : value.toString(); + }, [value, decimalPlaces]); + + // Handle input change with constraint awareness + const handleInputChange = React.useCallback( + (e: React.ChangeEvent) => { + const val = e.target.value; + + // Allow empty string, minus sign, or decimal point for flexibility + if (val === '' || val === '-' || val === '.') { + setValue(val); + return; } - return decimalPlaces > 0 ? value.toFixed(decimalPlaces) : value.toString(); - }, [value, decimalPlaces]); - - // Handle input change with constraint awareness - const handleInputChange = React.useCallback( - (e: React.ChangeEvent) => { - const val = e.target.value; - - // Allow empty string, minus sign, or decimal point for flexibility - if (val === '' || val === '-' || val === '.') { - setValue(val); - return; - } - const numValue = Number(val); - if (!isNaN(numValue)) { - setValue(numValue); - // Only call onChange if value is within boundaries - if (numValue >= effectiveMin && numValue <= effectiveMax) { - onChange(numValue); - } - } - }, - [effectiveMin, effectiveMax, onChange, setValue] - ); - - // Handle blur to format and validate - const handleBlur = React.useCallback(() => { - if (typeof value === 'string') { - // Handle empty or partial inputs - if (value === '' || value === '-' || value === '.') { - setValue(effectiveMin); - onChange(effectiveMin); - return; + const numValue = Number(val); + if (!isNaN(numValue)) { + setValue(numValue); + // Only call onChange if value is within boundaries + if (numValue >= effectiveMin && numValue <= effectiveMax) { + onChange(numValue); } + } + }, + [effectiveMin, effectiveMax, onChange, setValue] + ); - const numValue = parseFloat(value); - if (isNaN(numValue)) { - setValue(effectiveMin); - onChange(effectiveMin); - return; - } + // Handle blur to format and validate + const handleBlur = React.useCallback(() => { + if (typeof value === 'string') { + // Handle empty or partial inputs + if (value === '' || value === '-' || value === '.') { + setValue(effectiveMin); + onChange(effectiveMin); + return; + } - // Constrain value to min/max - const boundedValue = Math.max(effectiveMin, Math.min(effectiveMax, numValue)); - setValue(boundedValue); - onChange(boundedValue); + const numValue = parseFloat(value); + if (isNaN(numValue)) { + setValue(effectiveMin); + onChange(effectiveMin); + return; } - }, [value, effectiveMin, effectiveMax, onChange, setValue]); - return ( - - ); - } -); + // Constrain value to min/max + const boundedValue = Math.max(effectiveMin, Math.min(effectiveMax, numValue)); + setValue(boundedValue); + onChange(boundedValue); + } + }, [value, effectiveMin, effectiveMax, onChange, setValue]); + + return ( + + ); +}; InputNumberInput.displayName = 'InputNumber.Input'; @@ -184,26 +187,30 @@ export interface InputNumberLabelProps extends React.HTMLAttributes( - ({ className, position = 'left', children, ...props }, ref) => { - const positionClasses = { - left: 'mr-2', - right: 'ml-2', - top: 'mb-1', - bottom: 'mt-1', - }; - - return ( - - ); - } -); +const InputNumberLabel = ({ + className, + position = 'left', + children, + ref, + ...props +}: InputNumberLabelProps & { ref?: React.Ref }) => { + const positionClasses = { + left: 'mr-2', + right: 'ml-2', + top: 'mb-1', + bottom: 'mt-1', + }; + + return ( + + ); +}; InputNumberLabel.displayName = 'InputNumber.Label'; @@ -215,18 +222,27 @@ export interface InputNumberHorizontalControlsProps extends React.HTMLAttributes disabled?: boolean; } -const InputNumberHorizontalControls = React.forwardRef< - HTMLDivElement, - InputNumberHorizontalControlsProps ->(({ className, children, min = 0, max = 100, step = 1, disabled = false, ...props }, ref) => { +const InputNumberHorizontalControls = ({ + className, + children, + min = 0, + max = 100, + step = 1, + disabled = false, + ref, + ...props +}: InputNumberHorizontalControlsProps & { ref?: React.Ref }) => { // Get existing context and enhance it with constraints const context = useInputNumber(); const { value, onChange } = context; - // Set constraints in context for child components to use - React.useEffect(() => { - context.constraints = { min, max, step, disabled }; - }, [context, min, max, step, disabled]); + // Provide the constraints to children rather than writing them onto the context + // object. The root re-creates its context value whenever the input value + // changes, which silently discarded a mutated `constraints` on every keystroke + // until the effect re-ran - and children that had already rendered never saw it + // at all. Extending the context here means children have the constraints on + // their first render. + const contextWithConstraints = { ...context, constraints: { min, max, step, disabled } }; // Increment function with local constraints const increment = React.useCallback(() => { @@ -251,35 +267,37 @@ const InputNumberHorizontalControls = React.forwardRef< }, [value, min, max, step, onChange, disabled]); return ( -
- + - {children} + {children} - -
+ +
+ ); -}); +}; InputNumberHorizontalControls.displayName = 'InputNumber.HorizontalControls'; @@ -291,18 +309,27 @@ export interface InputNumberVerticalControlsProps extends React.HTMLAttributes(({ className, children, min = 0, max = 100, step = 1, disabled = false, ...props }, ref) => { +const InputNumberVerticalControls = ({ + className, + children, + min = 0, + max = 100, + step = 1, + disabled = false, + ref, + ...props +}: InputNumberVerticalControlsProps & { ref?: React.Ref }) => { // Get existing context and enhance it with constraints const context = useInputNumber(); const { value, onChange } = context; - // Set constraints in context for child components to use - React.useEffect(() => { - context.constraints = { min, max, step, disabled }; - }, [context, min, max, step, disabled]); + // Provide the constraints to children rather than writing them onto the context + // object. The root re-creates its context value whenever the input value + // changes, which silently discarded a mutated `constraints` on every keystroke + // until the effect re-ran - and children that had already rendered never saw it + // at all. Extending the context here means children have the constraints on + // their first render. + const contextWithConstraints = { ...context, constraints: { min, max, step, disabled } }; // Increment function with local constraints const increment = React.useCallback(() => { @@ -327,35 +354,37 @@ const InputNumberVerticalControls = React.forwardRef< }, [value, min, max, step, onChange, disabled]); return ( -
- {children} -
- - + +
+ {children} +
+ + +
-
+ ); -}); +}; InputNumberVerticalControls.displayName = 'InputNumber.VerticalControls'; @@ -371,25 +400,30 @@ export interface InputNumberContainerProps extends React.HTMLAttributes( - ({ className, size = 'md', sizeClassName, children, ...props }, ref) => { - const sizeToUse = sizeClassName || sizesClasses[size]; +const InputNumberContainer = ({ + className, + size = 'md', + sizeClassName, + children, + ref, + ...props +}: InputNumberContainerProps & { ref?: React.Ref }) => { + const sizeToUse = sizeClassName || sizesClasses[size]; - return ( -
- {children} -
- ); - } -); + return ( +
+ {children} +
+ ); +}; InputNumberContainer.displayName = 'InputNumber.Container'; diff --git a/platform/ui-next/src/components/InvestigationalUseDialog/InvestigationalUseDialog.tsx b/platform/ui-next/src/components/InvestigationalUseDialog/InvestigationalUseDialog.tsx index 4680f16dbaa..5fcd9782399 100644 --- a/platform/ui-next/src/components/InvestigationalUseDialog/InvestigationalUseDialog.tsx +++ b/platform/ui-next/src/components/InvestigationalUseDialog/InvestigationalUseDialog.tsx @@ -1,5 +1,4 @@ import React, { useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; import { Icons } from '@ohif/ui-next'; import { Button } from '../Button'; import { useTranslation } from 'react-i18next'; @@ -94,11 +93,6 @@ const InvestigationalUseDialog = ({ ); }; -InvestigationalUseDialog.propTypes = { - dialogConfiguration: PropTypes.shape({ - option: PropTypes.oneOf(Object.values(showDialogOption)).isRequired, - days: PropTypes.number, - }), -}; + export default InvestigationalUseDialog; diff --git a/platform/ui-next/src/components/Label/Label.tsx b/platform/ui-next/src/components/Label/Label.tsx index 60c0702b8a5..ceca147644e 100644 --- a/platform/ui-next/src/components/Label/Label.tsx +++ b/platform/ui-next/src/components/Label/Label.tsx @@ -8,16 +8,17 @@ const labelVariants = cva( 'text-base text-foreground font-normal leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' ); -const Label = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & VariantProps ->(({ className, ...props }, ref) => ( +const Label = ({ + className, + ref, + ...props +}: React.ComponentProps & VariantProps) => ( -)); +); Label.displayName = LabelPrimitive.Root.displayName; export { Label }; diff --git a/platform/ui-next/src/components/LayoutSelector/LayoutSelector.tsx b/platform/ui-next/src/components/LayoutSelector/LayoutSelector.tsx index 1a95686badc..b4921689978 100644 --- a/platform/ui-next/src/components/LayoutSelector/LayoutSelector.tsx +++ b/platform/ui-next/src/components/LayoutSelector/LayoutSelector.tsx @@ -1,10 +1,9 @@ -import React, { createContext, useContext, useState, useCallback } from 'react'; +import React, { createContext, useContext, useState } from 'react'; import { Popover, PopoverTrigger, PopoverContent } from '../Popover/Popover'; import { Tooltip, TooltipTrigger, TooltipContent } from '../Tooltip'; import { Button } from '../Button'; import { cn } from '../../lib/utils'; import { Icons } from '../Icons'; -import * as PropTypes from 'prop-types'; // Types type LayoutCommandOptions = { @@ -65,27 +64,21 @@ const LayoutSelector = ({ const isOpen = isControlled ? open : isOpenInternal; const setIsOpen = isControlled ? onOpenChange! : setIsOpenInternal; - const handleSelection = useCallback( - (commandOptions: LayoutCommandOptions) => { - onSelection(commandOptions); - if (onSelectionChange) { - onSelectionChange(commandOptions, false); - } - setIsOpen(false); - }, - [onSelection, onSelectionChange, setIsOpen] - ); + const handleSelection = (commandOptions: LayoutCommandOptions) => { + onSelection(commandOptions); + if (onSelectionChange) { + onSelectionChange(commandOptions, false); + } + setIsOpen(false); + }; - const handlePresetSelection = useCallback( - (commandOptions: LayoutCommandOptions) => { - onSelectionPreset(commandOptions); - if (onSelectionChange) { - onSelectionChange(commandOptions, true); - } - setIsOpen(false); - }, - [onSelectionPreset, onSelectionChange, setIsOpen] - ); + const handlePresetSelection = (commandOptions: LayoutCommandOptions) => { + onSelectionPreset(commandOptions); + if (onSelectionChange) { + onSelectionChange(commandOptions, true); + } + setIsOpen(false); + }; return ( @@ -105,17 +98,4 @@ const LineChart = ({ ); }; -LineChart.propTypes = { - title: PropTypes.string, - width: PropTypes.number, - height: PropTypes.number, - showAxisLabels: PropTypes.bool, - showAxisGrid: PropTypes.bool, - showLegend: PropTypes.bool, - legendWidth: PropTypes.number, - transparentChartBackground: PropTypes.bool, - containerClassName: PropTypes.string, - chartContainerClassName: PropTypes.string, -}; - export default LineChart; diff --git a/platform/ui-next/src/components/LoadingIndicatorTotalPercent/LoadingIndicatorTotalPercent.tsx b/platform/ui-next/src/components/LoadingIndicatorTotalPercent/LoadingIndicatorTotalPercent.tsx index b228a316467..72bdab75c2a 100644 --- a/platform/ui-next/src/components/LoadingIndicatorTotalPercent/LoadingIndicatorTotalPercent.tsx +++ b/platform/ui-next/src/components/LoadingIndicatorTotalPercent/LoadingIndicatorTotalPercent.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import LoadingIndicatorProgress from '../LoadingIndicatorProgress'; diff --git a/platform/ui-next/src/components/MeasurementTable/MeasurementTable.tsx b/platform/ui-next/src/components/MeasurementTable/MeasurementTable.tsx index 527973f71f8..64585e3285b 100644 --- a/platform/ui-next/src/components/MeasurementTable/MeasurementTable.tsx +++ b/platform/ui-next/src/components/MeasurementTable/MeasurementTable.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Icons, PanelSection, Tooltip, TooltipContent, TooltipTrigger } from '../../index'; import DataRow from '../DataRow/DataRow'; -import { createContext } from '../../lib/createContext'; interface MeasurementTableContext { data?: any[]; @@ -11,8 +10,13 @@ interface MeasurementTableContext { isExpanded: boolean; } -const [MeasurementTableProvider, useMeasurementTableContext] = - createContext('MeasurementTable', { data: [], isExpanded: true }); +// The default stands in when a part is rendered outside a MeasurementTable: it +// shows the empty state rather than throwing, which is what the previous +// createContext helper did too - it preferred a supplied default over its error. +const MeasurementTableContextValue = React.createContext({ + data: [], + isExpanded: true, +}); interface MeasurementDataProps extends MeasurementTableContext { title: string; @@ -31,11 +35,8 @@ const MeasurementTable = ({ const amount = data.length; return ( - {children} - + ); }; @@ -55,12 +56,16 @@ const Header = ({ children }: { children: React.ReactNode }) => { }; const Body = () => { - const { data } = useMeasurementTableContext('MeasurementTable.Body'); + // Hooks stay above the early return below: called inside it, useTranslation + // would run only when the list is empty, so the hook count would change as + // measurements come and go. + const { t } = useTranslation('MeasurementTable'); + const { data } = React.useContext(MeasurementTableContextValue); if (!data || data.length === 0) { return (
- {useTranslation('MeasurementTable').t('No tracked measurements')} + {t('No tracked measurements')}
); } @@ -102,8 +107,7 @@ interface RowProps { } const Row = ({ item, index }: RowProps) => { - const { onAction, isExpanded, disableEditing } = - useMeasurementTableContext('MeasurementTable.Row'); + const { onAction, isExpanded, disableEditing } = React.useContext(MeasurementTableContextValue); const { uid } = item; return ( diff --git a/platform/ui-next/src/components/NavBar/NavBar.tsx b/platform/ui-next/src/components/NavBar/NavBar.tsx index 8d12b496d18..25972be533e 100644 --- a/platform/ui-next/src/components/NavBar/NavBar.tsx +++ b/platform/ui-next/src/components/NavBar/NavBar.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; import classnames from 'classnames'; const stickyClasses = 'sticky top-0'; @@ -28,10 +27,6 @@ const NavBar = ({ ); }; -NavBar.propTypes = { - className: PropTypes.string, - children: PropTypes.node, - isSticky: PropTypes.bool, -}; + export default NavBar; diff --git a/platform/ui-next/src/components/Numeric/Numeric.test.ts b/platform/ui-next/src/components/Numeric/Numeric.test.ts new file mode 100644 index 00000000000..25a8fb33664 --- /dev/null +++ b/platform/ui-next/src/components/Numeric/Numeric.test.ts @@ -0,0 +1,124 @@ +import { createElement } from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import Numeric from './Numeric'; + +beforeAll(() => { + global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } as typeof global.ResizeObserver; +}); + +const numericInputs = [ + { + name: 'NumberInput', + mode: 'number', + child: () => createElement(Numeric.NumberInput), + }, + { + name: 'SingleRange', + mode: 'singleRange', + child: () => createElement(Numeric.SingleRange, { showNumberInput: true }), + }, + { + name: 'NumberStepper', + mode: 'stepper', + child: () => createElement(Numeric.NumberStepper), + }, +] as const; + +const renderNumericInput = ({ mode, child }) => { + const onChange = jest.fn(); + const result = render( + createElement( + Numeric.Container, + { + mode, + min: 0, + max: 100, + defaultValue: 50, + onChange, + }, + child() + ) + ); + const input = result.container.querySelector('input') as HTMLInputElement; + + return { ...result, input, onChange }; +}; + +describe.each(numericInputs)('Numeric.$name', numericInput => { + it('keeps draft text and commits only on Enter', () => { + const { input, onChange } = renderNumericInput(numericInput); + + for (const value of ['', '-', '.', '1', '15', '150']) { + fireEvent.change(input, { target: { value } }); + expect(input.value).toBe(value); + expect(onChange).not.toHaveBeenCalled(); + } + + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(input.value).toBe('100'); + expect(onChange).toHaveBeenLastCalledWith(100); + }); + + it('restores the last valid value when an invalid draft loses focus', () => { + const { input, onChange } = renderNumericInput(numericInput); + + fireEvent.change(input, { target: { value: '-' } }); + fireEvent.blur(input); + + expect(input.value).toBe('50'); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('does not commit Enter while an IME composition is active', () => { + const { input, onChange } = renderNumericInput(numericInput); + + fireEvent.change(input, { target: { value: '30' } }); + fireEvent.keyDown(input, { key: 'Enter', isComposing: true }); + + expect(input.value).toBe('30'); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onChange).toHaveBeenLastCalledWith(30); + }); + + it('updates the displayed input when the controlled value changes', () => { + const onChange = jest.fn(); + const renderControlledInput = value => + createElement( + Numeric.Container, + { + mode: numericInput.mode, + min: 0, + max: 100, + value, + onChange, + }, + numericInput.child() + ); + const result = render(renderControlledInput(50)); + const input = result.container.querySelector('input') as HTMLInputElement; + + fireEvent.change(input, { target: { value: 'draft' } }); + result.rerender(renderControlledInput(75)); + + expect(input.value).toBe('75'); + }); +}); + +it('commits a valid Numeric.NumberInput draft on blur', () => { + const { input, onChange } = renderNumericInput(numericInputs[0]); + + fireEvent.change(input, { target: { value: '30' } }); + fireEvent.blur(input); + + expect(input.value).toBe('30'); + expect(onChange).toHaveBeenLastCalledWith(30); +}); diff --git a/platform/ui-next/src/components/Numeric/Numeric.tsx b/platform/ui-next/src/components/Numeric/Numeric.tsx index 40badb61f97..66b3fe93146 100644 --- a/platform/ui-next/src/components/Numeric/Numeric.tsx +++ b/platform/ui-next/src/components/Numeric/Numeric.tsx @@ -1,5 +1,5 @@ // Numeric.tsx -import React, { createContext, useContext, useCallback, PropsWithChildren } from 'react'; +import React, { createContext, useContext, useCallback, useState, PropsWithChildren } from 'react'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; import { cn } from '../../lib/utils'; import { Input } from '../Input/Input'; @@ -28,6 +28,7 @@ interface NumericMetaContextValue { setDoubleValue: (vals: [number, number]) => void; min: number; max: number; + allowTypedExpansion?: boolean | [number, number]; step: number; } @@ -45,6 +46,7 @@ interface NumericMetaContainerProps { onChange?: (val: number | [number, number]) => void; min?: number; max?: number; + allowTypedExpansion?: boolean | [number, number]; step?: number; className?: string; } @@ -58,6 +60,7 @@ function NumericMetaContainer({ onChange, min = 0, max = 100, + allowTypedExpansion, step = 1, className, children, @@ -114,6 +117,7 @@ function NumericMetaContainer({ setDoubleValue: handleDoubleChange, min, max, + allowTypedExpansion, step, }} > @@ -175,6 +179,14 @@ function SingleRange({ showNumberInput, sliderClassName, numberInputClassName }: } const { mode, singleValue, setSingleValue, min, max, step } = ctx; + const [inputValue, setInputValue] = useState(singleValue.toString()); + // Adjust prop-derived state before React commits a render with a stale displayed value. + // See https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes + const [prevValue, setPrevValue] = useState(singleValue); + if (prevValue !== singleValue) { + setPrevValue(singleValue); + setInputValue(singleValue.toString()); + } const handleSliderChange = useCallback( (val: number[]) => { @@ -183,14 +195,42 @@ function SingleRange({ showNumberInput, sliderClassName, numberInputClassName }: [setSingleValue] ); - const handleNumberChange = useCallback( - (evt: React.ChangeEvent) => { - const parsed = parseFloat(evt.target.value); - if (!isNaN(parsed)) { - setSingleValue(Math.max(min, Math.min(parsed, max))); + const commitInputValue = useCallback(() => { + const parsedValue = Number(inputValue); + if (inputValue.trim() === '' || !Number.isFinite(parsedValue)) { + return false; + } + + const boundedValue = Math.max(min, Math.min(parsedValue, max)); + setSingleValue(boundedValue); + setInputValue(boundedValue.toString()); + return true; + }, [inputValue, max, min, setSingleValue]); + + const restorePreviousInputValue = useCallback(() => { + setInputValue(singleValue.toString()); + }, [singleValue]); + + const handleBlur = useCallback(() => { + if (!commitInputValue()) { + restorePreviousInputValue(); + } + }, [commitInputValue, restorePreviousInputValue]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.nativeEvent.isComposing) { + return; + } + + if (event.key === 'Enter') { + event.preventDefault(); + if (!commitInputValue()) { + restorePreviousInputValue(); + } } }, - [min, max, setSingleValue] + [commitInputValue, restorePreviousInputValue] ); if (mode !== 'singleRange') { @@ -209,13 +249,13 @@ function SingleRange({ showNumberInput, sliderClassName, numberInputClassName }: /> {showNumberInput && ( setInputValue(event.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} /> )}
@@ -236,7 +276,7 @@ function DoubleRange({ showNumberInputs, className }: DoubleRangeProps) { throw new Error('DoubleRange must be used inside .'); } - const { mode, doubleValue, setDoubleValue, min, max, step } = ctx; + const { mode, doubleValue, setDoubleValue, min, max, allowTypedExpansion, step } = ctx; const handleSliderChange = useCallback( (values: [number, number]) => { @@ -254,6 +294,7 @@ function DoubleRange({ showNumberInputs, className }: DoubleRangeProps) { .'); } - const { mode, singleValue, setSingleValue, min, max, step } = ctx; + const { mode, singleValue, setSingleValue, min, max } = ctx; + const [inputValue, setInputValue] = useState(singleValue.toString()); + // Adjust prop-derived state before React commits a render with a stale displayed value. + // See https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes + const [prevValue, setPrevValue] = useState(singleValue); + if (prevValue !== singleValue) { + setPrevValue(singleValue); + setInputValue(singleValue.toString()); + } + if (mode !== 'number') { return null; } - const handleChange = (evt: React.ChangeEvent) => { - const val = parseFloat(evt.target.value); - if (!isNaN(val)) { - setSingleValue(Math.max(min, Math.min(val, max))); + const commitInputValue = () => { + const parsedValue = Number(inputValue); + if (inputValue.trim() === '' || !Number.isFinite(parsedValue)) { + return false; } + + const boundedValue = Math.max(min, Math.min(parsedValue, max)); + setSingleValue(boundedValue); + setInputValue(boundedValue.toString()); + return true; }; // Calculate width based on max value's length, with a minimum of 3 characters @@ -294,12 +349,27 @@ function NumberInput({ className }: NumberInputProps) { return ( setInputValue(event.target.value)} + onKeyDown={event => { + if (event.nativeEvent.isComposing) { + return; + } + + if (event.key === 'Enter') { + event.preventDefault(); + if (!commitInputValue()) { + setInputValue(singleValue.toString()); + } + } + }} + onBlur={() => { + if (!commitInputValue()) { + setInputValue(singleValue.toString()); + } + }} className={cn('min-w-[60px]', `w-[${calculatedWidth}]`, className)} /> ); @@ -323,37 +393,53 @@ function NumberStepper({ className, children, direction, inputWidth }: NumberSte } const { mode, singleValue, setSingleValue, min, max, step } = ctx; + const decimalPlaces = getDecimalPlaces(step); + const formatDisplayValue = useCallback( + (value: number) => (decimalPlaces > 0 ? value.toFixed(decimalPlaces) : value.toString()), + [decimalPlaces] + ); + const formattedSingleValue = formatDisplayValue(singleValue); + const [inputValue, setInputValue] = useState(formattedSingleValue); + // Adjust prop-derived state before React commits a render with a stale displayed value. + // See https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes + const [prevFormattedValue, setPrevFormattedValue] = useState(formattedSingleValue); + if (prevFormattedValue !== formattedSingleValue) { + setPrevFormattedValue(formattedSingleValue); + setInputValue(formattedSingleValue); + } + if (mode !== 'stepper') { return null; } - // Calculate decimal places based on step - const decimalPlaces = getDecimalPlaces(step); - - // Format displayed value with proper decimal places - const displayValue = React.useMemo(() => { - return decimalPlaces > 0 ? singleValue.toFixed(decimalPlaces) : singleValue.toString(); - }, [singleValue, decimalPlaces]); + const commitInputValue = () => { + const parsedValue = Number(inputValue); + if (inputValue.trim() === '' || !Number.isFinite(parsedValue)) { + return false; + } - const handleInputChange = (evt: React.ChangeEvent) => { - const val = evt.target.value; + const boundedValue = Math.max(min, Math.min(parsedValue, max)); + setSingleValue(boundedValue); + setInputValue(formatDisplayValue(boundedValue)); + return true; + }; - // Allow empty string, minus sign, or decimal point for flexibility - if (val === '' || val === '-' || val === '.') { - return; + const handleBlur = () => { + if (!commitInputValue()) { + setInputValue(formatDisplayValue(singleValue)); } + }; - const numValue = Number(val); - if (!isNaN(numValue)) { - setSingleValue(Math.max(min, Math.min(numValue, max))); + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.nativeEvent.isComposing) { + return; } - }; - const handleBlur = () => { - // Ensure value is within constraints when input loses focus - const boundedValue = Math.max(min, Math.min(singleValue, max)); - if (boundedValue !== singleValue) { - setSingleValue(boundedValue); + if (event.key === 'Enter') { + event.preventDefault(); + if (!commitInputValue()) { + setInputValue(formatDisplayValue(singleValue)); + } } }; @@ -377,8 +463,10 @@ function NumberStepper({ className, children, direction, inputWidth }: NumberSte /> setInputValue(event.target.value)} + onKeyDown={handleKeyDown} onBlur={handleBlur} className={cn( 'h-6 appearance-none border-none p-0 text-center shadow-none focus:border-none focus:outline-none', @@ -405,8 +493,10 @@ function NumberStepper({ className, children, direction, inputWidth }: NumberSte > setInputValue(event.target.value)} + onKeyDown={handleKeyDown} onBlur={handleBlur} className={cn( 'h-6 appearance-none border-none p-0 text-center shadow-none focus:border-none focus:outline-none', diff --git a/platform/ui-next/src/components/OHIFDialogs/InputDialog.tsx b/platform/ui-next/src/components/OHIFDialogs/InputDialog.tsx index dd400c7fe39..967986f7498 100644 --- a/platform/ui-next/src/components/OHIFDialogs/InputDialog.tsx +++ b/platform/ui-next/src/components/OHIFDialogs/InputDialog.tsx @@ -25,32 +25,38 @@ export type InputDialogRootProps = { children: React.ReactNode; }; -const InputDialogRoot = React.forwardRef( - ({ value, defaultValue = '', onChange, className, submitOnEnter, children }, ref) => { - const [internalValue, setInternalValue] = useControllableState({ - prop: value, - defaultProp: defaultValue, - onChange, - }); - - return ( - }) => { + const [internalValue, setInternalValue] = useControllableState({ + prop: value, + defaultProp: defaultValue, + onChange, + }); + + return ( + +
-
- {children} -
- - ); - } -); + {children} +
+
+ ); +}; InputDialogRoot.displayName = 'InputDialog'; @@ -60,19 +66,22 @@ export interface InputDialogFieldProps extends React.HTMLAttributes( - ({ className, children, ...props }, ref) => { - return ( -
- {children} -
- ); - } -); +const Field = ({ + className, + children, + ref, + ...props +}: InputDialogFieldProps & { ref?: React.Ref }) => { + return ( +
+ {children} +
+ ); +}; Field.displayName = 'InputDialog.Field'; @@ -88,53 +97,57 @@ export interface InputDialogInputProps placeholder?: string; } -const InputDialogInput = React.forwardRef( - ({ id = 'dialog-input', className, onSave, ...props }, ref) => { - const context = useContext(InputDialogContext); - if (!context) { - throw new Error('InputDialog.Input must be used within an InputDialog'); - } +const InputDialogInput = ({ + id = 'dialog-input', + className, + onSave, + ref, + ...props +}: InputDialogInputProps & { ref?: React.Ref }) => { + const context = useContext(InputDialogContext); + if (!context) { + throw new Error('InputDialog.Input must be used within an InputDialog'); + } - const { value, setValue } = context; - const inputRef = useRef(null); + const { value, setValue } = context; + const inputRef = useRef(null); - // Combine the forwarded ref with our local ref - React.useImperativeHandle(ref, () => inputRef.current); + // Combine the forwarded ref with our local ref + React.useImperativeHandle(ref, () => inputRef.current); - // Focus the input when it mounts - useEffect(() => { - if (inputRef.current) { - inputRef.current.focus(); - } - }, []); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (context.submitOnEnter && e.key === 'Enter') { - e.preventDefault(); - const saveButton = document.querySelector( - '[data-cy="input-dialog-save-button"]' - ) as HTMLButtonElement; - if (saveButton) { - saveButton.click(); - } + // Focus the input when it mounts + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (context.submitOnEnter && e.key === 'Enter') { + e.preventDefault(); + const saveButton = document.querySelector( + '[data-cy="input-dialog-save-button"]' + ) as HTMLButtonElement; + if (saveButton) { + saveButton.click(); } - }; - - return ( -
- setValue(e.target.value)} - onKeyDown={handleKeyDown} - {...props} - /> -
- ); - } -); + } + }; + + return ( +
+ setValue(e.target.value)} + onKeyDown={handleKeyDown} + {...props} + /> +
+ ); +}; InputDialogInput.displayName = 'InputDialog.Input'; @@ -146,20 +159,24 @@ export interface InputDialogLabelProps extends React.LabelHTMLAttributes( - ({ className, htmlFor = 'dialog-input', children, ...props }, ref) => { - return ( - - ); - } -); +const InputDialogLabel = ({ + className, + htmlFor = 'dialog-input', + children, + ref, + ...props +}: InputDialogLabelProps & { ref?: React.Ref }) => { + return ( + + ); +}; InputDialogLabel.displayName = 'InputDialog.Label'; @@ -169,20 +186,23 @@ export interface InputDialogActionsProps extends React.HTMLAttributes( - ({ className, children, ...props }, ref) => { - return ( -
- - {children} - -
- ); - } -); +const Actions = ({ + className, + children, + ref, + ...props +}: InputDialogActionsProps & { ref?: React.Ref }) => { + return ( +
+ + {children} + +
+ ); +}; Actions.displayName = 'InputDialog.Actions'; @@ -194,59 +214,66 @@ export interface InputDialogActionButtonProps { children: React.ReactNode; } -const ActionsSecondary = React.forwardRef( - ({ className, onClick, children, ...props }, ref) => { - const context = useContext(InputDialogContext); - if (!context) { - throw new Error('InputDialog.ActionsSecondary must be used within an InputDialog'); - } +const ActionsSecondary = ({ + className, + onClick, + children, + ref, + ...props +}: InputDialogActionButtonProps & { ref?: React.Ref }) => { + const context = useContext(InputDialogContext); + if (!context) { + throw new Error('InputDialog.ActionsSecondary must be used within an InputDialog'); + } - const { value } = context; + const { value } = context; - return ( -
+ onClick(value)} + className={cn(className)} > - onClick(value)} - className={cn(className)} - > - {children} - -
- ); - } -); + {children} + +
+ ); +}; ActionsSecondary.displayName = 'InputDialog.ActionsSecondary'; -const ActionsPrimary = React.forwardRef( - ({ className, onClick, children, ...props }, ref) => { - const context = useContext(InputDialogContext); - if (!context) { - throw new Error('InputDialog.ActionsPrimary must be used within an InputDialog'); - } +const ActionsPrimary = ({ + className, + onClick, + children, + ref, + ...props +}: InputDialogActionButtonProps & { ref?: React.Ref }) => { + const context = useContext(InputDialogContext); + if (!context) { + throw new Error('InputDialog.ActionsPrimary must be used within an InputDialog'); + } - const { value } = context; - return ( -
+ onClick(value)} + className={cn(className)} > - onClick(value)} - className={cn(className)} - > - {children} - -
- ); - } -); + {children} + + + ); +}; ActionsPrimary.displayName = 'InputDialog.ActionsPrimary'; diff --git a/platform/ui-next/src/components/OHIFModals/UserPreferencesModal.tsx b/platform/ui-next/src/components/OHIFModals/UserPreferencesModal.tsx index 2ef9a17d765..6de2af3cffa 100644 --- a/platform/ui-next/src/components/OHIFModals/UserPreferencesModal.tsx +++ b/platform/ui-next/src/components/OHIFModals/UserPreferencesModal.tsx @@ -179,11 +179,8 @@ interface HotkeyProps { function Hotkey({ label, placeholder, className, value, onChange, hotkeys }: HotkeyProps) { const [isRecording, setIsRecording] = React.useState(false); const { t } = useTranslation('UserPreferencesModal'); - const translatedValue = React.useMemo(() => translateHotkeyValue(value, t), [value, t]); - const translatedPlaceholder = React.useMemo( - () => translateHotkeyValue(placeholder, t), - [placeholder, t] - ); + const translatedValue = translateHotkeyValue(value, t); + const translatedPlaceholder = translateHotkeyValue(placeholder, t); const onInputKeyDown = (event: React.KeyboardEvent) => { event.preventDefault(); diff --git a/platform/ui-next/src/components/OHIFToolSettings/RowDoubleRange.tsx b/platform/ui-next/src/components/OHIFToolSettings/RowDoubleRange.tsx index defd7158390..10ef0516393 100644 --- a/platform/ui-next/src/components/OHIFToolSettings/RowDoubleRange.tsx +++ b/platform/ui-next/src/components/OHIFToolSettings/RowDoubleRange.tsx @@ -8,6 +8,7 @@ interface RowDoubleRangeProps { onChange: (values: [number, number]) => void; minValue: number; maxValue: number; + allowTypedExpansion?: boolean | [number, number]; step: number; showLabel?: boolean; label?: string; @@ -20,6 +21,7 @@ const RowDoubleRange: React.FC = ({ onChange, minValue, maxValue, + allowTypedExpansion, step, showLabel = false, label = '', @@ -52,6 +54,7 @@ const RowDoubleRange: React.FC = ({ onChange={onChange} min={minValue} max={maxValue} + allowTypedExpansion={allowTypedExpansion} step={step} className={cn('flex flex-col space-y-2', className)} > diff --git a/platform/ui-next/src/components/OHIFToolSettings/RowSegmentedControl.tsx b/platform/ui-next/src/components/OHIFToolSettings/RowSegmentedControl.tsx index d86baaf72c7..cc095320479 100644 --- a/platform/ui-next/src/components/OHIFToolSettings/RowSegmentedControl.tsx +++ b/platform/ui-next/src/components/OHIFToolSettings/RowSegmentedControl.tsx @@ -62,6 +62,7 @@ export const RowSegmentedControl: React.FC = ({ {label} diff --git a/platform/ui-next/src/components/OHIFToolSettings/ToolSettings.tsx b/platform/ui-next/src/components/OHIFToolSettings/ToolSettings.tsx index 2ebfeda518b..894802365c9 100644 --- a/platform/ui-next/src/components/OHIFToolSettings/ToolSettings.tsx +++ b/platform/ui-next/src/components/OHIFToolSettings/ToolSettings.tsx @@ -118,16 +118,21 @@ const renderRadioSetting = option => { function renderDoubleRangeSetting(option) { return ( - + data-cy={option.id} + > + + ); } diff --git a/platform/ui-next/src/components/Popover/Popover.tsx b/platform/ui-next/src/components/Popover/Popover.tsx index 0a1833968bc..ec4b160d77c 100644 --- a/platform/ui-next/src/components/Popover/Popover.tsx +++ b/platform/ui-next/src/components/Popover/Popover.tsx @@ -9,10 +9,13 @@ const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverAnchor = PopoverPrimitive.Anchor; -const PopoverContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( +const PopoverContent = ({ + className, + align = 'center', + sideOffset = 4, + ref, + ...props +}: React.ComponentProps) => ( -)); +); PopoverContent.displayName = PopoverPrimitive.Content.displayName; export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/platform/ui-next/src/components/ProgressDropdown/ProgressDiscreteBar.tsx b/platform/ui-next/src/components/ProgressDropdown/ProgressDiscreteBar.tsx index b4c4c6770e9..7ea4087e16c 100644 --- a/platform/ui-next/src/components/ProgressDropdown/ProgressDiscreteBar.tsx +++ b/platform/ui-next/src/components/ProgressDropdown/ProgressDiscreteBar.tsx @@ -1,9 +1,8 @@ import React, { ReactElement } from 'react'; -import PropTypes from 'prop-types'; import classnames from 'classnames'; -import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types'; +import { ProgressDropdownOption } from './types'; -const ProgressDiscreteBar = ({ options }: { options: ProgressDropdownOption[] }): ReactElement => { +const ProgressDiscreteBar = ({ options }: { options: ProgressDropdownOption[] }): ReactElement => { return (
{options.map((option, i) => ( @@ -20,8 +19,6 @@ const ProgressDiscreteBar = ({ options }: { options: ProgressDropdownOption[] }) ); }; -ProgressDiscreteBar.propTypes = { - options: PropTypes.arrayOf(ProgressDropdownOptionPropType).isRequired, -}; + export default ProgressDiscreteBar; diff --git a/platform/ui-next/src/components/ProgressDropdown/ProgressDropdown.tsx b/platform/ui-next/src/components/ProgressDropdown/ProgressDropdown.tsx index 95a8fae13f0..82e7cdee454 100644 --- a/platform/ui-next/src/components/ProgressDropdown/ProgressDropdown.tsx +++ b/platform/ui-next/src/components/ProgressDropdown/ProgressDropdown.tsx @@ -1,11 +1,10 @@ -import React, { ReactNode, useEffect, useCallback, useState, useMemo, useRef } from 'react'; -import PropTypes from 'prop-types'; +import React, { ReactNode, useEffect, useCallback, useState, useMemo, useRef, type JSX } from 'react'; import classnames from 'classnames'; import ProgressDiscreteBar from './ProgressDiscreteBar'; import ProgressItemDetail from './ProgressItemDetail'; import ProgressItem from './ProgressItem'; import { Icons } from '../Icons'; -import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types'; +import { ProgressDropdownOption } from './types'; const ProgressDropdown = ({ options: optionsProps, @@ -154,12 +153,6 @@ const ProgressDropdown = ({ ); }; -ProgressDropdown.propTypes = { - options: PropTypes.arrayOf(ProgressDropdownOptionPropType).isRequired, - value: PropTypes.string, - onChange: PropTypes.func, - children: PropTypes.node, - dropDownWidth: PropTypes.string, -}; + export default ProgressDropdown; diff --git a/platform/ui-next/src/components/ProgressDropdown/ProgressItem.tsx b/platform/ui-next/src/components/ProgressDropdown/ProgressItem.tsx index 130c16fecf3..479970db8c9 100644 --- a/platform/ui-next/src/components/ProgressDropdown/ProgressItem.tsx +++ b/platform/ui-next/src/components/ProgressDropdown/ProgressItem.tsx @@ -1,7 +1,6 @@ import React, { ReactElement } from 'react'; -import PropTypes from 'prop-types'; import ProgressItemDetail from './ProgressItemDetail'; -import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types'; +import { ProgressDropdownOption } from './types'; const ProgressItem = ({ option, @@ -9,7 +8,7 @@ const ProgressItem = ({ }: { option: ProgressDropdownOption; onSelect: (option: ProgressDropdownOption) => void; -}): ReactElement => { +}): ReactElement => { const { value } = option; return ( @@ -23,9 +22,6 @@ const ProgressItem = ({ ); }; -ProgressItem.propTypes = { - option: ProgressDropdownOptionPropType.isRequired, - onSelect: PropTypes.func, -}; + export default ProgressItem; diff --git a/platform/ui-next/src/components/ProgressDropdown/ProgressItemDetail.tsx b/platform/ui-next/src/components/ProgressDropdown/ProgressItemDetail.tsx index 2c994c0a5d7..8532708b0bb 100644 --- a/platform/ui-next/src/components/ProgressDropdown/ProgressItemDetail.tsx +++ b/platform/ui-next/src/components/ProgressDropdown/ProgressItemDetail.tsx @@ -1,12 +1,12 @@ -import React, { useState, useMemo, ReactElement } from 'react'; +import React, { useState, ReactElement } from 'react'; import { Icons } from '../Icons'; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '../Tooltip'; -import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types'; +import { ProgressDropdownOption } from './types'; const MAX_TOOLTIP_LENGTH = 150; const iconClassNames = 'grow-0 text-highlight h-4 w-4 mt-1 mr-2 mb-0 ml-1'; -const ProgressItemDetail = ({ option }: { option: ProgressDropdownOption }): ReactElement => { +const ProgressItemDetail = ({ option }: { option: ProgressDropdownOption }): ReactElement => { const { label, info, completed } = option; const [truncate, setTruncate] = useState(true); const handleOnHideTooltip = () => setTruncate(true); @@ -18,18 +18,16 @@ const ProgressItemDetail = ({ option }: { option: ProgressDropdownOption }): Rea icon = 'launch-info'; } - const tooltipText = useMemo(() => { - if (!truncate || !info || info.length <= MAX_TOOLTIP_LENGTH) { - return info; - } - - const handleReadMoreClick = e => { - setTruncate(false); - e.stopPropagation(); - e.preventDefault(); - }; + const handleReadMoreClick = e => { + setTruncate(false); + e.stopPropagation(); + e.preventDefault(); + }; - return ( + const tooltipText = + !truncate || !info || info.length <= MAX_TOOLTIP_LENGTH ? ( + info + ) : ( <> {info.slice(0, MAX_TOOLTIP_LENGTH)}