- ClayJS
- Reference
- Plugins
- page
page
The page plugin: one article[data-page] as a live, collaborating page in the page editor.
The page plugin turns a single article[data-page] into a collaborating page inside the page editor. It loads by name, and in edit mode brings live, sync and upload with it. In view mode it brings its reader half only.
What it does#
The page plugin opens one article[data-page] with one merge="page" clay="live" Yjs store in the page editor. It loads in edit mode and requires a host that advertises the page extension. Without that extension, the saved article stays readable. clay.page.flush() waits for its edits to reach a checkpoint. Image and file blocks store their files through clay.upload, and on a host that stores no files the editor offers to embed them instead. In view mode the loader adds its reader half, page-view, by itself, so a reader’s article follows each saved file without a reload; no URL names it.
Writing in the page editor#
The page editor is built for everyday writing: one document title, Markdown shortcuts you can take back with one Backspace, a toolbar that fits a phone, and a keyboard route in and out of it. Desktop toolbars, the saved formats and the collaboration model are unchanged.
One title, derived or custom#
A page’s title comes from its content. The rule is the first top-level H1 in the page: its text with whitespace runs collapsed and trimmed. A page with no top-level H1 is Untitled. An H2, a heading inside a toggle or a list item’s heading is not the title.
The same rule writes <title>, so the tab, the search result and the file all say the same thing, and the save projection and a server checkpoint derive it from the same captured model. A title derived at save time cannot describe content other than the bytes saved beside it.
A custom title opts out. <meta name="clay-seo-title-mode" content="custom"> in <head> keeps the authored <title> exactly as written, and the SEO panel’s title field is what writes that marker. Remove the marker and the next save derives the title again. Two ownership rules follow:
- Derived is the default. An H1 you rename renames the document.
- Custom is explicit. Nothing derived ever overwrites a custom title, and a custom title is never invented from a heading.
The file’s name is not part of this. ClayJS never renames a document from its title: the host’s route, the downloaded file name and the path in a local folder stay what they were. A title is content, not identity.
Markdown shortcuts, one Backspace away#
Typed at the start of a block:
| Typed | Becomes |
|---|---|
# , ## , ### | heading 1, 2 or 3 |
- , * , + | bulleted list item |
1. or 1) | numbered list item |
[] , [ ] , [x] | checklist item, unchecked or checked |
> | toggle |
``` | code block (the whole paragraph) |
--- | divider |
The block keeps its id, its position and its parent: the shortcut retypes the block, it does not delete one and insert another, so a peer’s edit to the same block is not thrown away and the change is one undo step.
Typed around text:
| Typed | Becomes |
|---|---|
**bold** or __bold__ | bold |
*italic* or _italic_ | italic |
`code` | inline code |
~~strike~~ | strikethrough |
[label](address) | a link, when the address is a safe one |
The delimiters are removed and the mark goes over the body where it already is, so marks on the text inside survive. A | a | b | row followed by Enter becomes a table with those column names.
Backspace immediately after a shortcut puts the typed text back, delimiters included, through the editor’s own input-rule undo. It is the same history as everything else, so peer edits stay, and a later undo of that Backspace (redo) applies the shortcut again. A shortcut does not fire inside inline code or when the delimiter is escaped with a backslash, and text typed during an IME composition converts when the composition ends.
Typing joins one undo item the way it does in other editors: Cmd+Z removes the last word typed, not the last letter. A group continues while the keystroke lands in the same block, arrives within 500 ms of the one before it, inserts rather than replaces, and carries the same marks, and a space followed by the next word starts a new item. Deletions group the same way, one direction at a time. A command, and any change a script dispatches, is one item of its own.
On a phone#
At widths up to 560 px the toolbar keeps four controls: Bold, Italic, Link and More, each at least 44 by 44 CSS px, in a bar 204 px wide and 8 px from the edge. The selection bubble stands down so there are never two bars.
More holds everything the desktop bar and menu carry: Strikethrough, Code, bulleted, numbered and checklist lists, Turn into, Insert, Move up, Move down, Indent and Outdent. Turn into and Insert open the same pickers as on a desktop, and a block action from the phone menu acts on the caret’s block when nothing more is selected.
The bar is positioned from the visual viewport, not from the layout viewport: its top and horizontal bounds come from the visible area, so the on-screen keyboard cannot cover it, and it stays put when the visual viewport pans. The safe-area inset is read once per layout frame. Menus are constrained to the same viewport, open above the bar when there is room, and scroll inside themselves.
The keyboard route#
- Escape closes an open menu or link popover first, and puts focus back on the control that opened it, or the editor.
- With blocks selected, Escape leaves the writing surface for the first toolbar control and keeps the block selection, so a keyboard user is never stuck in contenteditable.
- Inside the toolbar, Left and Right walk the controls and Escape returns to the editor. The toolbar is one tab stop: Tab leaves it forward, Shift-Tab backward.
- Tab inside a table moves across its cells, Shift-Tab back; the editor leaves Tab to the browser everywhere else.
Mod-A widens one scope at a time. With the caret in a table cell it selects that cell’s text, then the table, then every block in the page. Outside a table it selects the block’s text, then every block. No press changes the document, so one Mod-A followed by Delete takes the cell’s text or the table and never the whole page.
A table’s column resize handle is a focusable vertical separator named for its column, with aria-valuemin 48, aria-valuemax 1200 and the current width as aria-valuenow. Left and Right preview a 10 px change, Shift makes the step 50 px, Home and End preview the minimum and the maximum, Enter commits and Escape cancels. A held arrow key and the keyup that ends it commit one command, not one undo item per repeat, and Tab or blur commits what is previewed before leaving. Double-click fits the column, and pointer dragging is unchanged. A phone keeps the column’s Width menu (Wider, Narrower, Fit to content, and Reset width once the column has a width) instead of a drag target, and a rerender puts focus back on the same column’s handle.
Remote carets#
Remote carets need the page editor, one <article data-page>, ?plugins=sync, and a host that announces page-awareness-v1. Anything less draws nothing, and sends nothing.
A selection is published as a page target. Its ends are the block id plus a Yjs relative position, toBase64(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(flow, index, assoc))). Peers resolve them against their own current document, so a caret follows edits, splits, joins, and moves.
store.setTarget({
kind: "page",
v: PRESENCE_VERSION,
epoch,
generation: binding.awareness.generation(),
selection, // { kind: "text", anchor: { block, rel }, head: { block, rel } }
});
A position that cannot be resolved is not drawn: a deleted or hidden block, another generation, another shared type, or bytes that do not decode. It is never placed at a neighbouring block.
Each peer is a highlighted range plus a caret with their name as text only. The colour comes from a fixed palette, chosen by the person’s id, and a peer can never supply a colour or markup.
A table cell is published as { kind: "cell", block, row, col }. It is drawn as an outlined cell with one name tag, never a character caret inside the cell. A selection across two cells publishes nothing. A block selection outlines the blocks, and over 128 blocks only the focused block is published.
Publication clears during IME composition, and peers’ decorations stay still until it ends. It also clears on blur, on pagehide, when the host stops announcing page awareness, and on disposal.
Carets are ProseMirror decorations. They never enter the document, its undo history, its Yjs value, or a save.
The reader#
A Document that boots in view and never enters edit loads the reader, page-view, not the editor. The loader adds it by itself; no URL names it. The plugin’s implications stay in edit mode: the reader implies no live data service, no generic sync and no upload, so plugins=page in view mode resolves to page-view alone.
- Saved, not live. The reader follows the file the host serves, through a receive-only transport (
src/sync/saved-stream.js): a startup GET once the stream opens, then frames, then a refetch and repair for whatever it missed. It never POSTs, never relays a live preview and needs no write credentials. - One receipt. A reader holds exactly one: the exact bytes on screen, their ETag and the sequence they cover (
{ bytes, etag, seq }). A duplicate or older frame is ignored without moving it, and a refused or failed read leaves both the article and the receipt as they were. - Keyed replacement. Each accepted file replaces only the blocks whose saved form changed (
src/page/reader-patch.js). An unchanged block keeps its node, so an open toggle, a mounted tool and find-in-page survive an update. - Explicit
sync. A page that also namessynckeeps one stream: the reader opens none of its own and takes the saved files that lane applied. A peer’s unsaved keystrokes never become a reader’s saved content. - Who is here. In view mode the reader also draws the roster of who is present, from the stream it already follows (Presence).
- What it reports.
clay:reader-statusfires ondocumentfor each file it handles:{ state: "applied", etag, seq }once one is on screen,{ state: "blocked" }when a read or an apply was refused. - When it is ready. The reader’s
readysettles without waiting for a file: withoutsyncit settlesnullonce its saved transport has started, and withsyncit settles to the started reader, because that lane starts the reader before it returns. Either wayclay:readynever waits on the network and fires even if the stream never opens, and a page that is not onearticle[data-page], or whose reader cannot start, settlesnull. - When the file is here. The reader module’s
acquired(src/plugins/page-view.js, besideready) settles to the started reader on the first accepted saved file, or tonullon a read or an apply that was refused or failed, and that first answer is the one it keeps. The stream keeps running either way, so a later accepted file still starts a reader. Awaitacquired, notready, for the file.
Across a mode change#
The page plugin is a participant of the mode lifecycle, with the id clay:page and order 400. Switching without a reload describes how a participant is prepared, and Leaving edit how an exit refuses.
On a live entry the editor is imported while the tab still shows view, and it takes the article over from the reader: the reader gives the article up (yieldArticle), and takes it back (reclaimArticle) if the entry is refused or the preparation fails. The session’s saves and autosave are held until the commit, so nothing is written before it.
Entering adopts the file already on screen as its baseline, so a clean enter and exit leaves the file byte for byte as the host served it. Anything typed after the commit is saved from there.
When it is ready#
clay.page.ready is the boot itself, not the loader’s. It resolves once the saved article has been adopted and the editor accepts typing, with the boot result { phase, reason, installId, at }. clay.page.status is that same result for the newest installation, or null before one has started.
clay.ready does not wait for it. plugins/page.js exports no ready, so await clay.ready can resolve while the saved article is still the thing on screen. Await clay.page.ready, or read clay.page.status.phase, before touching clay.page.view. Late plugins do wait: the loader holds them until clay.page.ready settles.
phase | What it means |
|---|---|
loading | the saved article is being read and validated |
joining | the editor’s code downloads beside the room join, and capability, the first save and the page handle are settled |
adopting | the editor is mounted and the saved article is being adopted into the room |
ready | adoption succeeded and input is enabled on this installation’s view |
blocked | the boot settled without an editable page; reason says what stopped it |
error | the boot threw; ClayJS reports reason boot-failed |
Every installation has its own id, and clay.page.install() starts a new one, disposing the last. A plain page load starts one too, which is what clay.page.ready is.
The boot marks#
Each boundary is a performance.mark with { detail: { installId } }:
| Mark | Exact boundary |
|---|---|
document:boot | an installation starts, before the source is validated |
document:assets-ready | the editor’s code is in hand; a failure here is editor-assets-failed |
document:authority-ready | the capability and first-save gates passed, the page handle is admitted and the binding is created |
document:input-ready | adoption succeeded and editable was enabled on the current mounted view |
A failed or cancelled installation never emits document:input-ready, and neither mark after document:boot is emitted by a branch that failed. document:authority-ready says the room admitted this tab and the binding exists; it does not say the writer election is over or that the feature views the article demands have installed. Those settle under the adopting phase, before input is enabled.
A blocked page is a settled page#
Blocked readiness is an answer, not a rejection. clay.page.ready resolves, clay.page.status.phase is blocked, and reason names the boundary that stopped it: no-page or ambiguous-page (not exactly one article[data-page] and one merge="page" tag), invalid-page-source, invalid-page-json, unsupported-page-format, missing-capability (no page extension), first-save-refused, first-save-conflict, missing-page-store, admission-failed, editor-assets-failed, mount-failed, adoption-refused, adoption-timeout, adoption-error, page-replaced, view-detached, or boot-incomplete. There is no editable page in that case: the saved article stays as the host served it, or an already mounted editor is left read-only because the page was replaced, and nothing takes a keystroke. The plugin draws its own notice, with Download a copy and Copy text where the host refused to keep the article. A page that must offer another route reads the phase rather than catching anything:
const result = await clay.page.ready;
if (result?.phase !== "ready") console.warn("this page cannot be edited here:", result?.reason);
Offline#
The editor, the theme sheets and the fonts are part of the release the local hosts bundle, so editing a page works offline in both Hyperclay Local and HTML Clay. Pictures from other sites do not. See Offline in a local host.
Pages#
- clayjs.com — the tutorial
- clayjs.com/get-started — zero to a saving file in four steps
- clayjs.com/docs — the reference
- clayjs.com/advanced — regions, save hooks, satellites
- clayjs.com/plugins — the heavyweight plugins
- htmlclay.com — the desktop app; htmlclay.com/features — everything it does
- github.com/panphora/clayjs — source