ubiquitous-invention/apps/web/components/editor/drag-handle.tsx

102 lines
2.9 KiB
TypeScript
Raw Normal View History

"use client";
import * as React from "react";
import type { Editor } from "@tiptap/react";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
type BlockDragHandleProps = {
editor: Editor | null;
containerRef: React.RefObject<HTMLElement | null>;
};
/**
* Visual drag handle on the left of the hovered top-level block.
* Drag-to-reorder is not wired (would require custom NodeViews).
*/
export function BlockDragHandle({ editor, containerRef }: BlockDragHandleProps) {
const [pos, setPos] = React.useState<{
top: number;
left: number;
} | null>(null);
React.useEffect(() => {
if (!editor) return;
const root = editor.view.dom;
const onMove = (e: MouseEvent) => {
const coords = editor.view.posAtCoords({
left: e.clientX,
top: e.clientY,
});
if (!coords) {
setPos(null);
return;
}
const $pos = editor.state.doc.resolve(coords.pos);
for (let d = $pos.depth; d > 0; d--) {
const node = $pos.node(d);
const parent = $pos.node(d - 1);
if (parent.type.name === "doc" && node.isBlock) {
const start = $pos.before(d);
const el = editor.view.nodeDOM(start);
if (el instanceof HTMLElement) {
const cr = el.getBoundingClientRect();
const wrap = containerRef.current?.getBoundingClientRect();
if (wrap) {
setPos({
top: cr.top - wrap.top + cr.height / 2 - 14,
left: -2,
});
} else {
setPos({
top: cr.top + cr.height / 2 - 14,
left: cr.left - 28,
});
}
}
return;
}
}
setPos(null);
};
const onLeave = () => setPos(null);
root.addEventListener("mousemove", onMove);
root.addEventListener("mouseleave", onLeave);
return () => {
root.removeEventListener("mousemove", onMove);
root.removeEventListener("mouseleave", onLeave);
};
}, [editor, containerRef]);
if (!editor || !pos) return null;
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<button
type="button"
draggable
onDragStart={(e) => {
e.preventDefault();
}}
className={cn(
"pointer-events-auto absolute z-10 flex size-7 items-center justify-center rounded-md",
"text-muted-foreground transition-colors hover:bg-accent hover:text-primary",
)}
style={{ top: pos.top, left: pos.left }}
aria-label="Drag block (visual only)"
>
<span className="select-none text-base leading-none tracking-tight" aria-hidden>
</span>
</button>
</TooltipTrigger>
<TooltipContent side="left">Drag to reorder (coming soon)</TooltipContent>
</Tooltip>
);
}