Skip to content

Instantly share code, notes, and snippets.

@jsnns
Last active November 1, 2021 22:22
Show Gist options
  • Save jsnns/9b588fee5383991022c0c37432e94cd5 to your computer and use it in GitHub Desktop.
Save jsnns/9b588fee5383991022c0c37432e94cd5 to your computer and use it in GitHub Desktop.
Drag and drop wrappers for complex drag and drop editors
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>
</>
);
};
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>
);
};
}
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>;
};
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>
);
};
}
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