ubiquitous-invention/apps/web/components/editor/editor.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

298 lines
11 KiB
TypeScript

"use client";
import * as React from "react";
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Image from "@tiptap/extension-image";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import Highlight from "@tiptap/extension-highlight";
import Link from "@tiptap/extension-link";
import Typography from "@tiptap/extension-typography";
import TextAlign from "@tiptap/extension-text-align";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import CodeBlock from "@tiptap/extension-code-block";
import { Table, TableRow, TableCell, TableHeader } from "@tiptap/extension-table";
import {
Bold,
Italic,
Underline as UnderlineIcon,
Strikethrough,
Code,
Link2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { SlashCommand } from "./slash-menu";
import { EditorToolbar } from "./toolbar";
import { BlockDragHandle } from "./drag-handle";
import { Callout } from "./extensions/callout";
import { Toggle } from "./extensions/toggle";
import { Mention } from "./extensions/mention";
import { Embed } from "./extensions/embed";
import { Divider } from "./extensions/divider";
import { AiBlock } from "./extensions/ai-block";
function bubbleCh(editor: Editor) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return editor.chain().focus() as any;
}
const editorContentStyles = cn(
"editor-prose min-h-[280px] w-full max-w-none px-14 py-10 text-[15px] leading-7 text-foreground antialiased",
"focus:outline-none [&_.ProseMirror]:outline-none [&_.ProseMirror]:min-h-[240px]",
"[&_.ProseMirror_p.is-empty]:relative [&_.ProseMirror_p.is-empty::before]:pointer-events-none [&_.ProseMirror_p.is-empty::before]:float-left [&_.ProseMirror_p.is-empty::before]:h-0 [&_.ProseMirror_p.is-empty::before]:text-muted-foreground [&_.ProseMirror_p.is-empty::before]:content-[attr(data-placeholder)]",
"[&_.ProseMirror_p]:my-1 [&_.ProseMirror_p]:leading-7",
"[&_.ProseMirror_h1]:mb-3 [&_.ProseMirror_h1]:mt-8 [&_.ProseMirror_h1]:text-3xl [&_.ProseMirror_h1]:font-bold [&_.ProseMirror_h1]:tracking-tight [&_.ProseMirror_h1]:text-foreground",
"[&_.ProseMirror_h2]:mb-2 [&_.ProseMirror_h2]:mt-6 [&_.ProseMirror_h2]:text-2xl [&_.ProseMirror_h2]:font-semibold [&_.ProseMirror_h2]:tracking-tight",
"[&_.ProseMirror_h3]:mb-2 [&_.ProseMirror_h3]:mt-5 [&_.ProseMirror_h3]:text-xl [&_.ProseMirror_h3]:font-semibold",
"[&_.ProseMirror_ul]:my-2 [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-6",
"[&_.ProseMirror_ol]:my-2 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-6",
"[&_.ProseMirror_li]:my-0.5 [&_.ProseMirror_li_p]:my-0",
"[&_.ProseMirror_blockquote]:my-3 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-teal [&_.ProseMirror_blockquote]:bg-muted/40 [&_.ProseMirror_blockquote]:py-1 [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:pr-2 [&_.ProseMirror_blockquote]:italic [&_.ProseMirror_blockquote]:text-muted-foreground",
"[&_.ProseMirror_pre]:my-4 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_pre]:rounded-lg [&_.ProseMirror_pre]:border [&_.ProseMirror_pre]:border-border [&_.ProseMirror_pre]:bg-muted/80 [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:font-mono [&_.ProseMirror_pre]:text-sm",
"[&_.ProseMirror_code]:rounded-md [&_.ProseMirror_code]:bg-muted [&_.ProseMirror_code]:px-1.5 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:font-mono [&_.ProseMirror_code]:text-[0.9em] [&_.ProseMirror_code]:text-foreground",
"[&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0",
"[&_.ProseMirror_hr]:my-6 [&_.ProseMirror_hr]:border-border",
"[&_.ProseMirror_img]:my-4 [&_.ProseMirror_img]:max-h-[480px] [&_.ProseMirror_img]:w-auto [&_.ProseMirror_img]:max-w-full [&_.ProseMirror_img]:rounded-lg [&_.ProseMirror_img]:border [&_.ProseMirror_img]:border-border",
"[&_.ProseMirror_table]:my-4 [&_.ProseMirror_table]:w-full [&_.ProseMirror_table]:border-collapse [&_.ProseMirror_table]:overflow-hidden [&_.ProseMirror_table]:rounded-md [&_.ProseMirror_table]:border [&_.ProseMirror_table]:border-border",
"[&_.ProseMirror_td]:border [&_.ProseMirror_td]:border-border [&_.ProseMirror_td]:bg-background [&_.ProseMirror_td]:px-3 [&_.ProseMirror_td]:py-2",
"[&_.ProseMirror_th]:border [&_.ProseMirror_th]:border-border [&_.ProseMirror_th]:bg-muted [&_.ProseMirror_th]:px-3 [&_.ProseMirror_th]:py-2 [&_.ProseMirror_th]:text-left [&_.ProseMirror_th]:font-medium",
"[&_.ProseMirror_.ProseMirror-selectednode]:ring-2 [&_.ProseMirror_.ProseMirror-selectednode]:ring-primary/40",
"dark:[&_.ProseMirror_blockquote]:bg-muted/20",
);
export type BlockEditorProps = {
content?: string;
onChange?: (html: string) => void;
editable?: boolean;
className?: string;
placeholder?: string;
};
export function BlockEditor({
content,
onChange,
editable = true,
className,
placeholder,
}: BlockEditorProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const extensions = React.useMemo(
() => [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
codeBlock: false,
horizontalRule: false,
link: {
openOnClick: false,
HTMLAttributes: {
class:
"text-primary underline underline-offset-2 decoration-primary/50",
},
},
}),
Placeholder.configure({
placeholder:
placeholder ??
'Write something, or type "/" for commands…',
}),
CodeBlock.configure({
HTMLAttributes: {
class: "font-mono text-sm",
},
}),
HorizontalRule,
Image.configure({
HTMLAttributes: {
class: "rounded-lg border border-border",
},
}),
TaskList,
TaskItem.configure({
nested: true,
HTMLAttributes: {
class: "flex gap-2",
},
}),
Highlight.configure({
multicolor: true,
}),
Typography,
TextAlign.configure({
types: ["heading", "paragraph"],
}),
Table.configure({
resizable: false,
}),
TableRow,
TableHeader,
TableCell,
SlashCommand,
Callout,
Toggle,
Mention,
Embed,
Divider,
AiBlock,
],
[placeholder],
);
const editor = useEditor(
{
immediatelyRender: false,
extensions,
content: content ?? "<p></p>",
editable,
editorProps: {
attributes: {
class: "focus:outline-none",
},
},
onUpdate: ({ editor: ed }) => {
onChange?.(ed.getHTML());
},
},
[extensions, editable],
);
React.useEffect(() => {
if (!editor) return;
editor.setEditable(editable);
}, [editable, editor]);
return (
<TooltipProvider delayDuration={200}>
<div
className={cn(
"group/editor overflow-hidden rounded-xl border border-border bg-card text-card-foreground shadow-sm",
className,
)}
>
<EditorToolbar editor={editor} />
<div ref={containerRef} className="relative bg-background dark:bg-background">
<BlockDragHandle editor={editor} containerRef={containerRef} />
<EditorContent editor={editor} className={editorContentStyles} />
{editor ? (
<BubbleMenu
editor={editor}
options={{
placement: "top",
}}
className="flex items-center gap-0.5 rounded-lg border border-border bg-popover p-1 shadow-lg"
>
<BubbleBtn
label="Bold"
shortcut="⌘B"
active={editor.isActive("bold")}
onClick={() => bubbleCh(editor).toggleBold().run()}
>
<Bold className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Italic"
shortcut="⌘I"
active={editor.isActive("italic")}
onClick={() => bubbleCh(editor).toggleItalic().run()}
>
<Italic className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Underline"
shortcut="⌘U"
active={editor.isActive("underline")}
onClick={() => bubbleCh(editor).toggleUnderline().run()}
>
<UnderlineIcon className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Strikethrough"
shortcut="⌘⇧S"
active={editor.isActive("strike")}
onClick={() => bubbleCh(editor).toggleStrike().run()}
>
<Strikethrough className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Code"
shortcut="⌘E"
active={editor.isActive("code")}
onClick={() => bubbleCh(editor).toggleCode().run()}
>
<Code className="size-4" />
</BubbleBtn>
<BubbleBtn
label="Link"
shortcut="⌘K"
active={editor.isActive("link")}
onClick={() => {
const prev = editor.getAttributes("link").href as
| string
| undefined;
const href = window.prompt("URL", prev ?? "https://");
if (href === null) return;
if (href === "") {
bubbleCh(editor).unsetLink().run();
return;
}
bubbleCh(editor).setLink({ href }).run();
}}
>
<Link2 className="size-4" />
</BubbleBtn>
</BubbleMenu>
) : null}
</div>
</div>
</TooltipProvider>
);
}
function BubbleBtn({
label,
shortcut,
active,
onClick,
children,
}: {
label: string;
shortcut: string;
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"size-8",
active && "bg-primary/15 text-primary hover:bg-primary/20",
)}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{label}
<span className="ml-2 text-xs opacity-70">{shortcut}</span>
</TooltipContent>
</Tooltip>
);
}