import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent, ReactNode, Reducer, WheelEvent, } from 'react'; import { createContext, useCallback, useContext, useEffect, useReducer, useRef, } from 'react'; import { assert } from 'react-science/ui'; import type { EventModifierKeys } from '../context/KeyModifierContext.js'; import type { ActionType } from '../reducer/types/ActionType.js'; type AdvanceOmit = T extends any ? Omit : never; export type BaseDetectBrushingOptions = AdvanceOmit< DetectBrushingOptions, 'width' | 'height' >; type Step = 'initial' | 'start' | 'end' | 'brushing'; export type BrushAxis = 'X' | 'Y' | 'XY'; export interface BrushTrackerData extends EventModifierKeys { step: Step; startX: number; endX: number; startY: number; endY: number; mouseButton: MouseButton; } type MouseButton = 'main' | 'secondary' | 'unknown'; const MouseButtons: Record = { 0: 'main', 2: 'secondary', } as const; export interface BrushCoordination { startX: number; endX: number; startY: number; endY: number; } interface BrushScreenCoordination { startScreenX: number; startScreenY: number; startClientX: number; startClientY: number; } interface BrushTrackerState extends BrushTrackerData, BrushCoordination, BrushScreenCoordination { step: Step; boundingRect: DOMRect | null; } const initialState: BrushTrackerState = { step: 'initial', mouseButton: 'unknown', shiftKey: false, altKey: false, ctrlKey: false, metaKey: false, startX: 0, endX: 0, startY: 0, endY: 0, startScreenX: 0, startScreenY: 0, startClientX: 0, startClientY: 0, boundingRect: null, }; function stopPageScrolling(event: any) { event.preventDefault(); } const BrushDetectionOptionsContext = createContext({ thresholdFormat: 'fixed', }); const BrushContext = createContext(initialState); export function useBrushDetectionOptions() { if (!BrushDetectionOptionsContext) { throw new Error('Brush detection options context was not found'); } return useContext(BrushDetectionOptionsContext); } export function useBrushTracker() { if (!BrushContext) { throw new Error('Brush context was not found'); } return useContext(BrushContext); } interface Position { x: number; y: number; } export type ClickOptions = MouseEvent & Position; export type OnClick = (element: ClickOptions) => void; export type { OnClick as OnDoubleClick }; export type ZoomOptions = Pick< WheelEvent, 'deltaY' | 'shiftKey' | 'deltaMode' | 'altKey' | 'deltaX' | 'ctrlKey' > & Position & { invertScroll?: boolean; isBidirectionalZoom: boolean }; export type OnZoom = (options: ZoomOptions) => void; export type OnBrush = (state: BrushTrackerData) => void; interface BrushTrackerProps { children: ReactNode; className?: string; style?: CSSProperties; onBrushEnd?: OnBrush; onBrush?: OnBrush; onZoom?: OnZoom; onDoubleClick?: OnClick; onClick?: OnClick; noPropagation?: boolean; brushDetectionOptions?: BaseDetectBrushingOptions; clickTriggerMode?: 'native' | 'debounced'; } function getMouseXY(event: MouseEvent, currentTarget?: Element) { const boundingRect = ( currentTarget ?? event.currentTarget ).getBoundingClientRect(); const x = event.clientX - boundingRect.x; const y = event.clientY - boundingRect.y; return { x, y }; } export function BrushTracker(options: BrushTrackerProps) { const { children, className, style, onBrushEnd, onBrush, onZoom = () => null, onDoubleClick = () => null, onClick = () => null, noPropagation, brushDetectionOptions = { thresholdFormat: 'fixed' }, clickTriggerMode = 'native', } = options; const [state, dispatch] = useReducer< Reducer >(reducer, initialState); const clickCountRef = useRef(0); const timeoutRef = useRef(null); const lastPointRef = useRef(0); const isDraggingRef = useRef(false); const boundingRectRef = useRef(null); const startPositionRef = useRef({ x: 0, y: 0 }); const lastRef = useRef({ x: 0, y: 0 }); const handleClickWithDebounce = useCallback( (event: MouseEvent, currentTarget: Element) => { const { x, y } = getMouseXY(event, currentTarget); // Clear timeout if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } // Count clicks clickCountRef.current += 1; // Set a timeout to distinguish between single and double clicks timeoutRef.current = setTimeout(() => { if (clickCountRef.current === 1) { onClick({ ...event, x, y }); } else if (clickCountRef.current === 2) { onDoubleClick({ ...event, x, y }); } // Reset the click count clickCountRef.current = 0; }, 200); }, [onClick, onDoubleClick], ); function handleClick(event: MouseEvent) { if (isDraggingRef.current || clickTriggerMode !== 'native') return; const { x, y } = getMouseXY(event); onClick({ ...event, x, y }); } function handleDoubleClick(event: MouseEvent) { if (isDraggingRef.current || clickTriggerMode !== 'native') return; const { x, y } = getMouseXY(event); onDoubleClick({ ...event, x, y }); } const pointerDownHandler = useCallback( (event: PointerEvent) => { event.persist(); const currentTarget = event.currentTarget; isDraggingRef.current = false; // Reset dragging flag //check that the right or left mouse button pressed if ([0, 2].includes(event.button)) { if (noPropagation) { event.stopPropagation(); } const boundingRect = event.currentTarget.getBoundingClientRect(); boundingRectRef.current = boundingRect; startPositionRef.current = { x: event.clientX - boundingRect.x, y: event.clientY - boundingRect.y, }; lastRef.current = { x: 0, y: 0 }; if (!event.ctrlKey) { dispatch({ type: 'DOWN', payload: { mouseButton: MouseButtons[event.button], shiftKey: event.shiftKey, altKey: event.altKey, screenX: event.screenX, screenY: event.screenY, clientX: event.clientX, clientY: event.clientY, boundingRect, }, }); } } function moveCallback(event: globalThis.PointerEvent) { isDraggingRef.current = true; // set flag to true to skip click event if the user dragged the mouse const { clientX, clientY, shiftKey, altKey, ctrlKey } = event; if (event.ctrlKey) { if (boundingRectRef.current) { const boundingRect = boundingRectRef.current; const x = clientX - boundingRect.x; const y = clientY - boundingRect.y; if (lastRef.current.x > 0 && lastRef.current.y > 0) { const deltaX = x - lastRef.current.x; const deltaY = y - lastRef.current.y; onZoom({ deltaY, deltaX, shiftKey, altKey, x: startPositionRef.current.x, y: startPositionRef.current.y, ctrlKey, deltaMode: 0, isBidirectionalZoom: true, }); } lastRef.current = { x, y }; } } else { dispatch({ type: 'MOVE', payload: { screenX: event.screenX, screenY: event.screenY, clientX: event.clientX, clientY: event.clientY, }, }); } } function upCallback() { if (isDraggingRef.current) { dispatch({ type: 'UP', }); } else { dispatch({ type: 'DONE', }); if (clickTriggerMode === 'debounced') { handleClickWithDebounce(event, currentTarget); } } globalThis.removeEventListener('pointermove', moveCallback); globalThis.removeEventListener('pointerup', upCallback); } globalThis.addEventListener('pointermove', moveCallback); globalThis.addEventListener('pointerup', upCallback); return false; }, [clickTriggerMode, handleClickWithDebounce, noPropagation, onZoom], ); const handleMouseWheel = useCallback( (event: WheelEvent) => { const boundingRect = event.currentTarget.getBoundingClientRect(); const x = event.clientX - boundingRect.x; const y = event.clientY - boundingRect.y; const { deltaY, deltaX, shiftKey, altKey, ctrlKey, deltaMode } = event; onZoom({ deltaY, deltaX, shiftKey, altKey, ctrlKey, deltaMode, x, y, isBidirectionalZoom: false, }); }, [onZoom], ); useEffect(() => { const { step, startX, endX, startY, endY } = state; const point = Math.hypot(endX - startX, endY - startY); if ( (step === 'end' || step === 'brushing') && lastPointRef.current !== point ) { onBrush?.(state); lastPointRef.current = point; } if (step === 'end' && point > 5) { onBrushEnd?.(state); dispatch({ type: 'DONE', }); } }, [onBrush, onBrushEnd, state]); function handleStopBrush(event: KeyboardEvent) { const isEscapePressed = event.key === 'Escape'; const isBrushing = state.step === 'brushing'; if (!isEscapePressed || !isBrushing) return; event.stopPropagation(); dispatch({ type: 'DONE', }); } return (
{ if (e.ctrlKey && e.button === 0) { e.preventDefault(); } }} className={className} style={{ ...style, touchAction: 'none' }} onPointerDown={pointerDownHandler} onWheel={handleMouseWheel} onKeyDown={handleStopBrush} onMouseEnter={() => { // disable page scrolling once the mouse over the Displayer window.addEventListener('wheel', stopPageScrolling, { passive: false, }); }} onMouseLeave={() => { // disable page scrolling once the mouse over the Displayer window.removeEventListener('wheel', stopPageScrolling); }} > {children}
); } interface MouseCoordinates { screenX: number; screenY: number; clientX: number; clientY: number; } type DownAction = ActionType< 'DOWN', MouseCoordinates & { shiftKey: boolean; altKey: boolean; mouseButton: MouseButton; boundingRect: DOMRect; } >; type MoveAction = ActionType<'MOVE', MouseCoordinates>; type BrushTrackerAction = ActionType<'UP' | 'DONE'> | DownAction | MoveAction; function reducer( state: BrushTrackerState, action: BrushTrackerAction, ): BrushTrackerState { switch (action.type) { case 'UP': if (state.step === 'brushing' || state.step === 'start') { return { ...state, step: state.step === 'start' ? 'initial' : 'end', }; } return state; case 'DOWN': if (state.step === 'initial' || state.step === 'end') { const { shiftKey, altKey, mouseButton, screenX, screenY, clientX, clientY, boundingRect, } = action.payload; const x = clientX - boundingRect.x; const y = clientY - boundingRect.y; return { ...state, shiftKey, altKey, mouseButton, startX: x, startY: y, startScreenX: screenX, startScreenY: screenY, startClientX: clientX, startClientY: clientY, boundingRect, step: 'start', }; } return state; case 'MOVE': if (state.step === 'start' || state.step === 'brushing') { const { clientX, clientY } = action.payload; const { x = 0, y = 0 } = state.boundingRect || {}; return { ...state, step: 'brushing', endX: clientX - x, endY: clientY - y, }; } return state; case 'DONE': return { ...initialState, }; default: return state; } } interface DetectBrushingResult extends BrushCoordination { type: BrushAxis; scaleX: number; scaleY: number; directionX: number; directionY: number; xThreshold: number; yThreshold: number; } interface DetectBrushingThreshold { /** Width in pixels */ width: number; /** Height in pixels */ height: number; /** * Threshold as a percentage of width and height (value between 0 and 1). * @default 0.02 */ threshold?: number; thresholdFormat: 'relative'; } interface DetectBrushingThresholdSize { /** Width in pixels */ width: number; /** Height in pixels */ height: number; /** * Threshold size in pixels. * @default 80 */ thresholdSize?: number; thresholdFormat: 'fixed'; } type BrushDetectionThresholdAxis = 'both' | 'x' | 'y'; type DetectBrushingOptions = { thresholdAxis?: BrushDetectionThresholdAxis } & ( | DetectBrushingThreshold | DetectBrushingThresholdSize ); export function detectBrushing( coordination: BrushCoordination, options: DetectBrushingOptions, ): DetectBrushingResult { const { width, height, thresholdFormat, thresholdAxis = 'both' } = options; const { startX: x1, endX: x2, startY: y1, endY: y2 } = coordination; const startX = Math.min(x1, x2); const endX = Math.max(x1, x2); const startY = Math.min(y1, y2); const endY = Math.max(y1, y2); const directionX = endX >= startX ? 1 : -1; const directionY = endY >= startY ? 1 : -1; const xDiff = Math.abs(endX - startX); const yDiff = Math.abs(endY - startY); let xThreshold: number; let yThreshold: number; if (thresholdFormat === 'relative') { const { threshold = 0.02 } = options; xThreshold = width * threshold; yThreshold = height * threshold; } else { assert(thresholdFormat === 'fixed'); const { thresholdSize = 80 } = options; xThreshold = thresholdSize; yThreshold = thresholdSize; } const scaleY = (endY - startY) / height; const scaleX = (endX - startX) / width; const common = { directionX, directionY, xThreshold, yThreshold, }; if (thresholdAxis === 'y') { if (yDiff >= yThreshold && yThreshold > 0) { return { type: 'XY', startX, startY, endX, endY, scaleX, scaleY, ...common, }; } return { type: 'X', startX, endX, startY: 0, endY: height, scaleX, scaleY: 1, ...common, }; } if (thresholdAxis === 'x') { if (xDiff >= xThreshold && xThreshold > 0) { return { type: 'XY', startX, startY, endX, endY, scaleX, scaleY, ...common, }; } return { type: 'Y', startX: 0, endX: width, startY, endY, scaleX: 1, scaleY, ...common, }; } if (yDiff < yThreshold) { return { type: 'X', startX, endX, startY: 0, endY: height, scaleX, scaleY: 1, ...common, }; } if (xDiff < xThreshold) { return { type: 'Y', startX: 0, endX: width, startY, endY, scaleX: 1, scaleY, ...common, }; } return { type: 'XY', startX, startY, endX, endY, scaleX, scaleY, ...common, }; }