diff --git a/packages/visx-brush/package.json b/packages/visx-brush/package.json index 537ff054c..bf21e9d74 100644 --- a/packages/visx-brush/package.json +++ b/packages/visx-brush/package.json @@ -34,6 +34,7 @@ }, "dependencies": { "@visx/drag": "1.7.4", + "@visx/event": "1.7.0", "@visx/group": "1.7.0", "@visx/shape": "1.14.0", "classnames": "^2.2.5", diff --git a/packages/visx-brush/src/BaseBrush.tsx b/packages/visx-brush/src/BaseBrush.tsx index d7d33dff3..3d4a22ed1 100644 --- a/packages/visx-brush/src/BaseBrush.tsx +++ b/packages/visx-brush/src/BaseBrush.tsx @@ -6,7 +6,16 @@ import Drag, { HandlerArgs as DragArgs } from '@visx/drag/lib/Drag'; import BrushHandle from './BrushHandle'; import BrushCorner from './BrushCorner'; import BrushSelection from './BrushSelection'; -import { MarginShape, Point, BrushShape, ResizeTriggerAreas, PartialBrushStartEnd } from './types'; +import { + MarginShape, + Point, + BrushShape, + ResizeTriggerAreas, + PartialBrushStartEnd, + BrushingType, + BrushPageOffset, +} from './types'; +import { getPageCoordinates } from './utils'; const BRUSH_OVERLAY_STYLES = { cursor: 'crosshair' }; @@ -33,11 +42,14 @@ export type BaseBrushProps = { clickSensitivity: number; disableDraggingSelection: boolean; resetOnEnd?: boolean; + useWindowMoveEvents?: boolean; }; export type BaseBrushState = BrushShape & { activeHandle: ResizeTriggerAreas | null; isBrushing: boolean; + brushPageOffset?: BrushPageOffset; + brushingType?: BrushingType; }; export type UpdateBrush = @@ -67,6 +79,7 @@ export default class BaseBrush extends React.Component { + const { useWindowMoveEvents } = this.props; + const { brushingType } = this.state; + + if ( + useWindowMoveEvents && + ['top', 'bottom', 'left', 'right', 'select', 'move'].includes(brushingType ?? '') + ) { + this.updateBrush((prevBrush: BaseBrushState) => { + const { start, end, extent } = prevBrush; + + start.x = Math.min(extent.x0, extent.x1); + start.y = Math.min(extent.y0, extent.y0); + end.x = Math.max(extent.x0, extent.x1); + end.y = Math.max(extent.y0, extent.y1); + + return { + ...prevBrush, + activeHandle: null, + isBrushing: false, + brushingType: undefined, + }; + }); + } + }; + + handleWindowPointerMove = (event: MouseEvent) => { + const { useWindowMoveEvents, onBrushEnd, resetOnEnd } = this.props; + const { brushingType, isBrushing, brushPageOffset, start } = this.state; + + if (!useWindowMoveEvents || !isBrushing) return; + + /* We use event page coordinates to calculate the offset between the initial pointer position and + the current pointer position so Brush could be resized/moved relatively. */ + const offsetX = event.pageX - (brushPageOffset?.pageX || 0); + const offsetY = event.pageY - (brushPageOffset?.pageY || 0); + + if (['left', 'right', 'top', 'bottom'].includes(brushingType ?? '')) { + this.updateBrush((prevBrush: BaseBrushState) => { + const { x: x0, y: y0 } = prevBrush.start; + const { x: x1, y: y1 } = prevBrush.end; + + return { + ...prevBrush, + isBrushing: true, + extent: { + ...prevBrush.extent, + ...this.getExtent( + { + x: + brushingType === 'left' + ? Math.min(Math.max(x0 + offsetX, prevBrush.bounds.x0), prevBrush.bounds.x1) + : x0, + y: + brushingType === 'bottom' + ? Math.min(Math.max(y0 + offsetY, prevBrush.bounds.y0), prevBrush.bounds.y1) + : y0, + }, + { + x: + brushingType === 'right' + ? Math.min(Math.max(x1 + offsetX, prevBrush.bounds.x0), prevBrush.bounds.x1) + : x1, + y: + brushingType === 'bottom' + ? Math.min(Math.max(y1 + offsetY, prevBrush.bounds.y0), prevBrush.bounds.y1) + : y1, + }, + ), + }, + }; + }); + } + + if (brushingType === 'move') { + this.updateBrush((prevBrush: BaseBrushState) => { + const { x: x0, y: y0 } = prevBrush.start; + const { x: x1, y: y1 } = prevBrush.end; + const validDx = + offsetX > 0 + ? Math.min(offsetX, prevBrush.bounds.x1 - x1) + : Math.max(offsetX, prevBrush.bounds.x0 - x0); + + const validDy = + offsetY > 0 + ? Math.min(offsetY, prevBrush.bounds.y1 - y1) + : Math.max(offsetY, prevBrush.bounds.y0 - y0); + + return { + ...prevBrush, + isBrushing: true, + extent: { + ...prevBrush.extent, + x0: x0 + validDx, + y0: y0 + validDy, + x1: x1 + validDx, + y1: y1 + validDy, + }, + }; + }); + } + + if (brushingType === 'select') { + this.updateBrush((prevBrush: BaseBrushState) => { + const { x: x0, y: y0 } = prevBrush.start; + const newEnd = { + x: Math.min(Math.max(x0 + offsetX, prevBrush.bounds.x0), prevBrush.bounds.x1), + y: Math.min(Math.max(y0 + offsetY, prevBrush.bounds.y0), prevBrush.bounds.y1), + }; + const extent = this.getExtent(start, newEnd); + + const newState = { + ...prevBrush, + end: newEnd, + extent, + }; + + if (onBrushEnd) { + onBrushEnd(newState); + } + + if (resetOnEnd) { + this.reset(); + } + + return newState; + }); + } + }; + getExtent = (start: Partial, end: Partial) => { const { brushDirection, width, height } = this.props; const x0 = brushDirection === 'vertical' ? 0 : Math.min(start.x || 0, end.x || 0); @@ -127,9 +285,9 @@ export default class BaseBrush extends React.Component { - const { onBrushStart, left, top, inheritedMargin } = this.props; - const marginLeft = inheritedMargin && inheritedMargin.left ? inheritedMargin.left : 0; - const marginTop = inheritedMargin && inheritedMargin.top ? inheritedMargin.top : 0; + const { onBrushStart, left, top, inheritedMargin, useWindowMoveEvents } = this.props; + const marginLeft = inheritedMargin?.left ? inheritedMargin.left : 0; + const marginTop = inheritedMargin?.top ? inheritedMargin.top : 0; const start = { x: (draw.x || 0) + draw.dx - left - marginLeft, y: (draw.y || 0) + draw.dy - top - marginTop, @@ -151,14 +309,16 @@ export default class BaseBrush extends React.Component { - const { left, top, inheritedMargin } = this.props; - if (!drag.isDragging) return; - const marginLeft = (inheritedMargin && inheritedMargin.left) || 0; - const marginTop = (inheritedMargin && inheritedMargin.top) || 0; + const { left, top, inheritedMargin, useWindowMoveEvents } = this.props; + if (!drag.isDragging || useWindowMoveEvents) return; + const marginLeft = inheritedMargin?.left || 0; + const marginTop = inheritedMargin?.top || 0; const end = { x: (drag.x || 0) + drag.dx - left - marginLeft, y: (drag.y || 0) + drag.dy - top - marginTop, @@ -175,33 +335,37 @@ export default class BaseBrush extends React.Component { - const { onBrushEnd, resetOnEnd } = this.props; - this.updateBrush((prevBrush: BaseBrushState) => { - const { extent } = prevBrush; - const newState = { - ...prevBrush, - start: { - x: extent.x0, - y: extent.y0, - }, - end: { - x: extent.x1, - y: extent.y1, - }, - isBrushing: false, - activeHandle: null, - }; + const { onBrushEnd, resetOnEnd, useWindowMoveEvents } = this.props; + + if (!useWindowMoveEvents) { + this.updateBrush((prevBrush: BaseBrushState) => { + const { extent } = prevBrush; + const newState = { + ...prevBrush, + start: { + x: extent.x0, + y: extent.y0, + }, + end: { + x: extent.x1, + y: extent.y1, + }, + isBrushing: false, + brushingType: undefined, + activeHandle: null, + }; - if (onBrushEnd) { - onBrushEnd(newState); - } + if (onBrushEnd) { + onBrushEnd(newState); + } - if (resetOnEnd) { - this.reset(); - } + if (resetOnEnd) { + this.reset(); + } - return newState; - }); + return newState; + }); + } }; getBrushWidth = () => { @@ -335,10 +499,28 @@ export default class BaseBrush extends React.Component { + this.updateBrush((prevBrush: BaseBrushState) => { + const next = { + ...prevBrush, + brushingType: type, + isBrushing: type !== undefined, + }; + + if (brushPageOffset || type === undefined) { + next.brushPageOffset = brushPageOffset; + } + + return next; + }); + }; + render() { const { start, end } = this.state; const { @@ -355,8 +537,11 @@ export default class BaseBrush extends React.Component {({ dragStart, isDragging, dragMove, dragEnd }) => ( )} {/* handles */} @@ -444,6 +633,9 @@ export default class BaseBrush extends React.Component ) ); diff --git a/packages/visx-brush/src/Brush.tsx b/packages/visx-brush/src/Brush.tsx index a1bcf0a03..667c34c47 100644 --- a/packages/visx-brush/src/Brush.tsx +++ b/packages/visx-brush/src/Brush.tsx @@ -58,6 +58,8 @@ export type BrushProps = { handleSize: number; /** Reference to the BaseBrush component. */ innerRef?: React.MutableRefObject; + /** Prevent drag end on mouse leaving from brush stage. */ + useWindowMoveEvents?: boolean; }; class Brush extends Component { @@ -94,6 +96,7 @@ class Brush extends Component { onMouseMove: null, onMouseLeave: null, onClick: null, + useWindowMoveEvents: false, }; handleChange = (brush: BaseBrushState) => { @@ -174,6 +177,7 @@ class Brush extends Component { onMouseMove, onClick, handleSize, + useWindowMoveEvents, } = this.props; if (!xScale || !yScale) return null; @@ -234,6 +238,7 @@ class Brush extends Component { onClick={onClick} onMouseLeave={onMouseLeave} onMouseMove={onMouseMove} + useWindowMoveEvents={useWindowMoveEvents} /> ); } diff --git a/packages/visx-brush/src/BrushHandle.tsx b/packages/visx-brush/src/BrushHandle.tsx index 3a859ba45..283bb8a01 100644 --- a/packages/visx-brush/src/BrushHandle.tsx +++ b/packages/visx-brush/src/BrushHandle.tsx @@ -2,7 +2,8 @@ import React from 'react'; import Drag, { HandlerArgs as DragArgs } from '@visx/drag/lib/Drag'; import { BaseBrushState as BrushState, UpdateBrush } from './BaseBrush'; -import { ResizeTriggerAreas } from './types'; +import { BrushPageOffset, BrushingType, ResizeTriggerAreas } from './types'; +import { getPageCoordinates } from './utils'; export type BrushHandleProps = { stageWidth: number; @@ -12,13 +13,24 @@ export type BrushHandleProps = { onBrushEnd?: (brush: BrushState) => void; type: ResizeTriggerAreas; handle: { x: number; y: number; width: number; height: number }; + isControlled?: boolean; + isDragInProgress?: boolean; + onBrushHandleChange?: (type?: BrushingType, options?: BrushPageOffset) => void; }; /** BrushHandle's are placed along the bounds of the brush and handle Drag events which update the passed brush. */ export default class BrushHandle extends React.Component { + handleDragStart = (drag: DragArgs) => { + const { onBrushHandleChange, type } = this.props; + + if (onBrushHandleChange) { + onBrushHandleChange(type, getPageCoordinates(drag.event)); + } + }; + handleDragMove = (drag: DragArgs) => { - const { updateBrush, type } = this.props; - if (!drag.isDragging) return; + const { updateBrush, type, isControlled } = this.props; + if (!drag.isDragging || isControlled) return; updateBrush((prevBrush: BrushState) => { const { start, end } = prevBrush; @@ -79,36 +91,51 @@ export default class BrushHandle extends React.Component { }; handleDragEnd = () => { - const { updateBrush, onBrushEnd } = this.props; - updateBrush((prevBrush: BrushState) => { - const { start, end, extent } = prevBrush; - start.x = Math.min(extent.x0, extent.x1); - start.y = Math.min(extent.y0, extent.y0); - end.x = Math.max(extent.x0, extent.x1); - end.y = Math.max(extent.y0, extent.y1); - const nextBrush: BrushState = { - ...prevBrush, - start, - end, - activeHandle: null, - isBrushing: false, - extent: { - x0: Math.min(start.x, end.x), - x1: Math.max(start.x, end.x), - y0: Math.min(start.y, end.y), - y1: Math.max(start.y, end.y), - }, - }; - if (onBrushEnd) { - onBrushEnd(nextBrush); - } + const { updateBrush, onBrushEnd, onBrushHandleChange, isControlled } = this.props; - return nextBrush; - }); + if (!isControlled) { + updateBrush((prevBrush: BrushState) => { + const { start, end, extent } = prevBrush; + start.x = Math.min(extent.x0, extent.x1); + start.y = Math.min(extent.y0, extent.y0); + end.x = Math.max(extent.x0, extent.x1); + end.y = Math.max(extent.y0, extent.y1); + const nextBrush: BrushState = { + ...prevBrush, + start, + end, + activeHandle: null, + isBrushing: false, + extent: { + x0: Math.min(start.x, end.x), + x1: Math.max(start.x, end.x), + y0: Math.min(start.y, end.y), + y1: Math.max(start.y, end.y), + }, + }; + if (onBrushEnd) { + onBrushEnd(nextBrush); + } + + return nextBrush; + }); + } + + if (onBrushHandleChange) { + onBrushHandleChange(); + } }; render() { - const { stageWidth, stageHeight, brush, type, handle } = this.props; + const { + stageWidth, + stageHeight, + brush, + type, + handle, + isControlled, + isDragInProgress, + } = this.props; const { x, y, width, height } = handle; const cursor = type === 'right' || type === 'left' ? 'ew-resize' : 'ns-resize'; @@ -116,9 +143,11 @@ export default class BrushHandle extends React.Component { {({ dragStart, dragEnd, dragMove, isDragging }) => ( @@ -130,8 +159,8 @@ export default class BrushHandle extends React.Component { height={stageHeight} style={{ cursor }} onMouseMove={dragMove} - onMouseUp={dragEnd} - onMouseLeave={dragEnd} + onMouseUp={isControlled ? undefined : dragEnd} + onMouseLeave={isControlled ? undefined : dragEnd} /> )} { className={`visx-brush-handle-${type}`} onPointerDown={dragStart} onPointerMove={dragMove} - onPointerUp={dragEnd} + onPointerUp={isControlled ? undefined : dragEnd} style={{ cursor, pointerEvents: !!brush.activeHandle || !!brush.isBrushing ? 'none' : 'all', diff --git a/packages/visx-brush/src/BrushSelection.tsx b/packages/visx-brush/src/BrushSelection.tsx index fd0ab708e..fbdaf7fbc 100644 --- a/packages/visx-brush/src/BrushSelection.tsx +++ b/packages/visx-brush/src/BrushSelection.tsx @@ -1,7 +1,10 @@ /* eslint react/jsx-handler-names: 0 */ import React from 'react'; import Drag, { HandlerArgs as DragArgs } from '@visx/drag/lib/Drag'; + import { BaseBrushState as BrushState, UpdateBrush } from './BaseBrush'; +import { BrushPageOffset, BrushingType } from './types'; +import { getPageCoordinates } from './utils'; const DRAGGING_OVERLAY_STYLES = { cursor: 'move' }; @@ -14,6 +17,7 @@ export type BrushSelectionProps = { stageHeight: number; brush: BrushState; updateBrush: (update: UpdateBrush) => void; + onMoveSelectionChange?: (type?: BrushingType, options?: BrushPageOffset) => void; onBrushEnd?: (brush: BrushState) => void; disableDraggingSelection: boolean; onMouseLeave: PointerHandler; @@ -21,6 +25,8 @@ export type BrushSelectionProps = { onMouseUp: PointerHandler; onClick: PointerHandler; selectedBoxStyle: React.SVGProps; + isControlled?: boolean; + isDragInProgress?: boolean; }; export default class BrushSelection extends React.Component< @@ -33,8 +39,19 @@ export default class BrushSelection extends React.Component< onClick: null, }; + selectionDragStart = (drag: DragArgs) => { + const { onMoveSelectionChange } = this.props; + + if (onMoveSelectionChange) { + onMoveSelectionChange('move', getPageCoordinates(drag.event)); + } + }; + selectionDragMove = (drag: DragArgs) => { - const { updateBrush } = this.props; + const { updateBrush, isControlled } = this.props; + + if (isControlled) return; + updateBrush((prevBrush: BrushState) => { const { x: x0, y: y0 } = prevBrush.start; const { x: x1, y: y1 } = prevBrush.end; @@ -63,28 +80,34 @@ export default class BrushSelection extends React.Component< }; selectionDragEnd = () => { - const { updateBrush, onBrushEnd } = this.props; - updateBrush((prevBrush: BrushState) => { - const nextBrush = { - ...prevBrush, - isBrushing: false, - start: { - ...prevBrush.start, - x: Math.min(prevBrush.extent.x0, prevBrush.extent.x1), - y: Math.min(prevBrush.extent.y0, prevBrush.extent.y1), - }, - end: { - ...prevBrush.end, - x: Math.max(prevBrush.extent.x0, prevBrush.extent.x1), - y: Math.max(prevBrush.extent.y0, prevBrush.extent.y1), - }, - }; - if (onBrushEnd) { - onBrushEnd(nextBrush); - } + const { updateBrush, onBrushEnd, onMoveSelectionChange, isControlled } = this.props; - return nextBrush; - }); + if (!isControlled) { + updateBrush((prevBrush: BrushState) => { + const nextBrush = { + ...prevBrush, + isBrushing: false, + start: { + ...prevBrush.start, + x: Math.min(prevBrush.extent.x0, prevBrush.extent.x1), + y: Math.min(prevBrush.extent.y0, prevBrush.extent.y1), + }, + end: { + ...prevBrush.end, + x: Math.max(prevBrush.extent.x0, prevBrush.extent.x1), + y: Math.max(prevBrush.extent.y0, prevBrush.extent.y1), + }, + }; + if (onBrushEnd) { + onBrushEnd(nextBrush); + } + return nextBrush; + }); + } + + if (onMoveSelectionChange) { + onMoveSelectionChange(); + } }; render() { @@ -100,6 +123,8 @@ export default class BrushSelection extends React.Component< onMouseUp, onClick, selectedBoxStyle, + isControlled, + isDragInProgress, } = this.props; return ( @@ -107,8 +132,10 @@ export default class BrushSelection extends React.Component< width={width} height={height} resetOnStart + onDragStart={this.selectionDragStart} onDragMove={this.selectionDragMove} onDragEnd={this.selectionDragEnd} + isDragging={isControlled ? isDragInProgress : undefined} > {({ isDragging, dragStart, dragEnd, dragMove }) => ( @@ -117,9 +144,9 @@ export default class BrushSelection extends React.Component< width={stageWidth} height={stageHeight} fill="transparent" - onPointerUp={dragEnd} + onPointerUp={isControlled ? undefined : dragEnd} onPointerMove={dragMove} - onPointerLeave={dragEnd} + onPointerLeave={isControlled ? undefined : dragEnd} style={DRAGGING_OVERLAY_STYLES} /> )} @@ -138,7 +165,9 @@ export default class BrushSelection extends React.Component< if (onMouseMove) onMouseMove(event); }} onPointerUp={event => { - dragEnd(event); + if (!isControlled) { + dragEnd(event); + } if (onMouseUp) onMouseUp(event); }} onClick={event => { diff --git a/packages/visx-brush/src/types.ts b/packages/visx-brush/src/types.ts index a67ef8c02..b9705fae6 100644 --- a/packages/visx-brush/src/types.ts +++ b/packages/visx-brush/src/types.ts @@ -44,6 +44,12 @@ export type ResizeTriggerAreas = | 'bottomLeft' | 'bottomRight'; +export type BrushingType = 'move' | 'select' | ResizeTriggerAreas; +export type BrushPageOffset = { + pageX?: number; + pageY?: number; +}; + export interface Scale { (value: Input): Output; ticks?: (count: number) => Input[]; diff --git a/packages/visx-brush/src/utils.ts b/packages/visx-brush/src/utils.ts index 571069e07..4198aad51 100644 --- a/packages/visx-brush/src/utils.ts +++ b/packages/visx-brush/src/utils.ts @@ -1,3 +1,5 @@ +import { MouseTouchOrPointerEvent } from '@visx/drag/lib/useDrag'; +import React from 'react'; import { Scale } from './types'; export function scaleInvert(scale: Scale, value: number) { @@ -53,3 +55,17 @@ export function getDomainFromExtent( return domain; } + +export function getPageCoordinates(event: MouseTouchOrPointerEvent) { + if (typeof window !== 'undefined' && window.TouchEvent && event instanceof TouchEvent) { + return { + pageX: event.touches[0].pageX, + pageY: event.touches[0].pageY, + }; + } + const pointerEvent = event as React.PointerEvent; + return { + pageX: pointerEvent.pageX, + pageY: pointerEvent.pageY, + }; +} diff --git a/packages/visx-demo/src/sandboxes/visx-brush/Example.tsx b/packages/visx-demo/src/sandboxes/visx-brush/Example.tsx index 59aed25da..743761f17 100644 --- a/packages/visx-demo/src/sandboxes/visx-brush/Example.tsx +++ b/packages/visx-demo/src/sandboxes/visx-brush/Example.tsx @@ -193,6 +193,7 @@ function BrushChart({ onChange={onBrushChange} onClick={() => setFilteredStock(stock)} selectedBoxStyle={selectedBrushStyle} + useWindowMoveEvents /> diff --git a/packages/visx-drag/src/Drag.tsx b/packages/visx-drag/src/Drag.tsx index 2e6257287..ee643c4a1 100644 --- a/packages/visx-drag/src/Drag.tsx +++ b/packages/visx-drag/src/Drag.tsx @@ -13,6 +13,8 @@ export type DragProps = UseDragOptions & { height: number; /** Whether to render an invisible rect below children to capture the drag area as defined by width and height. */ captureDragArea?: boolean; + /** If defined, parent controls dragging state. */ + isDragging?: boolean; }; export default function Drag({ @@ -28,8 +30,19 @@ export default function Drag({ width, x, y, + isDragging, }: DragProps) { - const drag = useDrag({ resetOnStart, onDragEnd, onDragMove, onDragStart, x, y, dx, dy }); + const drag = useDrag({ + resetOnStart, + onDragEnd, + onDragMove, + onDragStart, + x, + y, + dx, + dy, + isDragging, + }); return ( <> diff --git a/packages/visx-drag/src/useDrag.ts b/packages/visx-drag/src/useDrag.ts index ff904c18e..b8145aff3 100644 --- a/packages/visx-drag/src/useDrag.ts +++ b/packages/visx-drag/src/useDrag.ts @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { localPoint } from '@visx/event'; import useStateWithCallback from './util/useStateWithCallback'; -type MouseTouchOrPointerEvent = React.MouseEvent | React.TouchEvent | React.PointerEvent; +export type MouseTouchOrPointerEvent = React.MouseEvent | React.TouchEvent | React.PointerEvent; export type HandlerArgs = DragState & { /** Drag event. */ @@ -26,6 +26,8 @@ export type UseDragOptions = { dx?: number; /** Optionally set the initial drag dy, or override the current drag dy. */ dy?: number; + /** If defined, parent controls dragging state. */ + isDragging?: boolean; }; export type DragState = { @@ -60,6 +62,7 @@ export default function useDrag({ y, dx, dy, + isDragging, }: UseDragOptions | undefined = {}): UseDrag { // use ref to detect prop changes const positionPropsRef = useRef({ x, y, dx, dy }); @@ -85,6 +88,12 @@ export default function useDrag({ } }); + useEffect(() => { + if (isDragging !== undefined && dragState.isDragging !== isDragging) { + setDragStateWithCallback(currState => ({ ...currState, isDragging })); + } + }, [dragState.isDragging, isDragging, setDragStateWithCallback]); + const handleDragStart = useCallback( (event: MouseTouchOrPointerEvent) => { event.persist();