import { useEffect, useRef, useState, useCallback } from "react"; import type { Editor } from "@tiptap/core"; interface BlockHandleProps { editor: Editor; containerEl: HTMLElement | null; } const BLOCK_SELECTOR = ".tiptap > p, .tiptap > h1, .tiptap > h2, .tiptap > h3, " + ".tiptap > ul, .tiptap > ol, .tiptap > blockquote, " + ".tiptap > pre, .tiptap > hr, .tiptap > table, .tiptap > [data-type]"; /** * Notion-like block handle with two controls: * - A "+" button to insert a new block below * - A drag grip (⋮⋮) to reorder blocks via drag & drop * * Hover detection works on the entire container width (including left margin) * so the handles are easy to reach. */ export function BlockHandle({ editor, containerEl }: BlockHandleProps) { const handleRef = useRef(null); const [visible, setVisible] = useState(false); const [top, setTop] = useState(0); const [left, setLeft] = useState(0); const activeBlockRef = useRef(null); const blockPosRef = useRef(null); const dragImageRef = useRef(null); const hideTimer = useRef>(); const show = useCallback(() => { clearTimeout(hideTimer.current); setVisible(true); }, []); const scheduleHide = useCallback(() => { clearTimeout(hideTimer.current); hideTimer.current = setTimeout(() => { setVisible(false); activeBlockRef.current = null; }, 200); }, []); const positionHandle = useCallback( (block: HTMLElement) => { if (!handleRef.current || !containerEl) return; const containerRect = containerEl.getBoundingClientRect(); const blockRect = block.getBoundingClientRect(); setTop(blockRect.top - containerRect.top + containerEl.scrollTop); setLeft(blockRect.left - containerRect.left - 56); show(); activeBlockRef.current = block; const pos = editor.view.posAtDOM(block, 0); const resolved = editor.state.doc.resolve(pos); blockPosRef.current = resolved.before(1); }, [containerEl, editor, show], ); // Find the nearest block using elementFromPoint (O(1) instead of O(n)) const findBlockAtPoint = useCallback( (clientX: number, clientY: number): HTMLElement | null => { if (!containerEl) return null; // Sample a few X positions to handle margin hover const tiptapEl = containerEl.querySelector(".tiptap"); const centerX = tiptapEl ? tiptapEl.getBoundingClientRect().left + 20 : clientX; const el = document.elementFromPoint(centerX, clientY) as HTMLElement | null; if (!el) return null; return el.closest(BLOCK_SELECTOR); }, [containerEl], ); // Listen on the whole container with throttle (~50ms) useEffect(() => { if (!containerEl) return; let throttleId = 0; const THROTTLE_MS = 50; const handleMouseMove = (e: MouseEvent) => { if (throttleId) return; throttleId = window.setTimeout(() => { throttleId = 0; }, THROTTLE_MS); const target = e.target as HTMLElement; // Direct block hover (inside editor content) const directBlock = target.closest(BLOCK_SELECTOR); if (directBlock) { positionHandle(directBlock); return; } // Hovering in the left margin area - find nearest block by point if ( handleRef.current?.contains(target) || target === containerEl || target.classList.contains("tiptap") ) { const block = findBlockAtPoint(e.clientX, e.clientY); if (block) { positionHandle(block); } } }; const handleMouseLeave = () => { scheduleHide(); }; containerEl.addEventListener("mousemove", handleMouseMove); containerEl.addEventListener("mouseleave", handleMouseLeave); return () => { containerEl.removeEventListener("mousemove", handleMouseMove); containerEl.removeEventListener("mouseleave", handleMouseLeave); clearTimeout(hideTimer.current); clearTimeout(throttleId); }; }, [containerEl, positionHandle, findBlockAtPoint, scheduleHide]); const handleAdd = () => { if (!activeBlockRef.current || !editor) return; const pos = editor.view.posAtDOM(activeBlockRef.current, 0); const resolved = editor.state.doc.resolve(pos); const endOfBlock = resolved.end(1); editor .chain() .focus() .insertContentAt(endOfBlock, { type: "paragraph" }) .setTextSelection(endOfBlock + 1) .insertContent("/") .run(); }; const handleDragStart = (e: React.DragEvent) => { if (blockPosRef.current === null || !activeBlockRef.current) return; const block = activeBlockRef.current; const pos = blockPosRef.current; const node = editor.state.doc.nodeAt(pos); if (!node) return; const clone = block.cloneNode(true) as HTMLDivElement; clone.style.cssText = "position:fixed;top:-9999px;left:-9999px;width:" + block.offsetWidth + "px;opacity:0.6;pointer-events:none;background:#1a1a1a;border-radius:6px;padding:4px 8px;"; document.body.appendChild(clone); dragImageRef.current = clone; e.dataTransfer.setDragImage(clone, 20, 20); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData( "application/block-handle", JSON.stringify({ from: pos, to: pos + node.nodeSize }), ); block.style.opacity = "0.3"; }; const handleDragEnd = () => { if (activeBlockRef.current) { activeBlockRef.current.style.opacity = ""; } if (dragImageRef.current) { dragImageRef.current.remove(); dragImageRef.current = null; } setVisible(false); }; // Drop handler on the editor useEffect(() => { if (!containerEl) return; const editorEl = containerEl.querySelector(".tiptap"); if (!editorEl) return; const onDragOver = (e: Event) => { const de = e as DragEvent; if (!de.dataTransfer?.types.includes("application/block-handle")) return; de.preventDefault(); de.dataTransfer!.dropEffect = "move"; }; const onDrop = (e: Event) => { const de = e as DragEvent; const raw = de.dataTransfer?.getData("application/block-handle"); if (!raw) return; de.preventDefault(); const { from, to } = JSON.parse(raw); const node = editor.state.doc.nodeAt(from); if (!node) return; const coords = { left: de.clientX, top: de.clientY }; const dropPos = editor.view.posAtCoords(coords); if (!dropPos) return; const resolved = editor.state.doc.resolve(dropPos.pos); let insertBefore = resolved.before(1); const targetNode = editor.state.doc.nodeAt(insertBefore); if (targetNode) { const domNode = editor.view.nodeDOM(insertBefore) as HTMLElement | null; if (domNode) { const rect = domNode.getBoundingClientRect(); if (de.clientY > rect.top + rect.height / 2) { insertBefore = insertBefore + targetNode.nodeSize; } } } if (insertBefore >= from && insertBefore <= to) return; const { tr } = editor.state; const slice = editor.state.doc.slice(from, to); if (insertBefore > from) { tr.delete(from, to); const mappedPos = tr.mapping.map(insertBefore); tr.insert(mappedPos, slice.content); } else { tr.insert(insertBefore, slice.content); const mappedFrom = tr.mapping.map(from); const mappedTo = tr.mapping.map(to); tr.delete(mappedFrom, mappedTo); } editor.view.dispatch(tr); }; editorEl.addEventListener("dragover", onDragOver); editorEl.addEventListener("drop", onDrop); return () => { editorEl.removeEventListener("dragover", onDragOver); editorEl.removeEventListener("drop", onDrop); }; }, [containerEl, editor]); if (!containerEl) return null; return (
); }