diff --git a/README.md b/README.md index b10f029..c3d10d4 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ document updates in PostgreSQL, and shares live presence between participants. Use the JavaScript SDK to connect your own application, or try the included CodeMirror editor and shared settings examples. +Six more examples cover Kanban boards, whiteboards, rich-text editing, multiplayer +forms, flowchart builders, and collaborative tables. They share room permissions, +participant presence, undo, and recovery of saved state after a server crash. + - Durable document updates, snapshots, compaction, and recovery after a server crash. - Live cursors and participant presence, with reconnect support and local undo. - Accounts, expiring sessions, and owner/editor/viewer room permissions. @@ -41,7 +45,7 @@ python3 scripts/staging.py up ``` Open [https://localhost:8443](https://localhost:8443), accept the local self-signed -certificate, and create an account and room. The release includes both browser +certificate, and create an account and room. The release includes all browser examples and uses its own PostgreSQL volume and private Prometheus collector. Docker builds Elixir, Erlang, and the frontend inside the image. @@ -176,6 +180,264 @@ access callbacks. `/sdk.html?room=` provides a second small consumer that synchronizes a title in a Y.Map without CodeMirror. It uses the same signed-in account and room membership as the editor. +## Try the Kanban example + +Open [127.0.0.1:5173/kanban.html](http://127.0.0.1:5173/kanban.html) with the same +development servers running. Sign in and create a board, or open an existing room. +The text editor and board link to each other and share account and room access. +The production build also includes `/kanban.html`. + +- Add cards to Backlog, In progress, or Done. Open a card to edit its title, + description, color, and status. Changes save as you type. +- Drag cards between columns, or use the status selector with a keyboard or on + a touch device. Moved cards go to the end of the destination column. +- Open the same board in two tabs to see changes and card activity. To collaborate + with another account, grant its username access under **Manage access**. +- Undo and redo affect this tab's local changes. Deleted cards can be restored + with Undo. Viewers can inspect cards and publish presence, but cannot edit them. +- Offline changes remain in the open tab. Reconnect and wait for **Saved** before + closing it. Saved boards recover through the existing PostgreSQL document log. + +The [board model](examples/collaboration/kanban/model.js) stores each card as a +nested Y.Map under `kanban:cards:v1`. Column and order are a single placement value, +so simultaneous moves converge to one location without duplicating a card. +Edits to different fields merge. Concurrent edits to the same title, description, +color, or placement resolve through Y.Map's conflict rules to one value; these +fields do not provide character-level text merging. A deletion wins over a +concurrent edit to that card. Ordering ties use the card ID. This first example +has fixed columns and does not support reordering within a column. + +The [shared example shell](examples/collaboration/example-shell.js) owns account, +room, connection, permission, and cleanup UI for all examples. Each editing +surface consumes the public SDK. Kanban's data uses a separate shared type, so +opening a text room as a board does not change its text content. + +`npm test` includes model checks for concurrent creation, moves, deletion, and +local undo. The browser suite includes Kanban sync, presence, dragging, offline +recovery, server restart, permissions, room navigation, and mobile layout. + +Run only the Kanban browser checks with: + +```sh +npm test --workspace synixir-collaboration-example -- kanban.spec.js +``` + +## Try the whiteboard example + +Open [127.0.0.1:5173/whiteboard.html](http://127.0.0.1:5173/whiteboard.html) with the +same development servers running. Sign in and create a room, or open an existing +one. Links between the examples retain the room. +Each example stores its data separately inside that room's Yjs document. + +- Add sticky notes, rectangles, and ellipses. Select an object to edit its text, + color, width, or height, bring it to the front, or delete it. +- Drag objects with a mouse or touch. Arrow keys move the selected object one + canvas pixel; Shift + arrows move it ten. Escape cancels an active drag. +- Scroll around the fixed 1600 × 1000 canvas. Zoom controls range from 50% to + 150%; zoom and scroll are local to each tab. +- Teammates see cursors, selection outlines, and live movement previews. Presence + labels are client-supplied and do not establish identity or lock objects. +- Remote cursors and drag previews interpolate between presence updates with + an 80 ms animation. Local dragging updates on the next animation frame without + interpolation. Reduced-motion preferences disable interpolation. Cursor updates + reuse existing activity elements and do not rebuild the participant list or + edit controls. Presence still sends at most one scheduled movement update per + 60 ms, with immediate final positions and activity changes. +- A completed drag saves one position change and creates one undo step. Movement + previews use temporary awareness state. Cancelling a drag or closing its tab + before releasing it leaves the saved position unchanged. +- Undo and redo affect this tab's changes. Viewers can select objects and publish + cursors, but cannot change content, geometry, or layer order. + +The [whiteboard model](examples/collaboration/whiteboard/model.js) uses nested +Y.Maps under `whiteboard:objects:v1`. Position and size are separate atomic values, +so moving an object does not overwrite a teammate's text or color edits. +Simultaneous edits to the same property resolve to one value through Y.Map's +conflict rules. Text does not merge at the character level. Deleting an object +wins over a concurrent edit to that object. The UI bounds objects to the canvas. + +Offline edits remain in the open tab until reconnecting. Wait for **Saved** before +closing it. The production build includes `/whiteboard.html` and uses the same +room permissions, PostgreSQL persistence, and recovery flow as the other examples. + +`npm test` includes whiteboard concurrency and undo checks. Run its browser +checks separately from `mix test` with: + +```sh +npm test --workspace synixir-collaboration-example -- whiteboard.spec.js +``` + +## Try the rich-text example + +Open [127.0.0.1:5173/rich-text.html](http://127.0.0.1:5173/rich-text.html) with the +same development servers running. Sign in and create a room, or open an existing +one. The plain-text editor remains available as its own example. + +- Write paragraphs, three levels of headings, bullet and numbered lists, and quotes. +- Apply bold, italic, underline, and links to selected text. The link dialog accepts + HTTP and HTTPS URLs and can update or remove an existing link. +- See collaborators' carets and selected text in their participant colors. Undo + and redo affect edits made in this tab, leaving other participants' edits intact. +- Viewers can read and select text. Editing, formatting, and history commands are + disabled for viewers and when access is revoked. +- Edits made while disconnected remain in the open tab and merge when it reconnects. + Wait for **Saved** before closing the tab. Saved formatting and content recover + after everyone leaves or the server restarts. + +The [rich-text editor](examples/collaboration/rich-text/editor.js) uses Tiptap with +its [Yjs collaboration extension](https://tiptap.dev/docs/editor/extensions/functionality/collaboration). +It binds to the `rich-text:content:v1` Y.XmlFragment in the SDK's document and uses +Synixir for synchronization and persistence. It does not require a separate +collaboration service. Tiptap's ordinary history is disabled in favor of Yjs undo. +A small awareness adapter uses `richTextCursor` so plain-text and rich-text cursor +positions never get mixed when both examples are open in the same room. + +This is a text document example, with no images, file uploads, comments, or version +history. Each example has separate content within the room, while access and +storage quotas apply to the whole room. + +Run its browser checks separately from `mix test` with: + +```sh +npm test --workspace synixir-collaboration-example -- rich-text.spec.js +``` + +## Try the multiplayer form example + +Open [127.0.0.1:5173/multiplayer-form.html](http://127.0.0.1:5173/multiplayer-form.html) +with the same development servers running. Create a room or open an existing one, +then open the form in another tab to collaborate on a project brief. + +- Edit the project name, goal, and audience together. Text changes merge at the + character level, including changes made to the same field while disconnected. +- Choose a team, priority, target date, and launch channels. Separate fields and + separate checkboxes merge independently. Concurrent changes to the same choice + resolve to one value through Y.Map's conflict rules. +- See who is editing or viewing each field, with participant-colored outlines + and shared text carets. These indicators do not lock fields. Presence clears + when focus leaves the form, the window loses focus, or the peer disconnects. +- Track completion of the four required fields. Validation shows missing values + and text length limits without discarding the shared draft. +- **Review brief** opens a live preview once required fields are complete. The + preview updates when teammates edit. This example does not submit the form to + an external service or record a finalized submission. +- Undo and redo affect this tab's edits across text and choice fields. Viewers + can read, select text, and review the draft; they cannot edit or undo it. + +The [form model](examples/collaboration/multiplayer-form/model.js) stores text in +separate top-level Y.Text fields named `multiplayer-form::v1`, with choices +in `multiplayer-form:properties:v1`. The text controls reuse the existing +CodeMirror/Yjs binding for shared selections, composition input, and undo. +The `multiplayerForm` awareness field identifies the active form field. All data +is separate from the other examples, with the same room permissions and storage. + +Offline changes remain in the open tab until reconnecting. Wait for **Saved** +before closing it. Saved fields recover after all tabs close or the server restarts. + +`npm test` includes form merge and validation checks. Run the browser checks +separately from `mix test` with: + +```sh +npm test --workspace synixir-collaboration-example -- multiplayer-form.spec.js +``` + +## Try the flowchart builder + +Open [127.0.0.1:5173/flowchart.html](http://127.0.0.1:5173/flowchart.html) with the +same development servers running. Create a room or use an existing one, then open +another tab to build a workflow together. + +- Add process, decision, and start/end nodes. Drag nodes, move them with arrow + keys, and edit their labels, colors, and sizes. Zoom and scroll to explore. +- Select a source node and choose a destination in **Connect to**, or use + **Pick on canvas** and click another node. Label branches such as Yes and No. +- Select an arrow or its entry in **Connections** to edit its label or delete it. + Deleting a node removes its attached arrows. One undo restores both. +- Live cursors, collaborator selections, and drag previews show where teammates + are working. Arrows follow the displayed node positions during dragging. + Cursor labels use participant colors with white text, matching the whiteboard. +- Undo and redo affect this tab's changes. Viewers can explore and select nodes + and connections, while edits and history controls require editing permission. + +The [flowchart model](examples/collaboration/flowchart/model.js) stores nested +Y.Maps under `flowchart:nodes:v1` and `flowchart:edges:v1`. Fields merge independently; +concurrent edits to the same field resolve to one value using Y.Map's conflict +rules. Connections use a key derived from their source and target so concurrent +creation of the same arrow converges to one connection. Self-connections are +excluded; reverse connections and multiple outgoing branches are supported. +Arrows with deleted endpoints stay hidden, including ones created offline during +a deletion. Undo can restore their endpoints. + +The `flowchart` awareness field carries temporary cursor, selection, and drag +positions. The canvas reuses the whiteboard's motion helper and commits one +position change per completed drag. Other examples keep separate data and cursor +fields within the same room. This is a diagram editor; it does not execute workflows +or automatically route arrows around intervening nodes. + +Offline changes remain in the open tab until reconnecting. Wait for **Saved** +before closing it. Saved nodes and arrows recover after a server restart. + +`npm test` includes connection merge, deletion, and undo checks. Run the browser +checks separately from `mix test` with: + +```sh +npm test --workspace synixir-collaboration-example -- flowchart.spec.js +``` + +## Try the collaborative table + +Open [127.0.0.1:5173/table.html](http://127.0.0.1:5173/table.html) with the same +development servers running. Create a room or use an existing one, then open +another tab to work on the same table. + +- New tables show three empty rows and Task, Owner, Status, and Due date columns. + These are all text cells, so you can rename columns for a different use case. +- Click a cell to select it. Press Enter, double-click, or choose **Edit cell** to + edit. Typing on a selected cell replaces its contents. Arrow keys move between + selected cells; within an editor they move the caret. Tab moves to the next + cell, Enter moves down, and Escape returns to cell selection. +- Collaborator-colored outlines show active cells with editing/viewing labels. + When two people edit the same cell, they also see each other's text carets and + selections. Text edits merge at the character level, including offline edits. +- Add rows and columns, rename a selected column, clear cells, or delete rows + and columns. Undo restores deleted content and affects this tab's changes. +- Paste plain tab-separated text into a selected cell to fill a rectangle. The + table adds rows and columns as needed, and one undo reverses the entire paste. + Multi-cell paste also works while editing a cell. Paste uses literal text; + quoted CSV parsing and spreadsheet formulas are not included. +- Viewers can select and copy cells, but cannot change data, paste, or use undo. + On mobile, the table scrolls horizontally while the row numbers stay visible. + +The [table model](examples/collaboration/table/model.js) uses separate Y.Maps for +row order, column order, column names, and deletion markers. Default row and column +IDs are stable and require no initialization writes. Each cell uses a top-level +Y.Text named `collaborative-table:cell:[row-id,column-id]:v1`, with the IDs encoded +as a JSON array. Two users can edit an untouched cell without competing to create +its shared text. Concurrent rows and columns sort by order, then by stable ID. +Column names use Y.Map conflict resolution, while deletion markers hide rows and +columns independently of concurrent renames or text edits. + +Deleted cells remain in the document so undo can restore them. The UI allows +adding up to 100 rows and 12 columns, and pasting at most 50,000 characters at a +time. These are example interaction limits, not server-enforced quotas; +concurrent additions can exceed them. Only the active cell mounts a CodeMirror +editor. Other cells keep stable DOM elements and display shared text updates. +The `collaborativeTable` awareness field carries the selected row and column; +text caret positions use the existing CodeMirror/Yjs awareness binding. + +Data is separate from the other examples in the same room. Offline edits remain +in the open tab until reconnecting. Wait for **Saved** before closing it. Saved +cells and table structure recover after a server restart. + +`npm test` includes table concurrency, deletion, and paste/undo checks. Run the +browser checks separately from `mix test` with: + +```sh +npm test --workspace synixir-collaboration-example -- table.spec.js +``` + +## Editor presence and connection states + Each account has a stable username; each editor visit gets a cursor color. The participant list counts connected editor visits, including multiple tabs for one account. Names and cursor positions are @@ -568,7 +830,7 @@ only that room's data. PostgreSQL is required. Test partitions append `MIX_TEST_PARTITION` to the test database name. Install JavaScript dependencies from the root workspace lockfile, then check the -SDK and both browser examples: +SDK and browser examples: ```sh npm ci diff --git a/examples/collaboration/example-shell.js b/examples/collaboration/example-shell.js new file mode 100644 index 0000000..4caa8ab --- /dev/null +++ b/examples/collaboration/example-shell.js @@ -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(); + } + +} diff --git a/examples/collaboration/flowchart.html b/examples/collaboration/flowchart.html new file mode 100644 index 0000000..6a72398 --- /dev/null +++ b/examples/collaboration/flowchart.html @@ -0,0 +1,101 @@ + + + + + + Synixir · Collaborative Flowchart + + + + +
+
+ Synixir + +
+ + + + + + +
+ + + diff --git a/examples/collaboration/flowchart.js b/examples/collaboration/flowchart.js new file mode 100644 index 0000000..14ed6f5 --- /dev/null +++ b/examples/collaboration/flowchart.js @@ -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 } }); diff --git a/examples/collaboration/flowchart/canvas.css b/examples/collaboration/flowchart/canvas.css new file mode 100644 index 0000000..5c3b9fd --- /dev/null +++ b/examples/collaboration/flowchart/canvas.css @@ -0,0 +1,33 @@ +.flowchart-page .flow-node { background: transparent; border: 0; overflow: visible; font-size: 17px; padding: 16px; } +.flowchart-page .flow-node::before { content: ""; position: absolute; inset: 0; background: inherit; border: 1.5px solid #819b8d; border-radius: 10px; z-index: -1; box-shadow: 0 3px 8px #28332c12; } +.flowchart-page .flow-node { isolation: isolate; background: none !important; } +.flowchart-page .flow-node.color-blue::before { background: #d6e7ff; } +.flowchart-page .flow-node.color-yellow::before { background: #fff0a4; } +.flowchart-page .flow-node.color-mint::before { background: #d5eedd; } +.flowchart-page .flow-node.color-rose::before { background: #f9dbe3; } +.flowchart-page .flow-node.color-white::before { background: #fff; } +.flowchart-page .object-terminal::before { border-radius: 999px; } +.flowchart-page .object-decision::before { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); border-radius: 0; } +.flowchart-page .object-decision { padding: 30px 45px; } +.flow-node > span { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; } +.flowchart-page .object-layer { pointer-events: none; z-index: 1; } +.flowchart-page .flow-node { pointer-events: auto; } +.edge-layer { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } +.flow-edge { fill: none; stroke: #648172; stroke-width: 2; } +.flow-edge-hit { fill: none; stroke: transparent; stroke-width: 18; cursor: pointer; } +.edge-layer .is-selected .flow-edge { stroke: #216640; stroke-width: 3; } +.flow-edge-label { font: 13px system-ui, sans-serif; fill: #375745; text-anchor: middle; paint-order: stroke; stroke: #fafcfb; stroke-width: 6; stroke-linejoin: round; cursor: pointer; } +.flowchart-page .is-connecting .flow-node { cursor: crosshair; } +.flowchart-page .is-connecting .flow-node:hover { outline: 3px dashed #285c43; outline-offset: 4px; } +.connect-fields { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; border-top: 1px solid #d6e0d9; margin-top: 16px; padding-top: 16px; } +.connect-fields > div { flex: 1 1 170px; min-width: 0; } +.connect-fields input, .connect-fields select { width: 100%; } +#edge-label { max-width: 450px; } +.flow-connections { padding: 18px; border-top: 1px solid #d6e0d9; } +.flow-connections h2 { margin: 0 0 8px; } +.flow-connections p { color: #52695b; font-size: 14px; margin: 0; } +#connection-list { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 0; } +#connection-list li { max-width: 100%; } +#connection-list button { text-align: left; overflow-wrap: anywhere; } +#connection-list [aria-pressed="true"] { background: #deeee4; border-color: #285c43; } +.flowchart-page #delete-connection { color: #9d3838; } diff --git a/examples/collaboration/flowchart/canvas.js b/examples/collaboration/flowchart/canvas.js new file mode 100644 index 0000000..d23ac18 --- /dev/null +++ b/examples/collaboration/flowchart/canvas.js @@ -0,0 +1,526 @@ +import { createFlowchartModel, canvasSize, colors } from "./model.js"; +import { connectionPath } from "./geometry.js"; +import { createMotion } from "../whiteboard/motion.js"; + +export function createFlowchart(room) { + const kindLabel = kind => ({ process: "Process", decision: "Decision", terminal: "Start / End" })[kind]; + const root = document.querySelector("#editor"); + document.querySelector("#open-peer").href = window.location.href; + const model = createFlowchartModel(room.doc); + const painted = new Map(); + const edgeNodes = new Map(); + let edgeFrame; + const motion = createMotion({ onPaint(node, point) { + if (node.dataset.objectId) { + painted.set(node.dataset.objectId, point); + // Paint in the same frame as the node so arrows stay attached throughout + // interpolation, even when no new presence packet has arrived. + paintEdges(); + } + } }); + let items = model.list(); + let activityFrame; + const events = new AbortController(); + const on = (target, event, handler) => target.addEventListener(event, handler, { signal: events.signal }); + const nodes = new Map(); + const peerNodes = new Map(); + let readOnly = true; + let selectedId = null; + let selectedEdgeId = null; + let connecting = false; + let drag = null; + let cursor = null; + let zoom = 1; + let presenceTimer; + let lastPresenceAt = -Infinity; + let disposed = false; + const undo = document.querySelector("#undo"); + const redo = document.querySelector("#redo"); + const announce = text => { document.querySelector("#board-announcement").textContent = text; }; + root.innerHTML = ` +
+
+
100%
+
+
+
Every process starts somewhere.Add a step, then connect it to the next one.
+
+
0 nodesScroll to explore · Select a node to edit or connect
+
+

Select a node to edit it, or an arrow to label a branch.

+ + +
+

Connections

Connect two nodes to show what happens next.

    `; + const viewport = root.querySelector("#whiteboard-viewport"); + const sizer = root.querySelector(".canvas-sizer"); + const canvas = root.querySelector(".whiteboard-canvas"); + const layer = root.querySelector(".object-layer"); + const presenceLayer = root.querySelector(".presence-layer"); + const edgeLayer = root.querySelector(".edges"); + const targetInput = root.querySelector("#connection-target"); + const branchInput = root.querySelector("#connection-label"); + const edgeLabel = root.querySelector("#edge-label"); + const connectionList = root.querySelector("#connection-list"); + const svg = tag => document.createElementNS("http://www.w3.org/2000/svg", tag); + const textInput = root.querySelector("#object-text"); + const colorInput = root.querySelector("#object-color"); + const widthInput = root.querySelector("#object-width"); + const heightInput = root.querySelector("#object-height"); + canvas.style.width = `${canvasSize.width}px`; + canvas.style.height = `${canvasSize.height}px`; + for (const color of colors) { + const option = document.createElement("option"); + option.value = color; + option.textContent = color[0].toUpperCase() + color.slice(1); + colorInput.append(option); + } + const selected = () => items.find(item => item.id === selectedId); + const validPoint = point => point && Number.isFinite(point.x) && Number.isFinite(point.y) && + point.x >= 0 && point.y >= 0 && point.x <= canvasSize.width && point.y <= canvasSize.height; + const worldPoint = event => { + const rect = canvas.getBoundingClientRect(); + return { x: (event.clientX - rect.left) / zoom, y: (event.clientY - rect.top) / zoom }; + }; + function publish() { + clearTimeout(presenceTimer); + presenceTimer = undefined; + if (disposed) return; + lastPresenceAt = performance.now(); + room.awareness.setLocalStateField("flowchart", { cursor, selectedId, + drag: drag?.moved ? { id: drag.id, ...drag.position } : null }); + } + function schedulePresence() { + if (presenceTimer !== undefined) return; + const remaining = 60 - (performance.now() - lastPresenceAt); + if (remaining <= 0) publish(); + else presenceTimer = setTimeout(publish, remaining); + } + function select(id) { + connecting = false; + selectedEdgeId = null; + selectedId = id; + model.history.stopCapturing(); + render(); + publish(); + } + function cancelDrag() { + const previous = drag; + drag = null; + if (previous && viewport.hasPointerCapture(previous.pointerId)) viewport.releasePointerCapture(previous.pointerId); + publish(); + scheduleActivity(); + } + function setZoom(value) { + cancelDrag(); + const center = { x: (viewport.scrollLeft + viewport.clientWidth / 2) / zoom, + y: (viewport.scrollTop + viewport.clientHeight / 2) / zoom }; + zoom = Math.min(1.5, Math.max(0.5, value)); + canvas.style.transform = `scale(${zoom})`; + sizer.style.width = `${canvasSize.width * zoom}px`; + sizer.style.height = `${canvasSize.height * zoom}px`; + viewport.scrollLeft = center.x * zoom - viewport.clientWidth / 2; + viewport.scrollTop = center.y * zoom - viewport.clientHeight / 2; + root.querySelector("#zoom-level").textContent = `${Math.round(zoom * 100)}%`; + root.querySelector("#zoom-out").disabled = zoom <= 0.5; + root.querySelector("#zoom-in").disabled = zoom >= 1.5; + cursor = null; + publish(); + } + on(root.querySelector("#zoom-in"), "click", () => setZoom(zoom + 0.25)); + on(root.querySelector("#zoom-out"), "click", () => setZoom(zoom - 0.25)); + on(root.querySelector("#zoom-reset"), "click", () => setZoom(1)); + root.querySelectorAll("[data-add]").forEach(button => on(button, "click", () => { + if (readOnly) return; + cancelDrag(); + const columns = Math.max(1, Math.floor(viewport.clientWidth / zoom / 260)); + const index = model.list().length; + const id = model.add(button.dataset.add, { x: viewport.scrollLeft / zoom + 50 + index % columns * 260, + y: viewport.scrollTop / zoom + 60 + Math.floor(index / columns) % 3 * 230 }); + select(id); + textInput.focus(); + textInput.select(); + announce("Node added."); + })); + on(viewport, "pointerdown", event => { + if (event.button !== 0 || !event.isPrimary) return; + const edge = event.target.closest("[data-edge-id]"); + if (edge) { selectEdge(edge.dataset.edgeId); return; } + const node = event.target.closest(".whiteboard-object"); + if (connecting && node && !readOnly) { + event.preventDefault(); + completeConnection(node.dataset.objectId); + return; + } + if (!node) { select(null); return; } + event.preventDefault(); + node.focus({ preventScroll: true }); + select(node.dataset.objectId); + const item = selected(); + if (readOnly || !item) return; + const point = worldPoint(event); + drag = { id: item.id, pointerId: event.pointerId, start: point, original: item.position, + position: item.position, size: item.size, moved: false }; + viewport.setPointerCapture(event.pointerId); + }); + on(viewport, "pointermove", event => { + const point = worldPoint(event); + cursor = validPoint(point) ? point : null; + if (drag && event.pointerId === drag.pointerId && !readOnly) { + if (Math.hypot(point.x - drag.start.x, point.y - drag.start.y) > 2) drag.moved = true; + drag.position = model.boundedPosition({ x: drag.original.x + point.x - drag.start.x, + y: drag.original.y + point.y - drag.start.y }, drag.size); + scheduleActivity(); + } + schedulePresence(); + }); + on(viewport, "pointerup", event => { + if (!drag || event.pointerId !== drag.pointerId) return; + const completed = drag; + drag = null; + if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId); + if (!readOnly && completed.moved) { + model.move(completed.id, completed.position); + announce("Node moved."); + } + publish(); + scheduleActivity(); + }); + on(viewport, "pointercancel", cancelDrag); + on(viewport, "lostpointercapture", () => { if (drag) cancelDrag(); }); + on(viewport, "pointerleave", () => { cursor = null; publish(); }); + on(viewport, "scroll", () => { cursor = null; schedulePresence(); }); + on(window, "blur", () => { cursor = null; cancelDrag(); }); + on(document, "visibilitychange", () => { if (document.hidden) { cursor = null; cancelDrag(); } }); + on(viewport, "click", event => { + const node = event.target.closest(".whiteboard-object"); + if (node && event.detail === 0) { + if (connecting && !readOnly) completeConnection(node.dataset.objectId); + else select(node.dataset.objectId); + } + }); + on(root, "keydown", event => { + if (event.key === "Escape") { + if (drag) cancelDrag(); + else select(null); + viewport.focus({ preventScroll: true }); + return; + } + if (readOnly || event.isComposing) return; + if ((event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === "z") { + event.preventDefault(); + cancelDrag(); + if (event.shiftKey) model.history.redo(); else model.history.undo(); + return; + } + if (event.target !== viewport && !event.target.closest(".whiteboard-object")) return; + if (selectedEdgeId && ["Delete", "Backspace"].includes(event.key)) { + event.preventDefault(); model.disconnect(selectedEdgeId); return; + } + const item = selected(); + if (!item) return; + if (["Delete", "Backspace"].includes(event.key)) { event.preventDefault(); model.remove(item.id); return; } + const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]; + if (delta) { + event.preventDefault(); + const step = event.shiftKey ? 10 : 1; + model.move(item.id, { x: item.position.x + delta[0] * step, y: item.position.y + delta[1] * step }); + } + }); + for (const [input, field] of [[textInput, "text"], [colorInput, "color"]]) { + on(input, "focus", () => model.history.stopCapturing()); + on(input, "blur", () => model.history.stopCapturing()); + on(input, "input", () => { if (!readOnly && selectedId) model.edit(selectedId, field, input.value); }); + } + for (const input of [widthInput, heightInput]) on(input, "change", () => { + if (!readOnly && selectedId) model.resize(selectedId, { width: widthInput.valueAsNumber, height: heightInput.valueAsNumber }); + renderInspector(); + }); + on(root.querySelector("#bring-front"), "click", () => { if (!readOnly && selectedId) model.front(selectedId); }); + on(root.querySelector("#delete-object"), "click", () => { if (!readOnly && selectedId) model.remove(selectedId); }); + on(undo, "click", () => { if (!readOnly) { cancelDrag(); model.history.undo(); } }); + on(redo, "click", () => { if (!readOnly) { cancelDrag(); model.history.redo(); } }); + + function selectEdge(id) { + select(null); + selectedEdgeId = id; + render(); + } + function completeConnection(target) { + if (readOnly || !selectedId) return; + if (target === selectedId) { announce("Choose a different node."); return; } + const id = model.connect(selectedId, target, branchInput.value); + if (id) { branchInput.value = ""; selectEdge(id); announce("Nodes connected."); } + } + on(root.querySelector("#add-connection"), "click", () => completeConnection(targetInput.value)); + on(root.querySelector("#pick-target"), "click", () => { + if (readOnly) return; + connecting = !connecting; + renderInspector(); + if (connecting) { viewport.focus({ preventScroll: true }); announce("Choose the next node."); } + }); + on(edgeLabel, "focus", () => model.history.stopCapturing()); + on(edgeLabel, "blur", () => model.history.stopCapturing()); + on(edgeLabel, "input", () => { if (!readOnly && selectedEdgeId) model.label(selectedEdgeId, edgeLabel.value); }); + on(root.querySelector("#delete-connection"), "click", () => { if (!readOnly && selectedEdgeId) model.disconnect(selectedEdgeId); }); + on(connectionList, "click", event => { + const button = event.target.closest("[data-edge-id]"); + if (button) selectEdge(button.dataset.edgeId); + }); + function scheduleEdges() { + if (!disposed && edgeFrame === undefined) edgeFrame = requestAnimationFrame(paintEdges); + } + function paintEdges() { + cancelAnimationFrame(edgeFrame); + edgeFrame = undefined; + if (disposed) return; + const byId = new Map(items.map(item => [item.id, { ...item, position: painted.get(item.id) ?? item.position }])); + for (const edge of model.connections()) { + const pair = edgeNodes.get(edge.id); + if (!pair) continue; + const reverseId = JSON.stringify([edge.target, edge.source]); + const geometry = connectionPath(byId.get(edge.source), byId.get(edge.target), { reciprocal: edgeNodes.has(reverseId) }); + for (const path of [pair.path, pair.hit]) path.setAttribute("d", geometry.path); + pair.label.setAttribute("x", geometry.label.x); + pair.label.setAttribute("y", geometry.label.y - 10); + } + } + function syncConnections() { + const edges = model.connections(); + const present = new Set(edges.map(edge => edge.id)); + for (const [id, pair] of edgeNodes) if (!present.has(id)) { + pair.group.remove(); pair.button.parentElement.remove(); edgeNodes.delete(id); + } + const title = id => items.find(item => item.id === id)?.text || "Untitled"; + for (const edge of edges) { + let pair = edgeNodes.get(edge.id); + if (!pair) { + const group = svg("g"), path = svg("path"), hit = svg("path"), label = svg("text"); + group.dataset.edgeId = edge.id; + path.classList.add("flow-edge"); path.setAttribute("marker-end", "url(#flow-arrow)"); + hit.classList.add("flow-edge-hit"); label.classList.add("flow-edge-label"); + group.append(path, hit, label); edgeLayer.append(group); + const li = document.createElement("li"), button = document.createElement("button"); + button.type = "button"; button.dataset.edgeId = edge.id; button.className = "subtle"; + li.append(button); connectionList.append(li); + pair = { group, path, hit, label, button }; edgeNodes.set(edge.id, pair); + } + pair.group.classList.toggle("is-selected", selectedEdgeId === edge.id); + pair.label.textContent = edge.label; + pair.button.textContent = `${title(edge.source)} → ${title(edge.target)}${edge.label ? ` · ${edge.label}` : ""}`; + pair.button.setAttribute("aria-pressed", String(selectedEdgeId === edge.id)); + } + root.querySelector("#connections-empty").hidden = edges.length > 0; + scheduleEdges(); + } + + function peers() { + if (room.state.connection !== "connected") return []; + return [...room.awareness.getStates()].filter(([id, state]) => id !== room.awareness.clientID && state.flowchart) + .sort(([a], [b]) => a - b).map(([id, state]) => ({ id, ...state.flowchart, + name: typeof state.user?.name === "string" ? state.user.name.slice(0, 32) : "Guest", + color: /^#[0-9a-f]{6}$/i.test(state.user?.color) ? state.user.color : "#3565b0" })); + } + function scheduleActivity() { + if (!disposed && activityFrame === undefined) activityFrame = requestAnimationFrame(renderActivity); + } + function syncObjects() { + const present = new Set(items.map(item => item.id)); + for (const [id, node] of nodes) if (!present.has(id)) { + motion.forget(node); + node.remove(); + nodes.delete(id); + painted.delete(id); + } + items.forEach((item, index) => { + let node = nodes.get(item.id); + if (!node) { + node = document.createElement("button"); + node.type = "button"; + node.dataset.objectId = item.id; + nodes.set(item.id, node); + layer.append(node); + } + node.className = `whiteboard-object flow-node object-${item.kind} color-${item.color}`; + node.classList.toggle("is-selected", selectedId === item.id); + node.setAttribute("aria-pressed", String(selectedId === item.id)); + node.setAttribute("aria-label", `${kindLabel(item.kind)}: ${item.text || "Untitled"}`); + if (!node.firstChild) node.append(document.createElement("span")); + if (node.firstChild.textContent !== item.text) node.firstChild.textContent = item.text; + Object.assign(node.style, { width: `${item.size.width}px`, height: `${item.size.height}px`, zIndex: String(index) }); + }); + root.querySelector(".canvas-empty").hidden = items.length > 0; + const count = `${items.length} ${items.length === 1 ? "node" : "nodes"}`; + if (root.querySelector("#object-count").textContent !== count) root.querySelector("#object-count").textContent = count; + } + function renderActivity() { + activityFrame = undefined; + if (disposed) return; + const remote = peers(); + const displayed = new Map(); + for (const item of items) { + const node = nodes.get(item.id); + if (!node) continue; + const ownDrag = drag?.id === item.id; + const remoteDrag = remote.findLast(peer => peer.drag?.id === item.id && validPoint(peer.drag))?.drag; + const preview = ownDrag ? drag.position : remoteDrag; + const position = preview ? model.boundedPosition(preview, item.size) : item.position; + const smooth = !ownDrag && (Boolean(remoteDrag) || motion.moving(node)); + motion.move(node, position, smooth); + displayed.set(item.id, { position, smooth }); + } + const presentPeers = new Set(remote.map(peer => peer.id)); + for (const [id, pair] of peerNodes) if (!presentPeers.has(id)) { + motion.forget(pair.ring); + motion.forget(pair.pointer); + pair.ring?.remove(); + pair.pointer?.remove(); + peerNodes.delete(id); + } + for (const peer of remote) { + let pair = peerNodes.get(peer.id); + if (!pair) { pair = {}; peerNodes.set(peer.id, pair); } + const item = items.find(item => item.id === peer.selectedId); + if (item) { + if (!pair.ring) { + pair.ring = document.createElement("div"); + pair.ring.className = "remote-selection"; + pair.ring.append(document.createElement("span")); + presenceLayer.append(pair.ring); + } + const node = nodes.get(item.id); + const ring = pair.ring; + if (ring.dataset.peerSelection !== item.id) motion.forget(ring); + ring.dataset.peerSelection = item.id; + const display = displayed.get(item.id); + motion.move(ring, display.position, display.smooth); + Object.assign(ring.style, { width: node.style.width, height: node.style.height, borderColor: peer.color }); + const label = ring.firstChild; + if (label.textContent !== peer.name) label.textContent = peer.name; + label.style.background = peer.color; + } else { motion.forget(pair.ring); pair.ring?.remove(); pair.ring = null; } + if (validPoint(peer.cursor)) { + if (!pair.pointer) { + pair.pointer = document.createElement("div"); + pair.pointer.className = "remote-cursor"; + const arrow = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + arrow.setAttribute("viewBox", "0 0 28 28"); + arrow.setAttribute("aria-hidden", "true"); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", "M1.5 1.5 26.5 10.5 16 15 11 26.5Z"); + arrow.append(path); + pair.pointer.append(arrow, document.createElement("span")); + presenceLayer.append(pair.pointer); + } + const pointer = pair.pointer; + motion.move(pointer, peer.cursor); + pointer.style.color = peer.color; + const label = pointer.lastChild; + if (label.textContent !== peer.name) label.textContent = peer.name; + label.style.background = peer.color; + } else { motion.forget(pair.pointer); pair.pointer?.remove(); pair.pointer = null; } + } + } + + function renderInspector() { + const item = selected(); + root.querySelector("#selection-fields").hidden = !item; + root.querySelector("#selection-empty").hidden = Boolean(item || selectedEdgeId); + root.querySelector("#edge-fields").hidden = !selectedEdgeId; + const edge = model.connections().find(edge => edge.id === selectedEdgeId); + if (edge && edgeLabel.value !== edge.label) edgeLabel.value = edge.label; + edgeLabel.readOnly = readOnly; + root.querySelector("#delete-connection").disabled = readOnly; + root.querySelector("#pick-target").textContent = connecting ? "Cancel connection" : "Pick on canvas"; + root.querySelector("#pick-target").setAttribute("aria-pressed", String(connecting)); + viewport.classList.toggle("is-connecting", connecting); + root.querySelector("#canvas-help").textContent = connecting ? "Choose the next node · Escape to cancel" : "Scroll to explore · Select a node to edit or connect"; + if (!item) return; + const previousTarget = targetInput.value; + targetInput.replaceChildren(...items.filter(node => node.id !== item.id).map(node => { + const option = document.createElement("option"); option.value = node.id; option.textContent = node.text || kindLabel(node.kind); return option; + })); + if ([...targetInput.options].some(option => option.value === previousTarget)) targetInput.value = previousTarget; + for (const input of [targetInput, branchInput, root.querySelector("#add-connection"), root.querySelector("#pick-target")]) input.disabled = readOnly || !targetInput.options.length; + root.querySelector("#selection-title").textContent = kindLabel(item.kind); + if (textInput.value !== item.text) { + const start = textInput.selectionStart; + const end = textInput.selectionEnd; + textInput.value = item.text; + if (document.activeElement === textInput) textInput.setSelectionRange(Math.min(start, item.text.length), Math.min(end, item.text.length)); + } + colorInput.value = item.color; + widthInput.value = item.size.width; + heightInput.value = item.size.height; + textInput.readOnly = readOnly; + for (const input of [colorInput, widthInput, heightInput, root.querySelector("#bring-front"), root.querySelector("#delete-object")]) input.disabled = readOnly; + } + function renderHistory() { + undo.disabled = readOnly || !model.history.canUndo(); + redo.disabled = readOnly || !model.history.canRedo(); + } + function render() { + if (disposed) return; + items = model.list(); + if (selectedId && !items.some(item => item.id === selectedId)) { + selectedId = null; + connecting = false; + cancelDrag(); + publish(); + viewport.focus({ preventScroll: true }); + announce("The selected node was removed."); + } + if (selectedEdgeId && !model.connections().some(edge => edge.id === selectedEdgeId)) selectedEdgeId = null; + syncObjects(); + syncConnections(); + scheduleActivity(); + renderInspector(); + renderHistory(); + } + model.objects.observeDeep(render); + model.edges.observeDeep(render); + room.awareness.on("change", scheduleActivity); + for (const event of ["stack-item-added", "stack-item-popped", "stack-cleared", "stack-item-updated"]) model.history.on(event, renderHistory); + setZoom(1); + render(); + function destroy() { + disposed = true; + clearTimeout(presenceTimer); + cancelAnimationFrame(activityFrame); + cancelAnimationFrame(edgeFrame); + motion.destroy(); + peerNodes.clear(); + events.abort(); + model.objects.unobserveDeep(render); + model.edges.unobserveDeep(render); + room.awareness.off("change", scheduleActivity); + model.destroy(); + nodes.clear(); + root.replaceChildren(); + } + destroy.setReadOnly = value => { + if (readOnly !== value) { + readOnly = value; + if (readOnly) { connecting = false; cancelDrag(); } + root.querySelectorAll("[data-add]").forEach(button => { button.disabled = readOnly; }); + render(); + } + scheduleActivity(); + }; + return destroy; +} diff --git a/examples/collaboration/flowchart/geometry.js b/examples/collaboration/flowchart/geometry.js new file mode 100644 index 0000000..ea6bed3 --- /dev/null +++ b/examples/collaboration/flowchart/geometry.js @@ -0,0 +1,22 @@ +// Attach arrows to the side facing the other node. Control points keep the +// arrow perpendicular to the boundary, including the tips of decision nodes. +export function connectionPath(source, target, { reciprocal = false } = {}) { + const a = { x: source.position.x + source.size.width / 2, y: source.position.y + source.size.height / 2 }; + const b = { x: target.position.x + target.size.width / 2, y: target.position.y + target.size.height / 2 }; + const horizontal = Math.abs(b.x - a.x) > Math.abs(b.y - a.y); + const axis = horizontal ? 'x' : 'y'; + const sign = b[axis] >= a[axis] ? 1 : -1; + a[axis] += sign * (horizontal ? source.size.width : source.size.height) / 2; + b[axis] -= sign * (horizontal ? target.size.width : target.size.height) / 2; + const bend = Math.max(45, Math.abs(b[axis] - a[axis]) / 2); + const c = { ...a, [axis]: a[axis] + sign * bend }; + const d = { ...b, [axis]: b[axis] - sign * bend }; + if (reciprocal) { + // Opposite directions take opposite curves instead of hiding each other. + const cross = horizontal ? "y" : "x"; + c[cross] += sign * 60; + d[cross] += sign * 60; + } + return { path: `M ${a.x} ${a.y} C ${c.x} ${c.y}, ${d.x} ${d.y}, ${b.x} ${b.y}`, + label: { x: (a.x + 3 * c.x + 3 * d.x + b.x) / 8, y: (a.y + 3 * c.y + 3 * d.y + b.y) / 8 } }; +} diff --git a/examples/collaboration/flowchart/model.js b/examples/collaboration/flowchart/model.js new file mode 100644 index 0000000..c884274 --- /dev/null +++ b/examples/collaboration/flowchart/model.js @@ -0,0 +1,106 @@ +import * as Y from "yjs"; + +export const canvasSize = { width: 1600, height: 1000 }; +export const kinds = ["process", "decision", "terminal"]; +export const colors = ["yellow", "blue", "mint", "rose", "white"]; +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + +export function createFlowchartModel(doc) { + const objects = doc.getMap("flowchart:nodes:v1"); + const edges = doc.getMap("flowchart:edges:v1"); + const origin = {}; + const history = new Y.UndoManager([objects, edges], { trackedOrigins: new Set([origin]) }); + function list() { + return [...objects.entries()].filter(([, item]) => item instanceof Y.Map).map(([id, item]) => ({ + id, kind: item.get("kind"), text: String(item.get("text") ?? ""), + color: colors.includes(item.get("color")) ? item.get("color") : "yellow", + position: item.get("position"), size: item.get("size"), order: item.get("order"), + })).filter(item => kinds.includes(item.kind) && Number.isFinite(item.order) && + Number.isFinite(item.position?.x) && Number.isFinite(item.position?.y) && + Number.isFinite(item.size?.width) && Number.isFinite(item.size?.height) && + item.size.width >= 80 && item.size.width <= 600 && item.size.height >= 60 && item.size.height <= 500) + .map(item => ({ ...item, position: boundedPosition(item.position, item.size) })) + .sort((a, b) => a.order - b.order || a.id.localeCompare(b.id)); + } + function boundedPosition(position, size) { + return { x: clamp(position.x, 0, canvasSize.width - size.width), y: clamp(position.y, 0, canvasSize.height - size.height) }; + } + function action(fn) { + history.stopCapturing(); + doc.transact(fn, origin); + history.stopCapturing(); + } + // An edge created offline can outlive its endpoint. Keep it in the CRDT so + // undo can restore it, but only expose edges whose endpoints still exist. + function connections() { + const ids = new Set(list().map(item => item.id)); + return [...edges.entries()].filter(([, edge]) => edge instanceof Y.Map) + .map(([id, edge]) => ({ id, source: edge.get("source"), target: edge.get("target"), label: String(edge.get("label") ?? "").slice(0, 80) })) + .filter(edge => ids.has(edge.source) && ids.has(edge.target) && edge.source !== edge.target) + .sort((a, b) => a.id.localeCompare(b.id)); + } + const nextOrder = () => list().reduce((max, item) => Math.max(max, item.order), 0) + 1; + return { + objects, edges, history, list, connections, boundedPosition, + add(kind, position = { x: 80, y: 80 }) { + if (!kinds.includes(kind) || !Number.isFinite(position.x) || !Number.isFinite(position.y)) return; + const id = crypto.randomUUID(); + const size = kind === "decision" ? { width: 200, height: 160 } : { width: 200, height: 90 }; + action(() => objects.set(id, new Y.Map([ + ["kind", kind], ["text", kind === "decision" ? "A decision?" : kind === "terminal" ? "Start / End" : "New step"], + ["color", kind === "decision" ? "yellow" : kind === "terminal" ? "mint" : "blue"], + ["position", boundedPosition(position, size)], ["size", size], ["order", nextOrder()], + ]))); + return id; + }, + move(id, position) { + const item = objects.get(id); + if (!(item instanceof Y.Map) || !Number.isFinite(position.x) || !Number.isFinite(position.y)) return; + const next = boundedPosition(position, item.get("size")); + const previous = item.get("position"); + if (previous.x === next.x && previous.y === next.y) return; + action(() => item.set("position", next)); + }, + resize(id, size) { + const item = objects.get(id); + if (!(item instanceof Y.Map) || !Number.isFinite(size.width) || !Number.isFinite(size.height)) return; + const position = item.get("position"); + const next = { width: clamp(size.width, 80, Math.min(600, canvasSize.width - position.x)), + height: clamp(size.height, 60, Math.min(500, canvasSize.height - position.y)) }; + action(() => item.set("size", next)); + }, + edit(id, field, value) { + const item = objects.get(id); + if (!(item instanceof Y.Map) || !["text", "color"].includes(field) || typeof value !== "string") return; + if (field === "color" && !colors.includes(value)) return; + if (item.get(field) === value) return; + doc.transact(() => item.set(field, field === "text" ? value.slice(0, 2000) : value), origin); + }, + front(id) { + if (objects.get(id) instanceof Y.Map) action(() => objects.get(id).set("order", nextOrder())); + }, + connect(source, target, label = "") { + if (source === target || !objects.has(source) || !objects.has(target) || typeof label !== "string") return; + // A deterministic key collapses concurrent creation of the same arrow. + const id = JSON.stringify([source, target]); + if (edges.has(id)) return id; + action(() => edges.set(id, new Y.Map([["source", source], ["target", target], ["label", label.slice(0, 80)]]))); + return id; + }, + label(id, value) { + const edge = edges.get(id); + if (edge instanceof Y.Map && typeof value === "string" && edge.get("label") !== value) + doc.transact(() => edge.set("label", value.slice(0, 80)), origin); + }, + disconnect(id) { action(() => edges.delete(id)); }, + remove(id) { + action(() => { + objects.delete(id); + for (const [edgeId, edge] of edges) { + if (edge instanceof Y.Map && (edge.get("source") === id || edge.get("target") === id)) edges.delete(edgeId); + } + }); + }, + destroy() { history.destroy(); }, + }; +} diff --git a/examples/collaboration/flowchart/model.test.js b/examples/collaboration/flowchart/model.test.js new file mode 100644 index 0000000..46e9f55 --- /dev/null +++ b/examples/collaboration/flowchart/model.test.js @@ -0,0 +1,64 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as Y from "yjs"; +import { createFlowchartModel } from "./model.js"; + +function clients() { + const a = new Y.Doc(), b = new Y.Doc(); + const first = createFlowchartModel(a), second = createFlowchartModel(b); + return { first, second, sync() { + const left = Y.encodeStateAsUpdate(a), right = Y.encodeStateAsUpdate(b); + Y.applyUpdate(a, right); Y.applyUpdate(b, left); + assert.deepEqual(first.list(), second.list()); + assert.deepEqual(first.connections(), second.connections()); + }, close() { first.destroy(); second.destroy(); a.destroy(); b.destroy(); } }; +} + +test("concurrent connections converge while local undo preserves another user's label", () => { + const { first, second, sync, close } = clients(); + try { + const start = first.add("process"), end = first.add("decision"); + sync(); + first.connect(start, end, "First"); second.connect(start, end, "Second"); + sync(); + assert.equal(first.connections().length, 1); + const edge = first.connections()[0].id; + first.move(start, { x: 320, y: 400 }); + second.label(edge, "Approved"); sync(); + first.history.undo(); sync(); + assert.deepEqual(first.list().find(n => n.id === start).position, { x: 80, y: 80 }); + assert.equal(first.connections()[0].label, "Approved"); + } finally { close(); } +}); + +test("deletion removes attached arrows in one undoable action; offline arrows never dangle", () => { + const { first, second, sync, close } = clients(); + try { + const a = first.add("terminal"), b = first.add("decision"), c = first.add("process"); + first.connect(a, b); sync(); + first.remove(b); + second.connect(b, c, "Offline"); sync(); + assert.equal(first.list().length, 2); + assert.equal(first.connections().length, 0); + first.history.undo(); sync(); + assert.equal(first.list().length, 3); + assert.equal(first.connections().length, 2); + } finally { close(); } +}); + +test("arrows reject missing endpoints and self loops; reverse paths and branches stay distinct", () => { + const { first, second, sync, close } = clients(); + try { + const a = first.add("decision"), b = first.add("process"), c = first.add("terminal"); + assert.equal(first.connect(a, a), undefined); + assert.equal(first.connect(a, "missing"), undefined); + first.connect(a, b, "Yes"); first.connect(a, c, "No"); first.connect(b, a, "Retry"); + sync(); + assert.equal(second.connections().length, 3); + const id = first.connections()[0].id; + first.disconnect(id); sync(); + assert.equal(second.connections().length, 2); + first.history.undo(); sync(); + assert.equal(second.connections().length, 3); + } finally { close(); } +}); diff --git a/examples/collaboration/index.html b/examples/collaboration/index.html index 8e38271..11fefd6 100644 --- a/examples/collaboration/index.html +++ b/examples/collaboration/index.html @@ -10,7 +10,7 @@
    Synixir - Collaborative documents +