openpencil/packages/docs/de/programmable/collaboration.md
Danila Poyarkov 9dce9fdcbc
feat(collab)!: sync layer trees as a CRDT and open each room in its own tab (#902)
* fix: stop ancestor walks from hanging on a parent cycle

A collaborator's concurrent reparent can leave two layers as each other's
parent. isDescendant, the design check's pageOf, and component sync walked
parentId without a bound, so applying such a change froze the editor.

Add SceneGraph.closest(), a bounded nearest-ancestor lookup, and use it for
these walks so bad data ends the walk instead of the tab.

* fix(collab): sync layer moves without parent cycles or stale child lists

Remote changes assigned each layer's synced parentId and childIds as plain
properties. Two peers moving layers into each other made them each other's
parent, a moved layer stayed listed under its old parent, reorders never
synced, and concurrent additions ended up in different orders or missing
from the parent's list.

Apply the tree after a change's properties: move layers to their synced
parents, skip a move that would make a layer its own ancestor and write the
layer's current parent and position back so every peer settles on it, and
derive each touched parent's childIds from its synced order followed by
unlisted children by id. Locally, a parent's child list syncs once after
each edit that adds, removes, moves, or reorders its children.

Fixes #888
Fixes #889

* refactor(scene-graph)!: move sibling order keys to scene-graph

Collaboration needs the same fractional keys as .fig export to order
siblings, and the app must not depend on @open-pencil/fig for them. Move
fractionalPosition, orderKeyBetween, and siblingOrderKeys to
@open-pencil/scene-graph/order-keys.

orderKeyBetween now always returns a key: when no printable key fits it
returns one above lo, which hasOrderKeyBetween detects, so callers no
longer branch on null. It takes an optional suffix, and siblingOrderKeys
can request one per key, so two peers inserting at one spot get distinct
keys. .fig export keeps its keys.

* fix(scene-graph): report instance child reorders as graph events

Instance sync sorted an instance's children in place, so nothing that
listens to graph events saw the new order; in a shared room the order
never reached other peers. Move each child that changes position with
insertChildAt, which reports the reorder.

* feat(collab): resolve the layer tree from each layer's parent history

Add a pure LayerTree for the shared document: each layer records every
parent it was moved under with a move counter and an order key. A layer
sits under its newest parent, ties broken by parent id; when concurrent
moves close a loop, the latest move in the loop falls back to the
layer's next entry until none is left, and a layer no parent can take
goes to its page (Evan Wallace's mutable tree hierarchy CRDT).

The result depends only on the entries, and resolution revisits only
changed, orphaned, and displaced layers. Seeded random runs check that
peers converge and that the incremental result matches a full one.

* fix(collab): sync layer moves as parent history and order keys

Each layer's shared map now records every parent it was moved under with
a move counter from a document-wide Lamport clock, and its order key
among siblings, instead of parentId and childIds. Every peer derives
parentId and childIds from these with LayerTree, so concurrent moves,
reorders, and additions merge, a loop from concurrent moves undoes its
latest move, and a layer whose new parent was deleted meanwhile returns
to its previous one.

A local edit's graph events are written once after the edit, in one
transaction. It records a parent entry for each layer whose parent
changed, a new entry for displaced layers on the touched paths so a move cannot pull
them back, and keys between the moved layers' neighbours with a random
suffix, so concurrent inserts at one spot get distinct keys. Remote
changes find their layers through each event's path, resolve only what
they touch, and move and sort layers through insertChildAt.

This replaces the childIds merge and the write-back of rejected moves:
a rejected move now resolves the same way on every peer from the shared
history, so nothing needs to be written back.

Fixes #888
Fixes #889

* feat(collab)!: convert saved rooms and keep mismatched builds apart

A room saved by an earlier build records parentId and childIds. When a
change brings in such layers, from this browser's storage or a peer,
convert them in one transaction: each layer's synced parent becomes its
only entry with counter 0, its position in the parent's synced childIds
becomes an order key, and the old fields go. The result depends only on
the document, so two peers converting at once write the same values,
and converting again writes nothing. The document's meta map records
treeFormat 2.

Builds that record the tree differently would corrupt each other's
rooms, so the collaboration namespace becomes openpencil/2 for Trystero
and the test relay alike, and each peer publishes its treeFormat in
awareness for future version messages.

* fix(collab): send a layer whose parents were all deleted back to its own page

Each layer's shared map records the page it was last placed on, and the
layers under a frame moved to another page are re-recorded. A layer whose
parent chain was deleted goes back to that page, falling back to the first
page only when the recorded one is gone. Saved rooms record pages when
they are converted.

* fix(collab): keep one root per room when peers edit their own documents

Joining a room keeps the joiner's earlier document in its graph, and an
undo or an edit made before the room arrived could still reach it. That
edit shared the joiner's root, every peer adopted it, and the room's
pages disappeared.

The room now records its root as claims in meta, each with the time it
was made, and every peer follows the earliest. Sharing claims the room,
and so does the first edit in a room nobody has shared, which now shares
the whole document as Share does. A peer shares only layers under the
room's root. Converted rooms claim the root with the most children.

Move counters must also be safe integers, so an oversized counter from
another peer cannot stop the move clock from advancing.

* fix(collab): rank root claims by how they were made, not by clocks

Root claims carried the claiming peer's wall-clock time, so a guest whose
clock ran behind the sharer's could still win the room with an edit made
before the room reached them. A claim now records whether it came from
Share or a converted room, or from the first edit in an unshared room;
a shared root outranks an edited one, and the lower id breaks a tie.
A claim also replaces an invalid value already stored for its root.

* fix(collab): keep every root claim through concurrent writes

A root claim was one key per root holding its kind, so two peers claiming
the same root by Share and by an edit at once kept only one of the two
values, and the shared claim could be lost. Each kind of claim on a root
is now its own key. Converting a saved room also claims its root unless
a shared claim exists, so a guest's earlier edited claim no longer keeps
the converted room from outranking it.

* fix(collab): mint layer IDs under a session of each editor window

Every editor window started its IDs at 0:1 from the same counter, so two
people adding layers to a shared room at once could mint the same IDs,
and one person's layers replaced the other's in the room. A joiner's
starting page also took the sharer's page ID and stayed in their list.

SceneGraph's default IDs now carry a session set with setIdSession, as
in Figma's sessionID:localID GUIDs. The editor picks a random 32-bit
session at startup, as Yjs does for each document's clientID; headless
tools keep session 0, so the CLI and MCP server give a file's layers the
same IDs on every run.

* fix(collab): let only Share set a room's root

A guest's first edit in a room whose contents had not arrived claimed
the room and wrote the guest's whole open document into it, images
included, and adopting the sharer's root later only hid it. Every room
starts with someone sharing a document, so a guest has nothing to claim:
only Share, or converting a saved room, now sets the room's root, as a
single value in meta, and a peer writes nothing until the root is known.

Unbinding a room also writes an edit still waiting to be sent, so a move
or deletion made just before leaving reaches the room.

* feat(collab)!: open each room in a tab of its own

Joining a room bound it to whatever tab was active, so a pasted link
could turn a saved file into the room's document, and a share link first
showed an editable blank document. A room is now a document: joining
always opens it in a new tab, or switches to the tab already showing it,
and only Share puts an existing tab's document into a room.

Every room tab owns its session (src/app/collab/rooms.ts and
session.ts), so several rooms can be live at once and keep syncing in
the background. The collaboration panel, presence, following, and the
/share/<id> address follow the active tab, and a canvas publishes its
cursor and selection only to its own tab's room.

A room tab derives its state: joining while its saved copy loads, then
waiting, with an explanation, while nobody who has the file is online;
live with others, or alone on this device's copy. Until the document
arrives the room's screen replaces the editor. Reloading a share link
rejoins it; leaving a room you joined keeps its file as a local unsaved
copy. "Connected" now means another peer answered. Pasted links and IDs
are normalised and validated, and invalid ones say so.

People join right away under a generated name such as "Teal Fox", with
a hint to set one; the one app-wide name is set in the share panel or
in Settings. On a phone, Share copies the room's link instead of making
a new room, and the presence popover shows the room's state.

* feat(desktop): open rooms from openpencil://join links and Home

The desktop app could not receive a share link: links point at the web
app, and openpencil:// only opened files. openpencil://join?room=<id>
now opens the room in a tab of its own. The native parser refuses
anything but a room ID, queues rooms for the frontend through
take_pending_rooms, and a second launch on Windows and Linux forwards
its link through the single-instance handler.

In a browser on a computer, the room's screen and the share panel offer
Open in desktop app, a link the browser hands to the app on click; it
never opens the app by itself. Home gains Join room…, which takes a
pasted room link or ID and opens the room in a new tab.

* feat(collab): set your name on the room screen

The room screen told someone joining under a generated name to set
their name but offered nowhere to do it before the file arrived. It now
has the same name field as the room panel.

* feat(collab): offer the desktop download beside Open in desktop app

A browser on a computer offers a room's openpencil://join link, which
does nothing where the app is not installed. The room screen and panel
now link to the latest release beside it.

* docs: describe joining rooms in the German, Polish, and Russian guides

Bring the translated collaboration pages up to the English one: Share as
the only way into a room, joining in a tab of its own, the waiting
screen, leaving with a local copy, and how layer moves merge. The
English page now names the panel's Leave room button.

* fix(collab): lay out the room screens like the app's empty states

The joining and waiting screens were a left-aligned card with a stray
spinner, a primary Copy link button beside an outline button and a
bare link, and a name field on a screen that lasts seconds. They now use
AppPlaceholder, centred over the tab: a heading, the explanation, the
two hints, secondary Copy link and Leave, a 'You'll appear as' line
whose Change opens a small rename popover, and the desktop handoff on
one muted line. The room panel lines its status dot up with wrapped
text, no longer selects the room link when it opens, and puts the
desktop links and Leave room on one footer line.

* fix(collab): show what a room tab is doing instead of a timed guess

A joined tab said nobody with the file was online five seconds after it
opened, whether or not it had reached the signaling service or met the
people already in the room. Its state now follows what the tab can
observe: connecting until the service answers, looking for people for as
long as that transport takes to introduce everyone, getting the file
from someone who says they have it, waiting when nobody who has it
showed up (naming other guests waiting too), and a can't-connect screen
when the service cannot be reached. Each peer says in its presence
whether it has the room's file.

* fix(collab): list other waiting guests with the explanation

The line naming other guests who are waiting too is information, not an
action, so it follows the hints above the buttons. Peers' hasFile flag
is optional, as older builds do not send it.

* fix(collab): send a canvas's cursor and selection to its tab's room again

Canvases read the editor through a proxy that follows the active tab,
and the room lookup by store never matched it, so pointer moves and
selections stopped reaching the room: collaborators lost each other's
cursors, selections, and page markers. The canvas now looks up its
room by its tab's own store, and its selection listener ends when the
canvas unmounts instead of piling up across tab switches.

* feat(collab): one avatar stack for the toolbar, the mobile pill, and pages

The toolbar, the page list, and the mobile HUD each drew the people in a
room their own way, and the mobile pill read 'Online: 3' in hard-coded
English beside a status dot too small to render. AvatarStack now draws
people overlapping with their agent counts and '+N', and every place
uses it: the toolbar wraps each avatar in its menu or hover card, the
mobile pill shows the room's state dot and the stack with a translated
name, and hovering a page with people on it opens a card with the stack
and who is there, with their agents, to follow. The mobile list is the
shared presence list, so it is translated and can follow agents too.

* docs: note the page hover card and mobile avatars in the changelog

* fix(collab): stop listening to a room once its tab leaves it

A session left its Yjs observers and its awareness listener attached
after dispose, relying on destroy() and the order of teardown not to
touch the tab again. It now removes them explicitly and clears its peer
list. Also fix a missing comma in the Polish collaboration guide.
2026-10-06 10:40:25 +00:00

5.5 KiB
Raw Blame History

title description
Zusammenarbeit Gemeinsame Bearbeitung in Echtzeit direkt über WebRTC, ohne zentralen Server.

Zusammenarbeit

Mehrere Personen können dasselbe Dokument gleichzeitig bearbeiten. Die Teilnehmer verbinden sich direkt über WebRTC; ein Konto ist nicht erforderlich.

Raum teilen

  1. Schaltfläche „Teilen“ oben rechts öffnen.
  2. Diese Datei teilen wählen – der Link app.openpencil.dev/share/<room-id> wird kopiert.
  3. Link an die anderen Teilnehmer senden.

Nur „Teilen“ bringt ein Dokument in einen Raum: Der Tab, aus dem Sie teilen, wird zum Tab des Raums und bleibt mit seiner Datei verbunden. Jede Person mit dem Link kann beitreten.

Raum beitreten

Öffnen Sie den Link oder fügen Sie ihn (oder nur die Raumkennung) unter Beitreten im Teilen-Panel oder unter Raum beitreten… auf der Startseite ein. Der Raum öffnet sich in einem eigenen Tab, sodass bereits geöffnete Dokumente unverändert bleiben. Auf einem Computer bietet der Browser außerdem In der Desktop-App öffnen an; der Raum öffnet sich dann über einen openpencil://join-Link in OpenPencil.

Sie treten sofort unter einem erzeugten Namen wie Teal Fox bei. Ihren eigenen Namen legen Sie im Teilen-Panel oder in den Einstellungen fest; er gilt in jedem Raum.

Räume werden nicht auf einem Server gespeichert: Die Datei liegt auf den Geräten der Personen, die im Raum waren, daher öffnet ein Raum-Tab sein Dokument nur, solange eine von ihnen online ist. Bis dahin zeigt er an, dass er wartet, erklärt den Grund und öffnet die Datei, sobald jemand beitritt, der sie hat. Ein Raum, in dem Sie schon waren, öffnet sich sofort aus der Kopie auf diesem Gerät und synchronisiert Ihre Änderungen, sobald andere zurückkehren.

Raum verlassen im Teilen-Panel beendet Ihre Teilnahme am Raum. Ein Tab, der sein Dokument geteilt hat, wird wieder zu diesem Dokument; ein beigetretener Tab behält die Datei des Raums als lokale, ungespeicherte Kopie, die Sie speichern können. Jeder Raum-Tab hat eine eigene Verbindung, sodass Sie in mehreren Räumen gleichzeitig sein können.

Synchronisierte Daten

  • Dokument: Änderungen an Formen, Text, Eigenschaften und Anordnung;
  • Zeiger: Position, Name und Farbe jedes Teilnehmers;
  • Auswahl: ausgewählte Objekte der anderen Teilnehmer;
  • Agenten: Der integrierte AI-Chat erscheint als Zeiger an den Ebenen, die er bearbeitet; seine umrandete Beschriftung zeigt ein Funkelsymbol und einen Rufnamen wie Fern. Zeiger und Umrandung haben die Farbe der Person, die den Agenten ausführt, sodass erkennbar ist, wem er gehört. Geteilt werden nur Name, Art, Modell, Status, Seite, Position und bearbeitete Ebenen, niemals Prompts oder Antworten.

Ansichtsverfolgung

Ein Klick auf einen Avatar folgt der Ansicht dieses Teilnehmers. Position und Zoom werden angepasst, und ein Rahmen in dessen Farbe mit einer Leiste „Du folgst …“ zeigt, wem Sie folgen. Zum Beenden klicken Sie erneut auf den Avatar, drücken Esc oder klicken, scrollen, zoomen oder wechseln selbst die Seite.

Ein Avatar zählt die Agenten, die die Person ausführt. Beim Daraufzeigen erscheinen alle Agenten, ihre Tätigkeit und die jeweilige Seite; mit Folgen neben einem Agenten bleiben die Seite und die Ebenen, die er bearbeitet, im Blick. Die Verfolgung läuft zwischen seinen Antworten weiter und endet, wenn er den Raum verlässt. Die Schaltfläche nach den Avataren listet alle Personen im Raum mit ihren Agenten auf und ist per Tastatur bedienbar. Ihr eigener Avatar listet Ihre Agenten auf – ein Klick benennt einen Agenten um – und enthält Raum verlassen.

Das Teilen-Panel listet alle Personen im Raum mit den Agenten auf, die sie ausführen, mit ihrer Tätigkeit und der jeweiligen Seite. Einem Agenten folgen Sie auf dieselbe Weise, um die Seite und die Ebenen, die er bearbeitet, im Blick zu behalten; die Verfolgung läuft zwischen seinen Antworten weiter und endet, wenn er den Raum verlässt. Mit einem Doppelklick auf einen eigenen Agenten benennen Sie ihn um.

Technische Grundlage

WebRTC überträgt die Designdaten direkt zwischen den Teilnehmern. Ein zentraler Anwendungsserver leitet die Änderungen nicht weiter.

Yjs synchronisiert den Dokumentzustand als CRDT und führt gleichzeitige Änderungen automatisch zusammen.

Auch das Verschieben und Umordnen von Ebenen wird zusammengeführt. Jede Ebene merkt sich jedes Elternelement, in das sie verschoben wurde, und ihre Position unter ihren Geschwistern; jeder Teilnehmer leitet daraus denselben Ebenenbaum ab (Evan Wallaces Baum-CRDT). Verschiebungen, Umordnungen und neue Ebenen verschiedener Personen werden alle übernommen; verschieben zwei Personen gleichzeitig dieselbe Ebene, setzt sich bei allen dieselbe Verschiebung durch. Würden gleichzeitige Verschiebungen zwei Ebenen ineinander legen, wird die spätere rückgängig gemacht, und eine Ebene, deren neues Elternelement inzwischen gelöscht wurde, kehrt an ihren vorherigen Platz zurück.

Alle Teilnehmer eines Raums brauchen eine OpenPencil-Version, die den Ebenenbaum auf dieselbe Weise speichert; Versionen, die ihn anders speichern, sehen die Räume der jeweils anderen nicht.

IndexedDB speichert den lokalen Stand: Beim Neuladen der Seite treten Sie dem Raum automatisch mit demselben Stand wieder bei.

Hinweise

  • Zusammenarbeit funktioniert im Browser und in der Desktop-App.
  • Raumkennungen werden mit kryptografisch sicheren Zufallswerten erzeugt.
  • Zeiger und Anwesenheitseinträge getrennter Teilnehmer werden automatisch entfernt.