diff --git a/src/component/highlight/__tests__/highlightReducer.test.ts b/src/component/highlight/__tests__/highlightReducer.test.ts new file mode 100644 index 0000000000..7b31727281 --- /dev/null +++ b/src/component/highlight/__tests__/highlightReducer.test.ts @@ -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' }, + }); + }); +}); diff --git a/src/component/highlight/index.tsx b/src/component/highlight/index.tsx index fae22ce152..293028e693 100644 --- a/src/component/highlight/index.tsx +++ b/src/component/highlight/index.tsx @@ -5,8 +5,10 @@ import { use, useCallback, useEffect, + useLayoutEffect, useMemo, - useReducer, + useRef, + useState, } from 'react'; import type { ExclusionZone } from '../../data/types/data1d/ExclusionZone.js'; @@ -60,6 +62,17 @@ export function isHighlightEventSource( return types.includes(source.type as T); } +/** + * Public highlight state that hosts can read and write through + * ``. + * The internal `highlights` reference-count record stays private. + */ +export interface NMRiumHighlightState { + highlighted: string[]; + highlightedPermanently: string[]; + sourceData: HighlightEventSource | null; +} + const highLightStyle: CSSProperties = { backgroundColor: '#ff6f0091', }; @@ -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(emptyState); +const HighlightContext = + createContext(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 = {}; + 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; @@ -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(() => { @@ -179,11 +272,9 @@ export function HighlightProvider(props: PropsWithChildren) { }); } return { highlight, dispatch, remove }; - }, [highlight]); + }, [dispatch, highlight]); - return ( - {props.children} - ); + return {children}; } export function useHighlightData() { diff --git a/src/component/main/InnerNMRium.tsx b/src/component/main/InnerNMRium.tsx index 8f5ff4340c..d38dc981ed 100644 --- a/src/component/main/InnerNMRium.tsx +++ b/src/component/main/InnerNMRium.tsx @@ -46,6 +46,8 @@ export function InnerNMRium(props: InnerNMRiumProps) { emptyText, apiRef, core, + highlight, + onHighlightChange, } = props; const rootRef = useRef(null); @@ -122,7 +124,10 @@ export function InnerNMRium(props: InnerNMRiumProps) { - + 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; } diff --git a/src/component/main/index.ts b/src/component/main/index.ts index e9c9d51c96..33c93ac0f2 100644 --- a/src/component/main/index.ts +++ b/src/component/main/index.ts @@ -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'; diff --git a/src/demo/test/TestHighlight.tsx b/src/demo/test/TestHighlight.tsx index daa5c7144c..81085f7ee4 100644 --- a/src/demo/test/TestHighlight.tsx +++ b/src/demo/test/TestHighlight.tsx @@ -1,7 +1,10 @@ import styled from '@emotion/styled'; +import { useState } from 'react'; +import type { NMRiumHighlightState } from '../../component/highlight/index.js'; import { HighlightProvider, + emptyHighlightState, useHighlight, } from '../../component/highlight/index.js'; @@ -44,18 +47,54 @@ const TableCell = styled.td` padding: 5px; `; +const Controls = styled.div` + display: flex; + gap: 8px; + padding: 20px; +`; + export default function TestHighlight() { + const [highlight, setHighlight] = + useState(emptyHighlightState); + return ( - - - - - - - - - - + <> + + + + + permanent: {highlight.highlightedPermanently.join(', ') || 'none'} + + + + + + + + + + + + + ); }