File
Blob: src/client/components/editor/controllers/drag-handle.tsx
| 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { DragHandle as DragHandleReact } from "@tiptap/extension-drag-handle-react"; |
| 3 | import { offset } from "@floating-ui/dom"; |
| 4 | import type { Node as PMNode } from "@tiptap/pm/model"; |
| 5 | import { useTiptap } from "@tiptap/react"; |
| 6 | import { ArrowDown, ArrowUp, Menu, Plus, Trash2 } from "lucide-react"; |
| 7 | import { useEditorAffordance } from "../editor-affordance-context"; |
| 8 | import { useEditorRuntime } from "../editor-runtime-context"; |
| 9 | import { primeTopLevelBlockDragState } from "../lib/block-drag-state"; |
| 10 | import { prepareBlockDragPreview } from "../lib/block-drag-preview"; |
| 11 | import { getInsertablePageMentionCandidates } from "@/client/components/page-mention/candidates"; |
| 12 | import { |
| 13 | canMoveTopLevelBlock, |
| 14 | deleteTopLevelBlock, |
| 15 | getCurrentTopLevelBlock, |
| 16 | moveTopLevelBlock, |
| 17 | } from "../lib/block-actions"; |
| 18 | import { createInsertPaletteItems } from "../lib/insert-palette"; |
| 19 | import { filterItems } from "./slash/items"; |
| 20 | import { mountSlashMenu, type SlashMenuOverlayHandle } from "./slash/overlay"; |
| 21 | import "../styles/drag-handle.css"; |
| 22 | |
| 23 | const positionConfig = { |
| 24 | placement: "left-start" as const, |
| 25 | strategy: "absolute" as const, |
| 26 | middleware: [offset({ mainAxis: 8, crossAxis: 2 })], |
| 27 | }; |
| 28 | |
| 29 | export function DragHandle() { |
| 30 | const { editor } = useTiptap(); |
| 31 | const nodePos = useRef(-1); |
| 32 | const nodeBid = useRef<string | null>(null); |
| 33 | const groupRef = useRef<HTMLDivElement>(null); |
| 34 | const { workspaceId, pageId, shareToken } = useEditorRuntime(); |
| 35 | const affordance = useEditorAffordance(); |
| 36 | const [menuBid, setMenuBid] = useState<string | null>(null); |
| 37 | const getRuntime = useCallback( |
| 38 | () => ({ |
| 39 | workspaceId, |
| 40 | pageId, |
| 41 | shareToken, |
| 42 | }), |
| 43 | [workspaceId, pageId, shareToken], |
| 44 | ); |
| 45 | const getAffordance = useCallback(() => affordance, [affordance]); |
| 46 | const getInsertPaletteItems = useMemo( |
| 47 | () => () => |
| 48 | createInsertPaletteItems({ |
| 49 | getRuntime, |
| 50 | getAffordance, |
| 51 | getPageMentionCandidates: (excludePageId) => { |
| 52 | const runtime = getRuntime(); |
| 53 | if (runtime.shareToken) return []; |
| 54 | return getInsertablePageMentionCandidates(runtime.workspaceId, excludePageId); |
| 55 | }, |
| 56 | }), |
| 57 | [getAffordance, getRuntime], |
| 58 | ); |
| 59 | |
| 60 | const onNodeChange = useCallback(({ node, pos }: { node: PMNode | null; pos: number }) => { |
| 61 | nodePos.current = pos; |
| 62 | nodeBid.current = node && typeof node.attrs.bid === "string" ? node.attrs.bid : null; |
| 63 | }, []); |
| 64 | |
| 65 | const setHandleLocked = useCallback( |
| 66 | (locked: boolean) => { |
| 67 | if (!editor || editor.isDestroyed) return; |
| 68 | editor.view.dispatch(editor.state.tr.setMeta("lockDragHandle", locked).setMeta("addToHistory", false)); |
| 69 | }, |
| 70 | [editor], |
| 71 | ); |
| 72 | |
| 73 | const closeMenu = useCallback(() => { |
| 74 | setMenuBid(null); |
| 75 | setHandleLocked(false); |
| 76 | }, [setHandleLocked]); |
| 77 | |
| 78 | const onDragStart = useCallback( |
| 79 | (e: DragEvent) => { |
| 80 | if (!editor || !e.dataTransfer) return; |
| 81 | closeMenu(); |
| 82 | const pos = nodePos.current; |
| 83 | if (!prepareBlockDragPreview(editor, pos, e.dataTransfer)) return; |
| 84 | queueMicrotask(() => { |
| 85 | if (editor.isDestroyed || editor.view.dragging || pos < 0) return; |
| 86 | primeTopLevelBlockDragState(editor.view, pos); |
| 87 | }); |
| 88 | }, |
| 89 | [closeMenu, editor], |
| 90 | ); |
| 91 | |
| 92 | const onAddBlock = useCallback(() => { |
| 93 | if (!editor) return; |
| 94 | closeMenu(); |
| 95 | const pos = nodePos.current; |
| 96 | if (pos < 0) return; |
| 97 | const node = editor.state.doc.nodeAt(pos); |
| 98 | if (!node) return; |
| 99 | const insertPos = pos + node.nodeSize; |
| 100 | const cursorPos = insertPos + 1; |
| 101 | |
| 102 | editor.chain().insertContentAt(insertPos, { type: "paragraph" }).setTextSelection(cursorPos).run(); |
| 103 | editor.commands.focus(null, { scrollIntoView: false }); |
| 104 | |
| 105 | const items = filterItems(getInsertPaletteItems(), "", { editor }); |
| 106 | const range = { from: cursorPos, to: cursorPos }; |
| 107 | |
| 108 | let handle: SlashMenuOverlayHandle | null = null; |
| 109 | handle = mountSlashMenu(editor, { |
| 110 | items, |
| 111 | command: (item) => { |
| 112 | if (item.blockedReason) return; |
| 113 | item.command({ editor, range }); |
| 114 | cleanup(); |
| 115 | }, |
| 116 | clientRect: () => { |
| 117 | const coords = editor.view.coordsAtPos(cursorPos); |
| 118 | return new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top); |
| 119 | }, |
| 120 | onClose: cleanup, |
| 121 | }); |
| 122 | |
| 123 | const onKeyDown = (e: KeyboardEvent) => { |
| 124 | if (e.key === "Escape") { |
| 125 | e.preventDefault(); |
| 126 | cleanup(); |
| 127 | return; |
| 128 | } |
| 129 | if (handle?.onKeyDown({ event: e, view: editor.view, range })) { |
| 130 | e.preventDefault(); |
| 131 | } |
| 132 | }; |
| 133 | |
| 134 | document.addEventListener("keydown", onKeyDown, true); |
| 135 | editor.on("destroy", cleanup); |
| 136 | |
| 137 | function cleanup() { |
| 138 | document.removeEventListener("keydown", onKeyDown, true); |
| 139 | editor.off("destroy", cleanup); |
| 140 | handle?.destroy(); |
| 141 | handle = null; |
| 142 | } |
| 143 | }, [closeMenu, editor, getInsertPaletteItems]); |
| 144 | |
| 145 | useEffect(() => () => setHandleLocked(false), [setHandleLocked]); |
| 146 | |
| 147 | useEffect(() => { |
| 148 | if (menuBid === null) return; |
| 149 | |
| 150 | const onKeyDown = (e: KeyboardEvent) => { |
| 151 | if (e.key === "Escape") { |
| 152 | e.preventDefault(); |
| 153 | closeMenu(); |
| 154 | } |
| 155 | }; |
| 156 | |
| 157 | const onPointerDown = (e: MouseEvent) => { |
| 158 | const target = e.target as globalThis.Node | null; |
| 159 | if (target && groupRef.current?.contains(target)) return; |
| 160 | closeMenu(); |
| 161 | }; |
| 162 | |
| 163 | document.addEventListener("keydown", onKeyDown, true); |
| 164 | document.addEventListener("mousedown", onPointerDown, true); |
| 165 | return () => { |
| 166 | document.removeEventListener("keydown", onKeyDown, true); |
| 167 | document.removeEventListener("mousedown", onPointerDown, true); |
| 168 | }; |
| 169 | }, [closeMenu, menuBid]); |
| 170 | |
| 171 | useEffect(() => { |
| 172 | if (!editor || menuBid === null) return; |
| 173 | if (getCurrentTopLevelBlock(editor, menuBid)) return; |
| 174 | queueMicrotask(closeMenu); |
| 175 | }, [closeMenu, editor, menuBid]); |
| 176 | |
| 177 | const toggleMenu = useCallback(() => { |
| 178 | if (menuBid !== null) { |
| 179 | closeMenu(); |
| 180 | return; |
| 181 | } |
| 182 | |
| 183 | const bid = nodeBid.current; |
| 184 | if (!bid) return; |
| 185 | setMenuBid(bid); |
| 186 | setHandleLocked(true); |
| 187 | }, [closeMenu, menuBid, setHandleLocked]); |
| 188 | |
| 189 | const runBlockAction = useCallback( |
| 190 | (action: (bid: string) => boolean) => { |
| 191 | const bid = menuBid; |
| 192 | if (bid === null) return; |
| 193 | closeMenu(); |
| 194 | action(bid); |
| 195 | }, |
| 196 | [closeMenu, menuBid], |
| 197 | ); |
| 198 | |
| 199 | if (!editor || !affordance.documentEditable) return null; |
| 200 | |
| 201 | const canMoveUp = menuBid !== null && canMoveTopLevelBlock(editor.state.doc, menuBid, -1); |
| 202 | const canMoveDown = menuBid !== null && canMoveTopLevelBlock(editor.state.doc, menuBid, 1); |
| 203 | |
| 204 | return ( |
| 205 | <DragHandleReact |
| 206 | className="drag-handle" |
| 207 | editor={editor} |
| 208 | computePositionConfig={positionConfig} |
| 209 | onNodeChange={onNodeChange} |
| 210 | onElementDragStart={onDragStart} |
| 211 | > |
| 212 | <div ref={groupRef} className="tiptap-drag-handle-group"> |
| 213 | <button |
| 214 | type="button" |
| 215 | className="tiptap-drag-handle tiptap-drag-handle-add" |
| 216 | draggable={false} |
| 217 | onClick={onAddBlock} |
| 218 | onMouseDown={(e) => e.stopPropagation()} |
| 219 | onDragStart={(e) => e.preventDefault()} |
| 220 | > |
| 221 | <Plus size={14} /> |
| 222 | </button> |
| 223 | |
| 224 | <button |
| 225 | type="button" |
| 226 | className={`tiptap-drag-handle tiptap-drag-handle-toggle${menuBid !== null ? " is-active" : ""}`} |
| 227 | aria-label="Block actions" |
| 228 | aria-expanded={menuBid !== null} |
| 229 | aria-haspopup="menu" |
| 230 | onMouseDown={(e) => e.stopPropagation()} |
| 231 | onClick={toggleMenu} |
| 232 | > |
| 233 | <Menu size={14} strokeWidth={2.25} /> |
| 234 | </button> |
| 235 | |
| 236 | {menuBid !== null && ( |
| 237 | <div className="tiptap-block-menu" role="menu" aria-label="Block actions"> |
| 238 | <button |
| 239 | type="button" |
| 240 | role="menuitem" |
| 241 | className="tiptap-block-menu-item" |
| 242 | disabled={!canMoveUp} |
| 243 | onMouseDown={(e) => e.preventDefault()} |
| 244 | onClick={() => runBlockAction((bid) => moveTopLevelBlock(editor, bid, -1))} |
| 245 | > |
| 246 | <span className="tiptap-block-menu-item-icon"> |
| 247 | <ArrowUp size={14} /> |
| 248 | </span> |
| 249 | <span className="tiptap-block-menu-item-label">Move up</span> |
| 250 | </button> |
| 251 | <button |
| 252 | type="button" |
| 253 | role="menuitem" |
| 254 | className="tiptap-block-menu-item" |
| 255 | disabled={!canMoveDown} |
| 256 | onMouseDown={(e) => e.preventDefault()} |
| 257 | onClick={() => runBlockAction((bid) => moveTopLevelBlock(editor, bid, 1))} |
| 258 | > |
| 259 | <span className="tiptap-block-menu-item-icon"> |
| 260 | <ArrowDown size={14} /> |
| 261 | </span> |
| 262 | <span className="tiptap-block-menu-item-label">Move down</span> |
| 263 | </button> |
| 264 | <div className="tiptap-block-menu-sep" role="separator" /> |
| 265 | <button |
| 266 | type="button" |
| 267 | role="menuitem" |
| 268 | className="tiptap-block-menu-item is-danger" |
| 269 | onMouseDown={(e) => e.preventDefault()} |
| 270 | onClick={() => runBlockAction((bid) => deleteTopLevelBlock(editor, bid))} |
| 271 | > |
| 272 | <span className="tiptap-block-menu-item-icon"> |
| 273 | <Trash2 size={14} /> |
| 274 | </span> |
| 275 | <span className="tiptap-block-menu-item-label">Delete</span> |
| 276 | </button> |
| 277 | </div> |
| 278 | )} |
| 279 | </div> |
| 280 | </DragHandleReact> |
| 281 | ); |
| 282 | } |