Everyone who has the same template open sees the others' pointers and selections, live (TYP-918).
Each person's pointer is an arrow with their name in their color; what they have selected gets an
outline in that color. Everyone in the template has a color of their own, the same on every screen:
whoever has been in it longest keeps the color of their token, and someone whose color is taken
gets the first free one (people[].color is that color). A person's color does not change when
someone else arrives.
It is off unless you pass collaboration. Without it the editor opens no connection and
behaves exactly as before.
What happens to edits depends on edit_together. With it, everyone who may edit edits at once and
each edit reaches the others within a moment (see "Editing together" below); one window saves for
everyone. Without it, nobody's edits travel, so one window edits at a time (see "One person
edits at a time" below): the others comment or look until it is free, and saving stays your job,
as it always was.
const editor = new Typograph.Editor({
container: '#app',
host: { /* … */ },
collaboration: {
url: 'wss://collab.typograph.nl/collab',
document_id: templateId,
token: async (documentId) => {
// Ask YOUR backend; it asks Typograph for a collab token with the user's own token:
// POST /v1/file/templates/{documentId}/collaboration-token
const response = await fetch(`/editor/${documentId}/collab-token`, { credentials: 'same-origin' });
return (await response.json()).token;
},
},
});
Constructing the Editor once it is already running works too, for a page that lets the editor boot
itself and wires it up on typograph_editor_ready: new Typograph.Editor({ collaboration }) takes
the running editor over and joins the room.
| Field | |
|---|---|
url |
the collaboration server's WebSocket endpoint. The editor adds its collaboration protocol to it (?protocol=2); see below |
document_id |
the template's id; everyone with the same id is in the same room |
token(document_id) |
returns a fresh collab token. Called on every connect and reconnect: a token lives two minutes, so do not cache one |
release() |
optional, () => Promise<boolean>: save, before this window gives the pen to the user's other window. Resolve false (the save failed) and this window keeps it |
catch_up() |
optional, () => Promise<void>: load the latest saved document, before this window starts editing after someone else did. Editing stays withheld until it settles |
edit_together |
optional, boolean: everyone who may edit edits at once and sees the others' edits within a moment (TYP-919). Without it one window holds the pen; see below |
version |
optional, string: the saved version of the template this window loaded, such as the storage's ETag. The room records the version it was started from; see room_version() and conflict() |
The token is issued by Typograph to a person who may view the template, for that template only. It carries the name, color and photo the others see, and whether the person may edit. Each window publishes those to the others itself; collab-service closes a connection whose window claims another user, name or role than its token says, so a modified page cannot appear as someone else or take the pen with a view-only token. Fetch it from your backend: the API call needs the user's access token, which should never reach the browser.
edit_together, what someone drags or turns moves on everyone's screen while they drag it,
not only when they let go; what they resize shows as their outline growing, and the content
follows when they let go (TYP-919). A selected group counts as one element; past 50 elements at
once the others see the drag only where it lands.edit_together, a colour, tint or opacity changes on everyone's screen while someone holds
the slider or the swatch, not only when they let go (TYP-1599). Only the values travel, at most 20
times a second, and the others draw the newest once a frame; the change itself reaches the
document once, on release, as one undo step. Past 50 elements at once the others see it only when
it lands. An editor without this sends no preview and ignores the others', so a room with older
editors in it keeps working.The editor draws the pointers; who is in the template and whether the connection is up are yours to show, in your own header — avatars next to a Share button, a dot for the connection. Listen for:
document.addEventListener('typograph_collaboration_changed', (e) => {
const { status, people, seats } = e.detail;
// status: 'connecting' | 'connected' | 'offline' | 'denied' | 'full'
// seats: how many people a full room seats while status is 'full', null otherwise
// people: [{ user_id, name, color, avatar_url, windows, editing, you }] — the user first, then everyone else, once per person
// editing: { you, by, taken_over, catching_up, catch_up_failed, writer } — who holds the pen and who saves, see below
});
It fires when someone joins or leaves and when the connection changes, never for a pointer moving.
Someone with the template open in two windows is one person (windows: 2), drawn once, at the
window they moved in last. The user's own other windows are not drawn at all: a pointer or outline
with your own name on it would read as something you did here. The user comes first, with you: true, once the token has said who they
are: windows counts this window and their others, and editing is true when one of them holds the
pen. Show their photo too, so they see themselves among the others. avatar_url is null when the
person has no photo; show initials on their color then. denied means the server refused the token,
or refused this editor as too old for the room (below); retrying will not help.
collab-service renews every connection now and then, about every 15 minutes, so a user whose access
to the template was taken away loses the room within that time. The editor reconnects at once with
a fresh token, and status stays connected through it: you hear offline only when the renewal
has not reconnected within five seconds. editing.writer follows the connection itself, so it may
turn false and back for that moment; nothing is saved for everyone while the window has no room.
people keeps the others through it too, and so do their pointers and the pen: the window shows
them as it last saw them until the room speaks again, or for those five seconds.
collab-service seats at most 8 people in a room (TYP-1605), counted by person whatever their role;
a second window of someone seated takes no second seat. The next person's window reports full,
with seats saying how many the room seats: it shows the template read-only, draws nobody, holds
editing with the reason 'room_full' ('view_only' for someone who may only view), and asks for a
seat again every 10 to 12 seconds. Once it has one it reports connected and gives editing back as
soon as it has settled. Say on screen that the template is full and that it opens for editing once
someone leaves; the editor's own mode switcher does that already.
Editors that read the shared room differently never meet in it. Each editor declares its
collaboration protocol on the WebSocket URL (?protocol=2 since 1.0.0-20261101.1), and
collab-service refuses an editor older than the room's: an editor still open in a tab after a
release that changed the room. That window reports denied and keeps working alone, like an
editor without collaboration: it is never held, and editing.writer is false, so save there as you
do while the connection is down. The others never see it. Reloading the page loads the current
editor, which joins the room again. The browser console says why:
[collaboration] refused: protocol-outdated: ….
edit_together)With edit_together: true nobody waits for a pen: every window that may edit edits, and each edit
— moving, resizing, restyling, adding, deleting, grouping, reordering — reaches the others within a
moment, element by element. Two people changing different properties of one element both keep
their change; two people moving the same element end in the same place on every screen. A window
that joins late, or loaded the template before the others edited, is brought up to date when it
joins. Undo takes back your own step only, never what the others did since — not even a change
of theirs to the same element after yours.
A window whose token only lets the person view is held while it is in the room
(hold_editing()): its edits would reach nobody and be overwritten by the next one that does.
One window saves for everyone. Every window holds the same document, so one of them saving is
enough, and two saving at different moments could write an older state over a newer one.
detail.editing.writer is true in exactly one window that may edit — the same one in every
window — and only that window's host should autosave. When it leaves, another window becomes the
writer; save there if the document is changed. The writer's is_changed() turns true for the
others' edits too, and typograph_document_changed fires for them with reason: 'remote'.
A window that lost the room (status is not connected) is never the writer: it sees only
itself, and the windows still in the room have chosen one among them. Whether such a window saves
for itself meanwhile is the host's call — portal-ui does, so work is not lost while collab-service
is unreachable, and the room's writer saves the merged document once it is back.
While the room exists, it holds the document. The first window to arrive puts the template it loaded into the room; every window after is brought to what the room holds, and the writer saves that. A change made to the saved template outside the room meanwhile — through the API, by an agent, or by a window that does not edit together — is overwritten by the room's next save. The room ends when its last window leaves, and the next window to arrive starts it from the saved template again.
The room does record which saved version it holds: the version its first window loaded, then
each version the writer saved (collaboration.saved(version, document)), read with
collaboration.room_version(). A host that saves for everyone can send it as If-Match, so the
storage refuses a save over a template changed outside the room instead of overwriting it. On
that refusal the host loads the newer template and hands it to collaboration.conflict(document, version), which merges it into the room (TYP-1140):
conflict() resolves the host saves again with
If-Match on it.The room tells what the file changed by a hash it keeps of each element and spread as last saved,
so any window that may edit can merge, not only the one that saved last. It compares an element
by its properties, its group and its spread, not by its place among its siblings: a file that only
reorders elements keeps the room's order. A window that is dragging gets the merge when it lets go,
as any change of the others; when that is the window that merges, conflict() resolves only then,
so its save carries the merge. Not merged yet: the swatch library, styles and fonts, and whether
page 1 is on the left: a newer file's change to those is still overwritten by the room's save.
A window on the pen in the same room — an embed without edit_together, or a page loaded
before it was switched on — edits a document of its own that the windows editing together cannot
hear. While one holds the pen, a window editing together waits: editing.you is false, by says
who has the pen, and it is not the writer. It edits again when that pen is put down.
The other way round is not answered: a window on the pen that finds a window editing together
ahead of it waits too, and its take_over() resolves false after its time-out, because a window
editing together never hands its document to one that cannot hear it. That window edits once the
ones editing together have left, or after it is reloaded with edit_together.
A spread's own bleed, colour, name and lock travel too, and so do pages: adding, duplicating, deleting, moving and keeping pages together reach the others, and so does a page's width and height (the parameter bar or the page size dialog, one step for everyone, facing pages included), and the page each person is looking at stays on their screen. Two people adding a page in the same place both keep theirs. Ruler guides travel with the page they are on: a guide dragged out of a ruler, moved, deleted or given a position in the parameter bar appears, moves or goes for everyone, and elements snap to it on every screen. Not yet: undo of a guide edit while editing together, which can do nothing for now.
Swatches travel too. A colour picked for an element, a swatch added, changed or removed in the swatch panel, and a gradient reach the others, also while the swatch panel is still open: each change in it is sent as it is made. A window that joins keeps every swatch, the room's and those only its own template has. Undo works as for the rest of editing together, with one difference: it does not take a swatch away, nor put back an older version of one. The library stays as everyone has it, so a colour you picked and undid stays behind as an unused swatch. With an editor or a canvas from before this, the swatch panel offers no + and − while editing together (it says why).
Not shared yet: the document's styles and fonts. So while editing together nothing that changes those is offered: POD settings cannot replace colours or fonts, and the Templates and Clips tabs are disabled, since their pages and clips bring swatches, styles and fonts of their own. They come back when the window stops editing together; a tab your page disabled itself stays disabled. While you drag or resize, the others' changes wait until you let go.
Text travels as it is typed. Each text frame's story is shared keystroke by keystroke: what one person types appears in the other windows within a moment, two people typing in the same paragraph both keep what they typed, and making a word bold while someone types in it keeps both. A caret or selection moves with the text around it. Undo works as for the rest of editing together: it takes back your own steps, not what the others did.
An editor from before live text in the same room — a page loaded before this release — still works with the others: it sees what was typed live when the typing run ends (the next click or command, or the window being hidden or closed), and what it types reaches the others when its own run ends. Between the two, a frame's text goes by the last typing run, as it did before live text: what a live window typed after the older editor last heard from it is lost when the older editor's run arrives. Reloading the older page ends it. A table is shared as a whole: two people editing in one table at once, the last one wins for the whole table.
Without edit_together, two people editing one template would overwrite each other's work: whoever
saves last wins. So one window holds the pen. The first window that may edit takes
it; every other window is put in Commenting (or Viewing, when the host offers no commenting) and
Editing is refused until the pen is free. When the holder leaves, the window that has been waiting
longest picks it up, and its user is put back in the mode they were in. Every window reaches the
same answer from the same room state, so nobody asks a server for the pen.
Someone whose token says they may only view never takes the pen.
The pen changes hands with the document saved. A window that picks up the pen after someone
else held it shows the version from before their edits; editing that would overwrite them. So
before it starts, it calls catch_up() and its host loads the latest saved document;
editing.catching_up is true meanwhile, and the others do not count it as editing yet. When
catch_up() rejects, editing.catch_up_failed turns true and editing stays withheld, because
editing the version on screen would overwrite the saved one: tell the user and offer
collaboration.retry_catch_up(). The editor's own mode label only says editing is paused, not
that a load failed, so a host that passes catch_up() shows the failure and the retry itself
(from typograph_collaboration_changed), as portal-ui does with its pen chip. Leaving the room
(stop()) refreshes nothing on screen: a window that was out of date, or whose load failed or was
still running, loads the latest version again before it edits after start(). A window that hands the pen on — see take_over() —
calls release() first, so what it changed is saved before anyone else edits. Pass both, or two
windows can still overwrite each other.
detail.editing says who has it:
| Field | |
|---|---|
you |
this window holds the pen |
by |
{ user_id, name, color, avatar_url, same_person }: the window that holds it, or while it changes hands the one next in line; null when this window has it or nobody does. same_person is true when it is the user's own other window: offer take_over() rather than "wait for them" |
catching_up |
this window has the pen and is loading the latest version first (catch_up()) |
writer |
this window saves for everyone: with edit_together one window that may edit, without it the window that holds the pen |
catch_up_failed |
that load failed: this window keeps the pen but may not edit until retry_catch_up() succeeds |
taken_over |
this window was editing and lost the pen to a window that started earlier. That happens only when two windows started at the same moment or met after a lost connection, and changes made here meanwhile may be overwritten: tell the user |
people[].editing is true for the person whose window holds the pen and has finished catching up. Show why the tools are gone
— portal-ui puts a chip with the holder's face where its mode control sits, and over the user's own
other window a card with an Edit here button — because a silently locked editor reads as a bug. The mode switch itself is the editor's: typograph_mode_changed fires as usual,
and ui.get_editing_held() says whether Editing is withheld right now. A host that offers only
editing and draws nothing itself still gets the editor's locked label ("View only", with the
reason as its tooltip) while editing is held. When the hold lifts the window goes back to the mode
it was in, unless the user picked another one meanwhile.
| Member | |
|---|---|
editor.get_collaboration() |
the live connection, or null without collaboration |
collaboration.status() / collaboration.people() / collaboration.editing() |
the same as the event's detail, read when you need it |
collaboration.take_over() |
take the pen from the user's own other window: it saves (release()) and hands it over, and this window catches up before editing. Resolves true once the pen is this window's — it may still be catching up, which editing() reports — and false when the pen is someone else's or the other window did not let go within 10 seconds |
collaboration.room_version() |
with edit_together, the saved version of the template the room holds: the one it was started from, then each one its writer saved. null outside such a room, or when no host passed a version |
collaboration.saved(version, document) |
tell the editor this window's document was saved as version. A window editing together that may edit records it in the room, also when it stopped being the writer while the save was in flight; a window outside such a room keeps it for the room it starts next. document, the JSON that was saved (a string or parsed), is how the room later tells what a newer file changed; without it the room takes its own state for what was saved, which counts an edit made during the save as saved |
collaboration.conflict(document, version) |
the storage refused a save (412): hand over the newer template (a string or parsed; a document, or a bundle with it as template, as open_document() takes) and its version. It is merged into the room for everyone, and room_version() returns version. Returns a promise: true once the merge is on this window's canvas — a drag in progress holds it back until it ends — then save again. false when this window cannot merge — it does not edit together, may only view, or the document has no spread — and the newer file must not be overwritten |
collaboration.retry_catch_up() |
load the latest version again after editing.catch_up_failed; does nothing otherwise |
collaboration.stop() |
leave the room; the others' pointers and outlines disappear. start() afterwards joins it anew, behind whoever edits by then |
collaboration.latency_samples() |
milliseconds from someone's pointer moving to it being drawn here, newest last. Taken with the sender's clock, so only meaningful between two windows on one machine |
destroy() leaves the room as well. Leaving gives the pen back: stop() lifts the hold on this
window, and the next window in line takes over.