Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: drag drop revamp #6142

Draft
wants to merge 2 commits into
base: preview
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Extension } from "@tiptap/core";
import { DragHandlePlugin } from "./drag-handle-plugin";

export const DragAndDrop = Extension.create({
name: "DragAndDrop",
addProseMirrorPlugins() {
return [DragHandlePlugin()];
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
import { createRoot } from "react-dom/client";

import { NodeType, ResolvedPos } from "@tiptap/pm/model";
import { EditorState, NodeSelection, Plugin, PluginKey, TextSelection, Transaction } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import { findParentNodeOfTypeClosestToPos } from "prosemirror-utils";
// ui
import { DragHandle } from "@plane/ui";

const PLUGIN_CONFIG = {
KEY: new PluginKey("dragHandlePlugin"),
SCROLL: {
UP_THRESHOLD: 200,
DOWN_THRESHOLD: 100,
MIN_SPEED: 1,
MAX_SPEED: 20,
BEHAVIOR: "auto" as const,
},
ALLOWED_NODE_TYPES: ["paragraph", "listItem", "table", "codeBlock", "blockquote", "image", "imageComponent"],
} as const;

interface ScrollableContainer extends HTMLElement {
scrollBy(options: ScrollToOptions): void;
scrollBy(x: number, y: number): void;
}

const DOMHelpers = {
isScrollable: (element: HTMLElement | SVGElement): boolean => {
if (!(element instanceof HTMLElement || element instanceof SVGElement)) return false;
const style = getComputedStyle(element);
return ["overflow", "overflow-y"].some((property) => ["auto", "scroll"].includes(style.getPropertyValue(property)));
},

findScrollableParent: (element: HTMLElement | SVGElement): ScrollableContainer => {
let parent = element.parentElement;
while (parent) {
if (DOMHelpers.isScrollable(parent)) return parent as ScrollableContainer;
parent = parent.parentElement;
}
return (document.scrollingElement || document.documentElement) as ScrollableContainer;
},
};

const SelectionHandler = {
selectTextBlock: (position: number, view: EditorView) => {
const docSize = view.state.doc.content.size;
const validPosition = Math.max(0, Math.min(position, docSize - 1));
const node = view.state.doc.nodeAt(validPosition);

const endPosition =
validPosition +
(node?.type.name === "codeBlock" ? node.nodeSize : view.state.doc.resolve(validPosition).parent.nodeSize - 1);

view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, validPosition, endPosition)));
},

findClosestParentNode: (position: ResolvedPos, nodeTypes: NodeType[]) => {
const parents = nodeTypes
.map((type) => {
const parent = findParentNodeOfTypeClosestToPos(position, type);
return parent && { position: parent.pos, node: parent.node };
})
.filter(Boolean);

return parents[0];
},
};

// Scroll Handler Class
class ScrollHandler {
private isDragging = false;
private lastClientY = 0;
private animationFrame: number | null = null;

constructor(private readonly handleElement: HTMLElement) {}

startDragging(clientY: number) {
this.isDragging = true;
this.lastClientY = clientY;
this.scroll();
}

stopDragging() {
this.isDragging = false;
if (this.animationFrame) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}
}

updatePosition(clientY: number) {
if (this.isDragging) {
this.lastClientY = clientY;
}
}

private scroll() {
if (!this.isDragging) return;

const scrollableParent = DOMHelpers.findScrollableParent(this.handleElement);
if (!scrollableParent) return;

const scrollAmount = this.calculateScrollAmount();

if (scrollAmount !== 0) {
scrollableParent.scrollBy({
top: scrollAmount,
behavior: PLUGIN_CONFIG.SCROLL.BEHAVIOR,
});
}

this.animationFrame = requestAnimationFrame(() => this.scroll());
}

private calculateScrollAmount(): number {
const scrollRegionUp = PLUGIN_CONFIG.SCROLL.UP_THRESHOLD;
const scrollRegionDown = window.innerHeight - PLUGIN_CONFIG.SCROLL.DOWN_THRESHOLD;

if (this.lastClientY < scrollRegionUp) {
const ratio = (scrollRegionUp - this.lastClientY) / PLUGIN_CONFIG.SCROLL.UP_THRESHOLD;
const easedRatio = Math.pow(ratio, 3);
return -1 * this.calculateDynamicSpeed(easedRatio);
}

if (this.lastClientY > scrollRegionDown) {
const ratio = (this.lastClientY - scrollRegionDown) / PLUGIN_CONFIG.SCROLL.DOWN_THRESHOLD;
const easedRatio = Math.pow(ratio, 3);
return this.calculateDynamicSpeed(easedRatio);
}

return 0;
}

private calculateDynamicSpeed(easedRatio: number): number {
const baseSpeed =
PLUGIN_CONFIG.SCROLL.MIN_SPEED + (PLUGIN_CONFIG.SCROLL.MAX_SPEED - PLUGIN_CONFIG.SCROLL.MIN_SPEED) * easedRatio;
return Math.sign(baseSpeed) * Math.pow(Math.abs(baseSpeed), 1.5);
}
}

