diff --git a/packages/editor/src/core/extensions/drag-and-drop/drag-handle-extension.ts b/packages/editor/src/core/extensions/drag-and-drop/drag-handle-extension.ts new file mode 100644 index 00000000000..7cb427b717b --- /dev/null +++ b/packages/editor/src/core/extensions/drag-and-drop/drag-handle-extension.ts @@ -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()]; + }, +}); diff --git a/packages/editor/src/core/extensions/drag-and-drop/drag-handle-plugin.tsx b/packages/editor/src/core/extensions/drag-and-drop/drag-handle-plugin.tsx new file mode 100644 index 00000000000..6d45c553d1e --- /dev/null +++ b/packages/editor/src/core/extensions/drag-and-drop/drag-handle-plugin.tsx @@ -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(); + } + + 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; + }, + }, + }, + }); diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index b8910a56c37..c3e2a92016d 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -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, ]; }; diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts index b3c7d6cfc2e..00a652e5619 100644 --- a/packages/editor/src/core/hooks/use-collaborative-editor.ts +++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts @@ -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, diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index ed7d9134698..ab6a04002a3 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -1,5 +1,4 @@ // styles -// import "./styles/tailwind.css"; import "./styles/variables.css"; import "./styles/editor.css"; import "./styles/table.css";