Skip to content

Commit ace6dcc

Browse files
committed
feat: versioning + suggesting in y/prosemirror
1 parent 076ed16 commit ace6dcc

File tree

15 files changed

+15417
-622
lines changed

15 files changed

+15417
-622
lines changed

examples/07-collaboration/09-versioning/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
"@mantine/core": "^8.3.4",
2121
"@mantine/hooks": "^8.3.4",
2222
"@mantine/utils": "^6.0.22",
23+
"@y/protocols": "1.0.6-3",
24+
"@y/websocket": "^4.0.0-3",
2325
"@y/y": "14.0.0-19",
2426
"react": "^19.2.1",
2527
"react-dom": "^19.2.1",

examples/07-collaboration/09-versioning/src/App.tsx

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import "@blocknote/mantine/style.css";
1717
import { useEffect, useMemo, useState } from "react";
1818
import { RiChat3Line, RiHistoryLine } from "react-icons/ri";
1919
import * as Y from "@y/y";
20+
import { Awareness } from "@y/protocols/awareness";
21+
import { WebsocketProvider } from "@y/websocket";
2022

2123
import { getRandomColor, HARDCODED_USERS, MyUserType } from "./userdata";
2224
import { SettingsSelect } from "./SettingsSelect";
@@ -32,7 +34,29 @@ import { VersionHistorySidebar } from "./VersionHistorySidebar";
3234
import { SuggestionActions } from "./SuggestionActions";
3335
import { SuggestionActionsPopup } from "./SuggestionActionsPopup";
3436

37+
const roomName = "blocknote-versioning-example";
3538
const doc = new Y.Doc();
39+
const provider = new WebsocketProvider(
40+
"wss://demos.yjs.dev/ws",
41+
roomName,
42+
doc,
43+
{ connect: false },
44+
);
45+
provider.connectBc();
46+
47+
const suggestionModeDoc = new Y.Doc({ isSuggestionDoc: true });
48+
const suggestionModeProvider = new WebsocketProvider(
49+
"wss://demos.yjs.dev/ws",
50+
roomName + "-suggestions",
51+
suggestionModeDoc,
52+
{ connect: false },
53+
);
54+
const suggestionModeAttributionManager = Y.createAttributionManagerFromDiff(
55+
doc,
56+
suggestionModeDoc,
57+
{ attrs: [Y.createAttributionItem("insert", ["nickthesick"])] },
58+
);
59+
suggestionModeProvider.connectBc();
3660

3761
async function resolveUsers(userIds: string[]) {
3862
// fake a (slow) network request
@@ -54,6 +78,9 @@ export default function App() {
5478

5579
const editor = useCreateBlockNote({
5680
collaboration: {
81+
provider,
82+
suggestionDoc: suggestionModeDoc,
83+
attributionManager: suggestionModeAttributionManager,
5784
fragment: doc.getXmlFragment(),
5885
user: { color: getRandomColor(), name: activeUser.username },
5986
},
@@ -67,8 +94,12 @@ export default function App() {
6794
],
6895
});
6996

70-
const { enableSuggestions, disableSuggestions, checkUnresolvedSuggestions } =
71-
useExtension(SuggestionsExtension, { editor });
97+
const {
98+
enableSuggestions,
99+
disableSuggestions,
100+
showSuggestions,
101+
checkUnresolvedSuggestions,
102+
} = useExtension(SuggestionsExtension, { editor });
72103
const hasUnresolvedSuggestions = useEditorState({
73104
selector: () => checkUnresolvedSuggestions(),
74105
editor,
@@ -79,11 +110,14 @@ export default function App() {
79110
editor,
80111
});
81112

82-
const [editingMode, setEditingMode] = useState<"editing" | "suggestions">(
83-
"editing",
84-
);
113+
const [editingMode, setEditingMode] = useState<
114+
"editing" | "suggestions" | "view-suggestions"
115+
>("editing");
85116
useEffect(() => {
86-
setEditingMode("editing");
117+
if (editingMode !== "editing") {
118+
disableSuggestions();
119+
setEditingMode("editing");
120+
}
87121
}, [selectedSnapshotId]);
88122
const [sidebar, setSidebar] = useState<
89123
"comments" | "versionHistory" | "none"
@@ -169,7 +203,16 @@ export default function App() {
169203
isSelected: editingMode === "editing",
170204
},
171205
{
172-
text: "Suggestions",
206+
text: "Editing + Viewing Suggestions",
207+
icon: null,
208+
onClick: () => {
209+
showSuggestions();
210+
setEditingMode("view-suggestions");
211+
},
212+
isSelected: editingMode === "view-suggestions",
213+
},
214+
{
215+
text: "Suggesting",
173216
icon: null,
174217
onClick: () => {
175218
enableSuggestions();

examples/07-collaboration/09-versioning/src/SuggestionActionsPopup.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const SuggestionActionsPopup = () => {
2929
const [suggestion, setSuggestion] = useState<
3030
| {
3131
cursorType: "text" | "mouse";
32-
id: string;
32+
range: { from: number; to: number };
3333
element: HTMLElement;
3434
}
3535
| undefined
@@ -47,7 +47,7 @@ export const SuggestionActionsPopup = () => {
4747

4848
setSuggestion({
4949
cursorType: "text",
50-
id: textCursorSuggestion.mark.attrs.id as string,
50+
range: textCursorSuggestion.range,
5151
element: getSuggestionElementAtPos(textCursorSuggestion.range.from)!,
5252
});
5353

@@ -80,7 +80,7 @@ export const SuggestionActionsPopup = () => {
8080

8181
setSuggestion({
8282
cursorType: "mouse",
83-
id: mouseCursorSuggestion.mark.attrs.id as string,
83+
range: mouseCursorSuggestion.range,
8484
element: getSuggestionElementAtPos(mouseCursorSuggestion.range.from)!,
8585
});
8686
};
@@ -156,15 +156,19 @@ export const SuggestionActionsPopup = () => {
156156
<Components.Generic.Toolbar.Button
157157
label="Apply Change"
158158
icon={<RiCheckLine />}
159-
onClick={() => applySuggestion(suggestion.id)}
159+
onClick={() =>
160+
applySuggestion(suggestion.range.from, suggestion.range.to)
161+
}
160162
mainTooltip="Apply Change"
161163
>
162164
{/* Apply Change */}
163165
</Components.Generic.Toolbar.Button>
164166
<Components.Generic.Toolbar.Button
165167
label="Revert Change"
166168
icon={<RiArrowGoBackLine />}
167-
onClick={() => revertSuggestion(suggestion.id)}
169+
onClick={() =>
170+
revertSuggestion(suggestion.range.from, suggestion.range.to)
171+
}
168172
mainTooltip="Revert Change"
169173
>
170174
{/* Revert Change */}

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@
3737
"overrides": {
3838
"@headlessui/react": "^2.2.4",
3939
"@tiptap/core": "^3.0.0",
40-
"@tiptap/pm": "^3.0.0"
40+
"@tiptap/pm": "^3.0.0",
41+
"lib0": "0.2.117"
4142
},
4243
"patchedDependencies": {
43-
"@y/prosemirror": "patches/@y__prosemirror.patch"
44+
"@y/prosemirror": "patches/@y__prosemirror.patch",
45+
"@y/y": "patches/@y__y.patch"
4446
}
4547
},
4648
"packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b",

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@
112112
"emoji-mart": "^5.6.0",
113113
"fast-deep-equal": "^3.1.3",
114114
"hast-util-from-dom": "^5.0.1",
115-
"lib0": "0.2.116",
115+
"lib0": "0.2.117",
116116
"prosemirror-dropcursor": "^1.8.2",
117117
"prosemirror-highlight": "^0.13.0",
118118
"prosemirror-model": "^1.25.4",

packages/core/src/extensions/Collaboration/Collaboration.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ export type CollaborationOptions = {
4040
/**
4141
* The attribution manager for the collaboration.
4242
*/
43-
attributionManager?: Y.AbstractAttributionManager | Y.DiffAttributionManager;
43+
attributionManager?: Y.DiffAttributionManager;
44+
/**
45+
* The suggestion doc for the collaboration. If using suggestion mode
46+
*/
47+
suggestionDoc?: Y.Doc;
4448
};
4549

4650
export const CollaborationExtension = createExtension(

packages/core/src/extensions/Collaboration/YSync.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,20 @@ export const YSyncExtension = createExtension(
99
({
1010
options,
1111
}: ExtensionOptions<
12-
Pick<CollaborationOptions, "fragment" | "attributionManager">
12+
Pick<
13+
CollaborationOptions,
14+
"fragment" | "attributionManager" | "suggestionDoc"
15+
>
1316
>) => {
1417
return {
1518
key: "ySync",
1619
prosemirrorPlugins: [
1720
syncPlugin(options.fragment, {
1821
attributionManager: options.attributionManager,
22+
suggestionDoc: options.suggestionDoc,
1923
mapAttributionToMark(format, attribution) {
24+
console.log("attribution", attribution);
25+
console.log("format", format);
2026
if (attribution.delete) {
2127
return Object.assign({}, format, {
2228
deletion: { id: Date.now(), user: attribution.delete?.[0] },
@@ -27,6 +33,11 @@ export const YSyncExtension = createExtension(
2733
insertion: { id: Date.now(), user: attribution.insert?.[0] },
2834
});
2935
}
36+
if (attribution.format) {
37+
return Object.assign({}, format, {
38+
insertion: { id: Date.now(), user: attribution.format?.[0] },
39+
});
40+
}
3041
return format;
3142
},
3243
}),

packages/core/src/extensions/Suggestions/Suggestions.ts

Lines changed: 51 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,7 @@
1-
import {
2-
applySuggestion,
3-
applySuggestions,
4-
disableSuggestChanges,
5-
enableSuggestChanges,
6-
revertSuggestion,
7-
revertSuggestions,
8-
suggestChanges,
9-
withSuggestChanges,
10-
} from "@handlewithcare/prosemirror-suggest-changes";
111
import { getMarkRange, posToDOMRect } from "@tiptap/core";
122

133
import { createExtension } from "../../editor/BlockNoteExtension.js";
4+
import { ySyncPluginKey } from "@y/prosemirror";
145

156
export const SuggestionsExtension = createExtension(({ editor }) => {
167
function getSuggestionElementAtPos(pos: number) {
@@ -74,51 +65,56 @@ export const SuggestionsExtension = createExtension(({ editor }) => {
7465

7566
return {
7667
key: "suggestions",
77-
prosemirrorPlugins: [suggestChanges()],
78-
enableSuggestions: () =>
79-
enableSuggestChanges(
80-
editor.prosemirrorState,
81-
(editor._tiptapEditor as any).dispatchTransaction.bind(
82-
editor._tiptapEditor,
83-
),
84-
),
85-
disableSuggestions: () =>
86-
disableSuggestChanges(
87-
editor.prosemirrorState,
88-
(editor._tiptapEditor as any).dispatchTransaction.bind(
89-
editor._tiptapEditor,
90-
),
91-
),
92-
applySuggestion: (id: string) =>
93-
applySuggestion(id)(
94-
editor.prosemirrorState,
95-
withSuggestChanges(editor.prosemirrorView.dispatch).bind(
96-
editor._tiptapEditor,
97-
),
98-
editor.prosemirrorView,
99-
),
100-
revertSuggestion: (id: string) =>
101-
revertSuggestion(id)(
102-
editor.prosemirrorState,
103-
withSuggestChanges(editor.prosemirrorView.dispatch).bind(
104-
editor._tiptapEditor,
105-
),
106-
editor.prosemirrorView,
107-
),
108-
applyAllSuggestions: () =>
109-
applySuggestions(
110-
editor.prosemirrorState,
111-
withSuggestChanges(editor.prosemirrorView.dispatch).bind(
112-
editor._tiptapEditor,
113-
),
114-
),
115-
revertAllSuggestions: () =>
116-
revertSuggestions(
117-
editor.prosemirrorState,
118-
withSuggestChanges(editor.prosemirrorView.dispatch).bind(
119-
editor._tiptapEditor,
120-
),
121-
),
68+
runsBefore: ["ySync"],
69+
showSuggestions: () => {
70+
const pluginState = ySyncPluginKey.getState(editor.prosemirrorState);
71+
if (!pluginState) {
72+
throw new Error("ySync plugin state not found");
73+
}
74+
pluginState.setSuggestionMode("view");
75+
},
76+
enableSuggestions: () => {
77+
const pluginState = ySyncPluginKey.getState(editor.prosemirrorState);
78+
if (!pluginState) {
79+
throw new Error("ySync plugin state not found");
80+
}
81+
pluginState.setSuggestionMode("edit");
82+
},
83+
disableSuggestions: () => {
84+
const pluginState = ySyncPluginKey.getState(editor.prosemirrorState);
85+
if (!pluginState) {
86+
throw new Error("ySync plugin state not found");
87+
}
88+
pluginState.setSuggestionMode("off");
89+
},
90+
applySuggestion: (start: number, end?: number) => {
91+
const pluginState = ySyncPluginKey.getState(editor.prosemirrorState);
92+
if (!pluginState) {
93+
throw new Error("ySync plugin state not found");
94+
}
95+
pluginState.acceptChanges(start, end);
96+
},
97+
revertSuggestion: (start: number, end?: number) => {
98+
const pluginState = ySyncPluginKey.getState(editor.prosemirrorState);
99+
if (!pluginState) {
100+
throw new Error("ySync plugin state not found");
101+
}
102+
pluginState.rejectChanges(start, end);
103+
},
104+
applyAllSuggestions: () => {
105+
const pluginState = ySyncPluginKey.getState(editor.prosemirrorState);
106+
if (!pluginState) {
107+
throw new Error("ySync plugin state not found");
108+
}
109+
pluginState.acceptAllChanges();
110+
},
111+
revertAllSuggestions: () => {
112+
const pluginState = ySyncPluginKey.getState(editor.prosemirrorState);
113+
if (!pluginState) {
114+
throw new Error("ySync plugin state not found");
115+
}
116+
pluginState.rejectAllChanges();
117+
},
122118

123119
getSuggestionElementAtPos,
124120
getMarkAtPos,

0 commit comments

Comments
 (0)