Skip to content

Commit 7c35493

Browse files
committed
feat: bump y deps/patches, add blockMatchNodes, update suggestion attribution marks
1 parent 76dd095 commit 7c35493

11 files changed

Lines changed: 373 additions & 735 deletions

File tree

packages/core/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@
109109
"@tiptap/pm": "^3.13.0",
110110
"emoji-mart": "^5.6.0",
111111
"fast-deep-equal": "^3.1.3",
112-
"lib0": "1.0.0-rc.13",
112+
"lib0": "1.0.0-rc.14",
113113
"prosemirror-highlight": "^0.15.1",
114114
"prosemirror-model": "^1.25.4",
115115
"prosemirror-state": "^1.4.4",
@@ -131,7 +131,7 @@
131131
"y-prosemirror": "^1.3.7",
132132
"y-protocols": "^1.0.6",
133133
"yjs": "^13.6.27",
134-
"@y/y": "^14.0.0-rc.16",
134+
"@y/y": "^14.0.0-rc.17",
135135
"@y/prosemirror": "^2.0.0-2",
136136
"@y/protocols": "^1.0.6-rc.1"
137137
},

packages/core/src/extensions/tiptap-extensions/Suggestions/SuggestionMarks.ts

Lines changed: 76 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,30 @@ import { MarkSpec } from "prosemirror-model";
66

