Skip to content

Commit 96b242b

Browse files
committed
feat: change event allows getting a list of the changes made
1 parent 4a3d364 commit 96b242b

File tree

4 files changed

+225
-6
lines changed

4 files changed

+225
-6
lines changed

packages/core/src/api/nodeUtil.ts

+207-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,21 @@
1-
import { Node } from "prosemirror-model";
1+
import {
2+
combineTransactionSteps,
3+
findChildrenInRange,
4+
getChangedRanges,
5+
} from "@tiptap/core";
6+
import type { Node } from "prosemirror-model";
7+
import type { Transaction } from "prosemirror-state";
8+
import {
9+
Block,
10+
DefaultBlockSchema,
11+
DefaultInlineContentSchema,
12+
DefaultStyleSchema,
13+
} from "../blocks/defaultBlocks.js";
14+
import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
15+
import type { BlockSchema } from "../schema/index.js";
16+
import type { InlineContentSchema } from "../schema/inlineContent/types.js";
17+
import type { StyleSchema } from "../schema/styles/types.js";
18+
import { nodeToBlock } from "./nodeConversions/nodeToBlock.js";
219

320
/**
421
* Get a TipTap node by id
@@ -36,3 +53,192 @@ export function getNodeById(
3653
posBeforeNode: posBeforeNode,
3754
};
3855
}
56+
57+
/**
58+
* This attributes the changes to a specific source.
59+
*/
60+
export type BlockChangeSource =
61+
| {
62+
/**
63+
* When an event is triggered by the local user, the source is "local".
64+
* This is the default source.
65+
*/
66+
type: "local";
67+
}
68+
| {
69+
/**
70+
* When an event is triggered by a paste operation, the source is "paste".
71+
*/
72+
type: "paste";
73+
}
74+
| {
75+
/**
76+
* When an event is triggered by a drop operation, the source is "drop".
77+
*/
78+
type: "drop";
79+
}
80+
| {
81+
/**
82+
* When an event is triggered by an undo or redo operation, the source is "undo" or "redo".
83+
*/
84+
type: "undo" | "redo";
85+
}
86+
| {
87+
/**
88+
* When an event is triggered by a remote user, the source is "remote".
89+
*/
90+
type: "remote";
91+
};
92+
93+
export type BlocksChanged<
94+
BSchema extends BlockSchema = DefaultBlockSchema,
95+
ISchema extends InlineContentSchema = DefaultInlineContentSchema,
96+
SSchema extends StyleSchema = DefaultStyleSchema
97+
> = Array<
98+
{
99+
/**
100+
* The affected block.
101+
*/
102+
block: Block<BSchema, ISchema, SSchema>;
103+
/**
104+
* The source of the change.
105+
*/
106+
source: BlockChangeSource;
107+
} & (
108+
| {
109+
type: "insert" | "delete";
110+
/**
111+
* Insert and delete changes don't have a previous block.
112+
*/
113+
prevBlock: undefined;
114+
}
115+
| {
116+
type: "update";
117+
/**
118+
* The block before the update.
119+
*/
120+
prevBlock: Block<BSchema, ISchema, SSchema>;
121+
}
122+
)
123+
>;
124+
125+
/**
126+
* Get the blocks that were changed by a transaction.
127+
* @param transaction The transaction to get the changes from.
128+
* @param editor The editor to get the changes from.
129+
* @returns The blocks that were changed by the transaction.
130+
*/
131+
export function getBlocksChangedByTransaction<
132+
BSchema extends BlockSchema = DefaultBlockSchema,
133+
ISchema extends InlineContentSchema = DefaultInlineContentSchema,
134+
SSchema extends StyleSchema = DefaultStyleSchema
135+
>(
136+
transaction: Transaction,
137+
editor: BlockNoteEditor<BSchema, ISchema, SSchema>
138+
): BlocksChanged<BSchema, ISchema, SSchema> {
139+
let source: BlockChangeSource = { type: "local" };
140+
141+
if (transaction.getMeta("paste")) {
142+
source = { type: "paste" };
143+
} else if (transaction.getMeta("uiEvent") === "drop") {
144+
source = { type: "drop" };
145+
} else if (transaction.getMeta("history$")) {
146+
source = {
147+
type: transaction.getMeta("history$").redo ? "redo" : "undo",
148+
};
149+
} else if (transaction.getMeta("y-sync$")) {
150+
source = { type: "remote" };
151+
}
152+
153+
const changes: BlocksChanged<BSchema, ISchema, SSchema> = [];
154+
// TODO when we upgrade to Tiptap v3, we can get the appendedTransactions which would give us things like the actual inserted Block IDs.
155+
// since they are appended to the transaction via the unique-id plugin
156+
const combinedTransaction = combineTransactionSteps(transaction.before, [
157+
transaction,
158+
...[] /*appendedTransactions*/,
159+
]);
160+
161+
let prevAffectedBlocks: Block<BSchema, ISchema, SSchema>[] = [];
162+
let nextAffectedBlocks: Block<BSchema, ISchema, SSchema>[] = [];
163+
164+
getChangedRanges(combinedTransaction).forEach((range) => {
165+
// All the blocks that were in the range before the transaction
166+
prevAffectedBlocks = prevAffectedBlocks.concat(
167+
...findChildrenInRange(
168+
combinedTransaction.before,
169+
range.oldRange,
170+
(node) => node.type.isInGroup("bnBlock")
171+
).map(({ node }) =>
172+
nodeToBlock(
173+
node,
174+
editor.schema.blockSchema,
175+
editor.schema.inlineContentSchema,
176+
editor.schema.styleSchema,
177+
editor.blockCache
178+
)
179+
)
180+
);
181+
// All the blocks that were in the range after the transaction
182+
nextAffectedBlocks = nextAffectedBlocks.concat(
183+
findChildrenInRange(combinedTransaction.doc, range.newRange, (node) =>
184+
node.type.isInGroup("bnBlock")
185+
).map(({ node }) =>
186+
nodeToBlock(
187+
node,
188+
editor.schema.blockSchema,
189+
editor.schema.inlineContentSchema,
190+
editor.schema.styleSchema,
191+
editor.blockCache
192+
)
193+
)
194+
);
195+
});
196+
197+
// de-duplicate by block ID
198+
const nextBlockIds = new Set(nextAffectedBlocks.map((block) => block.id));
199+
const prevBlockIds = new Set(prevAffectedBlocks.map((block) => block.id));
200+
201+
// All blocks that are newly inserted (since they did not exist in the previous state)
202+
const addedBlockIds = Array.from(nextBlockIds).filter(
203+
(id) => !prevBlockIds.has(id)
204+
);
205+
206+
addedBlockIds.forEach((blockId) => {
207+
changes.push({
208+
type: "insert",
209+
block: nextAffectedBlocks.find((block) => block.id === blockId)!,
210+
source,
211+
prevBlock: undefined,
212+
});
213+
});
214+
215+
// All blocks that are newly removed (since they did not exist in the previous state)
216+
const removedBlockIds = Array.from(prevBlockIds).filter(
217+
(id) => !nextBlockIds.has(id)
218+
);
219+
220+
removedBlockIds.forEach((blockId) => {
221+
changes.push({
222+
type: "delete",
223+
block: prevAffectedBlocks.find((block) => block.id === blockId)!,
224+
source,
225+
prevBlock: undefined,
226+
});
227+
});
228+
229+
// All blocks that are updated (since they exist in both the previous and next state)
230+
const updatedBlockIds = Array.from(nextBlockIds).filter((id) =>
231+
prevBlockIds.has(id)
232+
);
233+
234+
updatedBlockIds.forEach((blockId) => {
235+
changes.push({
236+
type: "update",
237+
block: nextAffectedBlocks.find((block) => block.id === blockId)!,
238+
prevBlock: prevAffectedBlocks.find((block) => block.id === blockId)!,
239+
source,
240+
});
241+
});
242+
243+
return changes;
244+
}

