Introduced sorting, column visibility and column resizing

Summary:
Add support for resizable columns, column sorting, and hiding / showing columns

Moved some utilities from Flipper to flipper-plugin, such as Interactive and LowPassFilter

Split DataTable into two components; DataSourceRenderer which takes care of purely rendering the virtualization, and DataTable that has the Chrome around that, such as column headers, search bar, etc.

Reviewed By: nikoant

Differential Revision: D26321105

fbshipit-source-id: 32b8fc03b4fb97b3af52b23e273c3e5b8cbc4498
This commit is contained in:
Michel Weststrate
2021-03-16 14:54:53 -07:00
committed by Facebook GitHub Bot
parent 86ad413669
commit 44bb5b1beb
18 changed files with 1020 additions and 324 deletions

View File

@@ -34,7 +34,7 @@ exports[`load PluginInstaller list 1`] = `
width="25%"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
@@ -50,7 +50,7 @@ exports[`load PluginInstaller list 1`] = `
width="10%"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
@@ -66,7 +66,7 @@ exports[`load PluginInstaller list 1`] = `
width="flex"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
@@ -82,7 +82,7 @@ exports[`load PluginInstaller list 1`] = `
width="15%"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
@@ -336,7 +336,7 @@ exports[`load PluginInstaller list with one plugin installed 1`] = `
width="25%"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
@@ -352,7 +352,7 @@ exports[`load PluginInstaller list with one plugin installed 1`] = `
width="10%"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
@@ -368,7 +368,7 @@ exports[`load PluginInstaller list with one plugin installed 1`] = `
width="flex"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div
@@ -384,7 +384,7 @@ exports[`load PluginInstaller list with one plugin installed 1`] = `
width="15%"
>
<div
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive etmd34w0"
class="ejga3103 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
>
<div

View File

@@ -113,7 +113,7 @@ export {default as ErrorBlock} from './ui/components/ErrorBlock';
export {ErrorBlockContainer} from './ui/components/ErrorBlock';
export {default as ErrorBoundary} from './ui/components/ErrorBoundary';
export {OrderableOrder} from './ui/components/Orderable';
export {default as Interactive} from './ui/components/Interactive';
export {_Interactive as Interactive} from 'flipper-plugin';
export {default as Orderable} from './ui/components/Orderable';
export {default as VirtualList} from './ui/components/VirtualList';
export {Component, PureComponent} from 'react';

View File

@@ -1,721 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its 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 {Rect} from '../../utils/geometry';
import LowPassFilter from '../../utils/LowPassFilter';
import {
getDistanceTo,
maybeSnapLeft,
maybeSnapTop,
SNAP_SIZE,
} from '../../utils/snap';
import styled from '@emotion/styled';
import invariant from 'invariant';
import React from 'react';
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 default 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;
invariant(movingInitialProps, 'TODO');
invariant(movingInitialCursor, 'TODO');
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;
invariant(resizingInitialRect, 'TODO');
invariant(resizingInitialCursor, 'TODO');
invariant(resizingSides, 'TODO');
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;
invariant(ref, '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';
}
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>
);
}
}

View File

