@mocanvas/sync 4.0.1 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,6 +48,47 @@ const { status } = useSync(editor, { roomId, transport: () => createBroadcastCha
48
48
  selection outlines in screen space. It positions itself absolutely, so it goes
49
49
  anywhere inside the canvas container.
50
50
 
51
+ That is the whole integration: nothing above pins a page, and nothing names a
52
+ person. Two things make it work, and both are worth knowing because both used
53
+ to have to be worked around by hand.
54
+
55
+ ### The page both replicas start on
56
+
57
+ A blank document's first page has a **fixed id** — `DEFAULT_PAGE_ID`, i.e.
58
+ `page:page` — so two `<Mocanvas />`s that each built their own store start on
59
+ the *same* page. Shapes drawn in one tab land on the page the other is looking
60
+ at, and `getCollaboratorsOnCurrentPage()` has something to return. (Creating
61
+ the same record id on two peers is a field-by-field merge, and the two bodies
62
+ are identical, so there is nothing to settle.) Pages created *after* that get
63
+ random ids, so two people genuinely on different pages of one document stay
64
+ independent.
65
+
66
+ When a replica does end up alone on its page — someone moved to another page,
67
+ or two apps built their documents separately — the client says so once, in
68
+ development:
69
+
70
+ > mocanvas/sync: this replica is on page `page:…`, and none of the 1 peer(s) in
71
+ > room "…" is — they are on `page:…`. Their cursors and their shapes will not be
72
+ > visible here.
73
+
74
+ Everything is still `online` in that state, every message still arrives, and
75
+ nothing is drawn. It is the failure with the least evidence, which is why it
76
+ gets a sentence instead of silence.
77
+
78
+ ### A tab is not a person
79
+
80
+ Presence is per **editor instance**, not per user. `editor.user.getId()` is per
81
+ *browser* — the same in every tab, and the same on a phone and a laptop signed
82
+ in as one person — so a person with three tabs open would otherwise discard
83
+ their own other tabs as themselves. The identity that decides is
84
+ `editor.getInstancePresenceId()`: every editor mints one, this package
85
+ publishes the tab's presence record under it, and `editor.getCollaborators()`
86
+ drops that one record and no other.
87
+
88
+ So two tabs of one browser are two collaborators — which is the entire point of
89
+ `createBroadcastChannelTransport` — and an app does not have to invent a fake
90
+ per-tab identity to make its own demo work.
91
+
51
92
  ## Protocol
52
93
 
53
94
  Every message is one JSON object on the wire.
@@ -93,7 +134,13 @@ half-parsed. Nothing on the wire can crash this client.
93
134
  - Presence records are derived from the editor's camera,
94
135
  `inputs.currentPagePoint` and selection, throttled to at most 30 Hz, and
95
136
  re-sent on a heartbeat. A collaborator is dropped on `bye` or after 10s of
96
- silence.
137
+ silence. The record is keyed on the sending editor's instance id (see
138
+ "A tab is not a person" above), so one person's tabs do not overwrite each
139
+ other's presence.
140
+ - A `hello` makes every established peer **re-announce** its presence, even
141
+ when its own record has not changed — what changed is who is listening. A
142
+ peer that has been sitting still since before you joined is therefore visible
143
+ immediately rather than at its next heartbeat.
97
144
 
98
145
  ## Conflict policy: a CRDT over the record fields
99
146
 
package/UI.md CHANGED
@@ -4,6 +4,27 @@
4
4
  style panel, an optional statistics chip, and the chrome the editor draws on the
5
5
  canvas itself. All of it is optional and all of it is themeable from CSS.
6
6
 
7
+ ## Loading the stylesheet
8
+
9
+ The theme is published as `@mocanvas/mocanvas/mocanvas.css` and your app
10
+ imports it:
11
+
12
+ ```ts
13
+ import "@mocanvas/mocanvas/mocanvas.css"
14
+ ```
15
+
16
+ The package's JavaScript entry does not import it. That is deliberate: Node has
17
+ no loader for `.css`, so a CSS import inside `dist/index.js` makes
18
+ `import "@mocanvas/mocanvas"` throw `ERR_UNKNOWN_FILE_EXTENSION` anywhere there
19
+ is no bundler — vitest, SSR, a plain script. Up to and including 4.0.2 it did,
20
+ and the emitted file was content-hashed (`dist/ui-4C5V5GYT.css`) so there was no
21
+ stable name to import instead. Both are fixed; the cost is this one line in your
22
+ app.
23
+
24
+ `<Canvas />` from `@mocanvas/editor` carries fallback values for the canvas
25
+ chrome tokens, so an editor mounted without the stylesheet still renders
26
+ legibly — but the toolbar, panels, menus and dialogs need it.
27
+
7
28
  Source lives in `packages/mocanvas/src/ui/`:
8
29
 
9
30
  | File | What it holds |
@@ -122,8 +143,9 @@ so inline marks like the "mixed" badge keep the size they ask for.
122
143
  <Mocanvas hideUi />
123
144
  ```
124
145
 
125
- This drops the toolbar, zoom bar, style panel and stats chip. `ui.css` still
126
- loads, so the canvas chrome tokens above keep working. Default keyboard
146
+ This drops the toolbar, zoom bar, style panel and stats chip. The stylesheet is
147
+ imported by your app, not by the component, so the canvas chrome tokens above
148
+ keep working. Default keyboard
127
149
  shortcuts are wired by `<Mocanvas />` itself and are unaffected; call
128
150
  `useKeyboardShortcuts(editor)` yourself if you build on `<Canvas />` directly.
129
151
 
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Signal } from '@mocanvas/state';
2
2
  import { Store, UnknownRecord, RecordsDiff } from '@mocanvas/store';
3
- import { UserId, InstancePresence, InstancePresenceId, Editor } from '@mocanvas/editor';
3
+ import { UserId, InstancePresenceId, InstancePresence, Editor } from '@mocanvas/editor';
4
4
  import * as react from 'react';
5
5
 
6
6
  /** Presence is sent at most this often (30 Hz). */
@@ -47,6 +47,16 @@ interface PresenceEditor {
47
47
  scribbles: readonly unknown[];
48
48
  followingUserId: UserId | null;
49
49
  };
50
+ /**
51
+ * Optional: the id this instance's presence record is published under.
52
+ *
53
+ * A real `Editor` has one and it is the identity that matters here — a user
54
+ * id is per browser, so two tabs of one browser are one user and would
55
+ * discard each other's presence as their own. Structural stand-ins (tests,
56
+ * a headless participant) may omit it and fall back to
57
+ * {@link presenceIdForClient}.
58
+ */
59
+ getInstancePresenceId?(): InstancePresenceId;
50
60
  /** Optional: used to sample the cursor, which is not itself a signal. */
51
61
  on?(name: "event", fn: () => void): () => void;
52
62
  }
@@ -61,8 +71,17 @@ interface PresenceSyncOptions {
61
71
  interface PresenceSync {
62
72
  start(): void;
63
73
  stop(): void;
64
- /** Ask for a send; collapses with any other request inside the throttle window. */
65
- poke(): void;
74
+ /**
75
+ * Ask for a send; collapses with any other request inside the throttle
76
+ * window.
77
+ *
78
+ * `force` sends even when nothing about this client has changed, which is
79
+ * what a *re-announcement* is: somebody who was not listening when we last
80
+ * spoke has arrived, so the record has to go out again although it says the
81
+ * same thing. Without it a peer that has been sitting still since before the
82
+ * newcomer joined stays invisible until its next heartbeat.
83
+ */
84
+ poke(force?: boolean): void;
66
85
  /** The record last handed to `send`, for tests and debugging. */
67
86
  getLastSent(): InstancePresence | null;
68
87
  dispose(): void;
@@ -443,6 +462,11 @@ interface CollaboratorCursorsProps {
443
462
  /**
444
463
  * Other people's cursors and selections, drawn in screen space above the
445
464
  * canvas. Drop it inside `<Mocanvas>` or `<Canvas>`; it positions itself.
465
+ *
466
+ * The arrow below is the default. An app that supplies a
467
+ * {@link TLEditorComponents.CollaboratorCursor} component gets that instead —
468
+ * the slot was declared on two component maps and consulted by nothing, so an
469
+ * app that replaced the cursor silently kept seeing ours.
446
470
  */
447
471
  declare const CollaboratorCursors: ({ editor, showSelection, showNames, }: CollaboratorCursorsProps) => react.JSX.Element | null;
448
472
  interface UseSyncOptions {
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { react, atom } from '@mocanvas/state';
2
+ import { useEditorComponents, InstancePresenceRecordType, warnOnce } from '@mocanvas/editor';
2
3
  import { uniqueId, isRecordsDiffEmpty, createEmptyRecordsDiff } from '@mocanvas/store';
3
- import { InstancePresenceRecordType } from '@mocanvas/editor';
4
4
  import { track, useValue } from '@mocanvas/state/react';
5
5
  import { useRef, useState, useEffect } from 'react';
6
- import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
6
+ import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
7
7
 
8
8
  // src/SyncClient.ts
9
9
  var CRDT_STATE_VERSION = 1;
@@ -504,7 +504,7 @@ function createPresenceSync(options) {
504
504
  const { editor, clientId, send } = options;
505
505
  const throttleMs = options.throttleMs ?? DEFAULT_PRESENCE_THROTTLE_MS;
506
506
  const heartbeatMs = options.heartbeatMs ?? DEFAULT_PRESENCE_HEARTBEAT_MS;
507
- const id = presenceIdForClient(clientId);
507
+ const id = editor.getInstancePresenceId?.() ?? presenceIdForClient(clientId);
508
508
  let timer = null;
509
509
  let heartbeat = null;
510
510
  let stopReactor = null;
@@ -538,11 +538,16 @@ function createPresenceSync(options) {
538
538
  last = next;
539
539
  send(next);
540
540
  };
541
- const poke = () => {
542
- if (!running || timer !== null) return;
541
+ let pendingForce = false;
542
+ const poke = (force = false) => {
543
+ if (!running) return;
544
+ if (force) pendingForce = true;
545
+ if (timer !== null) return;
543
546
  timer = setTimeout(() => {
544
547
  timer = null;
545
- flush(false);
548
+ const forced = pendingForce;
549
+ pendingForce = false;
550
+ flush(forced);
546
551
  }, throttleMs);
547
552
  };
548
553
  return {
@@ -564,6 +569,7 @@ function createPresenceSync(options) {
564
569
  },
565
570
  stop() {
566
571
  running = false;
572
+ pendingForce = false;
567
573
  if (timer !== null) {
568
574
  clearTimeout(timer);
569
575
  timer = null;
@@ -736,6 +742,8 @@ function createSyncClient(options) {
736
742
  });
737
743
  const unsubscribes = [];
738
744
  const reportedVersions = /* @__PURE__ */ new Set();
745
+ const peerPages = /* @__PURE__ */ new Map();
746
+ let warnedAboutPages = false;
739
747
  let presence = null;
740
748
  let seq = 0;
741
749
  let awaitingSnapshot = false;
@@ -830,7 +838,7 @@ function createSyncClient(options) {
830
838
  }
831
839
  awaitingSnapshot = false;
832
840
  sendSnapshot();
833
- presence?.poke();
841
+ presence?.poke(true);
834
842
  return;
835
843
  }
836
844
  case "snapshot": {
@@ -864,21 +872,38 @@ function createSyncClient(options) {
864
872
  case "presence": {
865
873
  if (message.clientId === clientId) return;
866
874
  room.receive(message.clientId, message.record);
875
+ notePeerPage(message.clientId, message.record);
867
876
  return;
868
877
  }
869
878
  case "bye": {
870
879
  if (message.clientId === clientId) return;
871
880
  room.remove(message.clientId);
881
+ peerPages.delete(message.clientId);
872
882
  return;
873
883
  }
874
884
  }
875
885
  };
886
+ const notePeerPage = (peerId, record) => {
887
+ const editor = options.presence?.editor;
888
+ if (!editor || warnedAboutPages) return;
889
+ const page = record.currentPageId;
890
+ if (typeof page !== "string") return;
891
+ peerPages.set(peerId, page);
892
+ const mine = editor.getCurrentPageId();
893
+ for (const peerPage of peerPages.values()) if (peerPage === mine) return;
894
+ warnedAboutPages = true;
895
+ const theirs = Array.from(new Set(peerPages.values())).join(", ");
896
+ warnOnce(
897
+ `sync.page:${roomId}:${clientId}`,
898
+ `mocanvas/sync: this replica is on page ${mine}, and none of the ${peerPages.size} peer(s) in room "${roomId}" is \u2014 they are on ${theirs}. Their cursors and their shapes will not be visible here. That is correct if you moved to another page on purpose; if you did not, the two replicas are on pages that were built separately. Every editor that starts from a blank document starts on the same default page, so check whether one side pinned, created or loaded a page of its own \u2014 and if so, call editor.setCurrentPage() with a page id both sides agree on.`
899
+ );
900
+ };
876
901
  const onOpen = () => {
877
902
  if (disposed || !connected) return;
878
903
  status.set("online");
879
904
  send({ type: "hello", clientId, version: PROTOCOL_VERSION });
880
905
  if (!awaitingSnapshot) sendSnapshot();
881
- presence?.poke();
906
+ presence?.poke(true);
882
907
  };
883
908
  const onClose = () => {
884
909
  if (disposed || !connected) return;
@@ -1138,12 +1163,29 @@ var CollaboratorCursors = track(function CollaboratorCursors2({
1138
1163
  showSelection = true,
1139
1164
  showNames = true
1140
1165
  }) {
1166
+ const components = useEditorComponents();
1141
1167
  const collaborators = editor.getCollaboratorsOnCurrentPage();
1142
1168
  if (collaborators.length === 0) return null;
1143
- return /* @__PURE__ */ jsx("svg", { className: "mocanvas-collaborators", style: layerStyle, children: collaborators.map((presence) => /* @__PURE__ */ jsxs("g", { children: [
1144
- showSelection ? /* @__PURE__ */ jsx(CollaboratorSelection, { editor, presence }) : null,
1145
- /* @__PURE__ */ jsx(CollaboratorCursor, { editor, presence, showName: showNames })
1146
- ] }, presence.id)) });
1169
+ const Slot = components.CollaboratorCursor;
1170
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1171
+ /* @__PURE__ */ jsx("svg", { className: "mocanvas-collaborators", style: layerStyle, children: collaborators.map((presence) => /* @__PURE__ */ jsxs("g", { children: [
1172
+ showSelection ? /* @__PURE__ */ jsx(CollaboratorSelection, { editor, presence }) : null,
1173
+ Slot === void 0 ? /* @__PURE__ */ jsx(CollaboratorCursor, { editor, presence, showName: showNames }) : null
1174
+ ] }, presence.id)) }),
1175
+ Slot ? /* @__PURE__ */ jsx("div", { className: "mocanvas-collaborator-cursors", style: layerStyle, children: collaborators.map(
1176
+ (presence) => presence.cursor === null ? null : /* @__PURE__ */ jsx(
1177
+ Slot,
1178
+ {
1179
+ type: "default",
1180
+ rotation: presence.cursor.rotation,
1181
+ color: presence.color,
1182
+ name: showNames ? presence.userName || "Anonymous" : null,
1183
+ point: editor.pageToViewport(presence.cursor)
1184
+ },
1185
+ presence.id
1186
+ )
1187
+ ) }) : null
1188
+ ] });
1147
1189
  });
1148
1190
  var CollaboratorCursor = track(function CollaboratorCursor2({
1149
1191
  editor,