diff --git a/packages/core/src/api/positionMapping.test.ts b/packages/core/src/api/positionMapping.test.ts new file mode 100644 index 000000000..6b45e1da2 --- /dev/null +++ b/packages/core/src/api/positionMapping.test.ts @@ -0,0 +1,370 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import * as Y from "yjs"; +import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { trackPosition } from "./positionMapping.js"; + +describe("PositionStorage with local editor", () => { + let editor: BlockNoteEditor; + + beforeEach(() => { + editor = BlockNoteEditor.create(); + editor.mount(document.createElement("div")); + }); + + afterEach(() => { + editor.mount(undefined); + editor._tiptapEditor.destroy(); + }); + + describe("mount and unmount", () => { + it("should register transaction handler on creation", () => { + editor._tiptapEditor.on = vi.fn(); + trackPosition(editor, 0); + + expect(editor._tiptapEditor.on).toHaveBeenCalledWith( + "transaction", + expect.any(Function) + ); + }); + }); + + describe("set and get positions", () => { + it("should store and retrieve positions without Y.js", () => { + const getPos = trackPosition(editor, 10); + + expect(getPos()).toBe(10); + }); + + it("should handle right side positions", () => { + const getPos = trackPosition(editor, 10, "right"); + + expect(getPos()).toBe(10); + }); + }); + + it("should update mapping for local transactions before the position", () => { + // Set initial content + editor.insertBlocks( + [ + { + id: "1", + type: "paragraph", + content: [ + { + type: "text", + text: "Hello World", + styles: {}, + }, + ], + }, + ], + editor.document[0], + "before" + ); + + // Start tracking + const getPos = trackPosition(editor, 10); + + // Move the cursor to the start of the document + editor.setTextCursorPosition(editor.document[0], "start"); + + // Insert text at the start of the document + editor.insertInlineContent([ + { + type: "text", + text: "Test", + styles: {}, + }, + ]); + + // Position should be updated according to mapping + expect(getPos()).toBe(14); + }); + + it("should not update mapping for local transactions after the position", () => { + // Set initial content + editor.insertBlocks( + [ + { + id: "1", + type: "paragraph", + content: [ + { + type: "text", + text: "Hello World", + styles: {}, + }, + ], + }, + ], + editor.document[0], + "before" + ); + // Start tracking + const getPos = trackPosition(editor, 10); + + // Move the cursor to the end of the document + editor.setTextCursorPosition(editor.document[0], "end"); + + // Insert text at the end of the document + editor.insertInlineContent([ + { + type: "text", + text: "Test", + styles: {}, + }, + ]); + + // Position should not be updated + expect(getPos()).toBe(10); + }); + + it("should track positions on each side", () => { + editor.replaceBlocks(editor.document, [ + { + type: "paragraph", + content: "Hello World", + }, + ]); + + // Store position at "Hello| World" + const getCursorPos = trackPosition(editor, 6); + const getStartPos = trackPosition(editor, 3); + const getStartRightPos = trackPosition(editor, 3, "right"); + const getPosAfterPos = trackPosition(editor, 4); + const getPosAfterRightPos = trackPosition(editor, 4, "right"); + // Insert text at the beginning + editor._tiptapEditor.commands.insertContentAt(3, "Test "); + + // Position should be updated + expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length) + expect(getStartPos()).toBe(3); // 3 + expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length) + expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length) + expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length) + }); + + it("should handle multiple transactions", () => { + editor.replaceBlocks(editor.document, [ + { + type: "paragraph", + content: "Hello World", + }, + ]); + + // Store position at "Hello| World" + const getCursorPos = trackPosition(editor, 6); + const getStartPos = trackPosition(editor, 3); + const getStartRightPos = trackPosition(editor, 3, "right"); + const getPosAfterPos = trackPosition(editor, 4); + const getPosAfterRightPos = trackPosition(editor, 4, "right"); + + // Insert text at the beginning + editor._tiptapEditor.commands.insertContentAt(3, "T"); + editor._tiptapEditor.commands.insertContentAt(4, "e"); + editor._tiptapEditor.commands.insertContentAt(5, "s"); + editor._tiptapEditor.commands.insertContentAt(6, "t"); + editor._tiptapEditor.commands.insertContentAt(7, " "); + + // Position should be updated + expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length) + expect(getStartPos()).toBe(3); // 3 + expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length) + expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length) + expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length) + }); +}); + +describe("PositionStorage with remote editor", () => { + // Function to sync two documents + function syncDocs(sourceDoc: Y.Doc, targetDoc: Y.Doc) { + // Create update message from source + const update = Y.encodeStateAsUpdate(sourceDoc); + + // Apply update to target + Y.applyUpdate(targetDoc, update); + } + + // Set up two-way sync + function setupTwoWaySync(doc1: Y.Doc, doc2: Y.Doc) { + // Sync initial states + syncDocs(doc1, doc2); + syncDocs(doc2, doc1); + + // Set up observers for future changes + doc1.on("update", (update: Uint8Array) => { + Y.applyUpdate(doc2, update); + }); + + doc2.on("update", (update: Uint8Array) => { + Y.applyUpdate(doc1, update); + }); + } + + describe("remote editor", () => { + let localEditor: BlockNoteEditor; + let remoteEditor: BlockNoteEditor; + let ydoc: Y.Doc; + let remoteYdoc: Y.Doc; + + beforeEach(() => { + ydoc = new Y.Doc(); + remoteYdoc = new Y.Doc(); + // Create a mock editor + localEditor = BlockNoteEditor.create({ + collaboration: { + fragment: ydoc.getXmlFragment("doc"), + user: { color: "#ff0000", name: "Local User" }, + provider: undefined, + }, + }); + const div = document.createElement("div"); + localEditor.mount(div); + + remoteEditor = BlockNoteEditor.create({ + collaboration: { + fragment: remoteYdoc.getXmlFragment("doc"), + user: { color: "#ff0000", name: "Remote User" }, + provider: undefined, + }, + }); + + const remoteDiv = document.createElement("div"); + remoteEditor.mount(remoteDiv); + setupTwoWaySync(ydoc, remoteYdoc); + }); + + afterEach(() => { + ydoc.destroy(); + remoteYdoc.destroy(); + localEditor.mount(undefined); + localEditor._tiptapEditor.destroy(); + remoteEditor.mount(undefined); + remoteEditor._tiptapEditor.destroy(); + }); + + it("should update the local position when collaborating", () => { + localEditor.replaceBlocks(localEditor.document, [ + { + type: "paragraph", + content: "Hello World", + }, + ]); + + // Store position at "Hello| World" + const getCursorPos = trackPosition(localEditor, 6); + // Store position at "|Hello World" + const getStartPos = trackPosition(localEditor, 3); + // Store position at "|Hello World" (but on the right side) + const getStartRightPos = trackPosition(localEditor, 3, "right"); + // Store position at "H|ello World" + const getPosAfterPos = trackPosition(localEditor, 4); + // Store position at "H|ello World" (but on the right side) + const getPosAfterRightPos = trackPosition(localEditor, 4, "right"); + + // Insert text at the beginning + localEditor._tiptapEditor.commands.insertContentAt(3, "Test "); + + // Position should be updated + expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length) + expect(getStartPos()).toBe(3); // 3 + expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length) + expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length) + expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length) + }); + + it("should handle multiple transactions when collaborating", () => { + localEditor.replaceBlocks(localEditor.document, [ + { + type: "paragraph", + content: "Hello World", + }, + ]); + + // Store position at "Hello| World" + const getCursorPos = trackPosition(localEditor, 6); + // Store position at "|Hello World" + const getStartPos = trackPosition(localEditor, 3); + // Store position at "|Hello World" (but on the right side) + const getStartRightPos = trackPosition(localEditor, 3, "right"); + // Store position at "H|ello World" + const getPosAfterPos = trackPosition(localEditor, 4); + // Store position at "H|ello World" (but on the right side) + const getPosAfterRightPos = trackPosition(localEditor, 4, "right"); + + // Insert text at the beginning + localEditor._tiptapEditor.commands.insertContentAt(3, "T"); + localEditor._tiptapEditor.commands.insertContentAt(4, "e"); + localEditor._tiptapEditor.commands.insertContentAt(5, "s"); + localEditor._tiptapEditor.commands.insertContentAt(6, "t"); + localEditor._tiptapEditor.commands.insertContentAt(7, " "); + + // Position should be updated + expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length) + expect(getStartPos()).toBe(3); // 3 + expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length) + expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length) + expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length) + }); + + it("should update the local position from a remote transaction", () => { + remoteEditor.replaceBlocks(remoteEditor.document, [ + { + type: "paragraph", + content: "Hello World", + }, + ]); + + // Store position at "Hello| World" + const getCursorPos = trackPosition(localEditor, 6); + // Store position at "|Hello World" + const getStartPos = trackPosition(localEditor, 3); + // Store position at "|Hello World" (but on the right side) + const getStartRightPos = trackPosition(localEditor, 3, "right"); + // Store position at "H|ello World" + const getPosAfterPos = trackPosition(localEditor, 4); + // Store position at "H|ello World" (but on the right side) + const getPosAfterRightPos = trackPosition(localEditor, 4, "right"); + + // Insert text at the beginning + localEditor._tiptapEditor.commands.insertContentAt(3, "Test "); + + // Position should be updated + expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length) + expect(getStartPos()).toBe(3); // 3 + expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length) + expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length) + expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length) + }); + + it("should update the remote position from a remote transaction", () => { + remoteEditor.replaceBlocks(remoteEditor.document, [ + { + type: "paragraph", + content: "Hello World", + }, + ]); + + // Store position at "Hello| World" + const getCursorPos = trackPosition(remoteEditor, 6); + // Store position at "|Hello World" + const getStartPos = trackPosition(remoteEditor, 3); + // Store position at "|Hello World" (but on the right side) + const getStartRightPos = trackPosition(remoteEditor, 3, "right"); + // Store position at "H|ello World" + const getPosAfterPos = trackPosition(remoteEditor, 4); + // Store position at "H|ello World" (but on the right side) + const getPosAfterRightPos = trackPosition(remoteEditor, 4, "right"); + + // Insert text at the beginning + localEditor._tiptapEditor.commands.insertContentAt(3, "Test "); + + // Position should be updated + expect(getCursorPos()).toBe(11); // 6 + 5 ("Test " length) + expect(getStartPos()).toBe(3); // 3 + expect(getStartRightPos()).toBe(8); // 3 + 5 ("Test " length) + expect(getPosAfterPos()).toBe(9); // 4 + 5 ("Test " length) + expect(getPosAfterRightPos()).toBe(9); // 4 + 5 ("Test " length) + }); + }); +}); diff --git a/packages/core/src/api/positionMapping.ts b/packages/core/src/api/positionMapping.ts new file mode 100644 index 000000000..1f89e7b32 --- /dev/null +++ b/packages/core/src/api/positionMapping.ts @@ -0,0 +1,112 @@ +import { Mapping } from "prosemirror-transform"; +import { + absolutePositionToRelativePosition, + relativePositionToAbsolutePosition, + ySyncPluginKey, +} from "y-prosemirror"; +import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import * as Y from "yjs"; +import type { ProsemirrorBinding } from "y-prosemirror"; + +/** + * This is used to track a mapping for each editor. The mapping stores the mappings for each transaction since the first transaction that was tracked. + */ +const editorToMapping = new Map<BlockNoteEditor<any, any, any>, Mapping>(); + +/** + * This initializes a single mapping for an editor instance. + */ +function getMapping(editor: BlockNoteEditor<any, any, any>) { + if (editorToMapping.has(editor)) { + // Mapping already initialized, so we don't need to do anything + return editorToMapping.get(editor)!; + } + const mapping = new Mapping(); + editor._tiptapEditor.on("transaction", ({ transaction }) => { + mapping.appendMapping(transaction.mapping); + }); + editor._tiptapEditor.on("destroy", () => { + // Cleanup the mapping when the editor is destroyed + editorToMapping.delete(editor); + }); + + // There only is one mapping per editor, so we can just set it + editorToMapping.set(editor, mapping); + + return mapping; +} + +/** + * This is used to keep track of positions of elements in the editor. + * It is needed because y-prosemirror's sync plugin can disrupt normal prosemirror position mapping. + * + * It is specifically made to be able to be used whether the editor is being used in a collaboratively, or single user, providing the same API. + * + * @param editor The editor to track the position of. + * @param position The position to track. + * @param side The side of the position to track. "left" is the default. "right" would move with the change if the change is in the right direction. + * @returns A function that returns the position of the element. + */ +export function trackPosition( + /** + * The editor to track the position of. + */ + editor: BlockNoteEditor<any, any, any>, + /** + * The position to track. + */ + position: number, + /** + * This is the side of the position to track. "left" is the default. "right" would move with the change if the change is in the right direction. + */ + side: "left" | "right" = "left" +): () => number { + const ySyncPluginState = ySyncPluginKey.getState(editor.prosemirrorState) as { + doc: Y.Doc; + binding: ProsemirrorBinding; + }; + + if (!ySyncPluginState) { + // No y-prosemirror sync plugin, so we need to track the mapping manually + // This will initialize the mapping for this editor, if needed + const mapping = getMapping(editor); + + // This is the start point of tracking the mapping + const trackedMapLength = mapping.maps.length; + + return () => { + const pos = mapping + // Only read the history of the mapping that we care about + .slice(trackedMapLength) + .map(position, side === "left" ? -1 : 1); + + return pos; + }; + } + + const relativePosition = absolutePositionToRelativePosition( + // Track the position after the position if we are on the right side + position + (side === "right" ? 1 : 0), + ySyncPluginState.binding.type, + ySyncPluginState.binding.mapping + ); + + return () => { + const curYSyncPluginState = ySyncPluginKey.getState( + editor.prosemirrorState + ) as typeof ySyncPluginState; + const pos = relativePositionToAbsolutePosition( + curYSyncPluginState.doc, + curYSyncPluginState.binding.type, + relativePosition, + curYSyncPluginState.binding.mapping + ); + + // This can happen if the element is garbage collected + if (pos === null) { + throw new Error("Position not found, cannot track positions"); + } + + return pos + (side === "right" ? -1 : 0); + }; +} diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index aa8ffea6e..1a9a0abe7 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -723,6 +723,7 @@ export class BlockNoteEditor< // but we still need the schema this.pmSchema = getSchema(tiptapOptions.extensions!); } + this.emit("create"); } diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts index c21a8ddb5..26495d186 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts @@ -10,6 +10,7 @@ import { StyleSchema, } from "../../schema/index.js"; import { EventEmitter } from "../../util/EventEmitter.js"; +import { trackPosition } from "../../api/positionMapping.js"; const findBlock = findParentNode((node) => node.type.name === "blockContainer"); @@ -129,7 +130,7 @@ class SuggestionMenuView< .focus() .deleteRange({ from: - this.pluginState.queryStartPos! - + this.pluginState.queryStartPos() - (this.pluginState.deleteTriggerCharacter ? this.pluginState.triggerCharacter!.length : 0), @@ -143,7 +144,7 @@ type SuggestionPluginState = | { triggerCharacter: string; deleteTriggerCharacter: boolean; - queryStartPos: number; + queryStartPos: () => number; query: string; decorationId: string; ignoreQueryLength?: boolean; @@ -220,13 +221,22 @@ export class SuggestionMenuProseMirrorPlugin< suggestionPluginTransactionMeta !== null && prev === undefined ) { + const trackedPosition = trackPosition( + editor, + newState.selection.from - + // Need to account for the trigger char that was inserted, so we offset the position by the length of the trigger character. + suggestionPluginTransactionMeta.triggerCharacter.length + ); return { triggerCharacter: suggestionPluginTransactionMeta.triggerCharacter, deleteTriggerCharacter: suggestionPluginTransactionMeta.deleteTriggerCharacter !== false, - queryStartPos: newState.selection.from, + // When reading the queryStartPos, we offset the result by the length of the trigger character, to make it easy on the caller + queryStartPos: () => + trackedPosition() + + suggestionPluginTransactionMeta.triggerCharacter.length, query: "", decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`, ignoreQueryLength: @@ -252,7 +262,7 @@ export class SuggestionMenuProseMirrorPlugin< transaction.getMeta("pointer") || // Moving the caret before the character which triggered the menu should hide it. (prev.triggerCharacter !== undefined && - newState.selection.from < prev.queryStartPos!) + newState.selection.from < prev.queryStartPos()) ) { return undefined; } @@ -261,7 +271,7 @@ export class SuggestionMenuProseMirrorPlugin< // Updates the current query. next.query = newState.doc.textBetween( - prev.queryStartPos!, + prev.queryStartPos(), newState.selection.from ); @@ -324,9 +334,9 @@ export class SuggestionMenuProseMirrorPlugin< // Creates an inline decoration around the trigger character. return DecorationSet.create(state.doc, [ Decoration.inline( - suggestionPluginState.queryStartPos! - + suggestionPluginState.queryStartPos() - suggestionPluginState.triggerCharacter!.length, - suggestionPluginState.queryStartPos!, + suggestionPluginState.queryStartPos(), { nodeName: "span", class: "bn-suggestion-decorator",