Source on GitHub
Definition: shared/definitions/creator-load-save.json
Commercial client licence: without SURVEYJS_LICENSE_KEY the component shows a licence banner
shared/client/edit-together.js · sjs:edit-together.client
View on GitHub
// Every edit becomes a small JSON record; the plugin also captures and draws presence
const collab = new CollaborationPlugin(creator, { roomId: formId, getInviteLink: inviteLink });
creator.addPlugin("collaboration", collab);
// The relay runs as its own process, so its URL comes from the page config (EDIT_RELAY_URL)
const ws = new WebSocket(relayUrl("edit", `/ws/forms/${formId}?name=${encodeURIComponent(userName)}`));
let ready = false; // send nothing before init is applied
const sendPresence = () => ready && ws.send(JSON.stringify({ type: "presence", state: collab.getState() }));
ws.addEventListener("message", (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "init") {
creator.JSON = msg.seed; // the saved definition…
if (msg.log.length) collab.apply(msg.log); // …plus every edit since, in order
ready = true;
sendPresence(); // announce yourself to the others
}
if (msg.type === "record") collab.apply(msg.payload);
if (msg.type === "presence-sync") collab.setPeers(msg.peers);
if (msg.type === "presence") collab.upsertPeer(msg.peer);
if (msg.type === "presence-leave") collab.removePeer(msg.clientId);
});
const sendRecord = (_, { record }) => ready && ws.send(JSON.stringify({ type: "append", payload: record }));
collab.onRecordAdded.add(sendRecord);
collab.onRecordChanged.add(sendRecord); // a coalesced record re-sent as it grows
collab.onStateChanged.add(sendPresence);
app/Relay/EditRelay.php · sjs:edit-together.server
· run php artisan relay:edit (port 8082)
View on GitHub
// The same kind of relay as Fill Forms Together, keeping an append-only log instead of a value map:
// Creator records must be replayed in order. Run as its own process: php artisan relay:edit
private array $rooms = []; // room key => EditRoom { seed, log, clients }
public function onConnect(TcpConnection $c, Request $request): void
{
$user = $this->editor($request); // may this user edit this form?
$formId = $this->formId($request);
$roomKey = $formId === null ? null : RoomKey::fromHandshake($request, $formId);
if (! $user || $roomKey === null) {
$c->close();
return;
}
$room = $this->rooms[$roomKey] ??= new EditRoom($this->savedDefinition($formId)); // seed = saved definition
$room->cancelDrop(); // back within 2 s: keep the room
$c->context->roomKey = $roomKey;
$c->context->id = bin2hex(random_bytes(8));
$c->context->name = self::name($request->get('name'), $user->name);
$c->context->slot = $room->freeSlot();
$c->context->state = null;
$room->clients[$c->id] = $c;
$this->send($c, ['type' => 'init', 'clientId' => $c->context->id, 'colorIndex' => $c->context->slot,
'color' => EditRoom::PALETTE[$c->context->slot], 'seed' => $room->seed, 'log' => $room->log]); // init first
if ($peers = $room->roster($c)) {
$this->send($c, ['type' => 'presence-sync', 'peers' => $peers]);
}
}
public function onMessage(TcpConnection $from, string $raw): void
{
$msg = json_decode($raw); // records stay opaque: {} is relayed as {}
$room = $this->rooms[$from->context->roomKey ?? ''] ?? null;
if (! $room || ! $msg instanceof stdClass) {
return;
}
if (($msg->type ?? '') === 'append' && isset($msg->payload)) { // append, then fan out
$room->log[] = $msg->payload;
$room->broadcast($from, ['type' => 'record', 'from' => $from->context->id, 'payload' => $msg->payload]);
} elseif (($msg->type ?? '') === 'presence' && isset($msg->state) && strlen($raw) <= 4096) {
$from->context->state = $msg->state;
$room->broadcast($from, ['type' => 'presence', 'peer' => EditRoom::peer($from)]);
} // unknown types: ignore
}
public function onClose(TcpConnection $c): void
{
$room = $this->rooms[$c->context->roomKey ?? ''] ?? null;
if (! $room) {
return;
}
unset($room->clients[$c->id]);
$room->broadcast($c, ['type' => 'presence-leave', 'clientId' => $c->context->id]);
if (count($room->clients) === 0) { // the log lives until the room empties
$room->dropTimer = Timer::add(2, fn () => count($room->clients) === 0 && $this->drop($c->context->roomKey), [], false);
}
}