Compare commits

...

2 Commits

Author SHA1 Message Date
Palanikannan M
21f68674b6 Merge branch 'preview' into feat/drag-drop-revamp 2024-12-03 15:01:21 +05:30
Palanikannan M
eee4212f9b feat: drag and drop new implementation using decorations 2024-12-03 15:01:03 +05:30
5 changed files with 319 additions and 5 deletions

View File

@@ -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()];
},
});

View File

@@ -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;
},
},
},
});

View File

@@ -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[];
@@ -169,5 +170,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
...CoreEditorAdditionalExtensions({
disabledExtensions,
}),
DragAndDrop,
];
};

View File

@@ -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,

View File

@@ -1,5 +1,4 @@
// styles
// import "./styles/tailwind.css";
import "./styles/variables.css";
import "./styles/editor.css";
import "./styles/table.css";