diff --git a/.changeset/brand-menu-nvdocument.md b/.changeset/brand-menu-nvdocument.md new file mode 100644 index 00000000..59eac58d --- /dev/null +++ b/.changeset/brand-menu-nvdocument.md @@ -0,0 +1,28 @@ +--- +'@niivue/react': minor +'@niivue/pwa': minor +'@niivue/tauri': patch +'niivue': patch +'@niivue/streamlit': patch +'@niivue/jupyter': patch +--- + +Fold the Home button into the brand, and turn "Save Scene" into an NVDocument Save/Load split button. + +- The standalone "Home" menu-bar button is gone. On standalone hosts (web, desktop) + the niivue logo + wordmark is now a dropdown: **Reset Viewer** (the old Home + action) and **About** (a dialog with the app's purpose, NiiVue + NeuroDesk + credits, the data-privacy note, and the build version linking to its commit). + The brand stays static on embedded hosts (VS Code; Streamlit, which sets + `menuItems.home: false`), so their behavior is unchanged. +- "Save Scene" is renamed **NVDocument** and gains a chevron: clicking the label + still saves the active scene as a `.nvd` by default, while the dropdown offers + **Save** and a new **Load** (a `.nvd` file picker, complementing drag-and-drop). +- Hosts can pass an optional `appInfo` ({ version, buildDate, repoUrl }) to + `Menu`; the PWA wires in its build-time git metadata. The About dialog degrades + gracefully (omits the version line) when a host supplies none. +- The standalone home screens (PWA and desktop) now share a `HomeSection` + primitive for consistent styling while keeping their host-specific copy (PWA: + install / bookmarklet / update; desktop: native Open File). Both drop their + "Data Privacy" section, and the PWA also drops its version footer, since the + brand menu's About dialog now carries that information. diff --git a/apps/desktop-tauri/src/components/DesktopHomeScreen.tsx b/apps/desktop-tauri/src/components/DesktopHomeScreen.tsx index e88d9fe4..ddad8b1c 100644 --- a/apps/desktop-tauri/src/components/DesktopHomeScreen.tsx +++ b/apps/desktop-tauri/src/components/DesktopHomeScreen.tsx @@ -1,3 +1,4 @@ +import { HomeSection } from '@niivue/react' import { open } from '@tauri-apps/plugin-dialog' import { MEDICAL_IMAGE_EXTENSIONS, @@ -59,13 +60,10 @@ export const DesktopHomeScreen = () => { return ( <> -

- NiiVue Tauri -

-

- Open medical images from your local filesystem. Drag and drop files - onto this window, or use the button below. -

+ + Open medical images from your local filesystem. Drag and drop files onto this window, or use + the button below. + {isTauri() && ( )} -

- Supported Formats -

-

- NIfTI (.nii, .nii.gz), DICOM (.dcm), MHA/MHD, NRRD, FreeSurfer (.mgh, - .mgz), GIfTI (.gii), and many more. -

- -

- Data Privacy -

-

- All processing happens locally on your machine. No data is sent to any - remote server. Your medical images never leave your computer. -

+ + NIfTI (.nii, .nii.gz), DICOM (.dcm), MHA/MHD, NRRD, FreeSurfer (.mgh, .mgz), GIfTI (.gii), + and many more. + ) } diff --git a/apps/desktop-tauri/test/DesktopHomeScreen.test.tsx b/apps/desktop-tauri/test/DesktopHomeScreen.test.tsx index f0c04fde..b0d7017c 100644 --- a/apps/desktop-tauri/test/DesktopHomeScreen.test.tsx +++ b/apps/desktop-tauri/test/DesktopHomeScreen.test.tsx @@ -3,6 +3,7 @@ * open flow that orchestrates registerOpenedPath + initCanvas + per-file * loads. */ +import type { ComponentChildren } from 'preact' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { fireEvent, render } from '@testing-library/preact' @@ -34,6 +35,13 @@ vi.mock('@niivue/react', () => ({ ImageDrop: ({ children }: { children: unknown }) => children, Menu: () => null, listenToMessages: vi.fn(), + // Mirror the real HomeSection's title + body so getByText assertions work. + HomeSection: ({ title, children }: { title: string; children: ComponentChildren }) => ( +
+

{title}

+

{children}

+
+ ), })) import { invoke, isTauri as tauriIsTauri } from '@tauri-apps/api/core' @@ -67,10 +75,12 @@ describe('DesktopHomeScreen', () => { expect(queryByText(/Open File/)).toBeNull() }) - it('describes supported formats and the data-privacy guarantee', () => { - const { getByText } = render() + it('introduces the app and describes supported formats', () => { + const { getByText, queryByText } = render() + expect(getByText(/NiiVue Tauri/)).toBeInTheDocument() expect(getByText(/Supported Formats/)).toBeInTheDocument() - expect(getByText(/Data Privacy/)).toBeInTheDocument() + // The data-privacy note now lives in the brand menu's About dialog. + expect(queryByText(/Data Privacy/)).toBeNull() }) }) diff --git a/apps/pwa/src/Pwa.tsx b/apps/pwa/src/Pwa.tsx index b9353425..9ea69f86 100644 --- a/apps/pwa/src/Pwa.tsx +++ b/apps/pwa/src/Pwa.tsx @@ -10,6 +10,13 @@ import { computed } from '@preact/signals' import { useEffect } from 'preact/hooks' import { HomeScreen } from './components/HomeScreen' +// Build metadata for the brand menu's About dialog (injected by vite.config.ts). +const appInfo = { + version: __GIT_HASH__, + buildDate: __BUILD_DATE__, + repoUrl: __GIT_REPO_URL__, +} + export const Pwa = ({ appProps }: { appProps: AppProps }) => { const nImages = computed(() => appProps.nvArray.value.length) const showHomeScreen = computed(() => nImages.value == 0) @@ -53,7 +60,7 @@ export const Pwa = ({ appProps }: { appProps: AppProps }) => { return ( - + {showHomeScreen.value && } {appProps.hideUI.value > 0 && ( diff --git a/apps/pwa/src/components/HomeScreen.tsx b/apps/pwa/src/components/HomeScreen.tsx index 6098206a..ea2f29f8 100644 --- a/apps/pwa/src/components/HomeScreen.tsx +++ b/apps/pwa/src/components/HomeScreen.tsx @@ -1,17 +1,14 @@ +import { HomeSection } from '@niivue/react' import imageUrl from '/resources/pwa_install.png' export const HomeScreen = () => ( <> -

- Drop Files to load images -

-

+ Drag and drop files to an empty space on this window. Many medical image and mesh formats are supported. -

+ -

Install as local App

-

+ ( /> To install niivue-vscode as a local app, click the install button in the address bar. This is currently only supported in chromium-based browsers. -

+ -

Update App

-

+ The app will automatically check for updates and show a notification when a new version is available. You can also manually force an update by pressing{' '} Ctrl+Shift+R to force refresh and clear the cache. -

+ -

Bookmarklet

-

+ Drag this link ⇨ ( MNI -

- -

Data Privacy

-

- The vscode extension (or static webpage) runs locally and only accesses the images from your - machine for displaying. No data is sent or stored remotely. The extension is a complete - offline solution and does not use any online cache, storage, or network connectivity. The - extension does not track or log any user data. Local logging is minimal and only related to - hardware events. -

-

- The extension is an open source project depending on - - NiiVue - {' '} - and was initially developed at The University of Queensland by the Computational Imaging and - - NeuroDesk - {' '} - group. -

- + ) diff --git a/apps/pwa/test/HomeScreen.test.tsx b/apps/pwa/test/HomeScreen.test.tsx index e857019a..e422a98b 100644 --- a/apps/pwa/test/HomeScreen.test.tsx +++ b/apps/pwa/test/HomeScreen.test.tsx @@ -1,5 +1,20 @@ +import type { ComponentChildren } from 'preact' import { render } from '@testing-library/preact' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +// HomeScreen composes the shared HomeSection from @niivue/react. The PWA's +// vitest config resolves that package to its built dist, so stub it here to keep +// this unit test self-contained and independent of build order (mirrors the +// desktop home-screen test). +vi.mock('@niivue/react', () => ({ + HomeSection: ({ title, children }: { title: string; children: ComponentChildren }) => ( +
+

{title}

+

{children}

+
+ ), +})) + import { HomeScreen } from '../src/components/HomeScreen' describe('HomeScreen', () => { diff --git a/apps/pwa/tests/Document.spec.ts b/apps/pwa/tests/Document.spec.ts index 29ba1989..0e449a6b 100644 --- a/apps/pwa/tests/Document.spec.ts +++ b/apps/pwa/tests/Document.spec.ts @@ -10,13 +10,14 @@ import { BASE_URL, loadTestImage, waitForImageLoad } from './utils' * re-import it by dropping the file. */ -// Load one image, export the active canvas via the Save Scene button, and -// return the downloaded `.nvd` file's text (downloadNvd writes uncompressed JSON). +// Load one image, export the active canvas via the NVDocument button (its label +// click saves by default), and return the downloaded `.nvd` file's text +// (downloadNvd writes uncompressed JSON). async function exportScene(page: Page): Promise { await page.goto(BASE_URL) await loadTestImage(page) const downloadPromise = page.waitForEvent('download') - await page.getByRole('button', { name: 'Save Scene' }).click() + await page.getByRole('button', { name: 'NVDocument' }).click() const download = await downloadPromise expect(download.suggestedFilename()).toMatch(/\.nvd$/) const path = await download.path() @@ -42,9 +43,9 @@ test.describe('NVDocument (.nvd) import/export', () => { expect(viaFacade.blobs).toBeGreaterThan(0) expect(viaFacade.hasOpts).toBeTruthy() - // Export via the UI (Save Scene) downloads the same embedded shape. + // Export via the UI (NVDocument label click = Save) downloads the same shape. const downloadPromise = page.waitForEvent('download') - await page.getByRole('button', { name: 'Save Scene' }).click() + await page.getByRole('button', { name: 'NVDocument' }).click() const download = await downloadPromise expect(download.suggestedFilename()).toMatch(/\.nvd$/) const doc = JSON.parse(await readFile((await download.path()) as string, 'utf-8')) diff --git a/apps/pwa/tests/Menu.spec.ts b/apps/pwa/tests/Menu.spec.ts index 6a1f8eac..05690163 100644 --- a/apps/pwa/tests/Menu.spec.ts +++ b/apps/pwa/tests/Menu.spec.ts @@ -5,18 +5,29 @@ test.describe('Menu', () => { test('displays home screen', { tag: '@dom' }, async ({ page }) => { await page.goto(BASE_URL) - expect(await page.textContent('text=/Home/i')).toBeTruthy() + // The brand doubles as the viewer menu (Reset Viewer / About). + expect(await page.$('[data-testid="menu-brand"]')).toBeTruthy() expect(await page.textContent('text=/Add Image/i')).toBeTruthy() expect(await page.textContent('text=/View/i')).toBeTruthy() expect(await page.textContent('text=/Bookmarklet/i')).toBeTruthy() expect(await page.textContent('text=/Drop Files to load images/i')).toBeTruthy() }) + test('brand menu opens Reset Viewer and About', { tag: '@dom' }, async ({ page }) => { + await page.goto(BASE_URL) + + await page.click('data-testid=menu-brand') + expect(await page.textContent('text=/Reset Viewer/i')).toBeTruthy() + + await page.click('text=/About/i') + await expect(page.getByTestId('about-dialog')).toBeVisible() + expect(await page.textContent('text=/NeuroDesk/i')).toBeTruthy() + }) + test('menubar updates with loading images', async ({ page }) => { await page.goto(BASE_URL) // initially only these menu items are visible - expect(await page.textContent('text=/Home/i')).toBeTruthy() expect(await page.textContent('text=/Add Image/i')).toBeTruthy() expect(await page.textContent('text=/View/i')).toBeTruthy() @@ -33,7 +44,6 @@ test.describe('Menu', () => { ).toBeTruthy() // after loading an image these are visible - expect(await page.textContent('text=/Home/i')).toBeTruthy() expect(await page.textContent('text=/Add Image/i')).toBeTruthy() expect(await page.textContent('text=/View/i')).toBeTruthy() expect(await page.textContent('text=/ColorScale/i')).toBeTruthy() @@ -56,7 +66,7 @@ test.describe('Menu', () => { await page.textContent('text=/matrix size:.*voxelsize:/i'), ).toBeTruthy() - const menuBar = ['Home', 'Add Image', 'View', 'ColorScale', 'Overlay', 'Header'] + const menuBar = ['Add Image', 'View', 'ColorScale', 'Overlay', 'Header'] for (const item of menuBar) { expect(await page.textContent(`text=/${item}/i`)).toBeTruthy() } diff --git a/apps/pwa/tests/MenuOverflow.spec.ts b/apps/pwa/tests/MenuOverflow.spec.ts index ac5729b9..f2fe6850 100644 --- a/apps/pwa/tests/MenuOverflow.spec.ts +++ b/apps/pwa/tests/MenuOverflow.spec.ts @@ -6,7 +6,8 @@ test.describe('Menu overflow', () => { await page.setViewportSize({ width: 1280, height: 800 }) await page.goto(BASE_URL) - await expect(page.getByRole('button', { name: 'View' })).toBeVisible() + // `exact` so this doesn't also match the brand button ("niivue Viewer"). + await expect(page.getByRole('button', { name: 'View', exact: true })).toBeVisible() await expect(page.getByTestId('menu-overflow')).toHaveCount(0) }) @@ -39,7 +40,7 @@ test.describe('Menu overflow', () => { await page.setViewportSize({ width: 760, height: 800 }) await page.goto(BASE_URL) - // With no image only Home/Add Image/View/Zoom show and all fit at 760px. + // With no image only Add Image/View/Zoom show and all fit at 760px. await expect(page.getByTestId('menu-overflow')).toHaveCount(0) await page.click('data-testid=menu-item-dropdown-Add Image') diff --git a/packages/niivue-react/src/components/AboutDialog.css b/packages/niivue-react/src/components/AboutDialog.css new file mode 100644 index 00000000..03569f10 --- /dev/null +++ b/packages/niivue-react/src/components/AboutDialog.css @@ -0,0 +1,86 @@ +/* About dialog (brand menu). Dark-themed to match the top bar; the `nv-` + prefix scopes these globally. */ + +.nv-about { + width: min(440px, 92vw); + padding: 0; + border: 1px solid var(--line-2); + border-radius: var(--radius); + background: var(--bg-4); + color: var(--fg-1); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.6); +} +.nv-about::backdrop { + background: rgba(0, 0, 0, 0.55); +} + +.nv-about-body { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; +} + +.nv-about-head { + display: flex; + align-items: center; + gap: 12px; +} +.nv-about-title { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--fg-0); + letter-spacing: -0.01em; +} +.nv-about-tagline { + margin: 2px 0 0; + font-size: 12px; + color: var(--fg-2); +} + +.nv-about-text { + margin: 0; + font-size: 12.5px; + line-height: 1.5; + color: var(--fg-1); +} + +.nv-about-link { + color: var(--accent); + text-decoration: none; +} +.nv-about-link:hover { + text-decoration: underline; +} + +.nv-about-links { + display: flex; + gap: 16px; + font-size: 12.5px; +} + +.nv-about-version { + margin: 0; + font-family: var(--font-mono); + font-size: 11px; + color: var(--fg-3); +} + +.nv-about-close { + align-self: flex-end; + margin-top: 4px; + padding: 7px 16px; + border: 1px solid var(--line-2); + border-radius: 7px; + background: var(--bg-3); + color: var(--fg-1); + font-size: 12.5px; + font-weight: 500; + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.nv-about-close:hover { + background: var(--bg-5); + color: var(--fg-0); +} diff --git a/packages/niivue-react/src/components/AboutDialog.tsx b/packages/niivue-react/src/components/AboutDialog.tsx new file mode 100644 index 00000000..e53eac9c --- /dev/null +++ b/packages/niivue-react/src/components/AboutDialog.tsx @@ -0,0 +1,104 @@ +import './AboutDialog.css' +import { type Signal, effect } from '@preact/signals' +import { useRef } from 'preact/hooks' +import { niivueLogo } from '../assets/niivue-logo' +import type { AppInfo } from './AppProps' + +// Canonical source repo, used when a host doesn't supply its own `repoUrl`. +const DEFAULT_REPO_URL = 'https://github.com/niivue/niivue-vscode' + +/** + * The brand menu's "About" dialog: what niivue Viewer is, its credits, the data + * privacy guarantee, and (when the host provides `appInfo`) the build version. + * + * `isOpen` is a trigger signal in the HeaderDialog mold: set it true to open the + * native modal; the effect opens the dialog and immediately flips the signal + * back to false so a later set reopens it. The dialog itself closes via its own + * form button. + */ +export const AboutDialog = ({ + isOpen, + appInfo, +}: { + isOpen: Signal + appInfo?: AppInfo +}) => { + const dialogRef = useRef(null) + + effect(() => { + if (isOpen.value) { + dialogRef.current?.showModal() + isOpen.value = false + } + }) + + const repoUrl = appInfo?.repoUrl || DEFAULT_REPO_URL + const version = appInfo?.version + const built = appInfo?.buildDate ? new Date(appInfo.buildDate).toLocaleDateString() : '' + // Link the version to its commit when we know the repo it was built from. + const versionHref = version && appInfo?.repoUrl ? `${appInfo.repoUrl}/commit/${version}` : repoUrl + + return ( + +
+
+ +
+

niivue Viewer

+

Browser-based medical image & mesh viewer

+
+
+ +

+ An open-source viewer for NIfTI, DICOM, and mesh formats, built on{' '} + + NiiVue + + . Initially developed at The University of Queensland by the Computational Imaging and{' '} + + NeuroDesk + {' '} + group. +

+ +

+ Runs entirely on your machine. No image data is uploaded, sent, or stored remotely. +

+ + + + {version && ( +

+ + Version {version} + {built ? ` · built ${built}` : ''} + +

+ )} + + +
+
+ ) +} diff --git a/packages/niivue-react/src/components/AppProps.ts b/packages/niivue-react/src/components/AppProps.ts index e2b4c04b..7aa658fb 100644 --- a/packages/niivue-react/src/components/AppProps.ts +++ b/packages/niivue-react/src/components/AppProps.ts @@ -20,6 +20,21 @@ export interface AppProps { syncedIndices: Signal> } +/** + * Host-supplied build metadata for the brand menu's About dialog. Optional and + * host-specific (e.g. the PWA injects its git hash at build time), so it is + * passed as a prop rather than threaded through the persisted settings - a + * version frozen into `localStorage` would go stale on the next deploy. + */ +export interface AppInfo { + /** Short build/version identifier, e.g. a git short hash. */ + version?: string + /** ISO build timestamp; rendered as a localized date. */ + buildDate?: string + /** Source repository URL; used for the version's commit link. */ + repoUrl?: string +} + export interface ScalingOpts { isManual: boolean min: number diff --git a/packages/niivue-react/src/components/HomeSection.tsx b/packages/niivue-react/src/components/HomeSection.tsx new file mode 100644 index 00000000..6ef093b5 --- /dev/null +++ b/packages/niivue-react/src/components/HomeSection.tsx @@ -0,0 +1,24 @@ +import { ComponentChildren } from 'preact' + +/** + * A titled block for the standalone hosts' empty-state home screens (PWA, + * desktop). The two home screens carry host-specific copy but should look + * identical, so the shared heading/body styling lives here instead of being + * copy-pasted into each app. + * + * `children` render inside a paragraph; inline content (links, , , + * /) is fine. Standalone controls (e.g. a button) belong between + * sections, not inside one. + */ +export const HomeSection = ({ + title, + children, +}: { + title: string + children: ComponentChildren +}) => ( + <> +

{title}

+

{children}

+ +) diff --git a/packages/niivue-react/src/components/Menu.css b/packages/niivue-react/src/components/Menu.css index e3bfffdf..cb672772 100644 --- a/packages/niivue-react/src/components/Menu.css +++ b/packages/niivue-react/src/components/Menu.css @@ -56,6 +56,25 @@ margin-top: 2px; } +/* When the brand doubles as a dropdown trigger (standalone hosts): reset the + button chrome, give it a hit area and a hover/open affordance, and nudge it + left so the logo keeps its original alignment despite the new padding. */ +.nv-brand-trigger { + border: 0; + background: transparent; + cursor: pointer; + padding: 4px 8px; + margin-left: -8px; + border-radius: 8px; + color: inherit; + font: inherit; + text-align: left; + transition: background 0.12s; +} +.nv-brand-trigger:hover, +.nv-brand-trigger.is-active { background: var(--bg-3); } +.nv-brand-caret { color: var(--fg-3); margin-left: 2px; flex-shrink: 0; } + .nv-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; diff --git a/packages/niivue-react/src/components/Menu.tsx b/packages/niivue-react/src/components/Menu.tsx index 5d6a0bc3..5fc39663 100644 --- a/packages/niivue-react/src/components/Menu.tsx +++ b/packages/niivue-react/src/components/Menu.tsx @@ -12,11 +12,13 @@ import { addDcmFolderEvent, addImagesEvent, addOverlayEvent, + loadDocumentEvent, openImageFromURL, } from '../events' import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts' import { getMetadataString, getNumberOfPoints } from '../utility' -import { AppProps, SelectionMode } from './AppProps' +import { AboutDialog } from './AboutDialog' +import { AppInfo, AppProps, SelectionMode } from './AppProps' import { HeaderBox } from './HeaderBox' import { HeaderDialog, @@ -24,18 +26,20 @@ import { MenuEntry, StepperEntry, ToggleEntry, + activeMenu, toggle, } from './MenuElements' import { BarItem, MenuBar } from './MenuBar' import { DEFAULT_TILE_SPACING } from '../settings' import { ScalingBox } from './ScalingBox' -export const Menu = (props: AppProps) => { - const { selection, selectionMode, nvArray, sliceType, hideUI, settings } = props +export const Menu = (props: AppProps & { appInfo?: AppInfo }) => { + const { selection, selectionMode, nvArray, sliceType, hideUI, settings, appInfo } = props const isVscode = typeof (globalThis as any).vscode === 'object' // State const headerDialog = useSignal(false) + const aboutDialog = useSignal(false) const selectedOverlayNumber = useSignal(0) const overlayMenu = useSignal(false) const setHeaderMenu = useSignal(false) @@ -129,6 +133,8 @@ export const Menu = (props: AppProps) => { }) // Menu Click events + // "Reset Viewer" (brand menu): drop any loaded scene and query params by + // navigating back to the bare origin+path, then reload to a clean state. const homeEvent = () => { const url = new URL(location.href) location.href = url.origin + url.pathname @@ -487,13 +493,6 @@ export const Menu = (props: AppProps) => { // both the per-item settings flag and the data-dependent conditions that used // to live on the JSX (e.g. isVolume / isVolumeOrMesh). const barItems: BarItem[] = [ - { - key: 'home', - type: 'button', - label: 'Home', - visible: !isVscode && !!settings.value.menuItems?.home, - onClick: homeEvent, - }, { key: 'addImage', type: 'menu', @@ -515,11 +514,20 @@ export const Menu = (props: AppProps) => { ), }, { + // NVDocument (.nvd) scene file. The label is the default action (Save); + // the chevron opens Save / Load. Load is also reachable by dropping a + // `.nvd`, but the explicit entry pairs naturally with Save here. key: 'saveScene', - type: 'button', - label: 'Save Scene', + type: 'menu', + label: 'NVDocument', visible: !!settings.value.menuItems?.saveScene && !isVscode && isVolumeOrMesh.value, onClick: saveScene, + children: ( + <> + + + + ), }, { key: 'view', @@ -748,13 +756,12 @@ export const Menu = (props: AppProps) => { <>
-
- -
- niivue - {!isVscode && Viewer} -
-
+ (aboutDialog.value = true)} + />
@@ -773,10 +780,88 @@ export const Menu = (props: AppProps) => { /> + ) } +// activeMenu key reserved for the brand dropdown. +const BRAND_KEY = '__brand__' + +// The niivue logo + wordmark. On standalone hosts (`interactive`) it doubles as +// a dropdown trigger for viewer-level actions (Reset Viewer, About); elsewhere +// (vscode, embedded Streamlit) it renders as a static brand, unchanged. +const BrandMenu = ({ + showSubtext, + interactive, + onReset, + onAbout, +}: { + showSubtext: boolean + interactive: boolean + onReset: () => void + onAbout: () => void +}) => { + const open = computed(() => activeMenu.value === BRAND_KEY) + const inner = ( + <> + +
+ niivue + {showSubtext && Viewer} +
+ + ) + + if (!interactive) { + return
{inner}
+ } + + return ( +
+ + {open.value && ( +
+ + +
+ )} +
+ ) +} + +const BrandCaret = () => ( + +) + function applySelectionModeChange( selectionMode: Signal, selectionActive: Signal, diff --git a/packages/niivue-react/src/events.ts b/packages/niivue-react/src/events.ts index 75933cec..c276d43d 100644 --- a/packages/niivue-react/src/events.ts +++ b/packages/niivue-react/src/events.ts @@ -361,6 +361,31 @@ export function addImagesEvent() { } } +/** + * Open a file picker for a NiiVue scene document (.nvd) and import it into a + * fresh canvas via the `loadDocument` message. This is the browser-host + * counterpart to `saveScene`'s download; vscode hosts persist scenes through + * their own services, so the menu gates this to non-vscode. + */ +export function loadDocumentEvent() { + const input = document.createElement('input') + input.type = 'file' + input.accept = '.nvd' + + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement)?.files?.[0] + if (!file) return + try { + const document = await readNvdFile(file) + window.postMessage({ type: 'loadDocument', body: { document, name: file.name } }) + } catch (err) { + console.error(`Failed to read .nvd file ${file.name}:`, err) + } + } + + input.click() +} + export function addDcmFolderEvent() { if (typeof vscode === 'object') { vscode.postMessage({ type: 'addDcmFolder' }) diff --git a/packages/niivue-react/src/index.ts b/packages/niivue-react/src/index.ts index 67cbcbc1..8d365e91 100644 --- a/packages/niivue-react/src/index.ts +++ b/packages/niivue-react/src/index.ts @@ -1,10 +1,12 @@ import './index.css' +export { AboutDialog } from './components/AboutDialog' export { App } from './components/App' export { useAppState } from './components/AppProps' -export type { AppProps, ScalingOpts, SelectionMode } from './components/AppProps' +export type { AppInfo, AppProps, ScalingOpts, SelectionMode } from './components/AppProps' export { Container } from './components/Container' export { HeaderBox } from './components/HeaderBox' +export { HomeSection } from './components/HomeSection' export { ImageDrop } from './components/ImageDrop' export { Menu } from './components/Menu' export { NiiVueCanvas } from './components/NiiVueCanvas' diff --git a/packages/niivue-react/src/settings.ts b/packages/niivue-react/src/settings.ts index 79505f31..ef51f372 100644 --- a/packages/niivue-react/src/settings.ts +++ b/packages/niivue-react/src/settings.ts @@ -1,4 +1,7 @@ export interface MenuItems { + /** Gates the brand "viewer" dropdown (Reset Viewer / About). Named `home` + * for back-compat: it formerly toggled the standalone Home button, whose + * Reset action now lives in the brand menu. */ home: boolean addImage: boolean view: boolean diff --git a/packages/niivue-react/src/test/AboutDialog.test.tsx b/packages/niivue-react/src/test/AboutDialog.test.tsx new file mode 100644 index 00000000..e8e67e0e --- /dev/null +++ b/packages/niivue-react/src/test/AboutDialog.test.tsx @@ -0,0 +1,55 @@ +import { signal } from '@preact/signals' +import { cleanup, render, screen } from '@testing-library/preact' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { AboutDialog } from '../components/AboutDialog' + +// jsdom doesn't implement showModal; the open effect just needs it to not throw. +beforeAll(() => { + HTMLDialogElement.prototype.showModal = vi.fn() + HTMLDialogElement.prototype.close = vi.fn() +}) + +afterEach(() => cleanup()) + +const anchorIn = (testId: string) => + screen.getByTestId(testId).querySelector('a') as HTMLAnchorElement + +describe('AboutDialog version line', () => { + it('links the version to its commit and shows the build date when repoUrl is known', () => { + render( + , + ) + + const link = anchorIn('about-version') + expect(link.getAttribute('href')).toBe( + 'https://github.com/niivue/niivue-vscode/commit/abc1234', + ) + expect(link.textContent).toContain('abc1234') + expect(link.textContent).toContain('built') + }) + + it('falls back to the repo link (no commit, no date) when only a version is given', () => { + render() + + const link = anchorIn('about-version') + // No repoUrl -> can't build a commit URL, so it points at the default repo. + expect(link.getAttribute('href')).toBe('https://github.com/niivue/niivue-vscode') + expect(link.textContent).toContain('deadbee') + expect(link.textContent).not.toContain('built') + }) + + it('omits the version line and uses the default GitHub link when no appInfo is supplied', () => { + render() + + expect(screen.queryByTestId('about-version')).toBeNull() + const source = screen.getByText('Source on GitHub') as HTMLAnchorElement + expect(source.getAttribute('href')).toBe('https://github.com/niivue/niivue-vscode') + }) +}) diff --git a/packages/niivue-react/src/test/MenuBrandDocument.test.tsx b/packages/niivue-react/src/test/MenuBrandDocument.test.tsx new file mode 100644 index 00000000..d781d8f4 --- /dev/null +++ b/packages/niivue-react/src/test/MenuBrandDocument.test.tsx @@ -0,0 +1,141 @@ +import { SLICE_TYPE } from '@niivue/niivue' +import { signal } from '@preact/signals' +import { cleanup, fireEvent, render, screen } from '@testing-library/preact' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { AppProps, SelectionMode } from '../components/AppProps' +import { Menu } from '../components/Menu' +import { activeMenu } from '../components/MenuElements' + +// jsdom has no WebGL and no modal support; stub what the menu touches. +vi.mock('@niivue/niivue', () => ({ + SLICE_TYPE: { AXIAL: 0, CORONAL: 1, SAGITTAL: 2, MULTIPLANAR: 3, RENDER: 4 }, + Niivue: vi.fn(), + NVImage: { loadFromUrl: vi.fn() }, + NVMesh: { readMesh: vi.fn() }, + NVMeshLoaders: { readLayer: vi.fn() }, +})) + +// Stub only the download helper so "Save" can be asserted without a real Blob/anchor. +const downloadNvd = vi.fn() +vi.mock('../document', async (importOriginal) => ({ + ...(await importOriginal()), + downloadNvd: (...args: unknown[]) => downloadNvd(...args), +})) + +// Capture the dedicated Load action wired to the NVDocument "Load" entry. +const loadDocumentEvent = vi.fn() +vi.mock('../events', async (importOriginal) => ({ + ...(await importOriginal()), + loadDocumentEvent: () => loadDocumentEvent(), +})) + +beforeAll(() => { + // jsdom doesn't implement these; the dialog just needs to not throw. + HTMLDialogElement.prototype.showModal = vi.fn() + HTMLDialogElement.prototype.close = vi.fn() +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() + // activeMenu is a module-global signal; reset it so an open dropdown in one + // test doesn't start the next one already open. + activeMenu.value = null +}) + +function makeNv() { + return { + volumes: [ + { + name: 'brain.nii.gz', + getImageMetadata: () => ({ nx: 64, ny: 64, nz: 40, dx: 1, dy: 1, dz: 1, nt: 1 }), + }, + ], + meshes: [], + scene: { pan2Dxyzmm: [0, 0, 0, 1] }, + opts: {}, + json: () => ({ scene: 'data' }), + drawScene: vi.fn(), + updateGLVolume: vi.fn(), + getRadiologicalConvention: vi.fn(() => false), + setInterpolation: vi.fn(), + setRadiologicalConvention: vi.fn(), + setCrosshairWidth: vi.fn(), + dragModes: { slicer3D: 1, contrast: 2 }, + } +} + +function makeProps(menuItems: Record) { + return { + nvArray: signal([makeNv()]), + selection: signal([0]), + selectionMode: signal(SelectionMode.SINGLE), + sliceType: signal(SLICE_TYPE.MULTIPLANAR), + hideUI: signal(3), + settings: signal({ + interpolation: true, + showCrosshairs: true, + radiologicalConvention: false, + colorbar: false, + zoomDragMode: false, + menuItems, + }), + } as unknown as AppProps +} + +describe('NVDocument menu', () => { + it('clicking the NVDocument label saves by default', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'NVDocument' })) + + expect(downloadNvd).toHaveBeenCalledTimes(1) + // The exported filename derives from the active volume and ends in .nvd. + expect(downloadNvd.mock.calls[0][1]).toBe('brain.nvd') + }) + + it('the chevron opens Save and Load entries', async () => { + render() + + fireEvent.click(screen.getByTestId('menu-item-dropdown-NVDocument')) + + fireEvent.click(await screen.findByText('Load')) + expect(loadDocumentEvent).toHaveBeenCalledTimes(1) + expect(downloadNvd).not.toHaveBeenCalled() + }) + + it('there is no legacy "Save Scene" button', () => { + render() + expect(screen.queryByRole('button', { name: 'Save Scene' })).toBeNull() + }) +}) + +describe('Brand menu', () => { + it('replaces the Home button: no Home entry remains', () => { + render() + expect(screen.queryByRole('button', { name: 'Home' })).toBeNull() + }) + + it('clicking the brand reveals Reset Viewer and About', async () => { + render() + + fireEvent.click(screen.getByTestId('menu-brand')) + + expect(await screen.findByText('Reset Viewer')).toBeTruthy() + expect(await screen.findByText('About')).toBeTruthy() + }) + + it('About opens the about dialog', async () => { + render() + + fireEvent.click(screen.getByTestId('menu-brand')) + fireEvent.click(await screen.findByText('About')) + + expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled() + }) + + it('stays a static brand (no trigger) when the home flag is off', () => { + render() + expect(screen.queryByTestId('menu-brand')).toBeNull() + }) +})