@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/ARCHITECTURE.md +8 -2
- package/BENCHMARK.md +244 -84
- package/MIGRATION.md +46 -22
- package/README.md +48 -1
- package/UI.md +24 -2
- package/dist/index.d.ts +27 -3
- package/dist/index.js +54 -12
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
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.
|
|
126
|
-
|
|
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,
|
|
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
|
-
/**
|
|
65
|
-
|
|
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 {
|
|
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
|
-
|
|
542
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
/* @__PURE__ */ jsx(
|
|
1146
|
-
|
|
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,
|