SaaS & Ops

Doc Block

A Notion-style editable content block with a hover gutter, a drag grip that opens a block-type menu, and contentEditable text that re-renders as headings, lists, to-dos, quotes or code.

Install

npx shadcn@latest add @paragon/doc-block

Also installs: dropdown-menu

doc-block.tsx

"use client";

import * as React from "react";
import {
  GripVertical,
  Plus,
  Type,
  Heading1,
  Heading2,
  List,
  ListChecks,
  Quote,
  Code,
} from "lucide-react";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/registry/paragon/ui/dropdown-menu";
import { cn } from "@/lib/utils";

export type DocBlockType =
  | "text"
  | "heading1"
  | "heading2"
  | "bullet"
  | "todo"
  | "quote"
  | "code";

export interface DocBlockTypeOption {
  type: DocBlockType;
  label: string;
  icon: React.ElementType;
  hint?: string;
}

export const docBlockTypes: DocBlockTypeOption[] = [
  { type: "text", label: "Text", icon: Type, hint: "Plain paragraph" },
  { type: "heading1", label: "Heading 1", icon: Heading1, hint: "Section title" },
  { type: "heading2", label: "Heading 2", icon: Heading2, hint: "Subsection" },
  { type: "bullet", label: "Bulleted list", icon: List, hint: "Simple list" },
  { type: "todo", label: "To-do", icon: ListChecks, hint: "Checkbox item" },
  { type: "quote", label: "Quote", icon: Quote, hint: "Callout quote" },
  { type: "code", label: "Code", icon: Code, hint: "Monospace block" },
];

const blockClasses: Record<DocBlockType, string> = {
  text: "text-sm leading-relaxed",
  heading1: "text-xl font-semibold leading-tight",
  heading2: "text-base font-semibold leading-tight",
  bullet: "text-sm leading-relaxed",
  todo: "text-sm leading-relaxed",
  quote: "text-sm leading-relaxed italic text-muted-foreground",
  code: "font-mono text-[13px] leading-relaxed",
};

export interface DocBlockProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "content"> {
  type?: DocBlockType;
  content?: string;
  placeholder?: string;
  /** Only meaningful for `todo` blocks. */
  checked?: boolean;
  onCheckedChange?: (checked: boolean) => void;
  onTypeChange?: (type: DocBlockType) => void;
  onContentChange?: (content: string) => void;
  onAddBelow?: () => void;
}

/**
 * A Notion-style content block. A drag handle and an add-below button sit in a
 * left gutter, appearing on row hover (opacity only — no layout shift). The
 * grip opens a block-type menu that re-renders the row as a heading, list,
 * to-do, quote or code line. Text is a real `contentEditable` region so
 * caret, selection and typing work; empty blocks show a placeholder via the
 * `:empty` pseudo-class. Todo blocks get a leading checkbox with a strike on
 * completion.
 */
