Skip to content

Commit f696334

Browse files
committed
feat: decorations approach for y-prosemirror
1 parent 7399a46 commit f696334

8 files changed

Lines changed: 1805 additions & 666 deletions

File tree

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

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import "@blocknote/core/fonts/inter.css";
33
import "@blocknote/mantine/style.css";
44
import { BlockNoteView } from "@blocknote/mantine";
55
import { useCreateBlockNote } from "@blocknote/react";
6-
import { Awareness } from "@y/protocols/awareness";
6+
import {
7+
Awareness,
8+
encodeAwarenessUpdate,
9+
applyAwarenessUpdate,
10+
} from "@y/protocols/awareness";
711
import { withCollaboration } from "@blocknote/core/y";
812
import * as Y from "@y/y";
913

@@ -80,6 +84,36 @@ function setupTwoWaySync(doc1: Y.Doc, doc2: Y.Doc) {
8084
setupTwoWaySync(doc, doc2);
8185
setupTwoWaySync(suggestingDoc, suggestionModeDoc);
8286

87+
// Sync awareness states so cursors show up across editors
88+
function setupAwarenessSync(a1: Awareness, a2: Awareness) {
89+
// Initial sync
90+
applyAwarenessUpdate(
91+
a2,
92+
encodeAwarenessUpdate(a1, [a1.clientID]),
93+
"sync",
94+
);
95+
applyAwarenessUpdate(
96+
a1,
97+
encodeAwarenessUpdate(a2, [a2.clientID]),
98+
"sync",
99+
);
100+
101+
a1.on("update", ({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }) => {
102+
const update = encodeAwarenessUpdate(a1, added.concat(updated).concat(removed));
103+
applyAwarenessUpdate(a2, update, "sync");
104+
});
105+
106+
a2.on("update", ({ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }) => {
107+
const update = encodeAwarenessUpdate(a2, added.concat(updated).concat(removed));
108+
applyAwarenessUpdate(a1, update, "sync");
109+
});
110+
}
111+
112+
setupAwarenessSync(provider.awareness, provider2.awareness);
113+
setupAwarenessSync(suggestingProvider.awareness, suggestionModeProvider.awareness);
114+
setupAwarenessSync(provider.awareness, suggestingProvider.awareness);
115+
setupAwarenessSync(provider.awareness, suggestionModeProvider.awareness);
116+
83117
function Editor({
84118
fragment,
85119
provider,

packages/core/src/editor/Block.css

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ BASIC STYLES
3333
transition: all 0.2s;
3434
/* Workaround for selection issue on Chrome, see #1588 and also here:
3535
https://discuss.prosemirror.net/t/mouse-down-selection-behaviour-different-on-chrome/8426
36-
The :before element causes the selection to be set in the wrong place vs
37-
other browsers. Setting no height fixes this, while list item indicators are
36+
The :before element causes the selection to be set in the wrong place vs
37+
other browsers. Setting no height fixes this, while list item indicators are
3838
still displayed fine as overflow is not hidden. */
3939
height: 0;
4040
overflow: visible;
@@ -740,3 +740,59 @@ div[data-type="modification"] {
740740
text-decoration: line-through;
741741
text-decoration-thickness: 1px;
742742
}
743+
744+
/* Suggestion decoration styling (data-diff-type drives everything) */
745+
[data-diff-type="inline-insert"],
746+
[data-diff-type="block-insert"] {
747+
background-color: color-mix(
748+
in srgb,
749+
var(--author-color, #28a745) 22%,
750+
transparent
751+
);
752+
text-decoration: underline;
753+
text-decoration-color: var(--author-color, #28a745);
754+
text-decoration-thickness: 2px;
755+
border-radius: 2px;
756+
}
757+
758+
[data-diff-type="inline-delete"],
759+
[data-diff-type="block-delete"] {
760+
background-color: color-mix(
761+
in srgb,
762+
var(--author-color, #dc3545) 14%,
763+
transparent
764+
);
765+
text-decoration: line-through;
766+
text-decoration-color: var(--author-color, #dc3545);
767+
color: #555;
768+
border-radius: 2px;
769+
}
770+
[data-diff-type="block-delete"] {
771+
display: block;
772+
padding: 2px 4px;
773+
margin: 2px 0;
774+
}
775+
[data-diff-type="block-delete"] * {
776+
text-decoration: line-through;
777+
text-decoration-color: var(--author-color, #dc3545);
778+
color: #555;
779+
}
780+
[data-diff-type="inline-delete"] {
781+
padding: 0 1px;
782+
}
783+
784+
[data-diff-type="inline-update"],
785+
[data-diff-type="block-update"] {
786+
outline: 1.5px dashed var(--author-color, #ffc107);
787+
outline-offset: 1px;
788+
border-radius: 2px;
789+
}
790+
791+
[data-diff-type="block-delete"] strong,
792+
[data-diff-type="inline-delete"] strong {
793+
font-weight: normal;
794+
}
795+
[data-diff-type="block-delete"] em,
796+
[data-diff-type="inline-delete"] em {
797+
font-style: normal;
798+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { describe, expect, it } from "vitest";
2+
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
3+
import { Fragment } from "prosemirror-model";
4+
import { findWrappingPath, wrapFragmentInDoc } from "./YSuggestions.js";
5+
6+
/**
7+
* @vitest-environment jsdom
8+
*/
9+
10+
describe("findWrappingPath", () => {
11+
it("finds path [doc, blockGroup, blockContainer] for a checkListItem node", () => {
12+
const editor = BlockNoteEditor.create();
13+
const schema = editor.pmSchema;
14+
15+
const checkListItem = schema.nodes.checkListItem.create(
16+
{
17+
backgroundColor: "default",
18+
textColor: "default",
19+
textAlignment: "left",
20+
checked: false,
21+
},
22+
[schema.text("ds")],
23+
);
24+
25+
const path = findWrappingPath(schema, checkListItem);
26+
27+
expect(path).not.toBeNull();
28+
expect(path!.map((t) => t.name)).toEqual([
29+
"doc",
30+
"blockGroup",
31+
"blockContainer",
32+
]);
33+
});
34+
35+
it("finds path [doc, blockGroup, blockContainer] for a paragraph node", () => {
36+
const editor = BlockNoteEditor.create();
37+
const schema = editor.pmSchema;
38+
39+
const paragraph = schema.nodes.paragraph.create(null, [
40+
schema.text("hello"),
41+
]);
42+
43+
const path = findWrappingPath(schema, paragraph);
44+
45+
expect(path).not.toBeNull();
46+
expect(path!.map((t) => t.name)).toEqual([
47+
"doc",
48+
"blockGroup",
49+
"blockContainer",
50+
]);
51+
});
52+
});
53+
54+
describe("wrapFragmentInDoc", () => {
55+
it("wraps a fragment with a single checkListItem", () => {
56+
const editor = BlockNoteEditor.create();
57+
const schema = editor.pmSchema;
58+
59+
const checkListItem = schema.nodes.checkListItem.create(
60+
{
61+
backgroundColor: "default",
62+
textColor: "default",
63+
textAlignment: "left",
64+
checked: false,
65+
},
66+
[schema.text("ds")],
67+
);
68+
const fragment = Fragment.from(checkListItem);
69+
70+
const result = wrapFragmentInDoc(fragment, schema);
71+
72+
expect(result).not.toBeNull();
73+
expect(result!.type.name).toBe("doc");
74+
// Walk down to verify the content
75+
const blockGroup = result!.firstChild!;
76+
expect(blockGroup.type.name).toBe("blockGroup");
77+
expect(blockGroup.childCount).toBe(1);
78+
const bc = blockGroup.firstChild!;
79+
expect(bc.type.name).toBe("blockContainer");
80+
expect(bc.firstChild!.type.name).toBe("checkListItem");
81+
expect(bc.firstChild!.textContent).toBe("ds");
82+
});
83+
84+
it("wraps a fragment with multiple block content nodes", () => {
85+
const editor = BlockNoteEditor.create();
86+
const schema = editor.pmSchema;
87+
88+
const item1 = schema.nodes.checkListItem.create(
89+
{
90+
backgroundColor: "default",
91+
textColor: "default",
92+
textAlignment: "left",
93+
checked: false,
94+
},
95+
[schema.text("first")],
96+
);
97+
const item2 = schema.nodes.paragraph.create(null, [
98+
schema.text("second"),
99+
]);
100+
const fragment = Fragment.from([item1, item2]);
101+
102+
const result = wrapFragmentInDoc(fragment, schema);
103+
104+
expect(result).not.toBeNull();
105+
expect(result!.type.name).toBe("doc");
106+
const blockGroup = result!.firstChild!;
107+
expect(blockGroup.type.name).toBe("blockGroup");
108+
// Should have two blockContainers
109+
expect(blockGroup.childCount).toBe(2);
110+
expect(blockGroup.child(0).firstChild!.type.name).toBe("checkListItem");
111+
expect(blockGroup.child(0).firstChild!.textContent).toBe("first");
112+
expect(blockGroup.child(1).firstChild!.type.name).toBe("paragraph");
113+
expect(blockGroup.child(1).firstChild!.textContent).toBe("second");
114+
});
115+
116+
it("returns null for an empty fragment", () => {
117+
const editor = BlockNoteEditor.create();
118+
const result = wrapFragmentInDoc(Fragment.empty, editor.pmSchema);
119+
expect(result).toBeNull();
120+
});
121+
});

0 commit comments

Comments
 (0)