Skip to content

Commit 5deea51

Browse files
committed
Add collab and presence overviews and fix presence flickers
1 parent 2027286 commit 5deea51

9 files changed

Lines changed: 196 additions & 60 deletions

File tree

.envrc

Lines changed: 0 additions & 7 deletions
This file was deleted.

packages/collab-client/src/plugin.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,25 @@ export function collab(config: Parameters<typeof base>[0]) {
2727
baseState.stepMaps = [];
2828
return baseState;
2929
},
30-
apply(tr, ...args) {
31-
const baseState = baseSpec.state!.apply(tr, ...args) as CollabState;
32-
baseState.stepMaps ??= [];
33-
const meta = tr.getMeta(collabKey) as BaseCollabState | undefined;
34-
if (meta?.commit) {
35-
baseState.stepMaps.push({
36-
version: meta.commit.version,
37-
mappables: meta.commit.steps.map((step) => step.getMap()),
30+
apply(tr, value, ...args) {
31+
const next = baseSpec.state!.apply(tr, value, ...args) as CollabState;
32+
next.stepMaps ??= [...value.stepMaps];
33+
if (!tr.docChanged && value.version === next.version) return next;
34+
35+
const lastMap = next.stepMaps[next.stepMaps.length - 1];
36+
37+
if (lastMap?.version !== next.version) {
38+
next.stepMaps.push({
39+
version: next.version,
40+
mappables: tr.steps.map((step) => step.getMap()),
3841
});
42+
43+
return next;
3944
}
40-
return baseState;
45+
46+
lastMap.mappables.push(...tr.steps.map((step) => step.getMap()));
47+
48+
return next;
4149
},
4250
},
4351
historyPreserveItems: true,
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
title: Overview
3+
description: A ProseMirror plugin and backend implementation for fast, simple collaborative editing.
4+
---
5+
6+
Collaborative editing in rich text is, in a word, hard. But ProseMirror was designed for it from the
7+
ground up, and even has a first-party collaboration plugin (`prosemirror-collab`).
8+
9+
Unfortunately, `prosemirror-collab` only handles the _client_ collaboration implementation. It can
10+
be challenging to get the server-side constraints right, and even managing the client-side state and
11+
effects correctly isn’t trivial.
12+
13+
**Pitter Patter Collab takes care of both the client and server implementation, and guides you
14+
through the relevant database and API decisions as well.**
15+
16+
## How it works
17+
18+
Pitter Patter Collab is based on
19+
[`prosemirror-collab-commit`](https://github.com/stepwisehq/prosemirror-collab-commit), an
20+
alternative `prosemirror-collab` implementation that provides improved throughput and fairness over
21+
the default implementation. This means that it uses ProseMirror steps as the unit of change, just
22+
like `prosemirror-collab` (and just like non-collaborative ProseMirror), which makes it
23+
straightforward to inspect, debug, and analyze.
24+
25+
You can add Pitter Patter Collab to your existing stack. You provide the database and API, and it
26+
manages the collaboration state and synchronization.
27+
28+
### Simple client configuration
29+
30+
Just provide your client with the endpoints for sending and listening for changes. Collab handles
31+
the rest!
32+
33+
### Long-polling clients
34+
35+
Long-polling provides instant updates, is resilient to intermittent connections, and trivially
36+
supports horizontal autoscaling on the server side. No need to worry about sticky sessions or
37+
reconnecting websockets!
38+
39+
### Redis/Valkey (or whatever you like!) for pub/sub
40+
41+
Out of the box, we support Redis/Valkey for server-side pub/sub. This allows your horizontally
42+
scaled backend servers to notify each other of changes to documents that clients are subscribed to.
43+
44+
Already using another pub/sub provider? No problem! The broadcast manager is fully configurable —
45+
you can build your own. It only needs to implement three methods: broadcast, listen, and abort!
46+
47+
### BYODB
48+
49+
Collab doesn’t have any opinions about your persistent datastore. We have guidance for various SQL
50+
databases in our step-by-step [guide](/docs/guides/collab), but NoSQL databases that support
51+
transactions (or thoughtfully crafted primary keys!) can be used as well. If you’re looking for
52+
guidance for a specific datastore, start a
53+
[Discussion](https://github.com/handlewithcarecollective/pitter-patter/discussions)!
54+
55+
<Cards>
56+
<Card href="/docs/guides/collab" title="Guide">
57+
Set up Collab full-stack, step-by-step.
58+
</Card>
59+
<Card href="/docs/collab/reference/collab-client/README" title="Client API Reference" />
60+
<Card href="/docs/collab/reference/collab-server/README" title="Server API Reference" />
61+
</Cards>

packages/docs/content/docs/guides/collab.md

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,7 @@ const runWithTransaction = async (callback) => {
137137

138138
Saves a document along with its docId, version, and lastUpdatedTimestamp to your database.
139139

140-
If a transaction `tr` is provided, it must be used for all database operations. If `tr` is null, all
141-
database operations in this function should still be performed as an atomic unit.
140+
If a transaction `tr` is provided, it must be used for all database operations.
142141

143142
```ts
144143
const saveDoc = async (tr, docId, docJSON, version) => {
@@ -167,8 +166,7 @@ If you are using Postgres or MySql, getDoc should select the row holding the doc
167166
`SELECT FOR UPDATE`. This ensures that conflicting commits do not overwrite each other. We also
168167
recommend putting a unique constraint on the commit table for the fields docId and commit version.
169168

170-
If a transaction `tr` is provided, it must be used for all database operations. If `tr` is null, all
171-
database operations in this function should still be performed as an atomic unit.
169+
If a transaction `tr` is provided, it must be used for all database operations.
172170

173171
```ts
174172
const getDoc = async (tr, docId) => {
@@ -190,8 +188,7 @@ export async function getDocument(tr: Transaction<DB> | null, id: string) {
190188

191189
Saves a commit along with its version and ref to your database.
192190

193-
If a transaction `tr` is provided, it must be used for all database operations. If `tr` is null, all
194-
database operations in this function should still be performed as an atomic unit.
191+
If a transaction `tr` is provided, it must be used for all database operations.
195192

196193
```ts
197194
const saveCommit = async (tr, docId, commitRef, commitVersion, commitSteps) => {
@@ -215,8 +212,7 @@ export async function createCommit(tr: Transaction<DB> | null, commit: Insertabl
215212
Given a docId and commitRef, retrieves the associated commit's steps and version from your database
216213
and returns a joined CommitJSON object.
217214

218-
If a transaction `tr` is provided, it must be used for all database operations. If `tr` is null, all
219-
database operations in this function should still be performed as an atomic unit.
215+
If a transaction `tr` is provided, it must be used for all database operations.
220216

221217
```ts
222218
const getCommit = async (tr, docId, commitRef) => {
@@ -270,7 +266,7 @@ const broadcastManager = new RedisBroadcastManager({
270266

271267
### [Schema](https://pitter-patter.dev/docs/collab/reference/collab-server/interfaces/CollabAuthorityConfig#schema)
272268

273-
This is just the schema for your Prosemirror document.
269+
This is just the schema for your ProseMirror document.
274270

275271
### All together
276272

@@ -392,10 +388,7 @@ Next we need to create a listener for commit changes. The built-in
392388
import { LongPollListener as CollabLongPollListener } from "@pitter-patter/collab-client";
393389

394390
const commitListener = new CollabLongPollListener(
395-
new URL(
396-
`/api/docs/${doc.id}/commits`,
397-
typeof window !== "undefined" ? window.location.href : "http://localhost:3000",
398-
),
391+
new URL(`/api/docs/${doc.id}/commits`, "http://localhost:3000"),
399392
);
400393
```
401394

@@ -429,7 +422,8 @@ const collabConfig = {
429422
},
430423
receiveCommits: (commits) => {
431424
// merge a new set of commits into your editor state for example:
432-
// const newEditorState = commits.reduce((acc, commit) => acc.apply(receiveCommitTransaction(acc, commit)), oldEditorState),
425+
// const newEditorState = commits.reduce((acc, commit) => acc.apply(receiveCommitTransaction(acc, commit)), oldEditorState)
426+
// view.setState(newEditorState)
433427
},
434428
listener: commitListener,
435429
};
@@ -451,4 +445,4 @@ When the local editor state changes, you need to tell collabClient to create and
451445
collabClient.send(state).catch((e) => console.error(e));
452446
```
453447

454-
That's it! Your document can now be edited collaboratively by mulitiple simultaneous users.
448+
That's it! Your document can now be edited collaboratively by multiple simultaneous users.

packages/docs/content/docs/guides/presence.md

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -107,22 +107,16 @@ You can now connect your frontend client to the presence backend using PitterPat
107107
Like with the
108108
[CollabClient](https://pitter-patter.dev/docs/collab/reference/collab-client/classes/CollabClient),
109109
you create a listener for presence state updates with Presence's built-in
110-
[LongPollListner](https://pitter-patter.dev/docs/presence/reference/presence-client/classes/LongPollListener).
110+
[LongPollListener](https://pitter-patter.dev/docs/presence/reference/presence-client/classes/LongPollListener).
111111

112112
```ts
113113
const [presenceListener] = useState(
114-
() =>
115-
new PresenceListener(
116-
new URL(
117-
`/api/docs/${doc.id}/presence`,
118-
typeof window !== "undefined" ? window.location.href : "http://localhost:3000",
119-
),
120-
),
114+
() => new LongPollListener(new URL(`/api/docs/${doc.id}/presence`, "http://localhost:3000")),
121115
);
122116
```
123117

124118
The
125-
[LongPollListner](https://pitter-patter.dev/docs/presence/reference/presence-client/classes/LongPollListener)
119+
[LongPollListener](https://pitter-patter.dev/docs/presence/reference/presence-client/classes/LongPollListener)
126120
takes the url of the getPresence endpoint, as well as optional headers for requests to that
127121
endpoint. These headers can be updated with the listener's
128122
[update](https://pitter-patter.dev/docs/presence/reference/presence-client/classes/LongPollListener#update)

packages/docs/content/docs/introduction/getting-started.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,9 @@ description: ""
99

1010
## Rich text editing
1111

12-
- [`@pitter-patter/shuffle`](/docs/shuffle/overview): A 12-column, grid-based drag and drop library.
12+
- [`@pitter-patter/shuffle`](/docs/shuffle/overview): 12-column, grid-based drag and drop
13+
14+
## Collaboration
15+
16+
- [`@pitter-patter/collab`](/docs/collab/overview): Fast, simple, plug-and-play collaboration
17+
- [`@pitter-patter/presence`](/docs/presence/overview): Live presence
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
title: Overview
3+
description:
4+
A ProseMirror plugin and backend implementation for fast, simple live presence indicators.
5+
---
6+
7+
[Simple collaborative editing](/docs/collab/overview) is great, but without presence indicators it
8+
quickly gets confusing.
9+
10+
**Pitter Patter Presence takes care of both the client and server implementation, and guides you
11+
through the relevant API decisions as well.**
12+
13+
## How it works
14+
15+
You can add Pitter Patter Presence to your existing stack. You provide the API, and it manages the
16+
presence state and synchronization.
17+
18+
### Simple client configuration
19+
20+
Just provide your client with the endpoints for sending and listening for indicators. Presence
21+
handles the rest!
22+
23+
### Long-polling clients
24+
25+
Long-polling provides instant updates, is resilient to intermittent connections, and trivially
26+
supports horizontal autoscaling on the server side. No need to worry about sticky sessions or
27+
reconnecting websockets!
28+
29+
### Redis/Valkey (or whatever you like!) for pub/sub
30+
31+
Out of the box, we support Redis/Valkey for server-side pub/sub. This allows your horizontally
32+
scaled backend servers to notify each other of changes to documents that clients are subscribed to.
33+
34+
Already using another pub/sub provider? No problem! The broadcast manager is fully configurable —
35+
you can build your own. It only needs to implement three methods: broadcast, listen, and abort!
36+
37+
### BYODB
38+
39+
Presence doesn’t have any opinions about your persistent datastore. Out of the box, we support
40+
Redis/Valkey for server-side persistence, because presence state generally doesn’t need to be truly
41+
persistent. But you can use any datastore you like by implementing your own persistence manager. If
42+
you’re looking for guidance for a specific datastore, start a
43+
[Discussion](https://github.com/handlewithcarecollective/pitter-patter/discussions)!
44+
45+
<Cards>
46+
<Card href="/docs/guides/presence" title="Guide">
47+
Set up Presence full-stack, step-by-step.
48+
</Card>
49+
<Card href="/docs/presence/reference/presence-client/README" title="Client API Reference" />
50+
<Card href="/docs/presence/reference/presence-server/README" title="Server API Reference" />
51+
</Cards>

packages/presence-client/src/plugin.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,17 @@ export function presence(
8989
continue;
9090
}
9191

92-
const confirmedMappables = stepMaps
93-
.filter(({ version }) => version > indicator.version)
94-
.flatMap(({ mappables }) => mappables);
92+
const confirmedMaps = stepMaps.filter(({ version }) => version > indicator.version);
9593

9694
// This indicator may predate when we started tracking stepMaps.
9795
// If so, we have to ignore it, because we can't map it forward.
98-
if (confirmedMappables.length < version - indicator.version) {
96+
if (confirmedMaps.length < version - indicator.version) {
9997
continue;
10098
}
10199

102-
const mappables = confirmedMappables.concat(unconfirmed.map(({ step }) => step.getMap()));
100+
const mappables = confirmedMaps
101+
.flatMap(({ mappables }) => mappables)
102+
.concat(unconfirmed.map(({ step }) => step.getMap()));
103103

104104
const anchor = mappables.reduce((acc, mappable) => mappable.map(acc), indicator.anchor);
105105
const head = mappables.reduce((acc, mappable) => mappable.map(acc), indicator.head);
@@ -125,9 +125,11 @@ export function presence(
125125
);
126126
}
127127

128+
console.log(nextDecorations);
129+
128130
return {
129131
decorations: DecorationSet.create(editorState.doc, nextDecorations),
130-
indicators,
132+
indicators: nextIndicators,
131133
stepMaps,
132134
};
133135
},

0 commit comments

Comments
 (0)