ubiquitous-invention/apps/web/components/editor/drag-handle.tsx
Randall Stillwell a508ece6e7 feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:

- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)

Made-with: Cursor
2026-03-26 22:39:16 -05:00

101 lines
2.9 KiB
TypeScript

"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>
);
}