Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/component/highlight/__tests__/highlightReducer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';

import {
emptyHighlightState,
highlightReducer,
toInternalHighlightState,
toPublicHighlightState,
} from '../index.js';

describe('highlightReducer', () => {
it('starts from the empty public state when uncontrolled', () => {
const internal = toInternalHighlightState(emptyHighlightState);
expect(toPublicHighlightState(internal)).toStrictEqual(emptyHighlightState);
expect(internal.highlights).toStrictEqual({});
});

it('SHOW then HIDE updates highlighted ids', () => {
let state = toInternalHighlightState(emptyHighlightState);

state = highlightReducer(state, {
type: 'SHOW',
payload: {
convertedHighlights: ['peak-1'],
sourceData: { type: 'PEAK', extra: { id: 'peak-1' } },
},
});
expect(toPublicHighlightState(state)).toStrictEqual({
highlighted: ['peak-1'],
highlightedPermanently: [],
sourceData: { type: 'PEAK', extra: { id: 'peak-1' } },
});
expect(state.highlights).toStrictEqual({ 'peak-1': 1 });

state = highlightReducer(state, {
type: 'HIDE',
payload: { convertedHighlights: ['peak-1'] },
});
expect(toPublicHighlightState(state)).toStrictEqual({
highlighted: [],
highlightedPermanently: [],
sourceData: null,
});
});

it('composes consecutive dispatches against the latest reduced value', () => {
let state = toInternalHighlightState({
highlighted: ['peak-1'],
highlightedPermanently: ['peak-1'],
sourceData: { type: 'PEAK', extra: { id: 'peak-1' } },
});

// Mirrors useHighlight unmount cleanup: HIDE then UNSET_PERMANENT.
state = highlightReducer(state, {
type: 'HIDE',
payload: { convertedHighlights: ['peak-1'] },
});
state = highlightReducer(state, { type: 'UNSET_PERMANENT' });

expect(toPublicHighlightState(state)).toStrictEqual({
highlighted: [],
highlightedPermanently: [],
sourceData: null,
});
});

it('SET_PERMANENT replaces the previous permanent group', () => {
let state = toInternalHighlightState({
highlighted: ['a', 'b'],
highlightedPermanently: ['a'],
sourceData: null,
});

state = highlightReducer(state, {
type: 'SET_PERMANENT',
payload: { convertedHighlights: ['b'] },
});

expect(state.highlightedPermanently).toStrictEqual(['b']);
expect(state.highlighted).toStrictEqual(['a', 'b']);
});

it('toInternalHighlightState derives the private highlights record', () => {
const internal = toInternalHighlightState({
highlighted: ['x', 'y'],
highlightedPermanently: ['y'],
sourceData: { type: 'UNKNOWN' },
});
expect(internal.highlights).toStrictEqual({ x: 1, y: 1 });
expect(toPublicHighlightState(internal)).toStrictEqual({
highlighted: ['x', 'y'],
highlightedPermanently: ['y'],
sourceData: { type: 'UNKNOWN' },
});
});
});
127 changes: 109 additions & 18 deletions src/component/highlight/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import {
use,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react';

import type { ExclusionZone } from '../../data/types/data1d/ExclusionZone.js';
Expand Down Expand Up @@ -60,6 +62,17 @@ export function isHighlightEventSource<T extends HighlightEventSourceType>(
return types.includes(source.type as T);
}

/**
* Public highlight state that hosts can read and write through
* `<NMRium highlight={...} onHighlightChange={...} />`.
* The internal `highlights` reference-count record stays private.
*/
export interface NMRiumHighlightState {
highlighted: string[];
highlightedPermanently: string[];
sourceData: HighlightEventSource | null;
}

const highLightStyle: CSSProperties = {
backgroundColor: '#ff6f0091',
};
Expand Down Expand Up @@ -87,20 +100,54 @@ interface HighlightContextValue {
remove: () => void;
}

const emptyState = {
highlight: {
highlights: {},
highlighted: [],
highlightedPermanently: [],
sourceData: null,
},
export const emptyHighlightState: NMRiumHighlightState = {
highlighted: [],
highlightedPermanently: [],
sourceData: null,
};

const emptyInternalState: HighlightState = {
highlights: {},
highlighted: [],
highlightedPermanently: [],
sourceData: null,
};

const emptyContextValue: HighlightContextValue = {
highlight: emptyInternalState,
dispatch: () => null,
remove: () => null,
};

const HighlightContext = createContext<HighlightContextValue>(emptyState);
const HighlightContext =
createContext<HighlightContextValue>(emptyContextValue);

export function toPublicHighlightState(
state: HighlightState,
): NMRiumHighlightState {
return {
highlighted: state.highlighted,
highlightedPermanently: state.highlightedPermanently,
sourceData: state.sourceData,
};
}

function highlightReducer(
export function toInternalHighlightState(
value: NMRiumHighlightState,
): HighlightState {
const highlights: Record<string, number> = {};
for (const id of value.highlighted) {
highlights[id] = 1;
}
return {
highlights,
highlighted: value.highlighted,
highlightedPermanently: value.highlightedPermanently,
sourceData: value.sourceData,
};
}

export function highlightReducer(
state: HighlightState,
action: {
type: HighlightActions;
Expand Down Expand Up @@ -165,10 +212,56 @@ function highlightReducer(
}
}

export function HighlightProvider(props: PropsWithChildren) {
const [highlight, dispatch] = useReducer(
highlightReducer,
emptyState.highlight,
export interface HighlightProviderProps extends PropsWithChildren {
/**
* When provided, highlight state is controlled by the host, which must then
* also handle `onChange`, otherwise hover/click highlights inside NMRium are
* not rendered. Omit it to keep the previous uncontrolled behaviour. Do not
* switch between controlled and uncontrolled during the provider's lifetime.
*/
value?: NMRiumHighlightState;
/**
* Called for every highlight change, including internal hover/click and
* host-driven updates applied through the reducer.
*/
onChange?: (state: NMRiumHighlightState) => void;
}

export function HighlightProvider(props: HighlightProviderProps) {
const { value, onChange, children } = props;
const isControlled = value !== undefined;
const [internalState, setInternalState] = useState(emptyInternalState);

const controlledState = useMemo(
() => (value ? toInternalHighlightState(value) : null),
[value],
);
const highlight = controlledState ?? internalState;

// `dispatch` must keep a stable identity: every useHighlight consumer runs an
// unmount effect keyed on it, so a new identity per render would clear the
// highlight (and loop when the host passes an inline onChange).
// `latestRef` also lets consecutive dispatches in one tick compose, for
// example useHighlight's unmount cleanup (HIDE then UNSET_PERMANENT).
const latestRef = useRef(highlight);
const onChangeRef = useRef(onChange);
const isControlledRef = useRef(isControlled);
useLayoutEffect(() => {
latestRef.current = highlight;
onChangeRef.current = onChange;
isControlledRef.current = isControlled;
});

const dispatch = useCallback(
(action: { type: HighlightActions; payload?: HighlightPayload }) => {
const next = highlightReducer(latestRef.current, action);
latestRef.current = next;
if (!isControlledRef.current) {
setInternalState(next);
}
onChangeRef.current?.(toPublicHighlightState(next));
},
[],
);

const contextValue = useMemo(() => {
Expand All @@ -179,11 +272,9 @@ export function HighlightProvider(props: PropsWithChildren) {
});
}
return { highlight, dispatch, remove };
}, [highlight]);
}, [dispatch, highlight]);

return (
<HighlightContext value={contextValue}>{props.children}</HighlightContext>
);
return <HighlightContext value={contextValue}>{children}</HighlightContext>;
}

export function useHighlightData() {
Expand Down
7 changes: 6 additions & 1 deletion src/component/main/InnerNMRium.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export function InnerNMRium(props: InnerNMRiumProps) {
emptyText,
apiRef,
core,
highlight,
onHighlightChange,
} = props;

const rootRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -122,7 +124,10 @@ export function InnerNMRium(props: InnerNMRiumProps) {
<TopicMoleculeProvider>
<DialogProvider>
<AlertProvider>
<HighlightProvider>
<HighlightProvider
value={highlight}
onChange={onHighlightChange}
>
<AssignmentProvider>
<SpinnerContext value={getSpinner}>
<InnerNMRiumContents
Expand Down
16 changes: 16 additions & 0 deletions src/component/main/NMRium.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ import type { ErrorBoundaryPropsWithComponent } from 'react-error-boundary';
import { ErrorBoundary } from 'react-error-boundary';
import { RootLayout } from 'react-science/ui';

import type { NMRiumHighlightState } from '../highlight/index.js';

import ErrorOverlay from './ErrorOverlay.js';
import { InnerNMRium } from './InnerNMRium.js';
import type { NMRiumRefAPI } from './NMRiumRefAPI.js';
import type { NMRiumChangeCb, NMRiumWorkspace } from './types.js';

export type { NMRiumHighlightState } from '../highlight/index.js';

export interface NMRiumProps {
/**
* If you choose to bind the state to the component, you have to use a valid one.
Expand Down Expand Up @@ -76,6 +80,18 @@ export interface NMRiumProps {
*/
getSpinner?: () => ReactElement;
core?: NMRiumCore;
/**
* When provided, highlight state is controlled by the host, which must then
* also handle `onHighlightChange`, otherwise hover/click highlights inside
* NMRium are not rendered. Omit it to keep the previous uncontrolled
* behaviour.
*/
highlight?: NMRiumHighlightState;
/**
* Called for every highlight change, including internal hover/click.
* Works in both controlled and uncontrolled mode.
*/
onHighlightChange?: (state: NMRiumHighlightState) => void;

ref?: Ref<NMRiumRefAPI>;
}
Expand Down
5 changes: 5 additions & 0 deletions src/component/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ export * from './types.js';

export * from './NMRium.js';
export type { NMRiumRefAPI } from './NMRiumRefAPI.js';
export type {
HighlightEventSource,
NMRiumHighlightState,
} from '../highlight/index.js';
export { emptyHighlightState } from '../highlight/index.js';
Loading
Loading