// Main Plugin Factory
function createDragHandlePlugin(isBlock = false, onMouseDown?: () => void, onMouseUp?: () => void) {
let dragHandleElement: HTMLElement | null = null;
let scrollHandler: ScrollHandler;
let currentCleanup: (() => void) | null = null;

const createDragHandle = (view: EditorView, getPosition: () => number) => {
// Clean up previous instance if it exists
if (currentCleanup) {
currentCleanup();
currentCleanup = null;
}

if (!dragHandleElement) {
dragHandleElement = document.createElement("div");
dragHandleElement.contentEditable = "false";

const root = createRoot(dragHandleElement);
root.render(<DragHandle className="absolute drag-handle-container" />);
}

scrollHandler = new ScrollHandler(dragHandleElement);

const handleMouseDown = (event: MouseEvent) => {
event.stopPropagation();
scrollHandler.startDragging(event.clientY);
onMouseDown?.();

const currentPosition = getPosition();
const { tr } = view.state;
tr.setSelection(NodeSelection.create(tr.doc, currentPosition - (isBlock ? 0 : 1)));
view.dispatch(tr);
};

const handleDragOver = (event: DragEvent) => {
event.preventDefault();
scrollHandler.updatePosition(event.clientY);
};

const cleanupDragHandle = () => {
scrollHandler.stopDragging();
onMouseUp?.();

if (view.dragging) {
SelectionHandler.selectTextBlock(getPosition(), view);
}
};

// Clean up old event listeners
const cleanup = () => {
dragHandleElement?.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mouseup", cleanupDragHandle);
document.removeEventListener("drop", cleanupDragHandle);
document.removeEventListener("mouseenter", cleanupDragHandle);
document.removeEventListener("dragover", handleDragOver);
scrollHandler?.stopDragging();
};

// Add new event listeners
dragHandleElement.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mouseup", cleanupDragHandle);
document.addEventListener("drop", cleanupDragHandle);
document.addEventListener("mouseenter", cleanupDragHandle);
document.addEventListener("dragover", handleDragOver);

currentCleanup = cleanup;

return dragHandleElement;
};

return createDragHandle;
}

function handleMouseMoveEvent(view: EditorView, event: MouseEvent) {
const position = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});

if (!position) return;

const resolvedPos = view.state.doc.resolve(Math.min(position.inside + 1, view.state.doc.content.size - 1));
const allowedNodeTypes = PLUGIN_CONFIG.ALLOWED_NODE_TYPES.map((name) => view.state.schema.nodes[name]);

const closestParent = SelectionHandler.findClosestParentNode(resolvedPos, allowedNodeTypes);
const textBlockParent = resolvedPos.node(1);
const { tr } = view.state;
const { handlePos } = PLUGIN_CONFIG.KEY.getState(view.state);

if (position.inside === -1) return;

if (!textBlockParent || !closestParent?.node || handlePos === position.inside) {
if (handlePos !== position.inside && handlePos !== null) {
tr.setMeta("handle", "removeHandle");
view.dispatch(tr);
}
return;
}

const handleDecoration = Decoration.widget(position.inside + 1, createDragHandlePlugin(), {
containerId: textBlockParent.attrs.componentId,
pos: position.inside,
side: -1,
});

const decorationSet = DecorationSet.empty.add(view.state.doc, [handleDecoration]);

tr.setMeta("handle", {
set: decorationSet,
handlePos: position.inside,
});
view.dispatch(tr);
}

export const DragHandlePlugin = () =>
new Plugin({
key: PLUGIN_CONFIG.KEY,
state: {
init() {
return {
set: DecorationSet.empty,
handlePos: null,
};
},
apply(tr: Transaction, value) {
const pluginState = tr.getMeta("handle");

if (pluginState === "removeHandle" || !pluginState) {
if (tr.selection instanceof NodeSelection && value.handlePos && tr.getMeta("uiEvent") !== "drop") {
return value;
}
return {
set: DecorationSet.empty,
handlePos: null,
};
}

return {
set: pluginState.set,
handlePos: pluginState.handlePos,
};
},
},
props: {
decorations(state: EditorState) {
return this.getState(state).set;
},
handleDOMEvents: {
mousemove(view: EditorView, event: MouseEvent): boolean {
event.preventDefault();
if (!view.dragging) handleMouseMoveEvent(view, event);
return false;
},
mouseleave(view: EditorView, event: MouseEvent): boolean {
if ((event.target as Element).closest(".editor-container") === view.dom.closest(".editor-container")) {
const { tr } = view.state;
tr.setMeta("handle", "removeHandle");
view.dispatch(tr);
}
return false;
},
},
},
});
2 changes: 2 additions & 0 deletions packages/editor/src/core/extensions/extensions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { isValidHttpUrl } from "@/helpers/common";
import { IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types";
// plane editor extensions
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
import { DragAndDrop } from "./drag-and-drop/drag-handle-extension";

type TArguments = {
disabledExtensions: TExtensions[];
Expand Down Expand Up @@ -169,5 +170,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
...CoreEditorAdditionalExtensions({
disabledExtensions,
}),
DragAndDrop,
];
};
8 changes: 4 additions & 4 deletions packages/editor/src/core/hooks/use-collaborative-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,10 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
editorClassName,
enableHistory: false,
extensions: [
SideMenuExtension({
aiEnabled: !disabledExtensions?.includes("ai"),
dragDropEnabled: true,
}),
// SideMenuExtension({
// aiEnabled: !disabledExtensions?.includes("ai"),
// dragDropEnabled: true,
// }),
HeadingListExtension,
Collaboration.configure({
document: provider.document,
Expand Down
1 change: 0 additions & 1 deletion packages/editor/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// styles
// import "./styles/tailwind.css";
import "./styles/variables.css";
import "./styles/editor.css";
import "./styles/table.css";
Expand Down
Loading