@@ -7,7 +7,7 @@
* @format
*/
import Interactive, {InteractiveProps} from './Interactive';
import {_Interactive, _InteractiveProps} from 'flipper-plugin';
import FlexColumn from './FlexColumn';
import {colors} from './colors';
import {Component, ReactNode} from 'react';
@@ -18,7 +18,7 @@ import FlexRow from './FlexRow';
import {MoreOutlined} from '@ant-design/icons';
import {theme} from 'flipper-plugin';
const SidebarInteractiveContainer = styled(Interactive)<InteractiveProps>({
const SidebarInteractiveContainer = styled(_Interactive)<_InteractiveProps>({
flex: 'none',
});
SidebarInteractiveContainer.displayName = 'Sidebar:SidebarInteractiveContainer';

View File

@@ -18,7 +18,7 @@ import {
import {normaliseColumnWidth, isPercentage} from './utils';
import {PureComponent} from 'react';
import ContextMenu from '../ContextMenu';
import Interactive, {InteractiveProps} from '../Interactive';
import {_Interactive, _InteractiveProps} from 'flipper-plugin';
import styled from '@emotion/styled';
import {colors} from '../colors';
import FlexRow from '../FlexRow';
@@ -31,7 +31,7 @@ const TableHeaderArrow = styled.span({
});
TableHeaderArrow.displayName = 'TableHead:TableHeaderArrow';
const TableHeaderColumnInteractive = styled(Interactive)<InteractiveProps>({
const TableHeaderColumnInteractive = styled(_Interactive)<_InteractiveProps>({
display: 'inline-block',
overflow: 'hidden',
textOverflow: 'ellipsis',

View File

@@ -75,7 +75,6 @@ export {default as ErrorBoundary} from './components/ErrorBoundary';
// interactive components
export {OrderableOrder} from './components/Orderable';
export {default as Interactive} from './components/Interactive';
export {default as Orderable} from './components/Orderable';
export {default as VirtualList} from './components/VirtualList';

View File

@@ -1,60 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its 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 invariant from 'invariant';
export default class LowPassFilter {
constructor(smoothing: number = 0.9) {
this.smoothing = smoothing;
this.buffer = [];
this.bufferMaxSize = 5;
}
bufferMaxSize: number;
smoothing: number;
buffer: Array<number>;
hasFullBuffer(): boolean {
return this.buffer.length === this.bufferMaxSize;
}
push(value: number): number {
let removed: number = 0;
if (this.hasFullBuffer()) {
const tmp: number | undefined = this.buffer.shift();
invariant(
tmp !== undefined,
'Invariant violation: Buffer reported full but shift returned nothing.',
);
removed = tmp;
}
this.buffer.push(value);
return removed;
}
next(nextValue: number): number {
// push new value to the end, and remove oldest one
const removed = this.push(nextValue);
// smooth value using all values from buffer
const result = this.buffer.reduce(this._nextReduce, removed);
// replace smoothed value
this.buffer[this.buffer.length - 1] = result;
return result;
}
_nextReduce = (last: number, current: number): number => {
return this.smoothing * current + (1 - this.smoothing) * last;
};
}

View File

@@ -1,46 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its 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 LowPassFilter from '../LowPassFilter';
test('hasFullBuffer', () => {
const lpf = new LowPassFilter();
expect(lpf.hasFullBuffer()).toBeFalsy();
lpf.push(1);
lpf.push(2);
lpf.push(3);
lpf.push(4);
lpf.push(5);
expect(lpf.hasFullBuffer()).toBeTruthy();
});
test('push on full buffer returns shifted value', () => {
const lpf = new LowPassFilter();
expect(lpf.push(1)).toBe(0);
expect(lpf.push(2)).toBe(0);
expect(lpf.push(3)).toBe(0);
expect(lpf.push(4)).toBe(0);
expect(lpf.push(5)).toBe(0);
expect(lpf.push(6)).toBe(1);
expect(lpf.push(7)).toBe(2);
});
test('next returns smoothed value', () => {
const lpf = new LowPassFilter();
expect(lpf.next(1)).toBe(0.9);
expect(lpf.next(2)).toBe(1.881);
});
test('next returns smoothed value with custom smoothing', () => {
const lpf = new LowPassFilter(0.5);
expect(lpf.next(1)).toBe(0.5);
expect(lpf.next(2)).toBe(1.125);
});

View File

@@ -1,160 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its 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 {Rect} from './geometry';
export const SNAP_SIZE = 16;
export function snapGrid(val: number): number {
return val - (val % SNAP_SIZE);
}
export function getPossibleSnappedPosition(
windows: Array<Rect>,
{
getGap,
getNew,
}: {
getNew: (win: Rect) => number;
getGap: (win: Rect) => number;
},
): number | undefined {
for (const win of windows) {
const gap = Math.abs(getGap(win));
if (gap >= 0 && gap < SNAP_SIZE) {
return getNew(win);
}
}
}
export function getDistanceTo(props: Rect, win: Rect): number {
const x1 = win.left;
const y1 = win.top;
const x1b = win.left + win.width;
const y1b = win.top + win.height;
const x2 = props.left;
const y2 = props.top;
const x2b = props.left + props.width;
const y2b = props.top + props.height;
const left = x2b < x1;
const right = x1b < x2;
const bottom = y2b < y1;
const top = y1b < y2;
if (top && left) {
return distance(x1, y1b, x2b, y2);
} else if (left && bottom) {
return distance(x1, y1, x2b, y2b);
} else if (bottom && right) {
return distance(x1b, y1, x2, y2b);
} else if (right && top) {
return distance(x1b, y1b, x2, y2);
} else if (left) {
return x1 - x2b;
} else if (right) {
return x2 - x1b;
} else if (bottom) {
return y1 - y2b;
} else if (top) {
return y2 - y1b;
} else {
return 0;
}
}
export function distance(
x1: number,
y1: number,
x2: number,
y2: number,
): number {
return Math.abs(x1 - x2) + Math.abs(y1 - y2);
}
export function maybeSnapLeft(
props: Rect,
windows: Array<Rect>,
left: number,
): number {
// snap right side to left
// ┌─┬─┐
// │A│B│
// └─┴─┘
const snapRight = getPossibleSnappedPosition(windows, {
getGap: (win) => win.left - (props.width + left),
getNew: (win) => win.left - props.width,
});
if (snapRight != null) {
return snapRight;
}
// snap left side to right
// ┌─┬─┐
// │B│A│
// └─┴─┘
const snapLeft = getPossibleSnappedPosition(windows, {
getGap: (win) => left - (win.left + win.width),
getNew: (win) => win.left + win.width,
});
if (snapLeft != null) {
return snapLeft;
}
return snapGrid(left);
}
export function maybeSnapTop(
_props: Rect,
windows: Array<Rect>,
top: number,
): number {
// snap bottom to bottom
// ┌─┐
// │A├─┐
// │ │B│
// └─┴─┘
const snapBottom2 = getPossibleSnappedPosition(windows, {
getGap: (win) => top - win.top - win.height,
getNew: (win) => win.top + win.height,
});
if (snapBottom2 != null) {
return snapBottom2;
}
// snap top to bottom
// ┌─┐
// │B│
// ├─┤
// │A│
// └─┘
const snapBottom = getPossibleSnappedPosition(windows, {
getGap: (win) => top - win.top - win.height,
getNew: (win) => win.top + win.height,
});
if (snapBottom != null) {
return snapBottom;
}
// snap top to top
// ┌─┬─┐
// │A│B│
// │ ├─┘
// └─┘
const snapTop = getPossibleSnappedPosition(windows, {
getGap: (win) => top - win.top,
getNew: (win) => win.top,
});
if (snapTop != null) {
return snapTop;
}
return snapGrid(top);
}