On this sectionMentions and page links

Mentions and page links

Wire a MentionProvider and a PageProvider so the editor can search and resolve people and pages, and handle page clicks with onOpenPage.

Mentions

A mention is an inline reference to a person. Typing @ opens suggestions from your mentionProvider.

ts
export const MENTION_ENTITY_TYPE = "person";
export const MENTION_ENTITY_ID_MAX = 128;
export const MENTION_LABEL_MAX = 120;

export type MentionEntityType = typeof MENTION_ENTITY_TYPE;

export type MentionEntity = {
  entityType: MentionEntityType;
  entityId: string;
  label: string;
  email?: string;
};

export type MentionProvider = {
  search: (query: string, signal: AbortSignal) => Promise<MentionEntity[]>;
  resolve?: (
    entityType: string,
    entityId: string,
    signal: AbortSignal,
  ) => Promise<MentionEntity | null>;
};
  • Without a provider the @ trigger does nothing and the Mention item is not offered in the slash menu. Pasted mentions become their names as text.
  • Identity is entityType and entityId, never the label. A label can be renamed later and the mention still points at the same person.
  • resolve is optional. Without it, a mention shows the label it stored.
  • Labels are cut to 120 characters and stripped of control characters.
  • Search results are untrusted. Entries with the wrong entity type or an invalid id are dropped before they reach the list.

A mention provider

Map your own API rows to MentionEntity values. The signal is aborted when the query changes, so pass it to fetch and let a stale request die.

ts
import type {
  MentionEntity,
  MentionProvider,
} from "@/components/blocks/notion-like-editor";

type PersonRow = { id: string; name: string; email?: string };

function toEntity(row: PersonRow): MentionEntity {
  return {
    entityType: "person",
    entityId: row.id,
    label: row.name,
    email: row.email,
  };
}

export const mentionProvider: MentionProvider = {
  async search(query, signal) {
    const response = await fetch(`/api/people?q=${encodeURIComponent(query)}`, { signal });
    if (!response.ok) {
      return [];
    }

    const rows = (await response.json()) as PersonRow[];
    return rows.map(toEntity);
  },

  // Optional. Without it, a mention shows the label it stored.
  async resolve(entityType, entityId, signal) {
    if (entityType !== "person") {
      return null;
    }

    const response = await fetch(`/api/people/${entityId}`, { signal });
    if (!response.ok) {
      return null;
    }

    return toEntity((await response.json()) as PersonRow);
  },
};

A page link is a block that points at a page in your own product. It shows the page title and icon, and a click goes through onOpenPage.

ts
export const PAGE_LINK_KINDS = ["link", "child"] as const;
export const PAGE_LINK_TITLE_MAX = 200;
export const PAGE_LINK_SEARCH_LIMIT = 8;

export type PageLinkKind = (typeof PAGE_LINK_KINDS)[number];

export type PageEntity = {
  pageId: string;
  title: string;
  icon?: string;
};

export type PageResolution =
  { status: "ok"; page: PageEntity } | { status: "missing" } | { status: "denied" };

export type PageProvider = {
  search: (query: string, signal: AbortSignal) => Promise<PageEntity[]>;
  resolve: (pageId: string, signal: AbortSignal) => Promise<PageResolution>;
};

export type PageOpenRequest = { pageId: string; kind: PageLinkKind };
  • kind only picks the icon, which is a link glyph or a page glyph. It does not change the reference.
  • Without a PageProvider the Page link item is not offered, and an existing page link shows its stored title without resolving it.
  • Without onOpenPage a page link cannot be opened. A missing or denied resolution also prevents opening, so an unauthorized page stays unopenable even if the link is in the document.
  • Search results go through a cleaner: entries without a valid page id are dropped, titles are cleaned and cut to 200 characters, the icon is kept only when it is one grapheme, and the list is capped at 8.
  • A title that is empty after cleaning displays as Untitled.

A page provider

onOpenPage is a function prop, so the component that passes it must be a client component. The example routes to the page; you could open a panel or a preview instead.

tsx
"use client";

import { useRouter } from "next/navigation";

import {
  NotionLikeEditorBlock,
  type PageEntity,
  type PageOpenRequest,
  type PageProvider,
} from "@/components/blocks/notion-like-editor";

const pageProvider: PageProvider = {
  async search(query, signal) {
    const response = await fetch(`/api/pages?q=${encodeURIComponent(query)}`, { signal });
    if (!response.ok) {
      return [];
    }

    return (await response.json()) as PageEntity[];
  },

  async resolve(pageId, signal) {
    const response = await fetch(`/api/pages/${encodeURIComponent(pageId)}`, { signal });
    if (response.status === 404) {
      return { status: "missing" };
    }
    if (response.status === 403) {
      return { status: "denied" };
    }
    if (!response.ok) {
      return { status: "missing" };
    }

    return { status: "ok", page: (await response.json()) as PageEntity };
  },
};

export function DocEditor({ pageId }: { pageId: string }) {
  const router = useRouter();

  const openPage = (request: PageOpenRequest) => {
    router.push(`/pages/${encodeURIComponent(request.pageId)}`);
  };

  return (
    <NotionLikeEditorBlock documentId={pageId} pageProvider={pageProvider} onOpenPage={openPage} />
  );
}
A page id follows the mention rule for ids: 1 to 128 characters and no control characters. An invalid id is not repaired on load; the document is reported as unsupported, so validate what your API returns before it reaches the editor.

In static output

The read-only renderer keeps mentions as their labels and page links as their titles. A page link has no URL in static output, so it needs no server route and no provider to render.

Related