desktop/flipper-plugin/src/ui/Interactive.tsx (572 lines of code) (raw):

/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @format */ import styled from '@emotion/styled'; import React from 'react'; import LowPassFilter from '../utils/LowPassFilter'; import { getDistanceTo, maybeSnapLeft, maybeSnapTop, SNAP_SIZE, } from '../utils/snap'; import type {Rect} from '../utils/Rect'; const WINDOW_CURSOR_BOUNDARY = 5; type CursorState = { top: number; left: number; }; type ResizingSides = | { left?: boolean; top?: boolean; bottom?: boolean; right?: boolean; } | null | undefined; const ALL_RESIZABLE: ResizingSides = { bottom: true, left: true, right: true, top: true, }; export type InteractiveProps = { isMovableAnchor?: (event: React.MouseEvent) => boolean; onMoveStart?: () => void; onMoveEnd?: () => void; onMove?: (top: number, left: number, event: MouseEvent) => void; id?: string; movable?: boolean; hidden?: boolean; moving?: boolean; grow?: boolean; siblings?: Partial<{[key: string]: Rect}>; updateCursor?: (cursor?: string | null | undefined) => void; zIndex?: number; top?: number; left?: number; minTop?: number; minLeft?: number; width?: number | string; height?: number | string; minWidth?: number; minHeight?: number; maxWidth?: number; maxHeight?: number; onCanResize?: (sides: ResizingSides) => void; onResizeStart?: () => void; onResizeEnd?: () => void; onResize?: (width: number, height: number) => void; resizing?: boolean; resizable?: boolean | ResizingSides; innerRef?: (elem: HTMLElement | null) => void; style?: Object; className?: string; children?: React.ReactNode; gutterWidth?: number; }; type InteractiveState = { moving: boolean; movingInitialProps: InteractiveProps | null | undefined; movingInitialCursor: CursorState | null | undefined; cursor: string | undefined; resizingSides: ResizingSides; couldResize: boolean; resizing: boolean; resizingInitialRect: Rect | null | undefined; resizingInitialCursor: CursorState | null | undefined; }; const InteractiveContainer = styled.div({ willChange: 'transform, height, width, z-index', }); InteractiveContainer.displayName = 'Interactive:InteractiveContainer'; export class Interactive extends React.Component< InteractiveProps, InteractiveState > { constructor(props: InteractiveProps, context: Object) { super(props, context); this.state = { couldResize: false, cursor: undefined, moving: false, movingInitialCursor: null, movingInitialProps: null, resizing: false, resizingInitialCursor: null, resizingInitialRect: null, resizingSides: null, }; this.globalMouse = false; } globalMouse: boolean; ref?: HTMLElement | null; nextTop?: number | null; nextLeft?: number | null; nextEvent?: MouseEvent | null; static defaultProps = { minHeight: 0, minLeft: 0, minTop: 0, minWidth: 0, }; onMouseMove = (event: MouseEvent) => { if (this.state.moving) { this.calculateMove(event); } else if (this.state.resizing) { this.calculateResize(event); } else { this.calculateResizable(event); } }; startAction = (event: React.MouseEvent) => { this.globalMouse = true; window.addEventListener('pointerup', this.endAction, {passive: true}); window.addEventListener('pointermove', this.onMouseMove, {passive: true}); const {isMovableAnchor} = this.props; if (isMovableAnchor && isMovableAnchor(event)) { this.startTitleAction(event); } else { this.startWindowAction(event); } }; startTitleAction(event: React.MouseEvent) { if (this.state.couldResize) { this.startResizeAction(event); } else if (this.props.movable === true) { this.startMoving(event); } } startMoving(event: React.MouseEvent) { const {onMoveStart} = this.props; if (onMoveStart) { onMoveStart(); } if (this.context.os) { // pause OS timers to avoid lag when dragging this.context.os.timers.pause(); } const topLpf = new LowPassFilter(); const leftLpf = new LowPassFilter(); this.nextTop = null; this.nextLeft = null; this.nextEvent = null; const onFrame = () => { if (!this.state.moving) { return; } const {nextEvent, nextTop, nextLeft} = this; if (nextEvent && nextTop != null && nextLeft != null) { if (topLpf.hasFullBuffer()) { const newTop = topLpf.next(nextTop); const newLeft = leftLpf.next(nextLeft); this.move(newTop, newLeft, nextEvent); } else { this.move(nextTop, nextLeft, nextEvent); } } requestAnimationFrame(onFrame); }; this.setState( { cursor: 'move', moving: true, movingInitialCursor: { left: event.clientX, top: event.clientY, }, movingInitialProps: this.props, }, onFrame, ); } getPossibleTargetWindows(rect: Rect) { const closeWindows = []; const {siblings} = this.props; if (siblings) { for (const key in siblings) { if (key === this.props.id) { // don't target ourselves continue; } const win = siblings[key]; if (win) { const distance = getDistanceTo(rect, win); if (distance <= SNAP_SIZE) { closeWindows.push(win); } } } } return closeWindows; } startWindowAction(event: React.MouseEvent) { if (this.state.couldResize) { this.startResizeAction(event); } } startResizeAction(event: React.MouseEvent) { event.stopPropagation(); event.preventDefault(); const {onResizeStart} = this.props; if (onResizeStart) { onResizeStart(); } this.setState({ resizing: true, resizingInitialCursor: { left: event.clientX, top: event.clientY, }, resizingInitialRect: this.getRect(), }); } componentDidUpdate( _prevProps: InteractiveProps, prevState: InteractiveState, ) { if (prevState.cursor !== this.state.cursor) { const {updateCursor} = this.props; if (updateCursor) { updateCursor(this.state.cursor); } } } resetMoving() { const {onMoveEnd} = this.props; if (onMoveEnd) { onMoveEnd(); } if (this.context.os) { // resume os timers this.context.os.timers.resume(); } this.setState({ cursor: undefined, moving: false, movingInitialProps: undefined, resizingInitialCursor: undefined, }); } resetResizing() { const {onResizeEnd} = this.props; if (onResizeEnd) { onResizeEnd(); } this.setState({ resizing: false, resizingInitialCursor: undefined, resizingInitialRect: undefined, resizingSides: undefined, }); } componentWillUnmount() { this.endAction(); } endAction = () => { this.globalMouse = false; window.removeEventListener('pointermove', this.onMouseMove); window.removeEventListener('pointerup', this.endAction); if (this.state.moving) { this.resetMoving(); } if (this.state.resizing) { this.resetResizing(); } }; onMouseLeave = () => { if (!this.state.resizing && !this.state.moving) { this.setState({ cursor: undefined, }); } }; onClick = (e: React.MouseEvent) => { if (this.state.couldResize) { e.stopPropagation(); } }; calculateMove(event: MouseEvent) { const {movingInitialCursor, movingInitialProps} = this.state; const {clientX: cursorLeft, clientY: cursorTop} = event; const movedLeft = movingInitialCursor!.left - cursorLeft; const movedTop = movingInitialCursor!.top - cursorTop; let newLeft = (movingInitialProps!.left || 0) - movedLeft; let newTop = (movingInitialProps!.top || 0) - movedTop; if (event.altKey) { const snapProps = this.getRect(); const windows = this.getPossibleTargetWindows(snapProps); newLeft = maybeSnapLeft(snapProps, windows, newLeft); newTop = maybeSnapTop(snapProps, windows, newTop); } this.nextTop = newTop; this.nextLeft = newLeft; this.nextEvent = event; } resize(width: number, height: number) { if (width === this.props.width && height === this.props.height) { // noop return; } const {onResize} = this.props; if (!onResize) { return; } width = Math.max(this.props.minWidth || 0, width); height = Math.max(this.props.minHeight || 0, height); const {maxHeight, maxWidth} = this.props; if (maxWidth != null) { width = Math.min(maxWidth, width); } if (maxHeight != null) { height = Math.min(maxHeight, height); } onResize(width, height); } move(top: number, left: number, event: MouseEvent) { top = Math.max(this.props.minTop || 0, top); left = Math.max(this.props.minLeft || 0, left); if (top === this.props.top && left === this.props.left) { // noop return; } const {onMove} = this.props; if (onMove) { onMove(top, left, event); } } calculateResize(event: MouseEvent) { const {resizingInitialCursor, resizingInitialRect, resizingSides} = this.state; if (!resizingSides || !resizingInitialCursor) { return; } const deltaLeft = resizingInitialCursor.left - event.clientX; const deltaTop = resizingInitialCursor.top - event.clientY; let newLeft = resizingInitialRect!.left; let newTop = resizingInitialRect!.top; let newWidth = resizingInitialRect!.width; let newHeight = resizingInitialRect!.height; // right if (resizingSides.right === true) { newWidth -= deltaLeft; } // bottom if (resizingSides.bottom === true) { newHeight -= deltaTop; } const rect = this.getRect(); // left if (resizingSides.left === true) { newLeft -= deltaLeft; newWidth += deltaLeft; if (this.props.movable === true) { // prevent from being shrunk past the minimum width const right = rect.left + rect.width; const maxLeft = right - (this.props.minWidth || 0); let cleanLeft = Math.max(0, newLeft); cleanLeft = Math.min(cleanLeft, maxLeft); newWidth -= Math.abs(newLeft - cleanLeft); newLeft = cleanLeft; } } // top if (resizingSides.top === true) { newTop -= deltaTop; newHeight += deltaTop; if (this.props.movable === true) { // prevent from being shrunk past the minimum height const bottom = rect.top + rect.height; const maxTop = bottom - (this.props.minHeight || 0); let cleanTop = Math.max(0, newTop); cleanTop = Math.min(cleanTop, maxTop); newHeight += newTop - cleanTop; newTop = cleanTop; } } if (event.altKey) { const windows = this.getPossibleTargetWindows(rect); if (resizingSides.left === true) { const newLeft2 = maybeSnapLeft(rect, windows, newLeft); newWidth += newLeft - newLeft2; newLeft = newLeft2; } if (resizingSides.top === true) { const newTop2 = maybeSnapTop(rect, windows, newTop); newHeight += newTop - newTop2; newTop = newTop2; } if (resizingSides.bottom === true) { newHeight = maybeSnapTop(rect, windows, newTop + newHeight) - newTop; } if (resizingSides.right === true) { newWidth = maybeSnapLeft(rect, windows, newLeft + newWidth) - newLeft; } } this.move(newTop, newLeft, event); this.resize(newWidth, newHeight); } getRect(): Rect { const {props, ref} = this; if (!ref) throw new Error('expected ref'); return { height: ref!.offsetHeight || 0, left: props.left || 0, top: props.top || 0, width: ref!.offsetWidth || 0, }; } getResizable(): ResizingSides { const {resizable} = this.props; if (resizable === true) { return ALL_RESIZABLE; } else if (resizable == null || resizable === false) { return; } else { return resizable; } } checkIfResizable(event: MouseEvent): | { left: boolean; right: boolean; top: boolean; bottom: boolean; } | undefined { const canResize = this.getResizable(); if (!canResize) { return; } if (!this.ref) { return; } const {left: offsetLeft, top: offsetTop} = this.ref.getBoundingClientRect(); const {height, width} = this.getRect(); const x = event.clientX - offsetLeft; const y = event.clientY - offsetTop; const gutterWidth = this.props.gutterWidth || WINDOW_CURSOR_BOUNDARY; const atTop: boolean = y <= gutterWidth; const atBottom: boolean = y >= height - gutterWidth; const atLeft: boolean = x <= gutterWidth; const atRight: boolean = x >= width - gutterWidth; return { bottom: canResize.bottom === true && atBottom, left: canResize.left === true && atLeft, right: canResize.right === true && atRight, top: canResize.top === true && atTop, }; } calculateResizable(event: MouseEvent) { const resizing = this.checkIfResizable(event); if (!resizing) { return; } const canResize = this.getResizable(); if (!canResize) { return; } const {bottom, left, right, top} = resizing; let newCursor; const movingHorizontal = left || right; const movingVertical = top || left; // left if (left) { newCursor = 'ew-resize'; } // right if (right) { newCursor = 'ew-resize'; } // if resizing vertically and one side can't be resized then use different cursor if ( movingHorizontal && (canResize.left !== true || canResize.right !== true) ) { newCursor = 'col-resize'; } // top if (top) { newCursor = 'ns-resize'; // top left if (left) { newCursor = 'nwse-resize'; } // top right if (right) { newCursor = 'nesw-resize'; } } // bottom if (bottom) { newCursor = 'ns-resize'; // bottom left if (left) { newCursor = 'nesw-resize'; } // bottom right if (right) { newCursor = 'nwse-resize'; } } // if resizing horziontally and one side can't be resized then use different cursor if ( movingVertical && !movingHorizontal && (canResize.top !== true || canResize.bottom !== true) ) { newCursor = 'row-resize'; } if ( this.state.resizingSides?.bottom === bottom && this.state.resizingSides?.left === left && this.state.resizingSides?.top === top && this.state.resizingSides?.right === right && this.state.cursor === newCursor && this.state.couldResize === Boolean(newCursor) ) { return; } const resizingSides = { bottom, left, right, top, }; const {onCanResize} = this.props; if (onCanResize) { onCanResize({}); } this.setState({ couldResize: Boolean(newCursor), cursor: newCursor, resizingSides, }); } setRef = (ref: HTMLElement | null) => { this.ref = ref; const {innerRef} = this.props; if (innerRef) { innerRef(ref); } }; onLocalMouseMove = (event: React.MouseEvent) => { if (!this.globalMouse) { this.onMouseMove(event.nativeEvent); } }; render() { const {grow, height, left, movable, top, width, zIndex} = this.props; const style: React.CSSProperties = { cursor: this.state.cursor, zIndex: zIndex == null ? 'auto' : zIndex, }; if (movable === true || top != null || left != null) { if (grow === true) { style.left = left || 0; style.top = top || 0; } else { style.transform = `translate3d(${left || 0}px, ${top || 0}px, 0)`; } } if (grow === true) { style.right = 0; style.bottom = 0; style.width = '100%'; style.height = '100%'; } else { style.width = width == null ? 'auto' : width; style.height = height == null ? 'auto' : height; } if (this.props.style) { Object.assign(style, this.props.style); } return ( <InteractiveContainer className={this.props.className} hidden={this.props.hidden} ref={this.setRef} onMouseDown={this.startAction} onMouseMove={this.onLocalMouseMove} onMouseLeave={this.onMouseLeave} // eslint-disable-next-line onClick={this.onClick} style={style}> {this.props.children} </InteractiveContainer> ); } }