Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 264 additions & 2 deletions README.md

Large diffs are not rendered by default.

275 changes: 275 additions & 0 deletions examples/collaboration/example-shell.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import { SynixirRoom } from "@synixir/client";
import { showParticipants } from "./presence.js";
import "./style.css";

export function startExample({ createSurface, pagePath = "/", presenceOptions }) {
const roomId = new URL(window.location.href).searchParams.get("room");
let csrf;
let clearCollaboration = () => {};
let hasUnsavedChanges = () => false;

async function api(path, { method = "GET", body } = {}) {
const response = await fetch(path, {
method, credentials: "same-origin", signal: AbortSignal.timeout(10_000),
headers: { "Content-Type": "application/json", ...(csrf ? { "x-csrf-token": csrf } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
const result = await response.json();
if (!response.ok) throw new Error(result.error ?? "request_failed");
if (result.csrf_token) csrf = result.csrf_token;
return result;
}

function explain(error) {
return {
invalid_credentials: "Username or password is incorrect.",
invalid_registration: "Choose an available username and a password of 15–128 characters.",
rate_limited: "Too many attempts. Wait a minute and try again.",
unauthorized: "Access expired or denied",
forbidden: "Only an owner can manage room access.",
last_owner: "Add another owner before removing or changing the last owner.",
room_unavailable: "That room ID is unavailable. Choose another.",
room_quota: "This account has reached its room ownership limit. Ask an operator to review the limit.",
account_not_found: "No account has that username.",
}[error.message] ?? "The request failed. Please try again.";
}

function sessionChanged() {
try { localStorage.setItem("synixir:session-change", crypto.randomUUID()); } catch { /* Storage may be disabled. */ }
}
window.addEventListener("storage", event => {
if (event.key === "synixir:session-change") {
clearCollaboration();
window.location.reload();
}
});

document.querySelector("#auth-form").addEventListener("submit", async event => {
event.preventDefault();
const form = event.currentTarget;
const values = Object.fromEntries(new FormData(form));
const path = event.submitter.value === "register" ? "/api/accounts" : "/api/session";
const buttons = [...form.querySelectorAll("button")];
buttons.forEach(button => button.disabled = true);
try {
await api(path, { method: "POST", body: values });
sessionChanged();
window.location.reload();
} catch (error) {
document.querySelector("#account-error").textContent = explain(error);
buttons.forEach(button => button.disabled = false);
}
});

document.querySelector("#sign-out").addEventListener("click", async () => {
if (hasUnsavedChanges() && !window.confirm("Sign out and discard unsaved changes? Copy your draft first if you need it.")) return;
try {
await api("/api/session", { method: "DELETE" });
clearCollaboration();
sessionChanged();
window.location.reload();
} catch (error) { document.querySelector("#account-error").textContent = explain(error); }
});

document.querySelector("#create-room-form").addEventListener("submit", async event => {
event.preventDefault();
try {
const { data } = await api("/api/rooms", { method: "POST", body: Object.fromEntries(new FormData(event.currentTarget)) });
window.location.href = `${pagePath}?room=${encodeURIComponent(data.id)}`;
} catch (error) { document.querySelector("#account-error").textContent = explain(error); }
});

async function boot() {
try {
const { user } = await api("/api/session");
document.querySelector("#auth-panel").hidden = Boolean(user);
document.querySelector("#account-bar").hidden = !user;
if (!user) return;
document.querySelector("#account-name").textContent = user.username;
if (roomId) {
document.querySelector("#collaboration").hidden = false;
startCollaboration(user);
} else {
document.querySelector("#rooms-panel").hidden = false;
const { data } = await api("/api/rooms");
document.querySelector("#room-list").replaceChildren(...data.map(room => {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = `${pagePath}?room=${encodeURIComponent(room.id)}`;
link.textContent = `${room.id} · ${room.role}`;
item.append(link);
return item;
}));
}
} catch (error) { document.querySelector("#account-error").textContent = explain(error); }
}
document.querySelectorAll("[data-room-home]").forEach(link => { link.href = pagePath; });
if (roomId) document.querySelectorAll("#other-example, [data-example-link]").forEach(link => {
link.href += `?room=${encodeURIComponent(roomId)}`;
});
void boot();

function startCollaboration(user) {
document.querySelector("#room").value = roomId;
document.querySelector("#document-title").textContent = roomId;

const room = new SynixirRoom({ roomId, userId: user.id });
const status = document.querySelector("#status");
const connection = document.querySelector("#connection");
const stopParticipants = showParticipants(room, user.username, presenceOptions);
const destroySurface = createSurface(room);
let unsaved = false;
const saveErrors = {
storage_quota: "This room has reached its storage limit. Keep this tab open or copy your draft, and ask an operator for more space before retrying.",
message_too_large: "This update exceeds the transfer limit. Keep this tab open and reduce the size of your changes before retrying.",
rate_limited: "Too many updates at once. Wait a moment, then disconnect and connect to retry. Keep this tab open.",
invalid_message: "The server rejected this update. Keep this tab open so your draft remains available.",
timeout: "The save acknowledgement did not arrive. Disconnect and connect again to confirm your edits. Keep this tab open.",
};
function renderSave(value, reason) {
const saveStatus = document.querySelector("#save-status");
saveStatus.textContent = value;
saveStatus.dataset.state = value === "Saved" ? "saved" : value === "Save failed" ? "failed" : "unsaved";
unsaved = ["Saving", "Unsaved changes", "Save failed"].includes(value);
document.querySelector("#save-help").textContent = value === "Save failed"
? saveErrors[reason] ?? "Save could not be confirmed. Disconnect and connect again to retry. Keep this tab open."
: unsaved ? "Keep this tab open until Saved appears. Offline edits stay in this tab until you reconnect."
: "Saved edits stay in this room after everyone leaves.";
}
const saveLabels = { "not-saved": "Not saved", saving: "Saving", saved: "Saved",
unsaved: "Unsaved changes", failed: "Save failed", "view-only": "View only" };
const connectionLabels = { connected: "Connected", connecting: "Connecting",
reconnecting: "Reconnecting", disconnected: "Disconnected" };
const connectionErrors = {
node_channel_quota: "The server has reached its connection limit. Keep your draft and try again later.",
room_channel_quota: "This room has reached its participant limit. Keep your draft and try again later.",
account_channel_quota: "Too many room tabs are open for this account. Disconnect another tab before retrying.",
document_quota: "The server has reached its active document limit. Keep your draft and try again later.",
};
let disposed = false;
let previousRole;
let checkedRevocation = false;
let stopState = () => {};
stopState = room.subscribe(state => {
if (disposed) return;
renderSave(saveLabels[state.saveStatus], state.saveError);
unsaved = state.hasUnsavedChanges;
destroySurface.setReadOnly(state.readOnly);
document.querySelector("#role-label").textContent = state.role ?? "";
document.querySelector("#members-panel").hidden = state.role !== "owner";
if (state.role === "owner" && previousRole !== state.role) void loadMembers();
previousRole = state.role;
const frozen = ["access_changed", "account_changed"].includes(state.error?.code);
const active = ["connecting", "connected", "reconnecting"].includes(state.connection);
connection.textContent = frozen ? "Reload" : active ? "Disconnect" : "Connect";
connection.disabled = false;
status.textContent = connectionLabels[state.connection] ??
(frozen ? "Access changed. Reload to check permissions."
: state.error?.code === "unauthorized" ? "Access expired or denied"
: state.error?.code === "timeout" ? "Connection timed out"
: connectionErrors[state.error?.code] ?? "Document sync failed. Keep this tab open.");
status.dataset.state = state.connection === "error" ? "error"
: state.connection === "connected" ? "connected" : active ? "connecting" : "disconnected";
if (state.error?.code === "account_changed") {
clearCollaboration();
window.location.reload();
} else if (frozen && !checkedRevocation) {
checkedRevocation = true;
void checkRevokedSession();
}
});

// Connection errors are rendered by the subscription. Network failures also
// recover automatically; the button permits explicit disconnect/retry.
function connect() { void room.connect().catch(() => {}); }
connection.addEventListener("click", () => {
if (["access_changed", "account_changed"].includes(room.state.error?.code)) return window.location.reload();
if (["connecting", "connected", "reconnecting"].includes(room.state.connection)) void room.disconnect();
else connect();
});

function dispose() {
if (disposed) return;
disposed = true;
stopState();
stopParticipants();
destroySurface();
void room.destroy();
}
window.addEventListener("pagehide", dispose);
clearCollaboration = () => {
unsaved = false;
dispose();
document.querySelector("#editor").replaceChildren();
document.querySelector("#participants").replaceChildren();
document.querySelector("#collaboration").hidden = true;
};
hasUnsavedChanges = () => unsaved;

async function checkRevokedSession() {
try {
const session = await api("/api/session");
if (session.user?.id !== user.id) {
clearCollaboration();
window.location.reload();
}
} catch { /* Keep the revoked draft available to copy. */ }
}

async function loadMembers() {
try {
const { data } = await api(`/api/rooms/${encodeURIComponent(roomId)}/members`);
document.querySelector("#members-list").replaceChildren(...data.map(member => {
const item = document.createElement("li");
const label = document.createElement("span");
label.textContent = member.username;
const select = document.createElement("select");
select.setAttribute("aria-label", `Role for ${member.username}`);
for (const role of ["owner", "editor", "viewer"]) {
const option = document.createElement("option");
option.value = role;
option.textContent = role;
select.append(option);
}
select.value = member.role;
select.addEventListener("change", () => changeMember(member.username, select.value));
const remove = document.createElement("button");
remove.textContent = "Remove";
remove.setAttribute("aria-label", `Remove ${member.username}`);
remove.addEventListener("click", () => changeMember(member.username, null));
item.append(label, select, remove);
return item;
}));
} catch (error) { document.querySelector("#member-error").textContent = explain(error); }
}
async function changeMember(username, nextRole) {
try {
await api(`/api/rooms/${encodeURIComponent(roomId)}/members/${encodeURIComponent(username)}`, {
method: nextRole ? "PUT" : "DELETE", body: nextRole ? { role: nextRole } : undefined,
});
document.querySelector("#member-error").textContent = "";
await loadMembers();
} catch (error) { document.querySelector("#member-error").textContent = explain(error); }
}
document.querySelector("#member-form").addEventListener("submit", event => {
event.preventDefault();
const values = new FormData(event.currentTarget);
void changeMember(values.get("username"), values.get("role"));
});

window.addEventListener("beforeunload", (event) => {
if (unsaved) {
event.preventDefault();
event.returnValue = "";
}
});

// A page restored from the back/forward cache needs a fresh connection and editor.
window.addEventListener("pageshow", (event) => {
if (event.persisted) window.location.reload();
});

connect();
}

}
101 changes: 101 additions & 0 deletions examples/collaboration/flowchart.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Synixir · Collaborative Flowchart</title>
<meta name="description" content="Build workflows together with connected nodes, branching paths, and live cursors." />
<link rel="icon" href="data:," />
</head>
<body class="whiteboard-page flowchart-page">
<main>
<header class="masthead">
<a class="brand" href="/flowchart.html" aria-label="Synixir home"><span aria-hidden="true">s/</span> Synixir</a>
<nav class="example-links" aria-label="Examples"><a id="other-example" class="demo-label" href="/">Try the text editor</a><a data-example-link class="demo-label" href="/kanban.html">Try the Kanban board</a><a data-example-link class="demo-label" href="/rich-text.html">Try rich text</a><a data-example-link class="demo-label" href="/multiplayer-form.html">Try the multiplayer form</a><a data-example-link class="demo-label" href="/whiteboard.html">Try the whiteboard</a><a data-example-link class="demo-label" href="/table.html">Try the collaborative table</a></nav>
</header>

<section id="auth-panel" class="account-panel" aria-labelledby="sign-in-title" hidden>
<h1 id="sign-in-title">Map out the next step together</h1>
<p>Sign in or create an account. Usernames are public account handles.</p>
<form id="auth-form">
<label for="account-username">Username</label>
<input id="account-username" name="username" autocomplete="username" minlength="3" maxlength="32" pattern="[a-zA-Z0-9][a-zA-Z0-9_-]{2,31}" required />
<label for="account-password">Password</label>
<input id="account-password" name="password" type="password" autocomplete="current-password" minlength="15" maxlength="128" required />
<p class="note">Use at least 15 characters. Usernames use letters, numbers, underscores or hyphens.</p>
<div class="actions"><button type="submit" value="login">Sign in</button><button type="submit" value="register" class="subtle">Create account</button></div>
</form>
</section>
<p id="account-error" role="alert"></p>
<div id="account-bar" class="account-bar" hidden><span>Signed in as <strong id="account-name"></strong></span><button id="sign-out" class="subtle">Sign out</button></div>
<section id="rooms-panel" class="account-panel" hidden>
<h1>Your flowcharts</h1><ul id="room-list"></ul>
<form id="create-room-form"><label for="new-room">New room ID</label><input id="new-room" name="room_id" maxlength="128" pattern="[A-Za-z0-9][A-Za-z0-9_\-]*" required /><button>Create room</button></form>
</section>
<div id="collaboration" hidden>
<div class="workspace-heading">
<div>
<p class="eyebrow">MAP THE PROCESS</p>
<h1>Flowchart builder</h1>
<p class="intro">Connect the steps. Work through the possibilities.</p>
</div>
<div class="connection-controls">
<span id="status" role="status" data-state="connecting">Connecting</span>
<button id="connection" type="button" class="subtle">Disconnect</button>
</div>
</div>

<div class="workspace">
<section class="document-panel" aria-label="Flowchart builder">
<div class="editor-toolbar">
<span class="document-title" id="document-title">demo</span>
<div class="history-controls" role="group" aria-label="Edit history">
<button id="undo" type="button" disabled>Undo</button>
<button id="redo" type="button" disabled>Redo</button>
</div>
</div>
<div id="editor"></div>
<p id="board-announcement" class="sr-only" role="status"></p>
<div class="editor-footer">
<span id="save-status" aria-live="polite">Not saved</span>
<span>Drag nodes · Connect steps · Arrow keys to move</span>
</div>
</section>

<aside aria-label="Room details">
<section class="room-panel">
<h2>Room</h2>
<form method="get" id="room-form">
<label for="room">Room ID</label>
<input id="room" name="room" value="demo" maxlength="128" pattern="[A-Za-z0-9][A-Za-z0-9_\-]*" required />
<button type="submit">Open room</button>
</form>
<p class="note">An owner must grant access before someone can open this room. <a data-room-home href="/flowchart.html">All flowcharts</a></p>
</section>
<section class="people-panel" aria-labelledby="people-heading">
<div class="section-heading">
<h2 id="people-heading">In this room</h2>
<span id="participant-count" aria-live="polite">Offline</span>
</div>
<ul id="participants" aria-label="Participants"></ul>
<p class="note">You appear as <strong id="your-name"></strong>. Names and cursors are participant-supplied presence.</p>
</section>
<section id="members-panel" hidden><h2>Manage access</h2><ul id="members-list"></ul>
<form id="member-form"><label for="member-username">Account username</label><input id="member-username" name="username" required maxlength="32" />
<label for="member-role">Role</label><select id="member-role" name="role"><option value="editor">Editor</option><option value="viewer">Viewer</option><option value="owner">Owner</option></select><button>Grant access</button></form>
<p id="member-error" role="alert"></p>
</section>
<p id="editor-help" class="note">Select a node to edit it or connect it to another step. Use arrow keys to move it, or Shift + arrows for larger steps. Undo affects your own changes.</p>
<p class="note"><a id="open-peer" target="_blank" rel="noopener">Open this flowchart in another tab</a> to try collaboration.</p>
</aside>
</div>

<footer class="page-footer">
<p id="save-help">Saved edits stay in this room after everyone leaves.</p>
<p>Your role: <span id="role-label"></span></p>
</footer>
</div>
</main>
<script type="module" src="/flowchart.js"></script>
</body>
</html>
6 changes: 6 additions & 0 deletions examples/collaboration/flowchart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startExample } from "./example-shell.js";
import { createFlowchart } from "./flowchart/canvas.js";
import "./whiteboard/canvas.css";
import "./flowchart/canvas.css";

startExample({ createSurface: createFlowchart, pagePath: "/flowchart.html", presenceOptions: { avatars: true } });
Loading
Loading