Skip to content
File

Blob: src/client/components/editor/controllers/drag-handle.tsx

typescript283 lines
1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { DragHandle as DragHandleReact } from "@tiptap/extension-drag-handle-react";
3import { offset } from "@floating-ui/dom";
4import type { Node as PMNode } from "@tiptap/pm/model";
5import { useTiptap } from "@tiptap/react";
6import { ArrowDown, ArrowUp, Menu, Plus, Trash2 } from "lucide-react";
7import { useEditorAffordance } from "../editor-affordance-context";
8import { useEditorRuntime } from "../editor-runtime-context";
9import { primeTopLevelBlockDragState } from "../lib/block-drag-state";
10import { prepareBlockDragPreview } from "../lib/block-drag-preview";
11import { getInsertablePageMentionCandidates } from "@/client/components/page-mention/candidates";
12import {
13 canMoveTopLevelBlock,
14 deleteTopLevelBlock,
15 getCurrentTopLevelBlock,
16 moveTopLevelBlock,
17} from "../lib/block-actions";
18import { createInsertPaletteItems } from "../lib/insert-palette";
19import { filterItems } from "./slash/items";
20import { mountSlashMenu, type SlashMenuOverlayHandle } from "./slash/overlay";
21import "../styles/drag-handle.css";
22 
23const positionConfig = {
24 placement: "left-start" as const,
25 strategy: "absolute" as const,
26 middleware: [offset({ mainAxis: 8, crossAxis: 2 })],
27};
28 
29export 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}