77
// The ideal solution would be to not depend on tiptap nodes / marks, but be able to use prosemirror nodes / marks directly
88
// this way we could directly use the exported marks from @handlewithcare/prosemirror-suggest-changes
9+
10+
const formatAttributionTitle = (
11+
action: string,
12+
userIds: readonly string[] | null,
13+
timestamp: number | null,
14+
): string => {
15+
const who = userIds && userIds.length > 0 ? userIds.join(", ") : "unknown";
16+
const when =
17+
timestamp != null
18+
? new Date(timestamp).toLocaleString([], {
19+
dateStyle: "medium",
20+
timeStyle: "short",
21+
})
22+
: "unknown time";
23+
return `${action} by ${who} on ${when}`;
24+
};
925
export const SuggestionAddMark = Mark.create({
1026
name: "y-attributed-insert",
1127
inclusive: false,
12-
excludes: "",
28+
// excludes: "", TODO: what's desired?
1329
addAttributes() {
1430
return {
15-
id: { default: null, validate: "number" }, // note: validate is supported in prosemirror but not in tiptap, so this doesn't actually work (considered not critical)
31+
userIds: { default: null },
32+
timestamp: { default: null },
1633
"user-color": { default: null, validate: "string" },
1734
};
1835
},
@@ -28,7 +45,13 @@ export const SuggestionAddMark = Mark.create({
2845
return [
2946
"ins",
3047
{
31-
"data-id": String(mark.attrs["id"]),
48+
"data-description": formatAttributionTitle(
49+
"Inserted",
50+
mark.attrs["userIds"],
51+
mark.attrs["timestamp"],
52+
),
53+
"data-user-ids": JSON.stringify(mark.attrs["userIds"]),
54+
"data-timestamp": String(mark.attrs["timestamp"]),
3255
"data-user-color": String(mark.attrs["user-color"]),
3356
"data-inline": String(inline),
3457
style:
@@ -44,12 +67,15 @@ export const SuggestionAddMark = Mark.create({
4467
{
4568
tag: "ins",
4669
getAttrs(node) {
47-
if (!node.dataset["id"]) {
70+
if (!node.dataset["userIds"]) {
4871
return false;
4972
}
5073
return {
51-
id: parseInt(node.dataset["id"], 10),
52-
userColor: node.dataset["userColor"],
74+
userIds: JSON.parse(node.dataset["userIds"]),
75+
timestamp: node.dataset["timestamp"]
76+
? parseInt(node.dataset["timestamp"], 10)
77+
: null,
78+
"user-color": node.dataset["userColor"],
5379
};
5480
},
5581
},
@@ -61,10 +87,11 @@ export const SuggestionAddMark = Mark.create({
6187
export const SuggestionDeleteMark = Mark.create({
6288
name: "y-attributed-delete",
6389
inclusive: false,
64-
excludes: "",
90+
// excludes: "", TODO: what's desired?
6591
addAttributes() {
6692
return {
67-
id: { default: null, validate: "number" }, // note: validate is supported in prosemirror but not in tiptap
93+
userIds: { default: null },
94+
timestamp: { default: null },
6895
"user-color": { default: null, validate: "string" },
6996
};
7097
},
@@ -76,14 +103,17 @@ export const SuggestionDeleteMark = Mark.create({
76103
blocknoteIgnore: true,
77104
inclusive: false,
78105

79-
// attrs: {
80-
// id: { validate: "number" },
81-
// },
82106
toDOM(mark, inline) {
83107
return [
84108
"del",
85109
{
86-
"data-id": String(mark.attrs["id"]),
110+
"data-description": formatAttributionTitle(
111+
"Deleted",
112+
mark.attrs["userIds"],
113+
mark.attrs["timestamp"],
114+
),
115+
"data-user-ids": JSON.stringify(mark.attrs["userIds"]),
116+
"data-timestamp": String(mark.attrs["timestamp"]),
87117
"data-user-color": String(mark.attrs["user-color"]),
88118
"data-inline": String(inline),
89119
style:
@@ -99,12 +129,15 @@ export const SuggestionDeleteMark = Mark.create({
99129
{
100130
tag: "del",
101131
getAttrs(node) {
102-
if (!node.dataset["id"]) {
132+
if (!node.dataset["userIds"]) {
103133
return false;
104134
}
105135
return {
106-
id: parseInt(node.dataset["id"], 10),
107-
userColor: node.dataset["userColor"],
136+
userIds: JSON.parse(node.dataset["userIds"]),
137+
timestamp: node.dataset["timestamp"]
138+
? parseInt(node.dataset["timestamp"], 10)
139+
: null,
140+
"user-color": node.dataset["userColor"],
108141
};
109142
},
110143
},
@@ -116,10 +149,12 @@ export const SuggestionDeleteMark = Mark.create({
116149
export const SuggestionModificationMark = Mark.create({
117150
name: "y-attributed-format",
118151
inclusive: false,
119-
excludes: "",
152+
// excludes: "", TODO: what's desired?
120153
addAttributes() {
121154
return {
122-
id: { default: null, validate: "number" }, // note: validate is supported in prosemirror but not in tiptap
155+
userIds: { default: null },
156+
format: { default: null },
157+
timestamp: { default: null },
123158
"user-color": { default: null, validate: "string" },
124159
};
125160
},
@@ -134,8 +169,15 @@ export const SuggestionModificationMark = Mark.create({
134169
return [
135170
inline ? "span" : "div",
136171
{
172+
"data-description": formatAttributionTitle(
173+
"Modified",
174+
mark.attrs["userIds"],
175+
mark.attrs["timestamp"],
176+
),
137177
"data-type": "modification",
138-
"data-id": String(mark.attrs["id"]),
178+
"data-user-ids": JSON.stringify(mark.attrs["userIds"]),
179+
"data-format": JSON.stringify(mark.attrs["format"]),
180+
"data-timestamp": String(mark.attrs["timestamp"]),
139181
"data-user-color": String(mark.attrs["user-color"]),
140182
style:
141183
(inline ? "" : "display: contents") +
@@ -150,23 +192,35 @@ export const SuggestionModificationMark = Mark.create({
150192
{
151193
tag: "span[data-type='modification']",
152194
getAttrs(node) {
153-
if (!node.dataset["id"]) {
195+
if (!node.dataset["userIds"]) {
154196
return false;
155197
}
156198
return {
157-
id: parseInt(node.dataset["id"], 10),
199+
userIds: JSON.parse(node.dataset["userIds"]),
200+
format: node.dataset["format"]
201+
? JSON.parse(node.dataset["format"])
202+
: null,
203+
timestamp: node.dataset["timestamp"]
204+
? parseInt(node.dataset["timestamp"], 10)
205+
: null,
158206
"user-color": node.dataset["userColor"],
159207
};
160208
},
161209
},
162210
{
163211
tag: "div[data-type='modification']",
164212
getAttrs(node) {
165-
if (!node.dataset["id"]) {
213+
if (!node.dataset["userIds"]) {
166214
return false;
167215
}
168216
return {
169-
id: parseInt(node.dataset["id"], 10),
217+
userIds: JSON.parse(node.dataset["userIds"]),
218+
format: node.dataset["format"]
219+
? JSON.parse(node.dataset["format"])
220+
: null,
221+
timestamp: node.dataset["timestamp"]
222+
? parseInt(node.dataset["timestamp"], 10)
223+
: null,
170224
"user-color": node.dataset["userColor"],
171225
};
172226
},

packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import {
44
findChildrenInRange,
55
getChangedRanges,
66
} from "@tiptap/core";
7-
import { Fragment, Slice } from "prosemirror-model";
8-
import { Plugin, PluginKey } from "prosemirror-state";
97
import { uuidv4 } from "lib0/random";
8+
import { Fragment, Node, Slice } from "prosemirror-model";
9+
import { Plugin, PluginKey } from "prosemirror-state";
1010
import { isSuggestedDeletionNode } from "../../../api/getBlockInfoFromPos.js";
1111

1212
/**
@@ -42,6 +42,20 @@ function findDuplicates(items: any) {
4242
return duplicates;
4343
}
4444

45+
/**
46+
* Whether a node is marked as deleted by a suggestion (carries the
47+
* `y-attributed-delete` node mark).
48+
*
49+
* Under the suggestion/matchNodes binding, changing a block's content type
50+
* renders the block as a deleted copy (this mark) next to its inserted
51+
* replacement - and both copies share the same `id`. The deleted copy must be
52+
* ignored by the uniqueness logic, otherwise its `id` looks like a duplicate
53+
* and we'd regenerate the `id` on the surviving block.
54+
*/
55+
function isMarkedDeleted(node: Node) {
56+
return node.marks.some((mark) => mark.type.name === "y-attributed-delete");
57+
}
58+
4559
const UniqueID = Extension.create({
4660
name: "uniqueID",
4761
// we’ll set a very high priority to make sure this runs first
@@ -163,6 +177,10 @@ const UniqueID = Extension.create({
163177
const duplicatedNewIds = findDuplicates(newIds);
164178

165179
newNodes.forEach(({ node, pos }) => {
180+
// ignore ids on blocks marked as deleted (see above).
181+
if (isMarkedDeleted(node)) {
182+
return;
183+
}
166184
// instead of checking `node.attrs[attributeName]` directly
167185
// we look at the current state of the node within `tr.doc`.
168186
// this helps to prevent adding new ids to the same node

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type ExtensionOptions,
44
createExtension,
55
} from "../../editor/BlockNoteExtension.js";
6+
import { blockMatchNodes } from "./blockMatchNodes.js";
67
import { CollaborationOptions } from "./index.js";
78

89
/**
@@ -70,22 +71,26 @@ const mapAttributionToMark = (
7071

7172
if (attribution.insert) {
7273
out["y-attributed-insert"] = {
73-
id: attribution.insert[0] ?? null,
74+
userIds: attribution.insert,
75+
timestamp: attribution.insertAt ?? null,
7476
"user-color": colorForUserIds(attribution.insert),
7577
};
7678
}
7779

7880
if (attribution.delete) {
7981
out["y-attributed-delete"] = {
80-
id: attribution.delete[0] ?? null,
82+
userIds: attribution.delete,
83+
timestamp: attribution.deleteAt ?? null,
8184
"user-color": colorForUserIds(attribution.delete),
8285
};
8386
}
8487

8588
if (attribution.format) {
8689
const userIds = [...new Set(Object.values(attribution.format).flat())];
8790
out["y-attributed-format"] = {
88-
id: userIds[0] ?? null,
91+
userIds,
92+
format: attribution.format,
93+
timestamp: attribution.formatAt ?? null,
8994
"user-color": colorForUserIds(userIds),
9095
};
9196
}
@@ -118,6 +123,14 @@ export const YSyncExtension = createExtension(
118123
syncPlugin({
119124
suggestionDoc: options.suggestionDoc,
120125
mapAttributionToMark,
126+
// Node-pairing policy for the PM->Y diff: a `blockContainer` whose
127+
// block-content type changes is treated as a *different* node, so the
128+
// diff replaces the whole container (deleted + inserted siblings in
129+
// the blockGroup) instead of producing two block-contents in one
130+
// container => schema-invalid. No schema change / storage transform
131+
// needed; `blockContainer` already whitelists the `y-attributed-*`
132+
// marks. See blockMatchNodes.ts.
133+
matchNodes: blockMatchNodes,
121134
}),
122135
],
123136
runsBefore: ["default"],
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import * as delta from "lib0/delta";
2+
3+
/**
4+
* Canonical name of a content delta's first block child (the child carried by an
5+
* insert op), or `null`. For a BlockNote `blockContainer` (content
6+
* `blockContent blockGroup?`) this is its block-content type (paragraph,
7+
* heading, image, ...).
8+
*/
9+
const firstChildName = (d: delta.DeltaAny): string | null => {
10+
for (const op of (d as any).children) {
11+
if (delta.$insertOp.check(op)) {
12+
for (const it of op.insert) {
13+
if (delta.$deltaAny.check(it)) {
14+
return it.name;
15+
}
16+
}
17+
}
18+
}
19+
return null;
20+
};
21+
22+
/**
23+
* BlockNote's node-pairing policy for y-prosemirror's `matchNodes` option
24+
* (forwarded to `lib0/delta.diff`). This is the schema-specific bit that lives
25+
* in userland - the binding itself stays schema-agnostic.
26+
*
27+
* A `blockContainer` holds exactly one block content (`blockContent
28+
* blockGroup?`). Diffing a *type change* of that content as an in-place child
29+
* delete+insert would, under a suggestion, tombstone the old content next to the
30+
* new one => two block-contents in one container => schema-invalid. So we
31+
* declare a container's identity to be its first block-content child's type:
32+
* when that changes, the two containers are reported as *different*, the PM->Y
33+
* diff replaces the whole container, and the deleted + inserted containers sit
34+
* as siblings in the blockGroup (`blockGroupChild+` allows that). Each carries
35+
* the `y-attributed-*` node mark - which `blockContainer` already whitelists -
36+
* so no schema change and no storage transform are needed. A plain text edit
37+
* keeps the same first-child type => same identity => the diff descends and
38+
* merges as usual.
39+
*
40+
* @param a removed (old) node
41+
* @param b inserted (new) node
42+
* @returns whether `a` and `b` are the same node (diff in place) vs different (replace)
43+
*/
44+
export const blockMatchNodes = (
45+
a: delta.DeltaAny,
46+
b: delta.DeltaAny,
47+
): boolean =>
48+
(a as any).name === (b as any).name &&
49+
((a as any).name !== "blockContainer" ||
50+
firstChildName(a) === firstChildName(b));

packages/core/src/y/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from "./extensions/index.js";
22
export * from "./utils.js";
33
export * from "./comments/index.js";
4+
export * from "./versioning/index.js";

0 commit comments

Comments
 (0)