/** * Copyright 2018-present Facebook. * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * @format */ import { isEqual } from '@o/fast-compare' import { gloss } from 'gloss' import * as React from 'react' import { Rect } from './helpers/geometry' export type OrderableOrder = string[] type OrderableOrientation = 'horizontal' | 'vertical' type OrderableProps = { items: { [key: string]: any } orientation: OrderableOrientation onChange?: (order: OrderableOrder, key: string) => void order?: OrderableOrder className?: string reverse?: boolean altKey?: boolean moveDelay?: number dragOpacity?: number ignoreChildEvents?: boolean } type OrderableState = { shouldUpdate?: boolean order?: OrderableOrder movingOrder?: OrderableOrder } type TabSizes = { [key: string]: Rect } const OrderableContainer = gloss({ position: 'relative', display: 'inline-block', }) const OrderableItemContainer = gloss<{ orientation?: OrderableOrientation }>().theme( ({ orientation }) => ({ display: orientation === 'vertical' ? 'block' : 'inline-block', }), ) class OrderableItem extends React.Component<{ orientation: OrderableOrientation id: string children?: any addRef: (key: string, ref: HTMLElement) => void startMove: (KEY: string, event: React.MouseEvent) => void }> { addRef = (ref: HTMLElement) => { this.props.addRef(this.props.id, ref) } startMove = (event: React.MouseEvent) => { this.props.startMove(this.props.id, event) } render() { return ( {this.props.children} ) } } export class Orderable extends React.Component { tabRefs: { [key: string]: HTMLElement | void } = {} state = { order: null, shouldUpdate: false, movingOrder: null, } _mousemove?: any _mouseup?: any timer: any sizeKey: 'width' | 'height' offsetKey: 'left' | 'top' mouseKey: 'offsetX' | 'offsetY' screenKey: 'screenX' | 'screenY' containerRef?: HTMLElement static defaultProps = { dragOpacity: 1, moveDelay: 50, } shouldComponentUpdate() { return !this.state.movingOrder } static getDerivedStateFromProps(props, state) { if (!isEqual(props.order, state.order)) { return { shouldUpdate: true, order: props.order, } } } componentDidUpdate() { if (this.state.shouldUpdate) { const { orientation } = this.props this.sizeKey = orientation === 'horizontal' ? 'width' : 'height' this.offsetKey = orientation === 'horizontal' ? 'left' : 'top' this.mouseKey = orientation === 'horizontal' ? 'offsetX' : 'offsetY' this.screenKey = orientation === 'horizontal' ? 'screenX' : 'screenY' this.setState({ shouldUpdate: false }) } } startMove = (key: string, event: React.MouseEvent) => { if (this.props.altKey === true && event.altKey === false) { return } if (this.props.ignoreChildEvents === true) { const tabRef = this.tabRefs[key] // @ts-ignore if (event.target !== tabRef && event.target.parentNode !== tabRef) { return } } this.reset() event.persist() const { moveDelay } = this.props if (moveDelay == null) { this._startMove(key, event) } else { const cancel = () => { clearTimeout(this.timer) document.removeEventListener('mouseup', cancel) } document.addEventListener('mouseup', cancel) this.timer = setTimeout(() => { cancel() this._startMove(key, event) }, moveDelay) } } _startMove(activeKey: string, event: React.MouseEvent) { // @ts-ignore const clickOffset = event.nativeEvent[this.mouseKey] // calculate offsets before we start moving element const sizes: TabSizes = {} for (const key in this.tabRefs) { const elem = this.tabRefs[key] if (elem) { const rect: Rect = elem.getBoundingClientRect() sizes[key] = { height: rect.height, left: elem.offsetLeft, top: elem.offsetTop, width: rect.width, } } } const { containerRef } = this if (containerRef) { containerRef.style.height = `${containerRef.offsetHeight}px` containerRef.style.width = `${containerRef.offsetWidth}px` } for (const key in this.tabRefs) { const elem = this.tabRefs[key] if (elem) { const size = sizes[key] elem.style.position = 'absolute' elem.style.top = `${size.top}px` elem.style.left = `${size.left}px` elem.style.height = `${size.height}px` elem.style.width = `${size.width}px` } } document.addEventListener( 'mouseup', (this._mouseup = () => { this.stopMove(activeKey, sizes) }), { passive: true }, ) const screenClickPos = event.nativeEvent[this.screenKey] document.addEventListener( 'mousemove', (this._mousemove = (event: MouseEvent) => { const goingOpposite = event[this.screenKey] < screenClickPos this.possibleMove(activeKey, goingOpposite, event, clickOffset, sizes) }), { passive: true }, ) } possibleMove( activeKey: string, goingOpposite: boolean, event: MouseEvent, cursorOffset: number, sizes: TabSizes, ) { // update moving tab position const { containerRef } = this const movingSize = sizes[activeKey] const activeTab = this.tabRefs[activeKey] if (containerRef) { const containerRect: Rect = containerRef.getBoundingClientRect() let newActivePos = event[this.screenKey] - containerRect[this.offsetKey] - cursorOffset // $FlowFixMe newActivePos = Math.max(-1, newActivePos) newActivePos = Math.min(newActivePos, containerRect[this.sizeKey] - movingSize[this.sizeKey]) movingSize[this.offsetKey] = newActivePos if (activeTab) { activeTab.style.setProperty(this.offsetKey, `${newActivePos}px`) const { dragOpacity } = this.props if (dragOpacity != null && dragOpacity !== 1) { activeTab.style.opacity = `${dragOpacity}` } } } // figure out new order const zipped: [string, number][] = [] for (const key in sizes) { const rect = sizes[key] let offset = rect[this.offsetKey] let size = rect[this.sizeKey] if (goingOpposite) { // when dragging opposite add the size to the offset if (key === activeKey) { // calculate the active tab to be a quarter of the actual size so when dragging in the opposite // direction, you need to cover 75% of the previous tab to trigger a movement size *= 0.25 } offset += size } else if (key === activeKey) { // if not dragging in the opposite direction and we're the active tab, require covering 25% of the // next tab in roder to trigger a movement offset += size * 0.75 } zipped.push([key, offset]) } // calculate ordering const order = zipped .sort(([, a], [, b]) => { return Number(a > b) }) .map(([key]) => key) this.moveTabs(order, activeKey, sizes) this.setState({ movingOrder: order }) } moveTabs(order: OrderableOrder, activeKey: string | void, sizes: TabSizes) { let offset = 0 for (const key of order) { const size = sizes[key] const tab = this.tabRefs[key] if (tab) { let newZIndex = key === activeKey ? 2 : 1 const prevZIndex = tab.style.zIndex if (prevZIndex) { newZIndex += Number(prevZIndex) } tab.style.zIndex = String(newZIndex) if (key === activeKey) { tab.style.transition = 'opacity 100ms ease-in-out' } else { tab.style.transition = `${this.offsetKey} 300ms ease-in-out` tab.style.setProperty(this.offsetKey, `${offset}px`) } offset += size[this.sizeKey] } } } getMidpoint(rect: Rect) { return rect[this.offsetKey] + rect[this.sizeKey] / 2 } stopMove(activeKey: string, sizes: TabSizes) { const { movingOrder } = this.state const { onChange } = this.props if (onChange && movingOrder) { const activeTab = this.tabRefs[activeKey] if (activeTab) { activeTab.style.opacity = '' const transitionend = () => { activeTab.removeEventListener('transitionend', transitionend) this.reset() } activeTab.addEventListener('transitionend', transitionend) } this.resetListeners() this.moveTabs(movingOrder, null, sizes) onChange(movingOrder, activeKey) } else { this.reset() } this.setState({ movingOrder: null }) } resetListeners() { clearTimeout(this.timer) const { _mousemove, _mouseup } = this if (_mouseup) { document.removeEventListener('mouseup', _mouseup) } if (_mousemove) { document.removeEventListener('mousemove', _mousemove) } } reset() { this.resetListeners() const { containerRef } = this if (containerRef) { containerRef.removeAttribute('style') } for (const key in this.tabRefs) { const elem = this.tabRefs[key] if (elem) { elem.removeAttribute('style') } } } componentWillUnmount() { this.reset() } addRef = (key: string, elem: HTMLElement | void) => { this.tabRefs[key] = elem } setContainerRef = (ref: HTMLElement) => { this.containerRef = ref } render() { const { items } = this.props // calculate order of elements let { order } = this.state if (!order) { order = Object.keys(items) } for (const key in items) { if (order.indexOf(key) < 0) { if (this.props.reverse === true) { order.unshift(key) } else { order.push(key) } } } return ( {order.map(key => { const item = items[key] if (item) { return ( {item} ) } else { return null } })} ) } }