Page
The page plugin adds Notion's subpage block, a link to another document with its title, opened, named and created by functions you provide.
createPagePlugin adds Notion’s subpage block: a page block links to another document by its id and shows that page’s title. A click opens it (a modified or middle click in a new tab, and Enter over it selected alone); with create, the slash menu’s “Page” makes a new page and a block linking to it. It is not a default plugin: list it.
Each page is a document of its own, one room per page (see A page is a document). The block only names the page: moving a subpage under another parent is moving this block, and the page itself, its content and its history, never moves.
<script lang="ts">
import { Edytor, createPagePlugin } from 'edytor';
// Your app's pages: their titles, as reactive state, follow renames.
let titles = $state<Record<string, string>>({ 'doc-42': 'Roadmap' });
const page = createPagePlugin({
open: (pageId, { newTab }) =>
newTab ? window.open(`/p/${pageId}`, '_blank') : (location.href = `/p/${pageId}`),
href: (pageId) => `/p/${pageId}`,
title: (pageId) => titles[pageId],
create: () => {
const pageId = crypto.randomUUID();
titles[pageId] = 'Untitled';
return { pageId, title: 'Untitled' };
}
});
</script>
<Edytor plugins={[page]} />
createPagePlugin() is the plugin without options: the block shows the title it stores and opens nothing.
Options
createPagePlugin(options) takes a PagePluginOptions:
open?(pageId: string, how: { newTab: boolean }) => void
Open the page (navigate to it): a click on the block, or Enter over it selected alone, with `newTab: false`; a modified click (Mod, Shift) or a middle click with `newTab: true`, as Notion opens a new tab (with `href`, those clicks are the browser's). Without it the block is a plain link when `href` gives one, else inert.
(pageId: string, how: { newTab: boolean }) => voidtitle?(pageId: string) => string | null | undefined | Promise<string | null | undefined>
The page's title now. Read from reactive state, the block follows renames. `null` or `undefined` while unknown: the block shows the title it stores, else the `untitled` label ("Untitled").
(pageId: string) => string | null | undefined | Promise<string | null | undefined>href?(pageId: string) => string
A URL for the page: the block is a link to it, so a modified click (Mod, Shift, the middle button) opens a new tab or window, and its HTML export links it. A script URL is dropped.
(pageId: string) => stringcreate?() => string | { pageId: string; title?: string } | null | undefined
Create a page and answer its id (and title). Adds the "Page" command; without it there is none.
() => string | { pageId: string; title?: string } | null | undefinedlink?Snippet<[PageBlockLinkPayload]>
The block's link to its page, replacing Notion's row (see [Your link](#your-link)). The first page plugin listed in a view gives it.
Snippet<[PageBlockLinkPayload]>A title answer that differs from the stored title is stored on the block, as one data write outside the undo history, by a view that may write: so a view without the lookup, the room, the HTML export and a readonly viewer show it. An undo never brings back a stale title. A promise’s answer is used once it settles, and only for the page it was asked about.
Your link
link is drawn inside the block’s void wrapper ([data-edytor-page]). It receives a PageBlockLinkPayload: block (undefined in a suggestion’s preview), pageId, title (your lookup’s answer, else the stored one), label (the title, else the view’s page.untitled), href (your href’s URL, if any), openable (it names a page and the plugin has open), open(newTab?) and control. Draw an a with href={page.href} when href is set, else a button, and give it {@attach page.control}, as a menu’s row takes its attachment: the marker (data-edytor-page-link), draggable="false" on a link, disabled when it cannot open and a press that keeps the caret on a button, and the click and middle click that open the page as the default row does (a modified or middle click in a new tab, the browser’s own on a link).
<script lang="ts">
import { Edytor, createPagePlugin, type PageBlockLinkPayload } from 'edytor';
const pages = createPagePlugin({
open: (pageId, { newTab }) => window.open(`/p/${pageId}`, newTab ? '_blank' : '_self'),
href: (pageId) => `/p/${pageId}`,
link
});
</script>
{#snippet link(page: PageBlockLinkPayload)}
{#if page.href}
<a class="subpage" href={page.href} {@attach page.control}>📄 {page.label}</a>
{:else}
<button type="button" class="subpage" disabled={!page.openable} {@attach page.control}>
📄 {page.label}
</button>
{/if}
{/snippet}
<Edytor plugins={[pages]} />
The “Page” command
With create, the plugin adds the command page.new, “Page” in the slash menu (group Basic blocks, keywords subpage, new page, document, link) and the + menu. It calls create(), then, as one command and one undo step, turns the caret’s line into the page block when the line holds nothing, or inserts the block after it otherwise, then calls open with the new id (Notion opens a new page at once). A create that answers no valid id adds nothing.
A page id is any string of 1 to 256 characters, a room id’s bounds. With Cloudflare rooms a room exists from its first dial, so a page id can be made in the browser (crypto.randomUUID()); authorize it in authorize like any room.
To insert a block yourself (a “Link to page” picker of yours), insert it like any block: { type: 'page', data: { pageId: 'doc-42', title: 'Roadmap' } }.
Turning blocks into a page
Notion’s “Turn into page” moves content into a new page. Do it with cross-document moves: from your Worker, moveBlocksBetweenRooms the selected blocks into the new page’s room (it keeps edits made during the move), then put a page block where they were. The plugin does not do the move itself: it needs your Worker route and your authorization of both pages.
The kind
| Kind | Element | Data | Preset |
|---|---|---|---|
page |
div |
{ pageId: string, title?: string } |
none (the page.new command) |
The block is void and shows no text of its own, like a divider: it takes no children, is never merged and is selected whole. Its role is in defaultSemantics (pageKinds), so the room and headless documents treat it as void too. A stored pageId that is no string or is longer than 256 characters opens nothing; a title is trimmed and capped at 300 characters, and rendered as text.
Clipboard
- Copying exports
<p data-edytor-page="id"><a href="…">title</a></p>(the title alone withouthref); the plain text is the title. - Pasting that HTML back imports a page block with its id and title.
Styling
| Selector | Matches |
|---|---|
[data-edytor-type='page'] |
The block |
[data-edytor-page-link] |
The link: an a with href, else a button (yours too, through attributes) |
[data-edytor-page-icon] |
The page icon (drawn by the theme) |
[data-edytor-page-title] |
The title; [data-untitled] when it has none |
The Notion theme draws Notion’s page row: the page icon, the title underlined, a hover background. Without it the link is the browser’s link or button.