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

145 lines
4.1 KiB
TypeScript

"use client";
import { Node, mergeAttributes } from "@tiptap/react";
import { ReactNodeViewRenderer } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Star } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { NodeViewWrapper } from "@tiptap/react";
export type DividerStyle = "solid" | "dashed" | "dotted" | "dots" | "star";
function lineClass(style: DividerStyle) {
switch (style) {
case "solid":
return "h-px bg-border";
case "dashed":
return "h-0 w-full border-t-2 border-dashed border-border";
case "dotted":
return "h-0 w-full border-t-2 border-dotted border-border";
case "dots":
return "hidden";
case "star":
return "h-px w-full bg-border/70";
default:
return "h-px bg-border";
}
}
function DividerView(props: NodeViewProps) {
const { node, updateAttributes, selected } = props;
const style = (node.attrs.style as DividerStyle) ?? "solid";
return (
<NodeViewWrapper
className={cn(
"divider-block group relative my-6 flex min-h-[28px] items-center justify-center py-1",
selected &&
"rounded ring-2 ring-primary/40 ring-offset-2 ring-offset-background",
)}
data-type="divider"
data-divider-style={style}
contentEditable={false}
>
<div
className={cn(
"pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2",
lineClass(style),
)}
aria-hidden
/>
{style === "dots" ? (
<div className="relative flex items-center justify-center gap-2 px-8">
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/45" />
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/45" />
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/45" />
</div>
) : null}
{style === "star" ? (
<span
className="relative z-[1] inline-flex size-8 items-center justify-center rounded-full border border-border bg-background text-primary shadow-sm dark:bg-card"
contentEditable={false}
>
<Star className="size-4 fill-primary/15 text-primary" aria-hidden />
</span>
) : style !== "dots" ? (
<span className="sr-only">Divider</span>
) : null}
<select
aria-label="Divider style"
className={cn(
"absolute right-0 top-1/2 z-10 -translate-y-1/2 cursor-pointer rounded border border-border bg-background px-1 py-0.5 text-[10px] text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100",
"focus:opacity-100",
)}
value={style}
onChange={(e) =>
updateAttributes({ style: e.target.value as DividerStyle })
}
onClick={(e) => e.stopPropagation()}
>
<option value="solid">Solid</option>
<option value="dashed">Dashed</option>
<option value="dotted">Dotted</option>
<option value="dots">Dots</option>
<option value="star">Star</option>
</select>
</NodeViewWrapper>
);
}
export const Divider = Node.create({
name: "divider",
group: "block",
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
style: {
default: "solid" as DividerStyle,
parseHTML: (el) =>
(el.getAttribute("data-divider-style") as DividerStyle | null) ??
"solid",
renderHTML: (attrs) => ({
"data-divider-style": attrs.style ?? "solid",
}),
},
};
},
parseHTML() {
return [
{
tag: 'div[data-type="divider"]',
},
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, {
"data-type": "divider",
role: "separator",
}),
];
},
addNodeView() {
return ReactNodeViewRenderer(DividerView);
},
addKeyboardShortcuts() {
return {
"Mod-Shift-Alt-d": () =>
this.editor.commands.insertContent({
type: this.name,
attrs: { style: "solid" },
}),
};
},
});