# Document editor

> A rich text editor for a document with autosave and collaborators.

- Block: [documents](https://bettersupabase.com/blocks/documents.md)
- Kind: screen
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/document-editor

## Install the block

```bash
npx better-supabase add documents
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add alert badge button card field input skeleton textarea
```

From `apps/examples/nextjs/src/features/documents/components/document-editor.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/documents/components/document-editor.tsx):

```tsx
"use client";

import { rpcTransport } from "better-supabase/blocks";
import { createDocuments } from "better-supabase/blocks/documents";
import {
  createDocumentProvider,
  type DocumentProviderStatus,
} from "better-supabase/yjs";
import { TriangleAlertIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import { useEffect, useId, useRef, useState } from "react";
import * as Y from "yjs";

import {
  Alert,
  AlertAction,
  AlertDescription,
  AlertTitle,
} from "@/components/reui/alert";
import { Badge } from "@/components/reui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { useSupabase } from "@/lib/hooks";
import { blockConfig } from "@/lib/supabase/blocks.generated";

const DOCUMENT_NAME = "notes";
const LOCAL = Symbol("local edit");

/**
 * Replaces the text with `next` by deleting and inserting only the changed
 * middle, so an edit merges with another editor's edit elsewhere in it.
 */
function replaceText(text: Y.Text, next: string) {
  const current = text.toString();

  if (current === next) {
    return;
  }

  let start = 0;

  while (
    start < current.length &&
    start < next.length &&
    current[start] === next[start]
  ) {
    start += 1;
  }

  let end = 0;

  while (
    end < current.length - start &&
    end < next.length - start &&
    current[current.length - 1 - end] === next[next.length - 1 - end]
  ) {
    end += 1;
  }

  text.doc?.transact(() => {
    text.delete(start, current.length - start - end);
    text.insert(start, next.slice(start, next.length - end));
  }, LOCAL);
}

interface Editing {
  readonly title: Y.Text;
  readonly body: Y.Text;
}

interface DocumentTarget {
  readonly organizationId: string;
  readonly customerId: string;
  readonly customerName: string;
  readonly canWrite: boolean;
}

/**
 * The customer's notes as a Yjs document: a title and a body. Edits are
 * stored as updates through the documents block and reach the other
 * editors over the document's realtime topic.
 */
export function DocumentEditor(target: DocumentTarget) {
  const [attempt, setAttempt] = useState(0);

  return (
    <DocumentSession
      key={attempt}
      {...target}
      onRetry={() => {
        setAttempt((current) => current + 1);
      }}
    />
  );
}

/** One connection to the document; Retry remounts it with fresh state. */
function DocumentSession({
  organizationId,
  customerId,
  customerName,
  canWrite,
  onRetry,
}: DocumentTarget & { readonly onRetry: () => void }) {
  const t = useExtracted("documents");
  const fieldId = useId();
  const supabase = useSupabase();
  const editing = useRef<Editing | null>(null);
  const [status, setStatus] = useState<DocumentProviderStatus>("connecting");
  const [failed, setFailed] = useState(false);
  const [writable, setWritable] = useState(false);
  const [title, setTitle] = useState("");
  const [body, setBody] = useState("");

  useEffect(() => {
    const doc = new Y.Doc();
    const texts = { title: doc.getText("title"), body: doc.getText("body") };

    const sync = () => {
      setTitle(texts.title.toString());
      setBody(texts.body.toString());
    };

    const provider = createDocumentProvider({
      documents: createDocuments({
        transport: rpcTransport(supabase),
        schema: blockConfig.documents.api,
      }),
      client: supabase,
      doc,
      yjs: Y,
      ref: {
        organizationId,
        subjectType: "customer",
        subjectId: customerId,
        name: DOCUMENT_NAME,
      },
      onStatus: setStatus,
    });

    texts.title.observe(sync);
    texts.body.observe(sync);
    editing.current = texts;

    void provider.connect().then((opened) => {
      if (opened.ok) {
        setWritable(opened.data.canWrite);
        sync();
      } else {
        setFailed(true);
      }
    });

    return () => {
      texts.title.unobserve(sync);
      texts.body.unobserve(sync);
      editing.current = null;
      void provider.destroy();
      doc.destroy();
    };
  }, [supabase, organizationId, customerId]);

  const statusLabels: Record<DocumentProviderStatus, string> = {
    connecting: t("Connecting"),
    synced: t("Saved"),
    offline: t("Offline"),
    closed: t("Closed"),
  };

  const readOnly = !canWrite || !writable;

  if (failed) {
    return (
      <Alert variant="destructive" data-recipe="document-editor">
        <TriangleAlertIcon />
        <AlertTitle>{t("The document didn't open")}</AlertTitle>
        <AlertDescription>{t("Try again in a moment.")}</AlertDescription>
        <AlertAction>
          <Button size="sm" variant="outline" onClick={onRetry}>
            {t("Retry")}
          </Button>
        </AlertAction>
      </Alert>
    );
  }

  return (
    <Card data-recipe="document-editor">
      <CardHeader>
        <div className="flex items-center justify-between gap-2">
          <CardTitle>{t("Notes for {name}", { name: customerName })}</CardTitle>
          <Badge variant={statusVariant(status)}>{statusLabels[status]}</Badge>
        </div>
        <CardDescription>
          {readOnly
            ? t("You can read this document. Ask an admin for edit access.")
            : t("Changes save as you type and show up for everyone editing.")}
        </CardDescription>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        {status === "connecting" && title === "" && body === "" ? (
          <div className="flex flex-col gap-4" aria-busy="true">
            <Skeleton className="h-9 w-full" />
            <Skeleton className="h-64 w-full" />
          </div>
        ) : (
          <>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-title`}>{t("Title")}</FieldLabel>
              <Input
                id={`${fieldId}-title`}
                value={title}
                readOnly={readOnly}
                maxLength={200}
                placeholder={t("Untitled")}
                onChange={(event) => {
                  setTitle(event.target.value);

                  if (editing.current) {
                    replaceText(editing.current.title, event.target.value);
                  }
                }}
              />
            </Field>
            <Field>
              <FieldLabel htmlFor={`${fieldId}-body`}>{t("Body")}</FieldLabel>
              <Textarea
                id={`${fieldId}-body`}
                value={body}
                readOnly={readOnly}
                rows={16}
                placeholder={t("Write the notes here.")}
                onChange={(event) => {
                  setBody(event.target.value);

                  if (editing.current) {
                    replaceText(editing.current.body, event.target.value);
                  }
                }}
              />
              <FieldDescription>
                {t(
                  "Plain text. Everyone who can read the customer can read it.",
                )}
              </FieldDescription>
            </Field>
          </>
        )}
      </CardContent>
    </Card>
  );
}

function statusVariant(
  status: DocumentProviderStatus,
): "success-light" | "warning-light" | "secondary" | "info-light" {
  switch (status) {
    case "synced": {
      return "success-light";
    }

    case "offline": {
      return "warning-light";
    }

    case "connecting": {
      return "info-light";
    }

    case "closed": {
      return "secondary";
    }

    default: {
      const unhandled: never = status;

      return unhandled;
    }
  }
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Document editor" in a React web app that uses shadcn/ui components. A rich text editor for a document with autosave and collaborators.

It uses the better-supabase `documents` block (Collaborative documents). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add documents` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add alert badge button card field input skeleton textarea`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the screen. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/documents/components/document-editor.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/document-editor.md
- Block docs: https://bettersupabase.com/docs/blocks/documents
```
