From fb85116f78fdc5aad5cf40b5aaa6bf876389e75c Mon Sep 17 00:00:00 2001 From: jobo322 Date: Fri, 16 Oct 2026 09:49:29 -0500 Subject: [PATCH 1/9] feat: add drag and drag-end events to BrushTracker - Extend BrushTrackerProps with onDrag/onDragEnd and a DragTrackerEvent payload (chart-relative coordinates, start position, mouse button, modifier keys, domEvent and a gesture claim API). - Extend the emitter union with 'drag' | 'drag-end'. - Emit drag from the first pointermove with a pressed button (no threshold) and on every move; a consumer claim (stopPropagation) suppresses the brush dispatch for the rest of the gesture. - Extract the gesture logic to brush_tracker_drag_gesture.ts with unit tests. - Thread onChange to the operator ChartSVG slot so SVG-layer interactions (keyboard anchor deletion) can update the operation. --- src/component/1d/plugin_svg_chart.tsx | 19 ++- src/component/EventsTrackers/BrushTracker.tsx | 116 +++++++++++++++--- .../brush_tracker_drag_gesture.test.ts | 104 ++++++++++++++++ .../EventsTrackers/brush_tracker.types.ts | 43 +++++++ .../brush_tracker_drag_gesture.ts | 84 +++++++++++++ .../brush_tracker_events_listeners.ts | 8 +- .../core_operator_chart_brush.commons.ts | 43 +++++++ .../core_slots/core_operator_chart_svg.tsx | 2 + 8 files changed, 402 insertions(+), 17 deletions(-) create mode 100644 src/component/EventsTrackers/__tests__/brush_tracker_drag_gesture.test.ts create mode 100644 src/component/EventsTrackers/brush_tracker_drag_gesture.ts diff --git a/src/component/1d/plugin_svg_chart.tsx b/src/component/1d/plugin_svg_chart.tsx index 5f29d5ca8..f365c85a3 100644 --- a/src/component/1d/plugin_svg_chart.tsx +++ b/src/component/1d/plugin_svg_chart.tsx @@ -5,9 +5,11 @@ import { useChartData } from '../context/ChartContext.tsx'; import { useCore } from '../context/CoreContext.tsx'; import type { ScaleLinearNumberFunction } from '../context/ScaleContext.tsx'; import { useScaleChecked } from '../context/ScaleContext.tsx'; +import { useProcessingsMutations } from '../context/processings_mutations_context.tsx'; import { useIndicatorLineColor } from '../hooks/useIndicatorLineColor.ts'; import useSpectrum from '../hooks/useSpectrum.ts'; import { useVerticalAlign } from '../hooks/useVerticalAlign.ts'; +import { useLiveEdit } from '../panels/filtersPanel/processings/use_live_edit.ts'; import { useLiveOperation } from '../panels/filtersPanel/processings/use_live_operation.ts'; import { CoreOperatorChartSVG } from '../utility/core_slots/core_operator_chart_svg.tsx'; @@ -17,7 +19,9 @@ export function PluginSVGChart1D() { const core = useCore(); const spectrum = useSpectrum(); const { height, width, margin } = useChartData(); - const [liveOperation] = useLiveOperation(); + const [liveOperation, setLiveOperation] = useLiveOperation(); + const liveEdit = useLiveEdit(liveOperation?.uid); + const processingsMutations = useProcessingsMutations(); const { scaleX: scaleXBuilder } = useScaleChecked(); const indicatorLineColor = useIndicatorLineColor(); @@ -31,12 +35,25 @@ export function PluginSVGChart1D() { const operatorUI = core.slotOperator(liveOperation.operatorId); if (!operatorUI) return null; + const isLiveEditable = operatorUI?.isLiveEditable ?? 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..6c766f028 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, @@ -112,8 +114,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 +189,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 +205,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', @@ -248,19 +268,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()) { + 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 +315,14 @@ export function BrushTracker(options: BrushTrackerProps) { return false; }, - [clickTriggerMode, handleClickWithDebounce, noPropagation, onZoom], + [ + clickTriggerMode, + handleClickWithDebounce, + noPropagation, + onDrag, + onDragEnd, + onZoom, + ], ); const handleMouseWheel = useCallback( @@ -590,7 +629,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 +655,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 +727,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..21eef9ebd 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; +export 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; +} + +export type OnDrag = (event: DragTrackerEvent) => void; +export 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/utility/core_slots/core_operator_chart_brush.commons.ts b/src/component/utility/core_slots/core_operator_chart_brush.commons.ts index c6410c14a..6aa55a27e 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 { @@ -55,6 +57,16 @@ export function useBrushTrackerEvent(events: BrushTrackerEvents) { events.onDoubleClick?.(event); }); + const onDragStable = useEventCallback((data: DragTrackerEvent) => { + const event = dragTrackerEventToCoreDragEvent(data); + + events.onDrag?.(event); + }); + const onDragEndStable = useEventCallback((data: DragTrackerEvent) => { + const event = dragTrackerEventToCoreDragEvent(data); + + events.onDragEnd?.(event); + }); // // Use stable version if the event is defined. @@ -66,6 +78,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 +90,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( @@ -136,3 +152,30 @@ function clickOptionsToCoreClickEvent( return { domEvent, position: { x, y } }; } + +function dragTrackerEventToCoreDragEvent( + event: DragTrackerEvent, +): 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 }, + }; +} 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..bcbec551c 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,7 @@ export function CoreOperatorChartSVG(props: CoreOperatorChartSVGProps) { core, spectrum, operation, + onChange, height, width, margin, @@ -34,6 +35,7 @@ export function CoreOperatorChartSVG(props: CoreOperatorChartSVGProps) { core={core} spectrum={spectrum} operation={operation} + onChange={onChange} height={height} width={width} margin={margin} From 9ef11bf15c82a68cd2a09b288caf8f0a4b68382b Mon Sep 17 00:00:00 2001 From: jobo322 Date: Mon, 19 Oct 2026 10:29:00 -0500 Subject: [PATCH 2/9] chore: do not export the types and interface because it is consumed by the contract from nmrium-core --- src/component/EventsTrackers/brush_tracker.types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/component/EventsTrackers/brush_tracker.types.ts b/src/component/EventsTrackers/brush_tracker.types.ts index 21eef9ebd..8f668df98 100644 --- a/src/component/EventsTrackers/brush_tracker.types.ts +++ b/src/component/EventsTrackers/brush_tracker.types.ts @@ -49,7 +49,7 @@ export type OnZoom = (options: ZoomOptions) => void; export type OnBrush = (state: BrushTrackerData) => void; type OnBrushEnd = OnBrush; -export interface DragCoordination { +interface DragCoordination { /** x coordinate relative to the chart container */ @@ -87,8 +87,8 @@ export interface DragTrackerEvent extends DragCoordination { stopPropagation(): void; } -export type OnDrag = (event: DragTrackerEvent) => void; -export type OnDragEnd = OnDrag; +type OnDrag = (event: DragTrackerEvent) => void; +type OnDragEnd = OnDrag; export type BaseDetectBrushingOptions = AdvanceOmit< DetectBrushingOptions, From 1d3d2b5f57e0ed0b95f64c52b69eda34a44a7c8a Mon Sep 17 00:00:00 2001 From: jobo322 Date: Tue, 20 Oct 2026 10:07:16 -0500 Subject: [PATCH 3/9] fix: expose click claim API to core operator chart slots --- .../core_operator_chart_brush.commons.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) 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 6aa55a27e..3c3e28abe 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 @@ -150,7 +150,29 @@ function clickOptionsToCoreClickEvent( ): 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 }, + }; +} + +/** + * 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( From f91f5102abc7e1a79080e3c905e0f690f7760fe5 Mon Sep 17 00:00:00 2001 From: jobo322 Date: Tue, 20 Oct 2026 10:07:16 -0500 Subject: [PATCH 4/9] fix: require pointer travel to mark gesture as drag --- src/component/EventsTrackers/BrushTracker.tsx | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/component/EventsTrackers/BrushTracker.tsx b/src/component/EventsTrackers/BrushTracker.tsx index 6c766f028..5de869b37 100644 --- a/src/component/EventsTrackers/BrushTracker.tsx +++ b/src/component/EventsTrackers/BrushTracker.tsx @@ -85,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 = 2; function getMouseXY(event: MouseEvent, currentTarget?: Element) { const boundingRect = ( currentTarget ?? event.currentTarget @@ -231,16 +240,20 @@ 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. + if ( + Math.hypot( + clientX - startPositionRef.current.x, + clientY - startPositionRef.current.y, + ) > dragDetectionThreshold + ) { + isDraggingRef.current = true; + } + + const { screenX, screenY, shiftKey, altKey, ctrlKey } = event; if (ctrlKey) { if (boundingRectRef.current) { From ee4c4853df10248f8f7e99d1db42c52f7a8f31b0 Mon Sep 17 00:00:00 2001 From: jobo322 Date: Tue, 20 Oct 2026 10:34:09 -0500 Subject: [PATCH 5/9] feat: forward live preview state to operator chart slots --- src/component/1d/plugin_chart_brush_tracker.tsx | 2 ++ src/component/1d/plugin_svg_chart.tsx | 2 ++ src/component/utility/core_slots/core_operator_chart_svg.tsx | 2 ++ 3 files changed, 6 insertions(+) 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); 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 bcbec551c..593073d86 100644 --- a/src/component/utility/core_slots/core_operator_chart_svg.tsx +++ b/src/component/utility/core_slots/core_operator_chart_svg.tsx @@ -18,6 +18,7 @@ export function CoreOperatorChartSVG(props: CoreOperatorChartSVGProps) { spectrum, operation, onChange, + isLivePreview, height, width, margin, @@ -36,6 +37,7 @@ export function CoreOperatorChartSVG(props: CoreOperatorChartSVGProps) { spectrum={spectrum} operation={operation} onChange={onChange} + isLivePreview={isLivePreview} height={height} width={width} margin={margin} From 15926c0166382cf8f14a01959be4681e77ba08ca Mon Sep 17 00:00:00 2001 From: jobo322 Date: Tue, 20 Oct 2026 11:09:58 -0500 Subject: [PATCH 6/9] feat: render operator left content in the edition banner --- .../filtersPanel/processings/operator_edit_banner.tsx | 9 ++++++++- .../panels/filtersPanel/processings/processing_item.tsx | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) 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)} From d2971f30968cdf1ea94a0f14cb49cc7b75f8f81f Mon Sep 17 00:00:00 2001 From: jobo322 Date: Tue, 20 Oct 2026 11:58:19 -0500 Subject: [PATCH 7/9] fix: compare pointer travel in container coordinates --- src/component/EventsTrackers/BrushTracker.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/component/EventsTrackers/BrushTracker.tsx b/src/component/EventsTrackers/BrushTracker.tsx index 5de869b37..5dc107bd5 100644 --- a/src/component/EventsTrackers/BrushTracker.tsx +++ b/src/component/EventsTrackers/BrushTracker.tsx @@ -93,7 +93,7 @@ function isSelfControlledTarget(target: EventTarget) { * Keep it in sync with the consumer drag threshold * (`ProcessingOperatorUI.ChartBrushTracker` dragThreshold). */ -const dragDetectionThreshold = 2; +const dragDetectionThreshold = 4; function getMouseXY(event: MouseEvent, currentTarget?: Element) { const boundingRect = ( currentTarget ?? event.currentTarget @@ -244,10 +244,17 @@ export function BrushTracker(options: BrushTrackerProps) { // 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. + const boundingRect = boundingRectRef.current; if ( + boundingRect && Math.hypot( - clientX - startPositionRef.current.x, - clientY - startPositionRef.current.y, + clientX - boundingRect.x - startPositionRef.current.x, + clientY - boundingRect.y - startPositionRef.current.y, ) > dragDetectionThreshold ) { isDraggingRef.current = true; From 62296442449f83eaff0a53fa81406419afdc5f4e Mon Sep 17 00:00:00 2001 From: jobo322 Date: Tue, 20 Oct 2026 12:05:54 -0500 Subject: [PATCH 8/9] fix: gate brush move dispatch behind drag threshold --- src/component/EventsTrackers/BrushTracker.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/component/EventsTrackers/BrushTracker.tsx b/src/component/EventsTrackers/BrushTracker.tsx index 5dc107bd5..36d31c1c2 100644 --- a/src/component/EventsTrackers/BrushTracker.tsx +++ b/src/component/EventsTrackers/BrushTracker.tsx @@ -248,15 +248,19 @@ export function BrushTracker(options: BrushTrackerProps) { // `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. + // 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; - if ( + const isPointerTravel = boundingRect && Math.hypot( clientX - boundingRect.x - startPositionRef.current.x, clientY - boundingRect.y - startPositionRef.current.y, - ) > dragDetectionThreshold - ) { + ) > dragDetectionThreshold; + + if (isPointerTravel) { isDraggingRef.current = true; } @@ -293,7 +297,7 @@ export function BrushTracker(options: BrushTrackerProps) { onDrag(dragEvent); } - if (!dragEvent?.isPropagationStopped()) { + if (!dragEvent?.isPropagationStopped() && isPointerTravel) { dispatch({ type: 'MOVE', payload: { From 14c41306ebb02bc6e41abe55efb348bb7585fd02 Mon Sep 17 00:00:00 2001 From: jobo322 Date: Thu, 29 Oct 2026 09:33:34 -0500 Subject: [PATCH 9/9] feat: expose the primary key state to the operator chart slots The tracker events exposed the raw shiftKey, so operator UIs had to hard-code shift and could not follow the general.invert preference. Resolve the primary key with useMapKeyModifiers in the chart brush commons, pass it to the click and drag events, and make the default brush of the zoom tool follow the primary key too. --- src/component/1d/BrushTracker1D.tsx | 7 +++- .../core_operator_chart_brush.commons.ts | 39 +++++++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/component/1d/BrushTracker1D.tsx b/src/component/1d/BrushTracker1D.tsx index 3a0d4cb11..93750ab76 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 default brush follows the `general.invert` preference: the + // primary key activates the tool action, so the brush (the host + // default, i.e. the region zoom) is enabled when the primary key is + // NOT activated. 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/utility/core_slots/core_operator_chart_brush.commons.ts b/src/component/utility/core_slots/core_operator_chart_brush.commons.ts index 3c3e28abe..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 @@ -20,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`. @@ -27,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 @@ -48,22 +53,34 @@ 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); + const event = dragTrackerEventToCoreDragEvent( + data, + isPrimaryKeyActivated(getModifiers(getDragEventModifiers(data))), + ); events.onDrag?.(event); }); const onDragEndStable = useEventCallback((data: DragTrackerEvent) => { - const event = dragTrackerEventToCoreDragEvent(data); + const event = dragTrackerEventToCoreDragEvent( + data, + isPrimaryKeyActivated(getModifiers(getDragEventModifiers(data))), + ); events.onDragEnd?.(event); }); @@ -147,6 +164,7 @@ function zoomOptionsToCoreZoomEvent( function clickOptionsToCoreClickEvent( event: ClickOptions, + isPrimaryKeyActivated: boolean, ): BrushTrackerEventsOnClickEvent { const { x, y, ...domEvent } = event; @@ -158,6 +176,7 @@ function clickOptionsToCoreClickEvent( stopPropagation: () => claimTrackerEvent(event), domEvent, position: { x, y }, + isPrimaryKeyActivated, }; } @@ -177,6 +196,7 @@ function claimTrackerEvent(event: ClickOptions) { function dragTrackerEventToCoreDragEvent( event: DragTrackerEvent, + isPrimaryKeyActivated: boolean, ): BrushTrackerEventsDragEvent { const { x, @@ -199,5 +219,16 @@ function dragTrackerEventToCoreDragEvent( 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 }; +}