carbon-tokenization / frontend /src /editor /BlockHandle.tsx
tfrere's picture
tfrere HF Staff
feat(frontend): editor refresh (embed studio, comment popover, shiki, top bar, hooks, styles)
76fc93a
Raw
History Blame Contribute Delete
9.38 kB
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>
);
}