From e266da2450a0f6cb279336c889dfa9a42f3db6a3 Mon Sep 17 00:00:00 2001 From: Hakan Shehu Date: Wed, 22 Jan 2025 12:29:53 +0100 Subject: [PATCH] Implement initial version of block dragging in editor --- apps/desktop/package.json | 1 + .../components/documents/document-editor.tsx | 5 +- .../components/messages/message-editor.tsx | 4 +- .../src/renderer/editor/menu/bubble-menu.tsx | 119 ---------- .../src/renderer/editor/menus/action-menu.tsx | 203 ++++++++++++++++++ .../editor/{menu => menus}/color-button.tsx | 0 .../src/renderer/editor/menus/index.tsx | 2 + .../editor/{menu => menus}/link-button.tsx | 10 +- .../src/renderer/editor/menus/mark-button.tsx | 26 +++ .../renderer/editor/menus/toolbar-menu.tsx | 95 ++++++++ package-lock.json | 28 ++- 11 files changed, 358 insertions(+), 135 deletions(-) delete mode 100644 apps/desktop/src/renderer/editor/menu/bubble-menu.tsx create mode 100644 apps/desktop/src/renderer/editor/menus/action-menu.tsx rename apps/desktop/src/renderer/editor/{menu => menus}/color-button.tsx (100%) create mode 100644 apps/desktop/src/renderer/editor/menus/index.tsx rename apps/desktop/src/renderer/editor/{menu => menus}/link-button.tsx (95%) create mode 100644 apps/desktop/src/renderer/editor/menus/mark-button.tsx create mode 100644 apps/desktop/src/renderer/editor/menus/toolbar-menu.tsx diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 9f85525b..3ef5e510 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -42,6 +42,7 @@ "dependencies": { "@colanode/core": "*", "@colanode/crdt": "*", + "@floating-ui/react": "^0.27.3", "@hookform/resolvers": "^3.10.0", "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2", diff --git a/apps/desktop/src/renderer/components/documents/document-editor.tsx b/apps/desktop/src/renderer/components/documents/document-editor.tsx index 15874cab..d41b9a08 100644 --- a/apps/desktop/src/renderer/components/documents/document-editor.tsx +++ b/apps/desktop/src/renderer/components/documents/document-editor.tsx @@ -60,7 +60,7 @@ import { TrailingNode, UnderlineMark, } from '@/renderer/editor/extensions'; -import { EditorBubbleMenu } from '@/renderer/editor/menu/bubble-menu'; +import { ToolbarMenu, ActionMenu } from '@/renderer/editor/menus'; interface DocumentEditorProps { documentId: string; @@ -218,7 +218,8 @@ export const DocumentEditor = ({
{editor && canEdit && ( - + + )} diff --git a/apps/desktop/src/renderer/components/messages/message-editor.tsx b/apps/desktop/src/renderer/components/messages/message-editor.tsx index ddffebfe..1a7a14fa 100644 --- a/apps/desktop/src/renderer/components/messages/message-editor.tsx +++ b/apps/desktop/src/renderer/components/messages/message-editor.tsx @@ -25,7 +25,7 @@ import { TrailingNode, UnderlineMark, } from '@/renderer/editor/extensions'; -import { EditorBubbleMenu } from '@/renderer/editor/menu/bubble-menu'; +import { ToolbarMenu } from '@/renderer/editor/menus'; import { FileMetadata } from '@/shared/types/files'; interface MessageEditorProps { @@ -123,7 +123,7 @@ export const MessageEditor = React.forwardRef< return ( - + { diff --git a/apps/desktop/src/renderer/editor/menu/bubble-menu.tsx b/apps/desktop/src/renderer/editor/menu/bubble-menu.tsx deleted file mode 100644 index 8ffc0a5c..00000000 --- a/apps/desktop/src/renderer/editor/menu/bubble-menu.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { - BubbleMenu, - type BubbleMenuProps, - isNodeSelection, -} from '@tiptap/react'; -import { Bold, Code, Italic, Strikethrough, Underline } from 'lucide-react'; -import { useState } from 'react'; - -import { ColorButton } from '@/renderer/editor/menu/color-button'; -import { LinkButton } from '@/renderer/editor/menu/link-button'; -import { cn } from '@/shared/lib/utils'; - -type EditorBubbleMenuProps = Omit; - -export const EditorBubbleMenu = (props: EditorBubbleMenuProps) => { - const [isColorButtonOpen, setIsColorButtonOpen] = useState(false); - const [isLinkButtonOpen, setIsLinkButtonOpen] = useState(false); - - const bubbleMenuProps: EditorBubbleMenuProps = { - ...props, - shouldShow: ({ state, editor }) => { - const { selection } = state; - const { empty } = selection; - - // don't show bubble menu if: - // - the selected node is an image - // - the selection is empty - // - the selection is a node selection (for drag handles) - return !(editor.isActive('image') || empty || isNodeSelection(selection)); - }, - tippyOptions: { - moveTransition: 'transform 0.15s ease-out', - onHidden: () => { - setIsColorButtonOpen(false); - setIsLinkButtonOpen(false); - }, - }, - }; - - if (props.editor == null) { - return null; - } - - return ( - - { - setIsColorButtonOpen(false); - setIsLinkButtonOpen(isOpen); - }} - /> - - - - - - { - setIsColorButtonOpen(isOpen); - setIsLinkButtonOpen(false); - }} - /> - - ); -}; diff --git a/apps/desktop/src/renderer/editor/menus/action-menu.tsx b/apps/desktop/src/renderer/editor/menus/action-menu.tsx new file mode 100644 index 00000000..26275792 --- /dev/null +++ b/apps/desktop/src/renderer/editor/menus/action-menu.tsx @@ -0,0 +1,203 @@ +import { useFloating, shift, offset } from '@floating-ui/react'; +import { GripVertical, Plus } from 'lucide-react'; +import { useState, useEffect, useRef } from 'react'; +import { Node as ProseMirrorNode } from '@tiptap/pm/model'; +import { NodeSelection, TextSelection } from '@tiptap/pm/state'; +// @ts-ignore +import { __serializeForClipboard } from '@tiptap/pm/view'; +import { Editor } from '@tiptap/react'; + +interface ActionMenuProps { + editor: Editor | null; +} + +const LEFT_MARGIN = 45; + +type MenuState = { + show: boolean; + pmNode?: ProseMirrorNode; + domNode?: HTMLElement; + pos?: number; + rect?: DOMRect; +}; + +export const ActionMenu = ({ editor }: ActionMenuProps) => { + const view = useRef(editor?.view!); + const [menuState, setMenuState] = useState({ + show: false, + }); + + const { refs, floatingStyles } = useFloating({ + placement: 'left', + middleware: [offset(-10), shift()], + }); + + useEffect(() => { + if (menuState.rect) { + refs.setPositionReference({ + getBoundingClientRect: () => menuState.rect!, + contextElement: menuState.domNode!, + }); + } + }, [menuState.rect, menuState.domNode]); + + useEffect(() => { + const handleMouseMove = (event: MouseEvent) => { + const editorBounds = view.current.dom.getBoundingClientRect(); + const mouseOverEditor = + event.clientX > editorBounds.left - LEFT_MARGIN && + event.clientX < editorBounds.right && + event.clientY > editorBounds.top && + event.clientY < editorBounds.bottom; + + if (!mouseOverEditor) { + setMenuState({ + show: false, + }); + return; + } + + const coords = { + left: Math.max(event.clientX, editorBounds.left), + top: event.clientY, + }; + + const pos = view.current.posAtCoords(coords); + if (!pos) { + setMenuState({ + show: false, + }); + return; + } + + // Find the nearest block parent at the current horizontal position + let currentPos = pos.pos; + let pmNode = null; + let domNode = null; + while (currentPos >= 0) { + const node = view.current.state.doc.nodeAt(currentPos); + if (node?.isBlock) { + const nodeDOM = view.current.nodeDOM(currentPos) as HTMLElement; + const nodeDOMElement = + nodeDOM instanceof HTMLElement + ? nodeDOM + : ((nodeDOM as Node)?.parentElement as HTMLElement); + if (nodeDOMElement) { + const nodeRect = nodeDOMElement.getBoundingClientRect(); + // Check if the mouse is horizontally aligned with this block + if ( + event.clientX > nodeRect.left - LEFT_MARGIN && + event.clientX < nodeRect.right + ) { + pmNode = node; + domNode = nodeDOMElement; + break; + } + } + } + currentPos--; + } + + if (!pmNode || !domNode) { + setMenuState({ + show: false, + }); + return; + } + + const rect = domNode.getBoundingClientRect(); + const menuRect = DOMRect.fromRect({ + x: rect.x - 10, + y: rect.y, + width: 0, + height: rect.height, + }); + + setMenuState({ + show: true, + pmNode, + domNode, + pos: currentPos, + rect: menuRect, + }); + }; + + const handleScroll = () => { + setMenuState({ + show: false, + }); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('scroll', handleScroll, true); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('scroll', handleScroll, true); + }; + }, [editor]); + + if (editor == null || !menuState.show) { + return null; + } + + return ( +
+ { + if (menuState.pos === undefined || !menuState.domNode) { + return; + } + + editor + .chain() + .insertContentAt(menuState.pos, { type: 'paragraph' }) + .focus() + .run(); + }} + /> +
{ + if (menuState.pos === undefined || !menuState.domNode) { + return; + } + + view.current.focus(); + view.current.dispatch( + view.current.state.tr.setSelection( + NodeSelection.create(view.current.state.doc, menuState.pos) + ) + ); + + const slice = view.current.state.selection.content(); + const { dom, text } = __serializeForClipboard(view.current, slice); + + event.dataTransfer.clearData(); + event.dataTransfer.effectAllowed = 'copyMove'; + event.dataTransfer.setData('text/html', dom.innerHTML); + event.dataTransfer.setData('text/plain', text); + event.dataTransfer.setDragImage(menuState.domNode, 0, 0); + + view.current.dragging = { slice, move: true }; + }} + onDragEnd={() => { + view.current.dispatch( + view.current.state.tr.setSelection( + TextSelection.create(view.current.state.doc, 1) + ) + ); + + view.current.dom.blur(); + }} + > + +
+
+ ); +}; diff --git a/apps/desktop/src/renderer/editor/menu/color-button.tsx b/apps/desktop/src/renderer/editor/menus/color-button.tsx similarity index 100% rename from apps/desktop/src/renderer/editor/menu/color-button.tsx rename to apps/desktop/src/renderer/editor/menus/color-button.tsx diff --git a/apps/desktop/src/renderer/editor/menus/index.tsx b/apps/desktop/src/renderer/editor/menus/index.tsx new file mode 100644 index 00000000..caafd6e2 --- /dev/null +++ b/apps/desktop/src/renderer/editor/menus/index.tsx @@ -0,0 +1,2 @@ +export * from './toolbar-menu'; +export * from './action-menu'; diff --git a/apps/desktop/src/renderer/editor/menu/link-button.tsx b/apps/desktop/src/renderer/editor/menus/link-button.tsx similarity index 95% rename from apps/desktop/src/renderer/editor/menu/link-button.tsx rename to apps/desktop/src/renderer/editor/menus/link-button.tsx index d4990047..195ed471 100644 --- a/apps/desktop/src/renderer/editor/menu/link-button.tsx +++ b/apps/desktop/src/renderer/editor/menus/link-button.tsx @@ -1,5 +1,6 @@ import { Editor } from '@tiptap/core'; import { Check, Link, Trash2 } from 'lucide-react'; +import { isValidUrl } from '@colanode/core'; import { Input } from '@/renderer/components/ui/input'; import { @@ -9,15 +10,6 @@ import { } from '@/renderer/components/ui/popover'; import { cn } from '@/shared/lib/utils'; -const isValidUrl = (url: string) => { - try { - new URL(url); - return true; - } catch { - return false; - } -}; - const getUrlFromString = (str: string): string | null => { if (isValidUrl(str)) return str; try { diff --git a/apps/desktop/src/renderer/editor/menus/mark-button.tsx b/apps/desktop/src/renderer/editor/menus/mark-button.tsx new file mode 100644 index 00000000..a76c48dd --- /dev/null +++ b/apps/desktop/src/renderer/editor/menus/mark-button.tsx @@ -0,0 +1,26 @@ +import { cn } from '@/shared/lib/utils'; + +interface MarkButtonProps { + isActive: boolean; + onClick: () => void; + icon: React.ComponentType<{ className?: string }>; +} + +export const MarkButton = ({ + isActive, + onClick, + icon: Icon, +}: MarkButtonProps) => { + return ( + + ); +}; diff --git a/apps/desktop/src/renderer/editor/menus/toolbar-menu.tsx b/apps/desktop/src/renderer/editor/menus/toolbar-menu.tsx new file mode 100644 index 00000000..ec3f878e --- /dev/null +++ b/apps/desktop/src/renderer/editor/menus/toolbar-menu.tsx @@ -0,0 +1,95 @@ +import { + BubbleMenu, + type BubbleMenuProps, + Editor, + isNodeSelection, +} from '@tiptap/react'; +import { Bold, Code, Italic, Strikethrough, Underline } from 'lucide-react'; +import { useState } from 'react'; + +import { ColorButton } from '@/renderer/editor/menus/color-button'; +import { LinkButton } from '@/renderer/editor/menus/link-button'; +import { MarkButton } from '@/renderer/editor/menus/mark-button'; + +interface ToolbarMenuProps extends Omit { + editor: Editor; +} + +export const ToolbarMenu = (props: ToolbarMenuProps) => { + const [isColorButtonOpen, setIsColorButtonOpen] = useState(false); + const [isLinkButtonOpen, setIsLinkButtonOpen] = useState(false); + + const bubbleMenuProps: ToolbarMenuProps = { + ...props, + shouldShow: ({ state, editor }) => { + const { selection } = state; + const { empty } = selection; + + // don't show bubble menu if: + // - the selected node is an image + // - the selection is empty + // - the selection is a node selection (for drag handles) + return !(editor.isActive('image') || empty || isNodeSelection(selection)); + }, + tippyOptions: { + moveTransition: 'transform 0.15s ease-out', + onHidden: () => { + setIsColorButtonOpen(false); + setIsLinkButtonOpen(false); + }, + }, + }; + + if (props.editor == null) { + return null; + } + + return ( + + { + setIsColorButtonOpen(false); + setIsLinkButtonOpen(isOpen); + }} + /> + props.editor?.chain().focus().toggleBold().run()} + icon={Bold} + /> + props.editor?.chain().focus().toggleItalic().run()} + icon={Italic} + /> + props.editor?.chain().focus().toggleUnderline().run()} + icon={Underline} + /> + props.editor?.chain().focus().toggleStrike().run()} + icon={Strikethrough} + /> + props.editor?.chain().focus().toggleCode().run()} + icon={Code} + /> + { + setIsColorButtonOpen(isOpen); + setIsLinkButtonOpen(false); + }} + /> + + ); +}; diff --git a/package-lock.json b/package-lock.json index 30a923b5..88b40b6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "dependencies": { "@colanode/core": "*", "@colanode/crdt": "*", + "@floating-ui/react": "^0.27.3", "@hookform/resolvers": "^3.10.0", "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2", @@ -2983,6 +2984,21 @@ "@floating-ui/utils": "^0.2.8" } }, + "node_modules/@floating-ui/react": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.3.tgz", + "integrity": "sha512-CLHnes3ixIFFKVQDdICjel8muhFLOBdQH7fgtHNPY8UbCNqbeKZ262G7K66lGQOUQWWnYocf7ZbUsLJgGfsLHg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.9", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@floating-ui/react-dom": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", @@ -2997,9 +3013,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", - "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "license": "MIT" }, "node_modules/@gar/promisify": { @@ -18568,6 +18584,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",