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.
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
entityTypeandentityId, never the label. A label can be renamed later and the mention still points at the same person. resolveis 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.
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);
},
};Page links
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.
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 };kindonly picks the icon, which is a link glyph or a page glyph. It does not change the reference.- Without a
PageProviderthe Page link item is not offered, and an existing page link shows its stored title without resolving it. - Without
onOpenPagea page link cannot be opened. Amissingordeniedresolution 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.
"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} />
);
}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
- Feature switches to hide mentions or page links
- Known limits for what happens when an id turns out to be invalid