|
| 1 | +import * as React from 'react'; |
| 2 | +import { createPortal } from 'react-dom'; |
| 3 | +import { DroppableContext } from './DroppableContext'; |
| 4 | +import { DragDropContext } from './DragDrop'; |
| 5 | + |
| 6 | +export interface DraggableProps extends React.HTMLProps<HTMLDivElement> { |
| 7 | + /** Content rendered inside DragDrop */ |
| 8 | + children?: React.ReactNode; |
| 9 | + /** Class to add to outer div */ |
| 10 | + className?: string; |
| 11 | +} |
| 12 | + |
| 13 | +// Browsers really like being different from each other. |
| 14 | +function getDefaultBackground() { |
| 15 | + const div = document.createElement('div'); |
| 16 | + document.head.appendChild(div); |
| 17 | + const bg = window.getComputedStyle(div).backgroundColor; |
| 18 | + document.head.removeChild(div); |
| 19 | + return bg; |
| 20 | +} |
| 21 | + |
| 22 | +function getInheritedBackgroundColor(el: HTMLElement): string { |
| 23 | + const defaultStyle = getDefaultBackground(); |
| 24 | + const backgroundColor = window.getComputedStyle(el).backgroundColor; |
| 25 | + |
| 26 | + if (backgroundColor !== defaultStyle) { |
| 27 | + return backgroundColor; |
| 28 | + } else if (!el.parentElement) { |
| 29 | + return defaultStyle; |
| 30 | + } |
| 31 | + |
| 32 | + return getInheritedBackgroundColor(el.parentElement); |
| 33 | +} |
| 34 | + |
| 35 | +function removeBlankDiv(node: HTMLDivElement) { |
| 36 | + if (node.getAttribute('blankDiv') === 'true') { |
| 37 | + // eslint-disable-next-line @typescript-eslint/prefer-for-of |
| 38 | + for (let i = 0; i < node.children.length; i++) { |
| 39 | + const child = node.children[i]; |
| 40 | + if (child.getAttribute('blankDiv') === 'true') { |
| 41 | + node.removeChild(child); |
| 42 | + node.setAttribute('blankDiv', 'false'); |
| 43 | + break; |
| 44 | + } |
| 45 | + } |
| 46 | + } |
| 47 | +} |
| 48 | + |
| 49 | +interface DroppableItem { |
| 50 | + node: HTMLDivElement; |
| 51 | + rect: DOMRect; |
| 52 | + isDraggingHost: boolean; |
| 53 | + draggableNodes: HTMLDivElement[]; |
| 54 | + draggableNodesRects: DOMRect[]; |
| 55 | +} |
| 56 | + |
| 57 | +// Reset per-element state |
| 58 | +function resetDroppableItem(droppableItem: DroppableItem) { |
| 59 | + removeBlankDiv(droppableItem.node); |
| 60 | + droppableItem.node.style.boxShadow = ''; |
| 61 | + droppableItem.draggableNodes.forEach((n, i) => { |
| 62 | + n.style.transform = ''; |
| 63 | + n.style.transition = ''; |
| 64 | + droppableItem.draggableNodesRects[i] = n.getBoundingClientRect(); |
| 65 | + }); |
| 66 | +} |
| 67 | + |
| 68 | +function overlaps(ev: MouseEvent, rect: DOMRect) { |
| 69 | + return ( |
| 70 | + ev.clientX > rect.x && ev.clientX < rect.x + rect.width && ev.clientY > rect.y && ev.clientY < rect.y + rect.height |
| 71 | + ); |
| 72 | +} |
| 73 | + |
| 74 | +export const Draggable: React.FunctionComponent<DraggableProps> = ({ |
| 75 | + className, |
| 76 | + children, |
| 77 | + style: styleProp, |
| 78 | + ...props |
| 79 | +}: DraggableProps) => { |
| 80 | + /* eslint-disable prefer-const */ |
| 81 | + let [style, setStyle] = React.useState(styleProp); |
| 82 | + /* eslint-enable prefer-const */ |
| 83 | + const [isDragging, setIsDragging] = React.useState(false); |
| 84 | + const { zone, droppableId } = React.useContext(DroppableContext); |
| 85 | + const { onDrag, onDrop } = React.useContext(DragDropContext); |
| 86 | + const ref = React.createRef<HTMLDivElement>(); |
| 87 | + // Some state is better just to leave as vars passed around between various callbacks |
| 88 | + // You can only drag around one item at a time anyways... |
| 89 | + let startX = 0; |
| 90 | + let startY = 0; |
| 91 | + let index: number = null; // Index of this draggable |
| 92 | + let hoveringDroppable: HTMLDivElement; |
| 93 | + let hoveringIndex: number = null; |
| 94 | + let mouseMoveListener: EventListener; |
| 95 | + let mouseUpListener: EventListener; |
| 96 | + // Makes it so dragging the _bottom_ of the item over the halfway of another moves it |
| 97 | + let startYOffset = 0; |
| 98 | + |
| 99 | + // After item returning to where it started animation completes |
| 100 | + const onTransitionEnd = (_ev: React.TransitionEvent<HTMLDivElement>) => { |
| 101 | + if (isDragging) { |
| 102 | + setIsDragging(false); |
| 103 | + setStyle(styleProp); |
| 104 | + } |
| 105 | + }; |
| 106 | + |
| 107 | + const onMouseUpWhileDragging = (droppableItems: DroppableItem[]) => { |
| 108 | + droppableItems.forEach(resetDroppableItem); |
| 109 | + document.removeEventListener('mousemove', mouseMoveListener); |
| 110 | + document.removeEventListener('mouseup', mouseUpListener); |
| 111 | + document.removeEventListener('contextmenu', mouseUpListener); |
| 112 | + const hoveringDroppableId = hoveringDroppable ? hoveringDroppable.getAttribute('data-pf-droppableid') : null; |
| 113 | + const source = { |
| 114 | + droppableId, |
| 115 | + index |
| 116 | + }; |
| 117 | + const dest = |
| 118 | + hoveringDroppableId !== null && hoveringIndex !== null |
| 119 | + ? { |
| 120 | + droppableId: hoveringDroppableId, |
| 121 | + index: hoveringIndex |
| 122 | + } |
| 123 | + : undefined; |
| 124 | + const consumerReordered = onDrop(source, dest); |
| 125 | + if (consumerReordered && droppableId === hoveringDroppableId) { |
| 126 | + setIsDragging(false); |
| 127 | + setStyle(styleProp); |
| 128 | + } else if (!consumerReordered) { |
| 129 | + // Animate item returning to where it started |
| 130 | + setStyle({ |
| 131 | + ...style, |
| 132 | + transition: 'transform 0.5s cubic-bezier(0.2, 1, 0.1, 1) 0s', |
| 133 | + transform: '', |
| 134 | + background: styleProp.background, |
| 135 | + boxShadow: styleProp.boxShadow |
| 136 | + }); |
| 137 | + } |
| 138 | + }; |
| 139 | + |
| 140 | + // This is where the magic happens |
| 141 | + const onMouseMoveWhileDragging = (ev: MouseEvent, droppableItems: DroppableItem[], blankDivRect: DOMRect) => { |
| 142 | + // Compute each time what droppable node we are hovering over |
| 143 | + hoveringDroppable = null; |
| 144 | + droppableItems.forEach(droppableItem => { |
| 145 | + const { node, rect, isDraggingHost, draggableNodes, draggableNodesRects } = droppableItem; |
| 146 | + if (overlaps(ev, rect)) { |
| 147 | + // Add valid dropzone style |
| 148 | + node.style.boxShadow = '0px 0px 0px 1px blue, 0px 2px 5px rgba(0, 0, 0, 0.2)'; |
| 149 | + hoveringDroppable = node; |
| 150 | + // Check if we need to add a blank div row |
| 151 | + if (node.getAttribute('blankDiv') !== 'true' && !isDraggingHost) { |
| 152 | + const blankDiv = document.createElement('div'); |
| 153 | + blankDiv.setAttribute('blankDiv', 'true'); // Makes removing easier |
| 154 | + let blankDivPos = -1; |
| 155 | + for (let i = 0; i < draggableNodes.length; i++) { |
| 156 | + const childRect = draggableNodesRects[i]; |
| 157 | + const isLast = i === draggableNodes.length - 1; |
| 158 | + const startOverlaps = childRect.y >= startY - startYOffset; |
| 159 | + if ((startOverlaps || isLast) && blankDivPos === -1) { |
| 160 | + if (isLast && !startOverlaps) { |
| 161 | + draggableNodes[i].after(blankDiv); |
| 162 | + } else { |
| 163 | + draggableNodes[i].before(blankDiv); |
| 164 | + } |
| 165 | + blankDiv.style.height = `${blankDivRect.height}px`; |
| 166 | + blankDiv.style.width = `${blankDivRect.width}px`; |
| 167 | + node.setAttribute('blankDiv', 'true'); // Makes removing easier |
| 168 | + blankDivPos = i; |
| 169 | + } |
| 170 | + if (blankDivPos !== -1) { |
| 171 | + childRect.y += blankDivRect.height; |
| 172 | + } |
| 173 | + } |
| 174 | + // Insert so drag + drop behavior matches single-list case |
| 175 | + draggableNodes.splice(blankDivPos, 0, blankDiv); |
| 176 | + draggableNodesRects.splice(blankDivPos, 0, blankDivRect); |
| 177 | + // Extend hitbox of droppable zone |
| 178 | + rect.height += blankDivRect.height; |
| 179 | + } |
| 180 | + } else { |
| 181 | + resetDroppableItem(droppableItem); |
| 182 | + node.style.boxShadow = '0px 0px 0px 1px red, 0px 2px 5px rgba(0, 0, 0, 0.2)'; |
| 183 | + } |
| 184 | + }); |
| 185 | + |
| 186 | + // Move hovering draggable and style it based on cursor position |
| 187 | + setStyle({ |
| 188 | + ...style, |
| 189 | + boxShadow: `0px 0px 0px 1px ${hoveringDroppable ? 'blue' : 'red'}, 0px 2px 5px rgba(0, 0, 0, 0.2)`, |
| 190 | + transform: `translate(${ev.pageX - startX}px, ${ev.pageY - startY}px)`, |
| 191 | + cursor: !hoveringDroppable && 'not-allowed' |
| 192 | + }); |
| 193 | + |
| 194 | + // Iterate through sibling draggable nodes to reposition them and store correct hoveringIndex for onDrop |
| 195 | + hoveringIndex = null; |
| 196 | + if (hoveringDroppable) { |
| 197 | + const { draggableNodes, draggableNodesRects } = droppableItems.find(item => item.node === hoveringDroppable); |
| 198 | + let lastTranslate = 0; |
| 199 | + draggableNodes.forEach((n, i) => { |
| 200 | + n.style.transition = 'transform 0.5s cubic-bezier(0.2, 1, 0.1, 1) 0s'; |
| 201 | + const rect = draggableNodesRects[i]; |
| 202 | + const halfway = rect.y + rect.height / 2; |
| 203 | + let translateY = 0; |
| 204 | + // Use offset for more interactive translations |
| 205 | + if (startY < halfway && ev.pageY + (blankDivRect.height - startYOffset) > halfway) { |
| 206 | + translateY -= blankDivRect.height; |
| 207 | + } else if (startY >= halfway && ev.pageY - startYOffset <= halfway) { |
| 208 | + translateY += blankDivRect.height; |
| 209 | + } |
| 210 | + // Clever way to find item currently hovering over |
| 211 | + if ((translateY <= lastTranslate && translateY < 0) || (translateY > lastTranslate && translateY > 0)) { |
| 212 | + hoveringIndex = i; |
| 213 | + } |
| 214 | + n.style.transform = `translate(0, ${translateY}px`; |
| 215 | + lastTranslate = translateY; |
| 216 | + }); |
| 217 | + } |
| 218 | + }; |
| 219 | + |
| 220 | + const onDragStart = (ev: React.DragEvent<HTMLDivElement>) => { |
| 221 | + // Default HTML drag and drop doesn't allow us to change what the thing |
| 222 | + // being dragged looks like. Because of this we'll use prevent the default |
| 223 | + // and use `mouseMove` and `mouseUp` instead |
| 224 | + ev.preventDefault(); |
| 225 | + if (isDragging) { |
| 226 | + // still in animation |
| 227 | + return; |
| 228 | + } |
| 229 | + |
| 230 | + // Cache droppable and draggable nodes and their bounding rects |
| 231 | + const rect = ref.current.getBoundingClientRect(); |
| 232 | + const droppableNodes = Array.from(document.querySelectorAll(`[data-pf-droppable="${zone}"]`)) as HTMLDivElement[]; |
| 233 | + const droppableItems = droppableNodes.reduce((acc, cur) => { |
| 234 | + const draggableNodes = Array.from(cur.querySelectorAll(`[data-pf-draggable-zone="${zone}"]`)) as HTMLDivElement[]; |
| 235 | + const isDraggingHost = cur.contains(ref.current); |
| 236 | + if (isDraggingHost) { |
| 237 | + index = draggableNodes.indexOf(ref.current); |
| 238 | + } |
| 239 | + const droppableItem = { |
| 240 | + node: cur, |
| 241 | + rect: cur.getBoundingClientRect(), |
| 242 | + isDraggingHost, |
| 243 | + draggableNodes, |
| 244 | + draggableNodesRects: draggableNodes.map(node => node.getBoundingClientRect()) |
| 245 | + }; |
| 246 | + acc.push(droppableItem); |
| 247 | + return acc; |
| 248 | + }, []); |
| 249 | + |
| 250 | + if (!onDrag({ droppableId, index })) { |
| 251 | + // Consumer disallowed drag |
| 252 | + return; |
| 253 | + } |
| 254 | + |
| 255 | + // Set initial style so future style mods take effect |
| 256 | + style = { |
| 257 | + ...style, |
| 258 | + position: 'fixed', |
| 259 | + top: rect.y, |
| 260 | + left: rect.x, |
| 261 | + width: rect.width, |
| 262 | + height: rect.height, |
| 263 | + background: getInheritedBackgroundColor(ref.current), |
| 264 | + boxShadow: '0px 0px 0px 1px blue, 0px 2px 5px rgba(0, 0, 0, 0.2)', |
| 265 | + zIndex: 5000 |
| 266 | + }; |
| 267 | + setStyle(style); |
| 268 | + // Store event details |
| 269 | + startX = ev.pageX; |
| 270 | + startY = ev.pageY; |
| 271 | + startYOffset = startY - rect.y; |
| 272 | + setIsDragging(true); |
| 273 | + mouseMoveListener = ev => onMouseMoveWhileDragging(ev as MouseEvent, droppableItems, rect); |
| 274 | + mouseUpListener = () => onMouseUpWhileDragging(droppableItems); |
| 275 | + document.addEventListener('mousemove', mouseMoveListener); |
| 276 | + document.addEventListener('mouseup', mouseUpListener); |
| 277 | + // Comment out this line to debug while dragging by right clicking |
| 278 | + document.addEventListener('contextmenu', mouseUpListener); |
| 279 | + }; |
| 280 | + |
| 281 | + const div = ( |
| 282 | + <div |
| 283 | + data-pf-draggable-zone={isDragging ? null : zone} |
| 284 | + draggable |
| 285 | + role="button" |
| 286 | + className={className} |
| 287 | + onDragStart={onDragStart} |
| 288 | + onTransitionEnd={onTransitionEnd} |
| 289 | + style={style} |
| 290 | + ref={ref} |
| 291 | + {...props} |
| 292 | + > |
| 293 | + {children} |
| 294 | + </div> |
| 295 | + ); |
| 296 | + |
| 297 | + return ( |
| 298 | + <React.Fragment> |
| 299 | + {/* Leave behind blank spot per-design */} |
| 300 | + {isDragging && ( |
| 301 | + <div draggable role="button" {...props} style={{ ...styleProp, visibility: 'hidden' }}> |
| 302 | + {children} |
| 303 | + </div> |
| 304 | + )} |
| 305 | + {/* Move dragging part into portal to appear above top and side nav */} |
| 306 | + {isDragging ? createPortal(div, document.body) : div} |
| 307 | + </React.Fragment> |
| 308 | + ); |
| 309 | +}; |
| 310 | +Draggable.displayName = 'Draggable'; |
0 commit comments