packages/core/src/editor/BlockNoteEditor.ts

+14-3
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ import { ySyncPluginKey } from "y-prosemirror";
107107
import { createInternalHTMLSerializer } from "../api/exporters/html/internalHTMLSerializer.js";
108108
import { inlineContentToNodes } from "../api/nodeConversions/blockToNode.js";
109109
import { nodeToBlock } from "../api/nodeConversions/nodeToBlock.js";
110+
import {
111+
BlocksChanged,
112+
getBlocksChangedByTransaction,
113+
} from "../api/nodeUtil.js";
110114
import { nestedListsToBlockNoteStructure } from "../api/parsers/html/util/nestedLists.js";
111115
import { CodeBlockOptions } from "../blocks/CodeBlockContent/CodeBlockContent.js";
112116
import type { ThreadStore, User } from "../comments/index.js";
@@ -1466,15 +1470,22 @@ export class BlockNoteEditor<
14661470
* @returns A function to remove the callback.
14671471
*/
14681472
public onChange(
1469-
callback: (editor: BlockNoteEditor<BSchema, ISchema, SSchema>) => void
1473+
callback: (
1474+
editor: BlockNoteEditor<BSchema, ISchema, SSchema>,
1475+
context: {
1476+
getChanges(): BlocksChanged<BSchema, ISchema, SSchema>;
1477+
}
1478+
) => void
14701479
) {
14711480
if (this.headless) {
14721481
// Note: would be nice if this is possible in headless mode as well
14731482
return;
14741483
}
14751484

1476-
const cb = () => {
1477-
callback(this);
1485+
const cb = ({ transaction }: { transaction: Transaction }) => {
1486+
callback(this, {
1487+
getChanges: () => getBlocksChangedByTransaction(transaction, this),
1488+
});
14781489
};
14791490

14801491
this._tiptapEditor.on("update", cb);

packages/react/src/editor/BlockNoteView.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ export type BlockNoteViewProps<
7171
/**
7272
* A callback function that runs whenever the editor's contents change.
7373
*/
74-
onChange?: () => void;
74+
onChange?: Parameters<
75+
BlockNoteEditor<BSchema, ISchema, SSchema>["onChange"]
76+
>[0];
7577

7678
children?: ReactNode;
7779

packages/react/src/hooks/useEditorChange.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useEffect } from "react";
33
import { useBlockNoteContext } from "../editor/BlockNoteContext.js";
44

55
export function useEditorChange(
6-
callback: () => void,
6+
callback: Parameters<BlockNoteEditor<any, any, any>["onChange"]>[0],
77
editor?: BlockNoteEditor<any, any, any>
88
) {
99
const editorContext = useBlockNoteContext();

0 commit comments

Comments
 (0)