diff --git a/package-lock.json b/package-lock.json index b62e846a6..993757684 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,11 +15,11 @@ "@tanstack/react-form": "^1.33.5", "@tanstack/react-store": "^0.11.2", "@tanstack/react-table": "^9.2.6", - "@zakodium/nmr-types": "^0.5.32", - "@zakodium/nmrium-core": "^0.7.82", - "@zakodium/nmrium-core-plugins": "^0.7.119", + "@zakodium/nmr-types": "^0.5.34", + "@zakodium/nmrium-core": "^0.7.83", + "@zakodium/nmrium-core-plugins": "^0.7.121", "@zakodium/pdnd-esm": "^1.2.0", - "@zakodium/utils": "^0.5.1", + "@zakodium/utils": "^0.7.0", "@zip.js/zip.js": "^2.23.0", "cheminfo-font": "^1.28.0", "cheminfo-types": "^1.16.0", @@ -4418,9 +4418,9 @@ } }, "node_modules/@zakodium/nmr-types": { - "version": "0.5.32", - "resolved": "https://registry.npmjs.org/@zakodium/nmr-types/-/nmr-types-0.5.32.tgz", - "integrity": "sha512-9ijczG0Mepj5ojTW5Jb11Kbq/RBd49rwcRGcMFCvKDEVEFHXXdBQnxQw8WYCpKPnwlWudflHsRxQs1RrXh6WvA==", + "version": "0.5.34", + "resolved": "https://registry.npmjs.org/@zakodium/nmr-types/-/nmr-types-0.5.34.tgz", + "integrity": "sha512-ADirawnebI2l6NAtB4J5mWAaFXEu9WzdOiJGX7Jv6djcLyELN9FbiC/XcOKehovy7DqQsckxcvpLwprCS/IRHA==", "license": "CC-BY-NC-SA-4.0", "dependencies": { "cheminfo-types": "^1.16.0", @@ -4431,9 +4431,9 @@ } }, "node_modules/@zakodium/nmrium-core": { - "version": "0.7.82", - "resolved": "https://registry.npmjs.org/@zakodium/nmrium-core/-/nmrium-core-0.7.82.tgz", - "integrity": "sha512-Is7XliIVk/ak0p/KPYeLFGkgyUogl/vAyq7x4T4PeowDxD4IDBDg3Y38X7jU5AieO2lKkI805vYWmgimWNKPvA==", + "version": "0.7.83", + "resolved": "https://registry.npmjs.org/@zakodium/nmrium-core/-/nmrium-core-0.7.83.tgz", + "integrity": "sha512-UuFS5m5GRV/BiMhASNV6zUlAwkPOfwiY1WUP2rW33852u0zmy7s0U7VDpEr5e0RqJRgzi+xQtwtzu1DAQPZMsA==", "license": "CC-BY-NC-SA-4.0", "dependencies": { "@zakodium/utils": "^0.5.1", @@ -4449,13 +4449,13 @@ } }, "node_modules/@zakodium/nmrium-core-plugins": { - "version": "0.7.119", - "resolved": "https://registry.npmjs.org/@zakodium/nmrium-core-plugins/-/nmrium-core-plugins-0.7.119.tgz", - "integrity": "sha512-qo3oZE4ok9F9bWxq7K0HfQnDPojOpK+ixoQc5w8v/UNg3CzAaIzbm4LHoAZOI9udYBznLq/kAK+82Zp+JqJtvw==", + "version": "0.7.121", + "resolved": "https://registry.npmjs.org/@zakodium/nmrium-core-plugins/-/nmrium-core-plugins-0.7.121.tgz", + "integrity": "sha512-8x2I7VVYPAnXVRRlX4OB5lqbmZqe74rJCZwyh7wGVGM+VWfGXkbBMdpxWoL1qM1AVjL2bIBr3Aal1ZphXfU30A==", "license": "CC-BY-NC-SA-4.0", "dependencies": { "@date-fns/utc": "^2.1.1", - "@zakodium/nmrium-core": "^0.7.82", + "@zakodium/nmrium-core": "^0.7.83", "cheminfo-types": "^1.16.0", "convert-to-jcamp": "^7.0.2", "date-fns": "^4.4.0", @@ -4466,7 +4466,7 @@ "linear-sum-assignment": "^1.0.9", "lodash.merge": "^4.6.2", "ml-spectra-processing": "^14.35.1", - "nmr-processing": "^22.25.6", + "nmr-processing": "^22.26.0", "openchemlib": "^9.25.1", "openchemlib-utils": "^8.18.0", "sdf-parser": "^9.0.0", @@ -4486,6 +4486,12 @@ } } }, + "node_modules/@zakodium/nmrium-core/node_modules/@zakodium/utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@zakodium/utils/-/utils-0.5.1.tgz", + "integrity": "sha512-aRtHXol0w/nL4ZYiVNicw/7batBasftRDmYSCXj54AWf1MvLrKZNk9hthzp8I+wm7jL938HAIv8eg/45J+XyOg==", + "license": "MIT" + }, "node_modules/@zakodium/pdnd-esm": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@zakodium/pdnd-esm/-/pdnd-esm-1.2.0.tgz", @@ -4504,9 +4510,9 @@ "dev": true }, "node_modules/@zakodium/utils": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@zakodium/utils/-/utils-0.5.1.tgz", - "integrity": "sha512-aRtHXol0w/nL4ZYiVNicw/7batBasftRDmYSCXj54AWf1MvLrKZNk9hthzp8I+wm7jL938HAIv8eg/45J+XyOg==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@zakodium/utils/-/utils-0.7.0.tgz", + "integrity": "sha512-GzcfaFQnzPAY2K+qwORbIuCJnhc/4gJ7GeqMYtpBJJq3100/x5RMgeP+kq4jNY4fFe188hb3WG5cMuDWNuPhbg==", "license": "MIT" }, "node_modules/@zeit/schemas": { @@ -8966,9 +8972,9 @@ "license": "MIT" }, "node_modules/nmr-processing": { - "version": "22.25.6", - "resolved": "https://registry.npmjs.org/nmr-processing/-/nmr-processing-22.25.6.tgz", - "integrity": "sha512-+0o4t2W6BEs1lDgjfLm7YU/eFK+3BOH4XlMzq1Be17IjhBrXij9+Wjz4IZf3EeweWQCG2cJgrO1bMZmGKFQHpA==", + "version": "22.26.0", + "resolved": "https://registry.npmjs.org/nmr-processing/-/nmr-processing-22.26.0.tgz", + "integrity": "sha512-3JOyrUBMl1wOVaTu/q+sP/56qW0X/NPsdnhkEDj77+OtQM8AQDkL+qmI66vXTGaC6UCqDhsImLMWgN9uV+azOQ==", "license": "CC-BY-NC-SA-4.0", "dependencies": { "binary-search": "^1.3.6", @@ -10163,6 +10169,12 @@ "react-dom": "^19.3.0" } }, + "node_modules/react-science/node_modules/@zakodium/utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@zakodium/utils/-/utils-0.5.1.tgz", + "integrity": "sha512-aRtHXol0w/nL4ZYiVNicw/7batBasftRDmYSCXj54AWf1MvLrKZNk9hthzp8I+wm7jL938HAIv8eg/45J+XyOg==", + "license": "MIT" + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", diff --git a/package.json b/package.json index bdb406d18..a58b3ac90 100644 --- a/package.json +++ b/package.json @@ -66,11 +66,11 @@ "@tanstack/react-form": "^1.33.5", "@tanstack/react-store": "^0.11.2", "@tanstack/react-table": "^9.2.6", - "@zakodium/nmr-types": "^0.5.32", - "@zakodium/nmrium-core": "^0.7.82", - "@zakodium/nmrium-core-plugins": "^0.7.119", + "@zakodium/nmr-types": "^0.5.34", + "@zakodium/nmrium-core": "^0.7.83", + "@zakodium/nmrium-core-plugins": "^0.7.121", "@zakodium/pdnd-esm": "^1.2.0", - "@zakodium/utils": "^0.5.1", + "@zakodium/utils": "^0.7.0", "@zip.js/zip.js": "^2.23.0", "cheminfo-font": "^1.28.0", "cheminfo-types": "^1.16.0", diff --git a/src/component/1d/BrushTracker1D.tsx b/src/component/1d/BrushTracker1D.tsx index 3a0d4cb11..03e849500 100644 --- a/src/component/1d/BrushTracker1D.tsx +++ b/src/component/1d/BrushTracker1D.tsx @@ -260,9 +260,12 @@ export function BrushTracker1D({ children }: Required) { } const tools = new Set(['zoom']); + // The region zoom is the tool action of the zoom tool, so it follows + // the `general.invert` preference like the other tools' actions: it + // is enabled when the primary key is activated (with `invert: false` + // that means SHIFT + drag, with `invert: true` a plain drag). const isDefaultBrushEnabled = - !tools.has(selectedTool) || - (tools.has(selectedTool) && !brushData.shiftKey); + !tools.has(selectedTool) || (tools.has(selectedTool) && isPrimary); if (!shouldExecuteDefaultAction || selectedTool == null) { return; diff --git a/src/component/1d/plugin_chart_brush_tracker.tsx b/src/component/1d/plugin_chart_brush_tracker.tsx index 77da0d976..4c836980c 100644 --- a/src/component/1d/plugin_chart_brush_tracker.tsx +++ b/src/component/1d/plugin_chart_brush_tracker.tsx @@ -33,6 +33,7 @@ export function PluginChartBrushTracker() { if (!operatorUI) return null; const isLiveEditable = operatorUI?.isLiveEditable ?? false; + const isLivePreview = isLiveEditable && (liveEdit.value?.checked ?? false); return ( { + liveOperation = setLiveOperation(liveOperation); + + if (!isLiveEditable) return; + if (!liveEdit.value?.checked) return; + + void processingsMutations.applyLiveChange( + liveOperation, + liveEdit.value?.shouldProcessNext ?? false, + ); + }} height={height} width={width} margin={margin} diff --git a/src/component/EventsTrackers/BrushTracker.tsx b/src/component/EventsTrackers/BrushTracker.tsx index 254dc9f03..36d31c1c2 100644 --- a/src/component/EventsTrackers/BrushTracker.tsx +++ b/src/component/EventsTrackers/BrushTracker.tsx @@ -28,6 +28,8 @@ import type { MouseButton, Position, } from './brush_tracker.types.ts'; +import type { DragGestureController } from './brush_tracker_drag_gesture.ts'; +import { createDragGesture } from './brush_tracker_drag_gesture.ts'; import type { BrushTrackerEventEmitter } from './brush_tracker_events_listeners.ts'; import { BrushTrackerEventEmitterContext, @@ -83,6 +85,15 @@ export function useBrushTracker() { function isSelfControlledTarget(target: EventTarget) { return (target as Element).closest('[data-self-control="true"]') !== null; } + +/** + * Minimal pointer travel in px between pointerdown and pointerup for the + * gesture to be considered a drag. Pointer jitter below this threshold + * (hand tremor, trackpad imprecision) still counts as a click. + * Keep it in sync with the consumer drag threshold + * (`ProcessingOperatorUI.ChartBrushTracker` dragThreshold). + */ +const dragDetectionThreshold = 4; function getMouseXY(event: MouseEvent, currentTarget?: Element) { const boundingRect = ( currentTarget ?? event.currentTarget @@ -112,8 +123,16 @@ export function BrushTracker(options: BrushTrackerProps) { const lastRef = useRef({ x: 0, y: 0 }); const isSelfControlledRef = useRef(false); - const { onClick, onDoubleClick, onBrush, onBrushEnd, onZoom, emitter } = - useWrappedEvents(options); + const { + onClick, + onDoubleClick, + onBrush, + onBrushEnd, + onZoom, + onDrag, + onDragEnd, + emitter, + } = useWrappedEvents(options); const handleClickWithDebounce = useCallback( (event: MouseEvent, currentTarget: Element) => { @@ -179,6 +198,8 @@ export function BrushTracker(options: BrushTrackerProps) { isDraggingRef.current = false; // Reset dragging flag + let dragGesture: DragGestureController | null = null; + //check that the right or left mouse button pressed if ([0, 2].includes(event.button)) { if (noPropagation) { @@ -193,6 +214,14 @@ export function BrushTracker(options: BrushTrackerProps) { }; lastRef.current = { x: 0, y: 0 }; + dragGesture = createDragGesture({ + mouseButton: MouseButtons[event.button], + startX: startPositionRef.current.x, + startY: startPositionRef.current.y, + rectX: boundingRect.x, + rectY: boundingRect.y, + }); + if (!event.ctrlKey) { dispatch({ type: 'DOWN', @@ -211,16 +240,31 @@ export function BrushTracker(options: BrushTrackerProps) { } 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, - screenX, - screenY, - shiftKey, - altKey, - ctrlKey, - } = event; + const { clientX, clientY } = event; + + // Only a real pointer travel marks the gesture as a drag: pointer + // jitter must not suppress the click event dispatched on pointerup. + // Both positions must be compared in the same coordinate system: + // `clientX` is viewport-relative while `startPositionRef` is + // relative to the chart container, so the container offset must + // be removed before comparing, otherwise the layout offset itself + // counts as pointer travel. The same threshold also gates the + // `MOVE` dispatch, so pointer jitter neither shows the brush + // banner nor updates the brush range while clicking (e.g. adding + // anchors). + const boundingRect = boundingRectRef.current; + const isPointerTravel = + boundingRect && + Math.hypot( + clientX - boundingRect.x - startPositionRef.current.x, + clientY - boundingRect.y - startPositionRef.current.y, + ) > dragDetectionThreshold; + + if (isPointerTravel) { + isDraggingRef.current = true; + } + + const { screenX, screenY, shiftKey, altKey, ctrlKey } = event; if (ctrlKey) { if (boundingRectRef.current) { @@ -248,19 +292,31 @@ export function BrushTracker(options: BrushTrackerProps) { lastRef.current = { x, y }; } } else { - dispatch({ - type: 'MOVE', - payload: { - screenX, - screenY, - clientX, - clientY, - }, - }); + const dragEvent = dragGesture?.move(event); + if (dragEvent) { + onDrag(dragEvent); + } + + if (!dragEvent?.isPropagationStopped() && isPointerTravel) { + dispatch({ + type: 'MOVE', + payload: { + screenX, + screenY, + clientX, + clientY, + }, + }); + } } } - function upCallback() { + function upCallback(pointerUpEvent: globalThis.PointerEvent) { + const dragEndEvent = dragGesture?.end(pointerUpEvent); + if (dragEndEvent) { + onDragEnd(dragEndEvent); + } + if (isDraggingRef.current) { dispatch({ type: 'UP', @@ -283,7 +339,14 @@ export function BrushTracker(options: BrushTrackerProps) { return false; }, - [clickTriggerMode, handleClickWithDebounce, noPropagation, onZoom], + [ + clickTriggerMode, + handleClickWithDebounce, + noPropagation, + onDrag, + onDragEnd, + onZoom, + ], ); const handleMouseWheel = useCallback( @@ -590,7 +653,13 @@ export function detectBrushing( type UseWrappedEventsProps = Pick< BrushTrackerProps, - 'onBrush' | 'onBrushEnd' | 'onClick' | 'onDoubleClick' | 'onZoom' + | 'onBrush' + | 'onBrushEnd' + | 'onClick' + | 'onDoubleClick' + | 'onZoom' + | 'onDrag' + | 'onDragEnd' >; interface UseWrappedEvents extends Required { @@ -610,6 +679,8 @@ function useWrappedEvents(props: UseWrappedEventsProps): UseWrappedEvents { onZoom: onZoomProp = noop, onDoubleClick: onDoubleClickProp = noop, onClick: onClickProp = noop, + onDrag: onDragProp = noop, + onDragEnd: onDragEndProp = noop, } = props; const emitter = useInitBrushTrackerEventEmitter(); @@ -680,5 +751,44 @@ function useWrappedEvents(props: UseWrappedEventsProps): UseWrappedEvents { }, ); - return { onClick, onDoubleClick, onBrush, onBrushEnd, onZoom, emitter }; + const onDrag = useEventCallback, void>( + (event) => { + try { + emitter.emit('drag', event); + } catch (error) { + reportError(error); + } + + if (event.isPropagationStopped()) return; + if (event.isDefaultPrevented()) return; + + onDragProp(event); + }, + ); + + const onDragEnd = useEventCallback, void>( + (event) => { + try { + emitter.emit('drag-end', event); + } catch (error) { + reportError(error); + } + + if (event.isPropagationStopped()) return; + if (event.isDefaultPrevented()) return; + + onDragEndProp(event); + }, + ); + + return { + onClick, + onDoubleClick, + onBrush, + onBrushEnd, + onZoom, + onDrag, + onDragEnd, + emitter, + }; } diff --git a/src/component/EventsTrackers/__tests__/brush_tracker_drag_gesture.test.ts b/src/component/EventsTrackers/__tests__/brush_tracker_drag_gesture.test.ts new file mode 100644 index 000000000..623a30731 --- /dev/null +++ b/src/component/EventsTrackers/__tests__/brush_tracker_drag_gesture.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'vitest'; + +import type { DragGestureOptions } from '../brush_tracker_drag_gesture.ts'; +import { createDragGesture } from '../brush_tracker_drag_gesture.ts'; + +function createPointerEvent( + options: Partial = {}, +): globalThis.PointerEvent { + return { + clientX: 0, + clientY: 0, + shiftKey: false, + altKey: false, + ctrlKey: false, + ...options, + } as globalThis.PointerEvent; +} + +const options: DragGestureOptions = { + mouseButton: 'main', + startX: 30, + startY: 40, + rectX: 100, + rectY: 200, +}; + +describe('createDragGesture', () => { + test('move event coordinates are relative to the chart container', () => { + const gesture = createDragGesture(options); + + const event = gesture.move( + createPointerEvent({ clientX: 120, clientY: 280 }), + ); + + expect(event.x).toBe(20); + expect(event.y).toBe(80); + expect(event.startX).toBe(30); + expect(event.startY).toBe(40); + }); + + test('move event carries the pointerdown mouse button and move modifier keys', () => { + const gesture = createDragGesture({ + ...options, + mouseButton: 'secondary', + }); + + const event = gesture.move( + createPointerEvent({ shiftKey: true, altKey: true, ctrlKey: false }), + ); + + expect(event.mouseButton).toBe('secondary'); + expect(event.shiftKey).toBe(true); + expect(event.altKey).toBe(true); + expect(event.ctrlKey).toBe(false); + }); + + test('claiming a drag persists for the rest of the gesture', () => { + const gesture = createDragGesture(options); + + const firstEvent = gesture.move(createPointerEvent()); + expect(firstEvent.isPropagationStopped()).toBe(false); + + firstEvent.stopPropagation(); + + expect(firstEvent.isPropagationStopped()).toBe(true); + + const secondEvent = gesture.move(createPointerEvent()); + expect(secondEvent.isPropagationStopped()).toBe(true); + + const endEvent = gesture.end(createPointerEvent()); + expect(endEvent?.isPropagationStopped()).toBe(true); + }); + + test('default prevention persists for the rest of the gesture', () => { + const gesture = createDragGesture(options); + + const firstEvent = gesture.move(createPointerEvent()); + firstEvent.preventDefault(); + + expect(firstEvent.isDefaultPrevented()).toBe(true); + + const secondEvent = gesture.move(createPointerEvent()); + expect(secondEvent.isDefaultPrevented()).toBe(true); + }); + + test('end returns null when no drag was emitted', () => { + const gesture = createDragGesture(options); + + expect(gesture.end(createPointerEvent())).toBeNull(); + }); + + test('end event uses the pointerup coordinates', () => { + const gesture = createDragGesture(options); + + gesture.move(createPointerEvent({ clientX: 130, clientY: 240 })); + + const endEvent = gesture.end( + createPointerEvent({ clientX: 150, clientY: 260 }), + ); + + expect(endEvent?.x).toBe(50); + expect(endEvent?.y).toBe(60); + }); +}); diff --git a/src/component/EventsTrackers/brush_tracker.types.ts b/src/component/EventsTrackers/brush_tracker.types.ts index a44e6ea43..8f668df98 100644 --- a/src/component/EventsTrackers/brush_tracker.types.ts +++ b/src/component/EventsTrackers/brush_tracker.types.ts @@ -49,6 +49,47 @@ export type OnZoom = (options: ZoomOptions) => void; export type OnBrush = (state: BrushTrackerData) => void; type OnBrushEnd = OnBrush; +interface DragCoordination { + /** + x coordinate relative to the chart container + */ + x: number; + /** + y coordinate relative to the chart container + */ + y: number; + /** + x coordinate relative to the chart container at pointerdown + */ + startX: number; + /** + y coordinate relative to the chart container at pointerdown + */ + startY: number; +} + +export interface DragTrackerEvent extends DragCoordination { + /** + Native pointer event that triggered this event + */ + domEvent: globalThis.PointerEvent; + mouseButton: MouseButton; + shiftKey: boolean; + altKey: boolean; + ctrlKey: boolean; + isDefaultPrevented(): boolean; + isPropagationStopped(): boolean; + preventDefault(): void; + /** + * Claim the current gesture as a drag. + * Prevents the default brush behavior for the rest of the gesture. + */ + stopPropagation(): void; +} + +type OnDrag = (event: DragTrackerEvent) => void; +type OnDragEnd = OnDrag; + export type BaseDetectBrushingOptions = AdvanceOmit< DetectBrushingOptions, 'width' | 'height' @@ -60,6 +101,8 @@ export interface BrushTrackerProps { style?: CSSProperties; onBrushEnd?: OnBrushEnd; onBrush?: OnBrush; + onDrag?: OnDrag; + onDragEnd?: OnDragEnd; onZoom?: OnZoom; onDoubleClick?: OnDoubleClick; onClick?: OnClick; diff --git a/src/component/EventsTrackers/brush_tracker_drag_gesture.ts b/src/component/EventsTrackers/brush_tracker_drag_gesture.ts new file mode 100644 index 000000000..ea5546aaf --- /dev/null +++ b/src/component/EventsTrackers/brush_tracker_drag_gesture.ts @@ -0,0 +1,84 @@ +import type { DragTrackerEvent, MouseButton } from './brush_tracker.types.ts'; + +export interface DragGestureOptions { + mouseButton: MouseButton; + /** + x coordinate of the pointerdown relative to the chart container. + */ + startX: number; + /** + y coordinate of the pointerdown relative to the chart container. + */ + startY: number; + /** + x of the chart container bounding rect origin. + */ + rectX: number; + /** + y of the chart container bounding rect origin. + */ + rectY: number; +} + +export interface DragGestureController { + /** + * Create the drag event for a pointermove. + * The consumer decides via the event claim API (`stopPropagation`) + * whether the gesture takes precedence over the default brush behavior. + */ + move(pointerEvent: globalThis.PointerEvent): DragTrackerEvent; + /** + * Create the drag-end event for the pointerup, + * or `null` if no drag was emitted during the gesture. + */ + end(pointerEvent: globalThis.PointerEvent): DragTrackerEvent | null; +} + +/** + * Stateful factory for the drag events of a single pointer gesture. + * + * The claim state (`stopPropagation` / `preventDefault`) is shared by all + * the events of the gesture, so a consumer claiming the gesture on the + * first `drag` event keeps it claimed until `drag-end`. + */ +export function createDragGesture( + options: DragGestureOptions, +): DragGestureController { + const { mouseButton, startX, startY, rectX, rectY } = options; + let claimed = false; + let defaultPrevented = false; + let emitted = false; + + function build(pointerEvent: globalThis.PointerEvent): DragTrackerEvent { + return { + x: pointerEvent.clientX - rectX, + y: pointerEvent.clientY - rectY, + startX, + startY, + domEvent: pointerEvent, + mouseButton, + shiftKey: pointerEvent.shiftKey, + altKey: pointerEvent.altKey, + ctrlKey: pointerEvent.ctrlKey, + isDefaultPrevented: () => defaultPrevented, + isPropagationStopped: () => claimed, + preventDefault: () => { + defaultPrevented = true; + }, + stopPropagation: () => { + claimed = true; + }, + }; + } + + return { + move(pointerEvent) { + emitted = true; + return build(pointerEvent); + }, + end(pointerEvent) { + if (!emitted) return null; + return build(pointerEvent); + }, + }; +} diff --git a/src/component/EventsTrackers/brush_tracker_events_listeners.ts b/src/component/EventsTrackers/brush_tracker_events_listeners.ts index f7e960525..2955ff6d7 100644 --- a/src/component/EventsTrackers/brush_tracker_events_listeners.ts +++ b/src/component/EventsTrackers/brush_tracker_events_listeners.ts @@ -3,7 +3,13 @@ import { EventEmitter } from 'eventemitter3'; import { createContext, use, useState } from 'react'; export type BrushTrackerEventEmitterSupportedEvents = - 'click' | 'click-double' | 'zoom' | 'brush' | 'brush-end'; + | 'click' + | 'click-double' + | 'zoom' + | 'brush' + | 'brush-end' + | 'drag' + | 'drag-end'; export type BrushTrackerEventEmitter = EventEmitter; diff --git a/src/component/panels/filtersPanel/processings/operator_edit_banner.tsx b/src/component/panels/filtersPanel/processings/operator_edit_banner.tsx index 325208d6c..28460c015 100644 --- a/src/component/panels/filtersPanel/processings/operator_edit_banner.tsx +++ b/src/component/panels/filtersPanel/processings/operator_edit_banner.tsx @@ -7,6 +7,12 @@ import { Button } from 'react-science/ui'; import type { UseLiveEdit } from './use_live_edit.ts'; interface OperatorEditBannerProps { + /** + * Content rendered at the leftmost position of the banner, before the + * live preview controls. Provided by the processing operator UI + * (second argument of the `children` render method). + */ + leftContent?: ReactNode; isLiveEditable: boolean; liveEdit: UseLiveEdit; onClose: () => void; @@ -14,10 +20,11 @@ interface OperatorEditBannerProps { } export function OperatorEditBanner(props: OperatorEditBannerProps) { - const { isLiveEditable, liveEdit, children, onClose } = props; + const { leftContent, isLiveEditable, liveEdit, children, onClose } = props; return ( + {leftContent} {isLiveEditable && ( <> - {(submitButton) => + {(submitButton, leftContent) => !isEditable ? null : ( selectProcessingOperator(undefined)} diff --git a/src/component/utility/core_slots/core_operator_chart_brush.commons.ts b/src/component/utility/core_slots/core_operator_chart_brush.commons.ts index c6410c14a..faa014665 100644 --- a/src/component/utility/core_slots/core_operator_chart_brush.commons.ts +++ b/src/component/utility/core_slots/core_operator_chart_brush.commons.ts @@ -1,5 +1,6 @@ import type { BrushTrackerEvents, + BrushTrackerEventsDragEvent, BrushTrackerEventsOnClickEvent, BrushTrackerEventsTrackerEvent, BrushTrackerEventsZoomEvent, @@ -11,6 +12,7 @@ import { useEventCallback } from 'usehooks-ts'; import type { BrushTrackerData, ClickOptions, + DragTrackerEvent, ZoomOptions, } from '../../EventsTrackers/brush_tracker.types.ts'; import type { @@ -18,6 +20,10 @@ import type { BrushTrackerEventEmitterSupportedEvents, } from '../../EventsTrackers/brush_tracker_events_listeners.ts'; import { useBrushTrackerEventEmitter } from '../../EventsTrackers/brush_tracker_events_listeners.ts'; +import { + getModifiers, + useMapKeyModifiers, +} from '../../context/KeyModifierContext.tsx'; /** * Hook to forward to `ProcessingOperatorUI.ChartBrushTracker`. @@ -25,6 +31,7 @@ import { useBrushTrackerEventEmitter } from '../../EventsTrackers/brush_tracker_ */ export function useBrushTrackerEvent(events: BrushTrackerEvents) { const emitter = useBrushTrackerEventEmitter(); + const isPrimaryKeyActivated = useMapKeyModifiers(); // // Integration mapping to core events listening stabilized @@ -46,15 +53,37 @@ export function useBrushTrackerEvent(events: BrushTrackerEvents) { events.onZoom?.(event); }); const onClickStable = useEventCallback((options: ClickOptions) => { - const event = clickOptionsToCoreClickEvent(options); + const event = clickOptionsToCoreClickEvent( + options, + isPrimaryKeyActivated(getModifiers(options)), + ); events.onClick?.(event); }); const onDoubleClickStable = useEventCallback((options: ClickOptions) => { - const event = clickOptionsToCoreClickEvent(options); + const event = clickOptionsToCoreClickEvent( + options, + isPrimaryKeyActivated(getModifiers(options)), + ); events.onDoubleClick?.(event); }); + const onDragStable = useEventCallback((data: DragTrackerEvent) => { + const event = dragTrackerEventToCoreDragEvent( + data, + isPrimaryKeyActivated(getModifiers(getDragEventModifiers(data))), + ); + + events.onDrag?.(event); + }); + const onDragEndStable = useEventCallback((data: DragTrackerEvent) => { + const event = dragTrackerEventToCoreDragEvent( + data, + isPrimaryKeyActivated(getModifiers(getDragEventModifiers(data))), + ); + + events.onDragEnd?.(event); + }); // // Use stable version if the event is defined. @@ -66,6 +95,8 @@ export function useBrushTrackerEvent(events: BrushTrackerEvents) { const onZoom = events.onZoom && onZoomStable; const onClick = events.onClick && onClickStable; const onDoubleClick = events.onDoubleClick && onDoubleClickStable; + const onDrag = events.onDrag && onDragStable; + const onDragEnd = events.onDragEnd && onDragEndStable; // // Forward the events listeners @@ -76,6 +107,8 @@ export function useBrushTrackerEvent(events: BrushTrackerEvents) { useRegisterListener(emitter, 'zoom', onZoom); useRegisterListener(emitter, 'click', onClick); useRegisterListener(emitter, 'click-double', onDoubleClick); + useRegisterListener(emitter, 'drag', onDrag); + useRegisterListener(emitter, 'drag-end', onDragEnd); } function useRegisterListener( @@ -131,8 +164,71 @@ function zoomOptionsToCoreZoomEvent( function clickOptionsToCoreClickEvent( event: ClickOptions, + isPrimaryKeyActivated: boolean, ): BrushTrackerEventsOnClickEvent { const { x, y, ...domEvent } = event; - return { domEvent, position: { x, y } }; + return { + // Claiming the event flags the emitted tracker event (NOT the native + // domEvent, whose stopPropagation would not be honored by the veto + // check), so the host `useWrappedEvents` skips its default behavior + // for this event (like the double-click zoom-out). + stopPropagation: () => claimTrackerEvent(event), + domEvent, + position: { x, y }, + isPrimaryKeyActivated, + }; +} + +/** + * Flag a tracker event as propagation-stopped. + * + * The emitted click events are spreads of the React synthetic event, so + * they carry an own `isPropagationStopped` flag but no `stopPropagation` + * method: it lives on the synthetic event prototype and is lost in the + * spread. The claim reassigns the flag on the tracker event itself, + * which is the flag checked by the host `useWrappedEvents` after + * emitting to its listeners. + */ +function claimTrackerEvent(event: ClickOptions) { + event.isPropagationStopped = () => true; +} + +function dragTrackerEventToCoreDragEvent( + event: DragTrackerEvent, + isPrimaryKeyActivated: boolean, +): BrushTrackerEventsDragEvent { + const { + x, + y, + startX, + startY, + domEvent, + mouseButton, + ctrlKey, + altKey, + shiftKey, + } = event; + + return { + // Claiming the gesture delegates to the tracker event (NOT the native + // domEvent, whose stopPropagation is a no-op for the veto), so the + // brush suppression is honored by the host `BrushTracker`. + stopPropagation: () => event.stopPropagation(), + mouseButton, + modifiersKey: { ctrlKey, altKey, shiftKey, metaKey: domEvent.metaKey }, + position: { x, y }, + positionStart: { x: startX, y: startY }, + isPrimaryKeyActivated, + }; +} + +/** + * Modifier keys of a drag tracker event, in the shape expected by the + * `KeyModifierContext` helpers (`metaKey` comes from the native event). + */ +function getDragEventModifiers(event: DragTrackerEvent) { + const { shiftKey, altKey, ctrlKey, domEvent } = event; + + return { shiftKey, altKey, ctrlKey, metaKey: domEvent.metaKey }; } diff --git a/src/component/utility/core_slots/core_operator_chart_svg.tsx b/src/component/utility/core_slots/core_operator_chart_svg.tsx index 74305c6d1..593073d86 100644 --- a/src/component/utility/core_slots/core_operator_chart_svg.tsx +++ b/src/component/utility/core_slots/core_operator_chart_svg.tsx @@ -17,6 +17,8 @@ export function CoreOperatorChartSVG(props: CoreOperatorChartSVGProps) { core, spectrum, operation, + onChange, + isLivePreview, height, width, margin, @@ -34,6 +36,8 @@ export function CoreOperatorChartSVG(props: CoreOperatorChartSVGProps) { core={core} spectrum={spectrum} operation={operation} + onChange={onChange} + isLivePreview={isLivePreview} height={height} width={width} margin={margin} diff --git a/src/data/constants/Multiplets.ts b/src/data/constants/Multiplets.ts index 4a0e6f193..b4459b434 100644 --- a/src/data/constants/Multiplets.ts +++ b/src/data/constants/Multiplets.ts @@ -7,9 +7,9 @@ interface MultipletDefinition { } export const multiplets: MultipletDefinition[] = MultiplicityPatterns.map( - ({ label, value, acs, multiplicity }) => ({ + ({ label, value, acs = value, multiplicity }) => ({ label, - value: acs || value, + value: acs, multiplicity, }), );