Last active
November 1, 2021 22:22
-
-
Save jsnns/9b588fee5383991022c0c37432e94cd5 to your computer and use it in GitHub Desktop.
Drag and drop wrappers for complex drag and drop editors
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import classNames from "classnames"; | |
import _ from "lodash"; | |
import React from "react"; | |
import { PlutoBlock } from "../parser/blockTypes"; | |
import { DDContext, DDContextState } from "./types"; | |
export interface DraggableProps { | |
name: string; | |
block: PlutoBlock; | |
} | |
export interface DraggableChildProps { | |
context: DDContextState; | |
} | |
/** | |
* Exposed wrapper for draggable blocks | |
* | |
* Responsible for: | |
* - setting self as the current draggable | |
* - consuming the mouse position to update it's child's position | |
*/ | |
export const Draggable: React.FC<DraggableProps> = ({ | |
children, | |
block, | |
name, | |
}) => { | |
return ( | |
<DDContext.Consumer> | |
{(context) => { | |
return ( | |
<DraggableChild block={block} name={name} context={context}> | |
{children} | |
</DraggableChild> | |
); | |
}} | |
</DDContext.Consumer> | |
); | |
}; | |
/** | |
* Internal wrapper for draggable blocks | |
* - only handles drag start events, drag stop is handled by the dragging context | |
* - maintains height even when dragging | |
*/ | |
const DraggableChild: React.FC<DraggableChildProps & DraggableProps> = ({ | |
context, | |
children, | |
name, | |
block, | |
}) => { | |
return ( | |
<> | |
{context.currentDraggable?.name === name && ( | |
<div className="footprint" id={`footprint-${name}`} /> | |
)} | |
<div | |
id={`draggable-${name}`} | |
className={classNames("draggable")} | |
style={ | |
context.currentDraggable?.name === name | |
? { | |
position: "fixed", | |
top: context.mousePosition.y, | |
left: context.mousePosition.x, | |
zIndex: 9999, | |
} | |
: {} | |
} | |
onMouseDown={(e) => { | |
// on right click don't start drag | |
if (e.button === 2) { | |
return; | |
} | |
const selfRef = document.getElementById(`draggable-${name}`); | |
if (selfRef) { | |
selfRef.style.width = `${selfRef?.clientWidth}px`; | |
} | |
context.setCurrentDraggable({ | |
value: _.cloneDeep(block), | |
name, | |
}); | |
e.stopPropagation(); | |
}} | |
> | |
<div className="draggable-child">{children}</div> | |
</div> | |
</> | |
); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import _ from "lodash"; | |
import React, { Component } from "react"; | |
import { isCompatible } from "../parser/tokenTypes"; | |
import { DDContext, DDContextState, DDDraggable, DDDropZone } from "./types"; | |
/** | |
* DraggingContext is used to manage the drag and drop system | |
* | |
* Responsibilities: | |
* - Manage currently active drop zones | |
* - Manage current draggable | |
* - Manage targeted drop zone | |
* - Manage mouse position | |
* - Manage drag and drop events | |
* - On Drop, call the onDrop function of the targeted drop zone | |
* - Manage compatibility between draggables and drop zones | |
*/ | |
export class DraggingContext extends Component<{}, DDContextState> { | |
constructor(props: {}) { | |
super(props); | |
this.state = { | |
currentDraggable: null, | |
targetedDropZone: null, | |
dropZones: [], | |
mousePosition: { x: 0, y: 0 }, | |
deregisterDropZone: this.deregisterDropZone, | |
onDrop: this.onDrop, | |
registerDropZone: this.registerDropZone, | |
setCurrentDraggable: this.setCurrentDraggable, | |
}; | |
} | |
componentDidMount = () => { | |
window.addEventListener("mousemove", this.handleMouseMove); | |
window.addEventListener("mouseup", this.onMouseUp); | |
}; | |
onMouseUp = () => { | |
this.onDrop(); | |
this.setState({ | |
currentDraggable: null, | |
targetedDropZone: null, | |
}); | |
}; | |
handleMouseMove = (event: MouseEvent) => { | |
this.setState( | |
{ | |
mousePosition: { | |
x: event.clientX, | |
y: event.clientY, | |
}, | |
}, | |
this.calculateTargetedDropZone | |
); | |
}; | |
/** | |
* Passed through context and used by DropZone components to register themselves | |
*/ | |
registerDropZone = (zone: DDDropZone) => { | |
if (this.state.dropZones.find((z) => z.name === zone.name)) { | |
return; | |
} | |
this.setState((state) => ({ | |
dropZones: [...state.dropZones, zone], | |
})); | |
}; | |
/** | |
* Passed through context and used by DropZone components to deregister themselves | |
*/ | |
deregisterDropZone = (name: string) => { | |
this.setState((state) => ({ | |
dropZones: state.dropZones.filter((zone) => zone.name !== name), | |
})); | |
}; | |
/** | |
* Passed through context and used by Draggable components to set the current draggable | |
*/ | |
setCurrentDraggable = (currentDraggable: DDDraggable) => { | |
this.setState( | |
() => ({ | |
currentDraggable, | |
}), | |
this.updateFootprints | |
); | |
}; | |
/** | |
* Updates the footprints for all draggable components | |
*/ | |
updateFootprints = () => { | |
const draggableElement = document.getElementById( | |
`draggable-${this.state.currentDraggable?.name}` | |
); | |
const footprintElement = document.getElementById( | |
`footprint-${this.state.currentDraggable?.name}` | |
); | |
if (!draggableElement || !footprintElement) return; | |
footprintElement.style.height = `${draggableElement.clientHeight}px`; | |
}; | |
onDrop = () => { | |
this.resetDropZoneHeights(); | |
if (!this.state.targetedDropZone || !this.state.currentDraggable) { | |
console.log("no target for drop"); | |
return; | |
} | |
const zone = this.state.dropZones.find( | |
(z) => z.name === this.state.targetedDropZone | |
); | |
if (!zone) return; | |
if ( | |
!isCompatible( | |
this.state.currentDraggable.value.categories, | |
zone.compatibility | |
) | |
) { | |
console.info("this is not a compatible zone", { | |
draggableCategories: this.state.currentDraggable.value.categories, | |
acceptedCategores: zone.compatibility, | |
}); | |
return; | |
} | |
console.log("dropping block into zone", { | |
zoneCompat: zone.compatibility, | |
blockCategory: this.state.currentDraggable.value.categories, | |
}); | |
zone.onDrop( | |
_.cloneDeep(this.state.currentDraggable.value), | |
_.cloneDeep(zone) | |
); | |
}; | |
/** | |
* Resets the height of all drop zones to "unset" | |
*/ | |
resetDropZoneHeights = () => { | |
this.state.dropZones.forEach((zone) => { | |
const dropZoneElement = document.getElementById(`dropzone-${zone.name}`); | |
if (!dropZoneElement) return; | |
// dropZoneElement.style.height = "unset"; | |
}); | |
}; | |
/** | |
* determine the drop zone that is targeted by the current mouse position | |
*/ | |
calculateTargetedDropZone = () => { | |
let targeted = null; | |
this.state.dropZones.forEach((zone) => { | |
const ele = document.getElementById(`dropzone-${zone.name}`); | |
if (!ele) { | |
console.error(`did not find element for ${zone.name}`); | |
return; | |
} | |
const box = ele.getBoundingClientRect(); | |
const isTargetHovered = | |
box && | |
this.state.mousePosition.x && | |
this.state.mousePosition.y && | |
box.left <= this.state.mousePosition.x && | |
this.state.mousePosition.x <= box.right && | |
box.top <= this.state.mousePosition.y && | |
this.state.mousePosition.y <= box.bottom; | |
const isZoneCompatible = isCompatible( | |
this.state.currentDraggable?.value.categories || [], | |
zone.compatibility | |
); | |
if (isTargetHovered && isZoneCompatible) { | |
targeted = zone.name; | |
} | |
}); | |
this.setState({ targetedDropZone: targeted }); | |
}; | |
render = () => { | |
return ( | |
<DDContext.Provider | |
value={{ | |
...this.state, | |
}} | |
> | |
{this.props.children} | |
</DDContext.Provider> | |
); | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from "react"; | |
interface DragGuardProps {} | |
/** | |
* DragGuard prevents dragging by touch-enabled children like form controls, buttons, or links | |
*/ | |
export const DragGuard: React.FC<DragGuardProps> = ({ children }) => { | |
return <div onMouseDown={(e) => e.stopPropagation()}>{children}</div>; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import classNames from "classnames"; | |
import React, { Component } from "react"; | |
import { isCompatible } from "../parser/tokenTypes"; | |
import { DDContext, DDContextState, DDDropZone } from "./types"; | |
interface DropZoneChildProps { | |
context: DDContextState; | |
} | |
/** | |
* Exposed wrapper for drop zones. | |
*/ | |
export const DropZone: React.FC<DDDropZone> = (props) => { | |
return ( | |
<DDContext.Consumer> | |
{(context) => <DropZoneChild context={context} {...props} />} | |
</DDContext.Consumer> | |
); | |
}; | |
/** | |
* Internal wrapper for drop zones. | |
* | |
* Responsible for: | |
* - Registering itself with the drag and drop context | |
* - Deregistering itself when unmounted or when the name changes | |
* - Updating the targeted drop zone when the mouse moves based on the currently targeted drop zone | |
* - Using a known name so the drop zone can be identified by the drag and drop context | |
*/ | |
class DropZoneChild extends Component<DropZoneChildProps & DDDropZone> { | |
componentDidMount = () => { | |
const { name, compatibility, onDrop } = this.props; | |
this.props.context.registerDropZone({ | |
name, | |
compatibility, | |
onDrop, | |
}); | |
}; | |
componentWillUnmount = () => { | |
this.props.context.deregisterDropZone(this.props.name); | |
}; | |
componentDidUpdate = (prevProps: DropZoneChildProps & DDDropZone) => { | |
const { name, compatibility, onDrop } = this.props; | |
if (this.props.name !== prevProps.name) { | |
this.props.context.deregisterDropZone(prevProps.name); | |
this.props.context.registerDropZone({ | |
name, | |
compatibility, | |
onDrop, | |
}); | |
} | |
}; | |
render = () => { | |
const { context, children } = this.props; | |
return ( | |
<div | |
className={classNames("dropzone", { | |
active: context.currentDraggable !== null, | |
targeted: context.targetedDropZone === this.props.name, | |
"not-compatible": !isCompatible( | |
context.currentDraggable?.value.categories || [], | |
this.props.compatibility | |
), | |
})} | |
id={`dropzone-${this.props.name}`} | |
> | |
{children} | |
</div> | |
); | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from "react"; | |
import { PlutoBlock } from "../parser/blockTypes"; | |
import { PlutoTokenCategory } from "../parser/tokenTypes"; | |
export interface DDDropZone { | |
name: string; | |
compatibility: PlutoTokenCategory[]; | |
onDrop: (value: PlutoBlock, zone: DDDropZone) => void; | |
} | |
export interface DDDraggable { | |
name: string; | |
value: PlutoBlock; | |
} | |
export interface DDContextState { | |
dropZones: DDDropZone[]; | |
currentDraggable: DDDraggable | null; | |
targetedDropZone: string | null; | |
mousePosition: { | |
x: number; | |
y: number; | |
}; | |
registerDropZone: (zone: DDDropZone) => void; | |
deregisterDropZone: (name: string) => void; | |
setCurrentDraggable: (draggable: DDDraggable) => void; | |
onDrop: () => void; | |
} | |
export const DDContext = React.createContext<DDContextState>({ | |
currentDraggable: null, | |
dropZones: [], | |
mousePosition: { x: 0, y: 0 }, | |
targetedDropZone: null, | |
registerDropZone: () => {}, | |
deregisterDropZone: () => {}, | |
setCurrentDraggable: () => {}, | |
onDrop: () => {}, | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment