Spaces:
Runtime error
Runtime error
| 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<HTMLDivElement>(null); | |
| const [visible, setVisible] = useState(false); | |
| const [top, setTop] = useState(0); | |
| const [left, setLeft] = useState(0); | |
| const activeBlockRef = useRef<HTMLElement | null>(null); | |
| const blockPosRef = useRef<number | null>(null); | |
| const dragImageRef = useRef<HTMLDivElement | null>(null); | |
| const hideTimer = useRef<ReturnType<typeof setTimeout>>(); | |
| 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<HTMLElement>(".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<HTMLElement>(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<HTMLElement>(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 ( | |
| <div | |
| ref={handleRef} | |
| className="block-handle" | |
| style={{ | |
| position: "absolute", | |
| top, | |
| left, | |
| opacity: visible ? 1 : 0, | |
| pointerEvents: visible ? "auto" : "none", | |
| transition: "opacity 0.12s", | |
| zIndex: 10, | |
| }} | |
| onMouseEnter={show} | |
| onMouseLeave={scheduleHide} | |
| > | |
| <button | |
| className="block-handle-btn block-handle-add" | |
| onClick={handleAdd} | |
| title="Add block below" | |
| > | |
| <svg width="18" height="18" viewBox="0 0 14 14" fill="none"> | |
| <path d="M7 1v12M1 7h12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /> | |
| </svg> | |
| </button> | |
| <button | |
| className="block-handle-btn block-handle-grip" | |
| draggable | |
| onDragStart={handleDragStart} | |
| onDragEnd={handleDragEnd} | |
| title="Drag to reorder" | |
| > | |
| <svg width="14" height="18" viewBox="0 0 10 14" fill="none"> | |
| <circle cx="3" cy="2" r="1.2" fill="currentColor" /> | |
| <circle cx="7" cy="2" r="1.2" fill="currentColor" /> | |
| <circle cx="3" cy="7" r="1.2" fill="currentColor" /> | |
| <circle cx="7" cy="7" r="1.2" fill="currentColor" /> | |
| <circle cx="3" cy="12" r="1.2" fill="currentColor" /> | |
| <circle cx="7" cy="12" r="1.2" fill="currentColor" /> | |
| </svg> | |
| </button> | |
| </div> | |
| ); | |
| } | |