diff --git a/packages/ui/VirtualList/VirtualList.js b/packages/ui/VirtualList/VirtualList.js index 2e81a79980..13cfa35ef3 100644 --- a/packages/ui/VirtualList/VirtualList.js +++ b/packages/ui/VirtualList/VirtualList.js @@ -116,6 +116,8 @@ VirtualList.propTypes = /** @lends ui/VirtualList.VirtualList.prototype */ { */ direction: PropTypes.oneOf(['horizontal', 'vertical']), + editMode: PropTypes.bool, + /** * Specifies how to show horizontal scrollbar. * @@ -226,6 +228,8 @@ VirtualList.propTypes = /** @lends ui/VirtualList.VirtualList.prototype */ { */ onScrollStop: PropTypes.func, + onUpdateItemsOrder: PropTypes.func, + /** * Specifies overscroll effects shows on which type of inputs. * @@ -279,12 +283,14 @@ VirtualList.propTypes = /** @lends ui/VirtualList.VirtualList.prototype */ { VirtualList.defaultProps = { cbScrollTo: nop, direction: 'vertical', + editMode: false, horizontalScrollbar: 'auto', noScrollByDrag: false, noScrollByWheel: false, onScroll: nop, onScrollStart: nop, onScrollStop: nop, + onUpdateItemsOrder: nop, overscrollEffectOn: { drag: false, pageKey: false, @@ -386,6 +392,8 @@ VirtualGridList.propTypes = /** @lends ui/VirtualList.VirtualGridList.prototype */ direction: PropTypes.oneOf(['horizontal', 'vertical']), + editMode: PropTypes.bool, + /** * Specifies how to show horizontal scrollbar. * @@ -496,6 +504,8 @@ VirtualGridList.propTypes = /** @lends ui/VirtualList.VirtualGridList.prototype */ onScrollStop: PropTypes.func, + onUpdateItemsOrder: PropTypes.func, + /** * Specifies overscroll effects shows on which type of inputs. * @@ -549,12 +559,14 @@ VirtualGridList.propTypes = /** @lends ui/VirtualList.VirtualGridList.prototype VirtualGridList.defaultProps = { cbScrollTo: nop, direction: 'vertical', + editMode: false, horizontalScrollbar: 'auto', noScrollByDrag: false, noScrollByWheel: false, onScroll: nop, onScrollStart: nop, onScrollStop: nop, + onUpdateItemsOrder: nop, overscrollEffectOn: { drag: false, pageKey: false, diff --git a/packages/ui/VirtualList/VirtualListBasic.js b/packages/ui/VirtualList/VirtualListBasic.js index c3f36b05c2..cc71b31297 100644 --- a/packages/ui/VirtualList/VirtualListBasic.js +++ b/packages/ui/VirtualList/VirtualListBasic.js @@ -7,6 +7,8 @@ import PropTypes from 'prop-types'; import equals from 'ramda/src/equals'; import {createRef, Component} from 'react'; +import {utilDOM} from '../useScroll/utilDOM'; + import css from './VirtualList.module.less'; const nop = () => {}; @@ -169,6 +171,8 @@ class VirtualListBasic extends Component { */ direction: PropTypes.oneOf(['horizontal', 'vertical']), + editMode: PropTypes.bool, + /** * Called to get the scroll affordance from themed component. * @@ -211,6 +215,8 @@ class VirtualListBasic extends Component { */ onUpdateItems: PropTypes.func, + onUpdateItemsOrder: PropTypes.func, + /** * Number of spare DOM node. * `3` is good for the default value experimentally and @@ -300,6 +306,7 @@ class VirtualListBasic extends Component { cbScrollTo: nop, dataSize: 0, direction: 'vertical', + editMode: false, getAffordance: () => (0), overhang: 3, pageScroll: false, @@ -329,6 +336,11 @@ class VirtualListBasic extends Component { updateTo: 0, ...nextState }; + + this.emitUpdateItemsOrder = props.onUpdateItemsOrder; + window.moveItem = this.moveItem; // FIXME: for testing only + window.scrollIntoViewByIndex = this.scrollIntoViewByIndex; + window.setEditMode = this.setEditMode; // FIXME: for testing only } static getDerivedStateFromProps (props, state) { @@ -364,6 +376,8 @@ class VirtualListBasic extends Component { } else { this.setContainerSize(); } + + this.setEditMode(this.props.editMode); } componentDidUpdate (prevProps, prevState) { @@ -420,6 +434,8 @@ class VirtualListBasic extends Component { this.indexToScrollIntoView = -1; } + + this.resetEditModeInfo(); } if ( @@ -445,6 +461,8 @@ class VirtualListBasic extends Component { }); deferScrollTo = true; + + this.resetEditModeInfo(); } else if (this.hasDataSizeChanged) { const newState = this.getStatesAndUpdateBounds(this.props, this.state.firstIndex); // eslint-disable-next-line react/no-did-update-set-state @@ -467,6 +485,10 @@ class VirtualListBasic extends Component { this.props.cbScrollTo({position: (this.isPrimaryDirectionVertical) ? {y: maxPos} : {x: maxPos}, animate: false}); this.scrollToPositionTarget = -1; } + + if (prevProps.editMode !== this.props.editMode) { + this.setEditMode(this.props.editMode); + } } scrollBounds = { @@ -505,6 +527,20 @@ class VirtualListBasic extends Component { itemPositions = []; indexToScrollIntoView = -1; + // Edit mode + editModeInfo = { + editingIndex: null, + editingNode: null, + editingDataOrder: null, + guessPosition: false, + itemSize: {width: null, height: null}, + lastPointer: {clientX: null, clientY: null}, + lastVisualIndex: null, + scrollContentBounds: {clientWidth: null, x: null, y: null}, + transitionTime: '100ms', + positioningType: null + }; + updateScrollPosition = ({x, y}, rtl = this.props.rtl) => { if (this.props.scrollMode === 'native') { this.scrollToPosition(x, y, rtl); @@ -524,7 +560,7 @@ class VirtualListBasic extends Component { getCenterItemIndexFromScrollPosition = (scrollPosition) => Math.floor((scrollPosition + (this.primary.clientSize / 2)) / this.primary.gridSize) * this.dimensionToExtent + Math.floor(this.dimensionToExtent / 2); - getGridPosition (index) { + getGridPosition = (index) => { const {dataSize, itemSizes} = this.props, {dimensionToExtent, itemPositions, primary, secondary} = this, @@ -552,7 +588,7 @@ class VirtualListBasic extends Component { } return {primaryPosition, secondaryPosition}; - } + }; // For individually sized item getItemBottomPosition = (index) => { @@ -680,6 +716,8 @@ class VirtualListBasic extends Component { this.updateScrollPosition(this.getXY(this.scrollPosition, 0)); node.style.scrollBehavior = 'smooth'; } + + this.calculateMetricsForEditMode(node); } getStatesAndUpdateBounds = (props, firstIndex = 0) => { @@ -888,7 +926,7 @@ class VirtualListBasic extends Component { didScroll (x, y) { const - {dataSize, spacing, itemSizes} = this.props, + {dataSize, spacing, itemSizes, editMode} = this.props, {firstIndex} = this.state, {isPrimaryDirectionVertical, threshold, dimensionToExtent, maxFirstIndex, scrollBounds, itemPositions} = this, {clientSize, gridSize} = this.primary, @@ -972,6 +1010,10 @@ class VirtualListBasic extends Component { if (this.shouldUpdateBounds || firstIndex !== newFirstIndex) { this.setState({firstIndex: newFirstIndex}); } + + if (editMode && this.editModeInfo.editingNode) { + this.updateMovingItem(); + } } // For individually sized item @@ -1125,15 +1167,89 @@ class VirtualListBasic extends Component { this.cc[key] =
; }; + getPrevPosition (index, position, indexInExtent) { + const {itemSizes} = this.props; + const {dimensionToExtent, itemPositions, primary, secondary} = this; + + if (indexInExtent === 0) { + if (itemSizes) { + if (itemPositions[index - 1] || itemPositions[index - 1] === 0) { + position.primaryPosition = itemPositions[index - 1].position; + } else if (itemSizes[index]) { + position.primaryPosition -= itemSizes[index] + this.props.spacing; + } else { + position.primaryPosition -= primary.gridSize; + } + } else { + position.primaryPosition -= primary.gridSize; + } + position.secondaryPosition = 0; + return dimensionToExtent - 1; + } else { + position.secondaryPosition += secondary.gridSize; + return indexInExtent - 1; + } + } + + getNextPosition (index, position, indexInExtent) { + const {itemSizes} = this.props; + const {dimensionToExtent, itemPositions, primary, secondary} = this; + + if (indexInExtent + 1 >= dimensionToExtent) { + if (itemSizes) { + if (itemPositions[index + 1] || itemPositions[index + 1] === 0) { + position.primaryPosition = itemPositions[index + 1].position; + } else if (itemSizes[index]) { + position.primaryPosition += itemSizes[index] + this.props.spacing; + } else { + position.primaryPosition += primary.gridSize; + } + } else { + position.primaryPosition += primary.gridSize; + } + position.secondaryPosition = 0; + return 0; + } else { + position.secondaryPosition += secondary.gridSize; + return indexInExtent + 1; + } + } + + setItemContainerStyle (node, {action, position}) { + if (node) { + const style = node.style; + switch (action) { + case 'hide': + style.opacity = 0; + style.transition = null; + break; + case 'animate': + style.opacity = null; + style.transition = `transform ${this.editModeInfo.transitionTime}`; + break; + case 'reset': + default: + style.opacity = null; + style.transition = null; + break; + } + if (position) { + const {x, y} = this.getXY(position.primaryPosition, position.secondaryPosition); + style.transform = `translate3d(${this.props.rtl ? -x : x}px, ${y}px, 0)`; + } + } + } + positionItems () { const - {dataSize, itemSizes} = this.props, + {dataSize} = this.props, {firstIndex, numOfItems} = this.state, - {cc, isPrimaryDirectionVertical, dimensionToExtent, primary, secondary, itemPositions} = this; + {cc, isPrimaryDirectionVertical, dimensionToExtent, primary, secondary} = this; + const {editingIndex, guessPosition} = this.editModeInfo; let hideTo = 0, - updateFrom = cc.length ? this.state.updateFrom : firstIndex, - updateTo = cc.length ? this.state.updateTo : firstIndex + numOfItems; + updateFrom = cc.length && !guessPosition ? this.state.updateFrom : firstIndex, + updateTo = cc.length && !guessPosition ? this.state.updateTo : firstIndex + numOfItems; if (updateFrom >= updateTo) { return; @@ -1142,40 +1258,53 @@ class VirtualListBasic extends Component { updateTo = dataSize; } - let - width, height, - {primaryPosition, secondaryPosition} = this.getGridPosition(updateFrom); + let width = (isPrimaryDirectionVertical ? secondary.itemSize : primary.itemSize) + 'px'; + let height = (isPrimaryDirectionVertical ? primary.itemSize : secondary.itemSize) + 'px'; + let position = this.getGridPosition(updateFrom); + let indexInExtent = updateFrom % dimensionToExtent; - width = (isPrimaryDirectionVertical ? secondary.itemSize : primary.itemSize) + 'px'; - height = (isPrimaryDirectionVertical ? primary.itemSize : secondary.itemSize) + 'px'; + if (editingIndex !== null) { + if (guessPosition) { + this.itemMoving(this.editModeInfo.lastPointer, true); + } + if (updateFrom > editingIndex) { + indexInExtent = this.getPrevPosition(updateFrom, position, indexInExtent); + } + } // positioning items - for (let i = updateFrom, j = updateFrom % dimensionToExtent; i < updateTo; i++) { - this.applyStyleToNewNode(i, width, height, primaryPosition, secondaryPosition); + for (let index = updateFrom; index < updateTo; index++) { + const itemContainer = this.itemContainerRefs[index % this.state.numOfItems]; - if (++j === dimensionToExtent) { - secondaryPosition = 0; + if (this.props.editMode && editingIndex !== null) { + const {lastVisualIndex} = this.editModeInfo; - if (this.props.itemSizes) { - if (itemPositions[i + 1] || itemPositions[i + 1] === 0) { - primaryPosition = itemPositions[i + 1].position; - } else if (itemSizes[i]) { - primaryPosition += itemSizes[i] + this.props.spacing; - } else { - primaryPosition += primary.gridSize; - } - } else { - primaryPosition += primary.gridSize; + if (index === editingIndex) { + this.setItemContainerStyle(itemContainer, {action: 'hide'}); + continue; } - j = 0; - } else { - secondaryPosition += secondary.gridSize; + if (lastVisualIndex >= editingIndex && index === lastVisualIndex + 1 || + lastVisualIndex < editingIndex && index === lastVisualIndex) { + indexInExtent = this.getNextPosition(index, position, indexInExtent); + } + + if (index !== this.getDataIndexFromNode(itemContainer)) { + this.setItemContainerStyle(itemContainer, {action: 'reset'}); + this.applyStyleToNewNode(index, width, height, position.primaryPosition, position.secondaryPosition); + } else { + this.setItemContainerStyle(itemContainer, {action: 'animate', position}); + } + indexInExtent = this.getNextPosition(index, position, indexInExtent); + } else { // normal case + this.setItemContainerStyle(itemContainer, {action: 'reset', position}); + this.applyStyleToNewNode(index, width, height, position.primaryPosition, position.secondaryPosition); + indexInExtent = this.getNextPosition(index, position, indexInExtent); } } - for (let i = updateTo; i < hideTo; i++) { - this.applyStyleToHideNode(i); + for (let index = updateTo; index < hideTo; index++) { + this.applyStyleToHideNode(index); } } @@ -1218,7 +1347,336 @@ class VirtualListBasic extends Component { return false; }; - // render + /* + * Edit mode + */ + + /* Edit mode common */ + + setEditMode = (on) => { + if (this.props.scrollContentRef.current) { + if (on) { + const node = this.props.scrollContentRef.current; + node.addEventListener('mousedown', this.itemMovingBegin); + node.addEventListener('mousemove', this.itemMoving); + node.addEventListener('mouseup', this.itemMovingEnd); + node.addEventListener('mouseenter', this.itemMovingEnter); + node.addEventListener('mouseleave', this.itemMovingLeave); + } else { + const node = this.props.scrollContentRef.current; + node.removeEventListener('mousedown', this.itemMovingBegin); + node.removeEventListener('mousemove', this.itemMoving); + node.removeEventListener('mouseup', this.itemMovingEnd); + node.removeEventListener('mouseenter', this.itemMovingEnter); + node.removeEventListener('mouseleave', this.itemMovingLeave); + } + } + }; + + moveItem = (dataIndex, toVisualIndex, {skipRendering = false, scrollIntoView = false}) => { + if (this.props.editMode && this.editModeInfo.editingIndex !== null) { + const order = this.editModeInfo.editingDataOrder; + const fromVisualIndex = order.indexOf(dataIndex); + if (fromVisualIndex !== toVisualIndex) { + order.splice( + toVisualIndex, + 0, + order.splice( + fromVisualIndex, + 1 + )[0] + ); + this.editModeInfo.lastVisualIndex = toVisualIndex; + + if (!skipRendering) { + this.forceUpdate(); + if (scrollIntoView) { + this.scrollIntoViewByIndex(toVisualIndex); + } + } + } + } else if (dataIndex !== toVisualIndex) { // In this case, visual index is the same as data index + const newItemsOrder = [...Array(this.props.dataSize).keys()]; + newItemsOrder.splice(toVisualIndex, 0, newItemsOrder.splice(dataIndex, 1)[0]); + this.emitUpdateItemsOrder(newItemsOrder); + } + }; + + calculateMetricsForEditMode = (node) => { + if (node) { + const {primary, secondary, isPrimaryDirectionVertical} = this; + const {itemSize, scrollContentBounds} = this.editModeInfo; + const {clientWidth} = node; + const {x, y} = node.getBoundingClientRect(); + let [xAxis, yAxis] = [primary, secondary]; + if (isPrimaryDirectionVertical) { + [xAxis, yAxis] = [yAxis, xAxis]; + } + + scrollContentBounds.clientWidth = clientWidth; + scrollContentBounds.x = x; + scrollContentBounds.y = y; + itemSize.width = xAxis.itemSize; + itemSize.height = yAxis.itemSize; + } + }; + + initializeEditingDataOrder = () => { + this.editModeInfo.editingDataOrder = [...Array(this.props.dataSize).keys()]; + }; + + editingBegin = (editingIndex, positioningType, callbackRef = nop) => { + this.initializeEditingDataOrder(); + this.editModeInfo.editingIndex = editingIndex; + this.editModeInfo.lastVisualIndex = editingIndex; + this.editModeInfo.positioningType = positioningType; + + this.addMovingItem(callbackRef); + }; + + editingEnd = () => { + this.removeMovingItem(); + + this.editModeInfo.positioningType = null; + this.editModeInfo.editingIndex = null; + this.emitUpdateItemsOrder(this.editModeInfo.editingDataOrder); + this.editModeInfo.editingDataOrder = null; + }; + + resetEditModeInfo = () => { + this.editModeInfo.editingIndex = null; + this.editModeInfo.editingNode = null; + this.editModeInfo.editingDataOrder = null; + this.editModeInfo.guessPosition = false; + this.editModeInfo.lastVisualIndex = null; + this.removeMovingItem(); + }; + + scrollIntoViewByIndex = (index) => { + const {scrollPosition, primary} = this; + const {primaryPosition} = this.getGridPosition(index); + if (primaryPosition < scrollPosition) { + this.props.cbScrollTo({ + index, + stickTo: 'start', + animate: true + }); + } else if (primaryPosition + primary.itemSize > scrollPosition + primary.clientSize) { + // TBD: affordance is not calculated for now + this.props.cbScrollTo({ + index, + stickTo: 'end', + animate: true + }); + } + }; + + /* Edit mode for pointer mode */ + + updateMovingPosition = (clientX, clientY) => { + this.editModeInfo.lastPointer.clientX = clientX; + this.editModeInfo.lastPointer.clientY = clientY; + }; + + getClientXYFromXYPosition = (x, y) => { + const {scrollContentBounds: {clientWidth, x: boundX, y: boundY}} = this.editModeInfo; + let relativeX = x + let relativeY = y; + if (this.isPrimaryDirectionVertical) { + relativeY -= this.scrollPosition; + } else { + relativeX -= this.scrollPosition; + } + + return { + clientX: this.props.rtl? (clientWidth - relativeX) + boundX : relativeX + boundX, + clientY: relativeY + boundY + }; + }; + + getXYPositionFromClientXY = (clientX, clientY) => { + const {scrollContentBounds: {clientWidth, x: boundX, y: boundY}} = this.editModeInfo; + const relativeX = this.props.rtl ? clientWidth - (clientX - boundX) : clientX - boundX; + const relativeY = clientY - boundY; + if (this.isPrimaryDirectionVertical) { + return { + x: relativeX, + y: relativeY + this.scrollPosition + }; + } else { + return { + x: relativeX + this.scrollPosition, + y: relativeY + }; + } + }; + + getPositionFromClientXY = () => { + const {clientX, clientY} = this.editModeInfo.lastPointer; + const {x, y} = this.getXYPositionFromClientXY(clientX, clientY); + if (this.isPrimaryDirectionVertical) { + return { + primaryPosition: y, + secondaryPosition: x + }; + } else { + return { + primaryPosition: x, + secondaryPosition: y + }; + } + }; + + getVisualIndexFromPosition = () => { + const {itemSizes} = this.props; + const {dimensionToExtent, primary, secondary} = this; + const {primaryPosition, secondaryPosition} = this.getPositionFromClientXY(); + + if (itemSizes && typeof itemSizes[index] !== 'undefined') { + // TBD: refer getGridPosition() to fill this + return null; + } else { + const primaryIndex = Math.floor(primaryPosition / primary.gridSize); + const secondaryIndex = Math.floor(secondaryPosition / secondary.gridSize); + + // return null if the coordinate points to space between items + if ((primaryIndex * primary.gridSize + primary.itemSize < primaryPosition) || + (secondaryIndex * secondary.gridSize + secondary.itemSize < secondaryPosition)) { + return null; + } + + return primaryIndex * dimensionToExtent + secondaryIndex; + } + }; + + getDataIndexFromNode = (node) => { + const targetNode = node && node.querySelector('[data-index]'); + if (targetNode) { + const index = parseInt(targetNode.dataset.index); + if (!isNaN(index)) { + return index; + } + } + return null; + }; + + getDataIndexFromPosition = (clientX, clientY) => { + if (typeof window !== 'undefined') { + const contentNode = this.contentRef.current; + let node = document.elementFromPoint(clientX, clientY); + if (utilDOM.containsDangerously(contentNode, node)) { + while (node.parentNode !== contentNode) { + if (node === document) { + return null; + } + node = node.parentNode; + } + + if (node) { + return this.getDataIndexFromNode(node); + } + } + } + + return null; + }; + + calculateMovingItemPosition = () => { + const {positioningType, itemSize: {width, height}, lastPointer: {clientX, clientY}} = this.editModeInfo; + if (positioningType === 'pointer') { + const {x, y} = this.getXYPositionFromClientXY( + clientX - (this.props.rtl ? -1 : 1) * width / 2, + clientY - height / 2 + ); + return {x, y, width, height}; + } else if (positioningType === 'index') { + const {lastVisualIndex} = this.editModeInfo; + const {left, top} = this.gridPositionToItemPosition(this.getGridPosition(lastVisualIndex)); + return {x: left, y: top, width, height}; + } + }; + + addMovingItem = (callbackRef) => { + /* TBD: using this.itemContainerRefs[key] ? */ + const {childProps, itemRenderer, getComponentProps} = this.props; + const {x, y, width, height} = this.calculateMovingItemPosition(); + const index = this.editModeInfo.editingIndex; + const componentProps = getComponentProps && getComponentProps(index) || {}; + const itemContainerRef = (ref) => { + this.editModeInfo.editingNode = ref; + if (ref) { + callbackRef(ref); + } + }; + const style = { + width: width + 'px', + height: height + 'px', + /* FIXME: RTL / this calculation only works for Chrome */ + transform: `translate3d(${this.props.rtl ? -x : x}px, ${y}px, 0)`, + zIndex: 10 + }; + + this.cc[this.state.numOfItems] = ( +