| 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]"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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], |
| ); |
|
|
| |
| const findBlockAtPoint = useCallback( |
| (clientX: number, clientY: number): HTMLElement | null => { |
| if (!containerEl) return null; |
| |
| 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], |
| ); |
|
|
| |
| 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; |
|
|
| |
| const directBlock = target.closest<HTMLElement>(BLOCK_SELECTOR); |
| if (directBlock) { |
| positionHandle(directBlock); |
| return; |
| } |
|
|
| |
| 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); |
| }; |
|
|
| |
| 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> |
| ); |
| } |
|
|