export function DocBlock({
  type = "text",
  content = "",
  placeholder = "Write, or press the grip to change block type",
  checked = false,
  onCheckedChange,
  onTypeChange,
  onContentChange,
  onAddBelow,
  className,
  ...props
}: DocBlockProps) {
  const editableRef = React.useRef<HTMLDivElement>(null);

  // Seed the editable node once; contentEditable owns it after that so React
  // never fights the caret.
  React.useEffect(() => {
    if (editableRef.current && editableRef.current.textContent !== content) {
      editableRef.current.textContent = content;
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div
      data-slot="doc-block"
      className={cn("group/block relative flex items-start gap-1", className)}
      {...props}
    >
      {/* Left gutter controls. */}
      <div
        className={cn(
          "flex shrink-0 items-center gap-0.5 pt-0.5 opacity-0",
          "group-hover/block:opacity-100 group-focus-within/block:opacity-100",
          "transition-opacity duration-(--duration-fast) ease-(--ease-out) motion-reduce:transition-none",
        )}
      >
        <button
          type="button"
          aria-label="Add block below"
          onClick={onAddBelow}
          className={cn(
            "pressable relative flex size-6 items-center justify-center rounded-md text-muted-foreground",
            "transition-colors duration-(--duration-fast) hover:bg-accent hover:text-foreground",
            "outline-none focus-visible:ring-2 focus-visible:ring-ring",
            "after:absolute after:top-1/2 after:left-1/2 after:size-9 after:-translate-1/2",
          )}
        >
          <Plus className="size-4" aria-hidden />
        </button>

        <DropdownMenu>
          <DropdownMenuTrigger
            aria-label="Change block type"
            className={cn(
              "relative flex size-6 cursor-grab items-center justify-center rounded-md text-muted-foreground",
              "transition-colors duration-(--duration-fast) hover:bg-accent hover:text-foreground",
              "outline-none focus-visible:ring-2 focus-visible:ring-ring",
            )}
          >
            <GripVertical className="size-4" aria-hidden />
          </DropdownMenuTrigger>
          <DropdownMenuContent align="start" className="w-52">
            <DropdownMenuLabel>Turn into</DropdownMenuLabel>
            <DropdownMenuSeparator />
            {docBlockTypes.map((option) => {
              const Icon = option.icon;
              return (
                <DropdownMenuItem
                  key={option.type}
                  onSelect={() => onTypeChange?.(option.type)}
                  className="gap-2.5"
                >
                  <Icon className="size-4" aria-hidden />
                  <span className="flex-1">{option.label}</span>
                  {option.type === type && (
                    <span className="size-1.5 rounded-full bg-foreground" aria-hidden />
                  )}
                </DropdownMenuItem>
              );
            })}
          </DropdownMenuContent>
        </DropdownMenu>
      </div>

      {/* Block body. */}
      <div className="flex min-w-0 flex-1 items-start gap-2 py-0.5">
        {type === "bullet" && (
          <span
            aria-hidden
            className="mt-2 size-1.5 shrink-0 rounded-full bg-foreground"
          />
        )}
        {type === "quote" && (
          <span
            aria-hidden
            className="mt-0.5 self-stretch w-0.5 shrink-0 rounded-full bg-border"
          />
        )}
        {type === "todo" && (
          <button
            type="button"
            role="checkbox"
            aria-checked={checked}
            aria-label="Toggle to-do"
            onClick={() => onCheckedChange?.(!checked)}
            className={cn(
              "mt-1 flex size-4 shrink-0 items-center justify-center rounded-[5px] border",
              "transition-[background-color,border-color] duration-(--duration-fast) ease-out",
              "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
              checked
                ? "border-primary bg-primary text-primary-foreground"
                : "border-input",
            )}
          >
            <svg
              viewBox="0 0 12 12"
              fill="none"
              stroke="currentColor"
              strokeWidth={1.75}
              strokeLinecap="round"
              strokeLinejoin="round"
              className="size-3"
              aria-hidden
            >
              <path
                d="M2.25 6.4 4.9 9 9.75 3.5"
                pathLength={1}
                className={cn(
                  "[stroke-dasharray:1] [stroke-dashoffset:1]",
                  "transition-[stroke-dashoffset] duration-200 ease-out motion-reduce:transition-none",
                  checked && "[stroke-dashoffset:0]",
                )}
              />
            </svg>
          </button>
        )}

        <div
          ref={editableRef}
          role="textbox"
          aria-multiline="true"
          contentEditable
          suppressContentEditableWarning
          data-placeholder={placeholder}
          onInput={(event) =>
            onContentChange?.(event.currentTarget.textContent ?? "")
          }
          className={cn(
            "min-w-0 flex-1 rounded-sm outline-none",
            "focus-visible:ring-0",
            "empty:before:pointer-events-none empty:before:text-muted-foreground empty:before:content-[attr(data-placeholder)]",
            blockClasses[type],
            type === "code" &&
              "rounded-md bg-secondary px-2.5 py-1.5 text-secondary-foreground",
            type === "todo" && checked && "text-muted-foreground line-through",
          )}
        />
      </div>
    </div>
  );
}