Skip to content

Commit 3324246

Browse files
committed
feat: attribution endpoints for version diffing
1 parent 8489bf2 commit 3324246

11 files changed

Lines changed: 331 additions & 191 deletions

File tree

examples/07-collaboration/11-yhub/src/App.tsx

Lines changed: 103 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const provider = {
1212
awareness: new Awareness(doc),
1313
};
1414
provider.awareness.setLocalStateField("user", {
15-
name: "Client A",
15+
name: "Alice",
1616
color: "#30bced",
1717
});
1818

@@ -21,18 +21,88 @@ const provider2 = {
2121
awareness: new Awareness(doc2),
2222
};
2323
provider2.awareness.setLocalStateField("user", {
24-
name: "Client B",
24+
name: "Bob",
2525
color: "#6eeb83",
2626
});
2727

2828
const attrs = new Y.Attributions();
2929

30+
// Batch timestamps: reuse the same timestamp for edits from the same user
31+
// within a 10-second window of inactivity.
32+
const BATCH_INTERVAL_MS = 10_000;
33+
const batchTimestamps = new Map<string, number>();
34+
const batchTimers = new Map<string, ReturnType<typeof setTimeout>>();
35+
36+
function getBatchedTimestamp(userName: string): number {
37+
const existing = batchTimestamps.get(userName);
38+
const now = Date.now();
39+
40+
// Clear any pending reset timer
41+
const timer = batchTimers.get(userName);
42+
if (timer) clearTimeout(timer);
43+
44+
// Start a new batch if none exists or the previous one expired
45+
if (existing == null) {
46+
batchTimestamps.set(userName, now);
47+
}
48+
49+
// Reset the batch after 10s of inactivity
50+
batchTimers.set(
51+
userName,
52+
setTimeout(() => {
53+
batchTimestamps.delete(userName);
54+
batchTimers.delete(userName);
55+
}, BATCH_INTERVAL_MS),
56+
);
57+
58+
return batchTimestamps.get(userName)!;
59+
}
60+
61+
// Track attributions per user for each doc
62+
function trackAttributions(
63+
trackedDoc: Y.Doc,
64+
userName: string,
65+
attributions: Y.Attributions,
66+
) {
67+
trackedDoc.on(
68+
"update",
69+
(
70+
update: Uint8Array,
71+
_origin: unknown,
72+
_ydoc: Y.Doc,
73+
tr: { local: boolean },
74+
) => {
75+
if (!tr.local) return;
76+
const contentIds = Y.createContentIdsFromUpdate(update);
77+
const timestamp = getBatchedTimestamp(userName);
78+
Y.insertIntoIdMap(
79+
attributions.inserts,
80+
Y.createIdMapFromIdSet(contentIds.inserts, [
81+
Y.createContentAttribute("insert", userName),
82+
Y.createContentAttribute("insertAt", timestamp),
83+
]),
84+
);
85+
Y.insertIntoIdMap(
86+
attributions.deletes,
87+
Y.createIdMapFromIdSet(contentIds.deletes, [
88+
Y.createContentAttribute("delete", userName),
89+
Y.createContentAttribute("deleteAt", timestamp),
90+
]),
91+
);
92+
},
93+
);
94+
}
95+
96+
// Track local changes on each doc with a distinct user name
97+
trackAttributions(doc, "Alice", attrs);
98+
trackAttributions(doc2, "Bob", attrs);
99+
30100
const suggestingDoc = new Y.Doc({ isSuggestionDoc: true });
31101
const suggestingProvider = {
32102
awareness: new Awareness(suggestingDoc),
33103
};
34104
suggestingProvider.awareness.setLocalStateField("user", {
35-
name: "View Suggestions",
105+
name: "Charlie",
36106
color: "#ffbc42",
37107
});
38108
const suggestingAttributionManager = Y.createAttributionManagerFromDiff(
@@ -47,7 +117,7 @@ const suggestionModeProvider = {
47117
awareness: new Awareness(suggestionModeDoc),
48118
};
49119
suggestionModeProvider.awareness.setLocalStateField("user", {
50-
name: "Suggestion Mode",
120+
name: "Debbie",
51121
color: "#ee6352",
52122
});
53123
const suggestionModeAttributionManager = Y.createAttributionManagerFromDiff(
@@ -57,6 +127,10 @@ const suggestionModeAttributionManager = Y.createAttributionManagerFromDiff(
57127
);
58128
suggestionModeAttributionManager.suggestionMode = true;
59129

130+
// Track local changes on suggestion docs with distinct user names
131+
trackAttributions(suggestingDoc, "Charlie", attrs);
132+
trackAttributions(suggestionModeDoc, "Debbie", attrs);
133+
60134
// Function to sync two documents
61135
function syncDocs(sourceDoc: Y.Doc, targetDoc: Y.Doc) {
62136
const update = Y.encodeStateAsUpdate(sourceDoc);
@@ -84,18 +158,22 @@ function Editor({
84158
fragment,
85159
provider,
86160
attributionManager,
161+
userName,
162+
userColor,
87163
}: {
88164
fragment: Y.Type;
89165
provider: { awareness?: Awareness };
90166
attributionManager?: Y.DiffAttributionManager;
167+
userName: string;
168+
userColor: string;
91169
}) {
92170
const editor = useCreateBlockNote(
93171
withCollaboration({
94172
collaboration: {
95173
fragment,
96174
provider,
97175
attributionManager,
98-
user: { name: "Client A", color: "#30bced" },
176+
user: { name: userName, color: userColor },
99177
},
100178
}),
101179
);
@@ -116,12 +194,22 @@ export default function App() {
116194
}}
117195
>
118196
<div style={{ flex: 1 }}>
119-
Client A
120-
<Editor fragment={doc.get("doc")} provider={provider} />
197+
Client A (Alice)
198+
<Editor
199+
fragment={doc.get("doc")}
200+
provider={provider}
201+
userName="Alice"
202+
userColor="#30bced"
203+
/>
121204
</div>
122205
<div style={{ flex: 1 }}>
123-
Client B
124-
<Editor fragment={doc2.get("doc")} provider={provider2} />
206+
Client B (Bob)
207+
<Editor
208+
fragment={doc2.get("doc")}
209+
provider={provider2}
210+
userName="Bob"
211+
userColor="#6eeb83"
212+
/>
125213
</div>
126214
</div>
127215
<div
@@ -133,19 +221,23 @@ export default function App() {
133221
}}
134222
>
135223
<div style={{ flex: 1 }}>
136-
View Suggestions Mode
224+
View Suggestions (Charlie)
137225
<Editor
138226
fragment={suggestingDoc.get("doc")}
139227
provider={suggestingProvider}
140228
attributionManager={suggestingAttributionManager}
229+
userName="Charlie"
230+
userColor="#ffbc42"
141231
/>
142232
</div>
143233
<div style={{ flex: 1 }}>
144-
Suggestion Mode
234+
Suggestion Mode (Debbie)
145235
<Editor
146236
fragment={suggestionModeDoc.get("doc")}
147237
provider={suggestionModeProvider}
148238
attributionManager={suggestionModeAttributionManager}
239+
userName="Debbie"
240+
userColor="#ee6352"
149241
/>
150242
</div>
151243
</div>
Lines changed: 0 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +0,0 @@
1-
ins {
2-
background-color: hsl(120 100 90);
3-
color: hsl(120 100 30);
4-
position: relative;
5-
}
6-
7-
ins:hover::after {
8-
content: attr(data-description);
9-
position: absolute;
10-
top: 100%;
11-
left: 50%;
12-
transform: translateX(-50%);
13-
margin-top: 4px;
14-
padding: 4px 8px;
15-
background-color: rgba(0, 0, 0, 0.9);
16-
color: white;
17-
border-radius: 4px;
18-
font-size: 12px;
19-
white-space: nowrap;
20-
pointer-events: none;
21-
z-index: 1000;
22-
}
23-
24-
.dark ins {
25-
background-color: hsl(120 100 10);
26-
color: hsl(120 80 70);
27-
}
28-
29-
.dark ins:hover::after {
30-
background-color: rgba(30, 30, 30, 0.95);
31-
color: hsl(120 80 70);
32-
border: 1px solid rgba(255, 255, 255, 0.1);
33-
}
34-
35-
del {
36-
background-color: hsl(0 100 90);
37-
color: hsl(0 100 30);
38-
position: relative;
39-
}
40-
41-
del:hover::after {
42-
content: attr(data-description);
43-
position: absolute;
44-
top: 100%;
45-
left: 50%;
46-
transform: translateX(-50%);
47-
margin-top: 4px;
48-
padding: 4px 8px;
49-
background-color: rgba(0, 0, 0, 0.9);
50-
color: white;
51-
border-radius: 4px;
52-
font-size: 12px;
53-
white-space: nowrap;
54-
pointer-events: none;
55-
z-index: 1000;
56-
}
57-
58-
.dark del {
59-
background-color: hsl(0 100 10);
60-
color: hsl(0 80 70);
61-
}
62-
63-
.dark del:hover::after {
64-
background-color: rgba(30, 30, 30, 0.95);
65-
color: hsl(0 80 70);
66-
border: 1px solid rgba(255, 255, 255, 0.1);
67-
}

examples/07-collaboration/13-versioning-yjs14/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import "./style.css";
2424
// HTTP) for the same document, so the backend URL, org, and docId are shared.
2525
const yhubHost = "yhub-standalone-x9kss.ondigitalocean.app";
2626
const org = "blocknote-versioning-yjs14";
27-
const docId = "blocknote-versioning-y-example-4";
27+
const docId = "blocknote-versioning-y-example-5";
2828

2929
const doc = new Y.Doc();
3030
// YHub expects clients to connect to `/ws/{org}/{docId}`. WebsocketProvider

examples/07-collaboration/14-multi-doc-versioning/src/DocumentEditor.tsx

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import "@blocknote/core/fonts/inter.css";
2-
import { withCollaboration, SuggestionsExtension } from "@blocknote/core/y";
3-
import { VersioningExtension } from "@blocknote/core/extensions";
2+
import {
3+
withCollaboration,
4+
SuggestionsExtension,
5+
createYHubVersioningEndpoints,
6+
} from "@blocknote/core/y";
7+
import {
8+
VersioningExtension,
9+
type VersioningEndpoints,
10+
} from "@blocknote/core/extensions";
411
import {
512
BlockNoteViewEditor,
613
useCreateBlockNote,
@@ -15,7 +22,7 @@ import { fromBase64 } from "lib0/buffer";
1522
import { WebsocketProvider } from "@y/websocket";
1623

1724
import type { DemoUser } from "./userdata.js";
18-
import { createLocalStorageVersioningEndpoints } from "./localStorageEndpoints.js";
25+
1926
import { HistorySidebar } from "./HistorySidebar.js";
2027

2128
/**
@@ -35,7 +42,7 @@ export function DocumentEditor({
3542
docTitle: string;
3643
onTouch: () => void;
3744
}) {
38-
const roomName = `bn-multi-doc-${workspaceId}-${docId}`;
45+
const roomName = `${workspaceId}/${docId}`;
3946

4047
// Stable refs for Y.js resources that persist for this mount
4148
const resourcesRef = useRef<{
@@ -44,9 +51,7 @@ export function DocumentEditor({
4451
provider: WebsocketProvider;
4552
suggestionProvider: WebsocketProvider;
4653
attributionManager: ReturnType<typeof Y.createAttributionManagerFromDiff>;
47-
versioningEndpoints: ReturnType<
48-
typeof createLocalStorageVersioningEndpoints
49-
>;
54+
versioningEndpoints: VersioningEndpoints;
5055
} | null>(null);
5156

5257
if (!resourcesRef.current) {
@@ -61,29 +66,28 @@ export function DocumentEditor({
6166
}
6267

6368
const suggestionDoc = new Y.Doc({ isSuggestionDoc: true });
69+
const yhubHost = "yhub-standalone-x9kss.ondigitalocean.app";
70+
6471
const provider = new WebsocketProvider(
65-
"wss://demos.yjs.dev/ws",
72+
`wss://${yhubHost}/ws`,
6673
roomName,
6774
doc,
68-
{ connect: false },
6975
);
7076
const suggestionProvider = new WebsocketProvider(
71-
"wss://demos.yjs.dev/ws",
77+
`wss://${yhubHost}/ws`,
7278
roomName + "-suggestions",
7379
suggestionDoc,
74-
{ connect: false },
7580
);
7681
const attributionManager = Y.createAttributionManagerFromDiff(
7782
doc,
7883
suggestionDoc,
7984
);
8085

81-
provider.connectBc();
82-
suggestionProvider.connectBc();
83-
84-
const versioningEndpoints = createLocalStorageVersioningEndpoints(
85-
`bn-versioning-${docId}`,
86-
);
86+
const versioningEndpoints = createYHubVersioningEndpoints({
87+
baseUrl: `https://${yhubHost}`,
88+
org: workspaceId,
89+
docId,
90+
});
8791

8892
resourcesRef.current = {
8993
doc,

examples/07-collaboration/14-multi-doc-versioning/src/style.css

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -64,31 +64,6 @@
6464
--mod-border: rgba(166, 190, 255, 0.85);
6565
}
6666

67-
/* ===== Base ===== */
68-
69-
* {
70-
box-sizing: border-box;
71-
}
72-
73-
html,
74-
body,
75-
#root {
76-
height: 100%;
77-
margin: 0;
78-
}
79-
80-
body {
81-
background: var(--bg);
82-
color: var(--text);
83-
font-family:
84-
-apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", system-ui,
85-
sans-serif;
86-
font-size: 14px;
87-
line-height: 1.5;
88-
-webkit-font-smoothing: antialiased;
89-
-moz-osx-font-smoothing: grayscale;
90-
}
91-
9267
button {
9368
font: inherit;
9469
color: inherit;

0 commit comments

Comments
 (0)