diff --git a/packages/ui/VirtualList/VirtualList.js b/packages/ui/VirtualList/VirtualList.js index 2e81a79980..654576b177 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']), + editable: PropTypes.object, // TBD // FIXME + /** * Specifies how to show horizontal scrollbar. * @@ -279,6 +281,7 @@ VirtualList.propTypes = /** @lends ui/VirtualList.VirtualList.prototype */ { VirtualList.defaultProps = { cbScrollTo: nop, direction: 'vertical', + editable: null, horizontalScrollbar: 'auto', noScrollByDrag: false, noScrollByWheel: false, @@ -386,6 +389,8 @@ VirtualGridList.propTypes = /** @lends ui/VirtualList.VirtualGridList.prototype */ direction: PropTypes.oneOf(['horizontal', 'vertical']), + editable: PropTypes.object, // TBD // FIXME + /** * Specifies how to show horizontal scrollbar. * @@ -549,6 +554,7 @@ VirtualGridList.propTypes = /** @lends ui/VirtualList.VirtualGridList.prototype VirtualGridList.defaultProps = { cbScrollTo: nop, direction: 'vertical', + editable: null, horizontalScrollbar: 'auto', noScrollByDrag: false, noScrollByWheel: false, diff --git a/packages/ui/VirtualList/VirtualListBasic.js b/packages/ui/VirtualList/VirtualListBasic.js index 7f25179230..9416c7fc62 100644 --- a/packages/ui/VirtualList/VirtualListBasic.js +++ b/packages/ui/VirtualList/VirtualListBasic.js @@ -2,11 +2,13 @@ import classNames from 'classnames'; import EnactPropTypes from '@enact/core/internal/prop-types'; import {forward} from '@enact/core/handle'; import {platform} from '@enact/core/platform'; -import {clamp} from '@enact/core/util'; +import {clamp, Job} from '@enact/core/util'; 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 = () => {}; @@ -153,6 +155,24 @@ class VirtualListBasic extends Component { */ direction: PropTypes.oneOf(['horizontal', 'vertical']), + /** + * The editable feature of the list. + * + * @type {Object} + * @property {Function} onComplete The callback function called when editing is finished. + * It has an event object contains `orders` array which app can use for repopulate items. + * @property {Object} [css] Customizes the component by mapping the supplied CSS class name to the + * corresponding internal element. + * The following class is supported: + * + * * `selected` - The selected item class + * @private + */ + editable: PropTypes.shape({ + onComplete: PropTypes.func.isRequired, + css: PropTypes.object + }), + /** * Called to get the scroll affordance from themed component. * @@ -347,6 +367,10 @@ class VirtualListBasic extends Component { } else { this.setContainerSize(); } + + if (this.props.editable) { + this.featureEditable.enable(); + } } componentDidUpdate (prevProps, prevState) { @@ -403,6 +427,8 @@ class VirtualListBasic extends Component { this.indexToScrollIntoView = -1; } + + this.featureEditable.editingCancel(); } if ( @@ -427,6 +453,8 @@ class VirtualListBasic extends Component { }, 'instant'); deferScrollTo = true; + + this.featureEditable.editingCancel(); } else if (this.hasDataSizeChanged) { const newState = this.getStatesAndUpdateBounds(this.props, this.state.firstIndex); this.setState(newState); @@ -450,6 +478,10 @@ class VirtualListBasic extends Component { } } + componentWillUnmount () { + this.featureEditable.disable(); + } + scrollBounds = { clientWidth: 0, clientHeight: 0, @@ -505,7 +537,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, @@ -533,7 +565,7 @@ class VirtualListBasic extends Component { } return {primaryPosition, secondaryPosition}; - } + }; // For individually sized item getItemBottomPosition = (index) => { @@ -956,6 +988,10 @@ class VirtualListBasic extends Component { if (this.shouldUpdateBounds || firstIndex !== newFirstIndex) { this.setState({firstIndex: newFirstIndex}); } + + if (this.featureEditable.enabled) { + this.featureEditable.movingItemUpdate(); + } } // For individually sized item @@ -1109,15 +1145,94 @@ class VirtualListBasic extends Component { this.cc[key] =
; }; + // Update 'position' and return 'indexInExtent' for the previous item of the item matching the given 'index' + 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; + } + } + + // Update 'position' and return 'indexInExtent' for the next item of the item matching the given 'index' + 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; + } + } + + // styles item to hide it, to animate it, or to make it normal during editing + 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.featureEditable.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 {enabled, editingMode, editingIndex, untrackedPointer} = this.featureEditable; + const featureEditableEnabled = enabled && editingMode; let hideTo = 0, - updateFrom = cc.length ? this.state.updateFrom : firstIndex, - updateTo = cc.length ? this.state.updateTo : firstIndex + numOfItems; + // During editing, all visible items should be calculated for relocation + updateFrom = cc.length && !editingMode ? this.state.updateFrom : firstIndex, + updateTo = cc.length && !editingMode ? this.state.updateTo : firstIndex + numOfItems; if (updateFrom >= updateTo) { return; @@ -1126,40 +1241,58 @@ 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 (featureEditableEnabled) { + if (untrackedPointer) { + // move the editing item since the scrolling position is updated + // skip rendering by 'handleMouseMove' since 'positionItems' is called by 'render' function + this.featureEditable.handleMouseMove(this.featureEditable.lastPointer, true); + } + if (updateFrom > editingIndex) { + // the first re-rendered item should be positioned at the previous index in this case + 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 (featureEditableEnabled) { + const {lastVisualIndex} = this.featureEditable; - 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) { + // the original item for the moving index should be hidden since we use cloned one for moving + this.setItemContainerStyle(itemContainer, {action: 'hide'}); + continue; } - j = 0; - } else { - secondaryPosition += secondary.gridSize; + if (lastVisualIndex >= editingIndex && index === lastVisualIndex + 1 || + lastVisualIndex < editingIndex && index === lastVisualIndex) { + // make a room to render the moving item + indexInExtent = this.getNextPosition(index, position, indexInExtent); + } + + if (index !== this.featureEditable.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); } } @@ -1202,7 +1335,436 @@ class VirtualListBasic extends Component { return false; }; - // render + /* + * Edit mode + */ + + featureEditable = { + /* + * Core interfaces + */ + + /* The indicator of whether editable feature is enabled or not */ + enabled: false, + /* The indicator of whether editing is started or not */ + editingMode: false, + + /* Enable an editable feature */ + /* Note that this function does not check necessary props are provided or not, therefore this function is internal use only. */ + enable: () => { + const node = this.props.scrollContentRef?.current; + this.featureEditable.enablerJob = new Job(this.featureEditable.editingStartByPointer, this.featureEditable.enablingTime); + if (node) { + const {featureEditable} = this; + featureEditable.enabled = true; + + // add event listeners for editing + node.addEventListener('mousedown', featureEditable.handleMouseDown); + node.addEventListener('mousemove', featureEditable.handleMouseMove); + node.addEventListener('mouseup', featureEditable.handleMouseUp); + node.addEventListener('mouseenter', featureEditable.handleMouseEnter); + node.addEventListener('mouseleave', featureEditable.handleMouseLeave); + + // prepare internal info for editing + const {primary, secondary, isPrimaryDirectionVertical} = this; + const {cachedItemSize, cachedSCrollContentBounds} = featureEditable; + const {clientWidth} = node; + const {x, y} = node.getBoundingClientRect(); + let [xAxis, yAxis] = [primary, secondary]; + if (isPrimaryDirectionVertical) { + [xAxis, yAxis] = [yAxis, xAxis]; + } + + cachedSCrollContentBounds.clientWidth = clientWidth; + cachedSCrollContentBounds.x = x; + cachedSCrollContentBounds.y = y; + cachedItemSize.width = xAxis.itemSize; + cachedItemSize.height = yAxis.itemSize; + } + }, + + /* Disable an editable feature */ + /* Note that this function does not check necessary props are provided or not, therefore this function is internal use only. */ + disable: () => { + const node = this.props.scrollContentRef?.current; + const {featureEditable} = this; + featureEditable.enabled = false; + if (node) { + // remove event listeners for editing + node.removeEventListener('mousedown', featureEditable.handleMouseDown); + node.removeEventListener('mousemove', featureEditable.handleMouseMove); + node.removeEventListener('mouseup', featureEditable.handleMouseUp); + node.removeEventListener('mouseenter', featureEditable.handleMouseEnter); + node.removeEventListener('mouseleave', featureEditable.handleMouseLeave); + } + }, + + /* Move an item to a new position. */ + /* FIXME: Note that this function works regardless item is selected or not but we may not need this functionality in our UX. */ + moveItem: (fromDataIndex, toVisualIndex, {skipRendering = false, scrollIntoView = false}) => { + const {featureEditable} = this; + if (featureEditable.enabled) { + if (featureEditable.editingMode) { // editing by user input + const order = featureEditable.editingDataOrder; + const fromVisualIndex = order.indexOf(fromDataIndex); + // when items' order is updated + if (fromVisualIndex !== toVisualIndex) { + order.splice( + toVisualIndex, + 0, + order.splice( + fromVisualIndex, + 1 + )[0] + ); + featureEditable.lastVisualIndex = toVisualIndex; + + if (!skipRendering) { + this.forceUpdate(); + if (scrollIntoView) { + this.scrollIntoViewByIndex(toVisualIndex); + } + } + } + } else if (fromDataIndex !== toVisualIndex) { // editing by programmatic API call + const newItemsOrder = [...Array(this.props.dataSize).keys()]; + newItemsOrder.splice(toVisualIndex, 0, newItemsOrder.splice(fromDataIndex, 1)[0]); + forward('onComplete', {detail: {order: newItemsOrder}}, this.props.editable); + } + } + }, + + /* Start editing with the selected item */ + editingStart: (editingIndex, positioningType, callbackRef = nop) => { + const {featureEditable} = this; + if (featureEditable.enabled && !featureEditable.editingMode) { + featureEditable.editingMode = true; + + featureEditable.editingDataOrder = [...Array(this.props.dataSize).keys()]; + featureEditable.editingIndex = editingIndex; + featureEditable.lastVisualIndex = editingIndex; + featureEditable.positioningType = positioningType; + + featureEditable.movingItemAdd(callbackRef); + } + }, + + /* Finish editing with the selected item and emit items' orders */ + editingFinish: (cancel = false) => { + const {featureEditable} = this; + if (featureEditable.enabled && featureEditable.editingMode) { + featureEditable.editingMode = false; + + featureEditable.movingItemRemove(); + + featureEditable.positioningType = null; + featureEditable.editingIndex = null; + featureEditable.editingNode = null; + if (!cancel) { + forward('onComplete', {detail: {order: featureEditable.editingDataOrder}}, this.props.editable); + } + featureEditable.editingDataOrder = null; + featureEditable.untrackedPointer = false; + featureEditable.lastVisualIndex = null; + } + featureEditable.enablerJob.stop(); + }, + + /* Cancel editing */ + editingCancel: () => this.featureEditable.editingFinish(true), + + /* + * Interfaces for a cloned item for moving effect + * A cloned item is to display an editing item in a virtualized list. + */ + + /* add a cloned item of the moving item for animation */ + movingItemAdd: (callbackRef) => { + /* TBD: using this.itemContainerRefs[key] ? */ + const {childProps, itemRenderer, getComponentProps} = this.props; + const {x, y, width, height} = this.featureEditable.calculateMovingItemXY(); + const index = this.featureEditable.editingIndex; + const componentProps = getComponentProps && getComponentProps(index) || {}; + const itemContainerRef = (ref) => { + this.featureEditable.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] = ( +