From 0ae54a2b4f73ad836a999763a651593794cd8e9c Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 14 Mar 2025 12:47:19 +0100 Subject: [PATCH 1/8] feat: position storage WIP --- packages/core/src/api/positionMapping.test.ts | 184 ++++++++++++++++++ packages/core/src/api/positionMapping.ts | 175 +++++++++++++++++ 2 files changed, 359 insertions(+) create mode 100644 packages/core/src/api/positionMapping.test.ts create mode 100644 packages/core/src/api/positionMapping.ts diff --git a/packages/core/src/api/positionMapping.test.ts b/packages/core/src/api/positionMapping.test.ts new file mode 100644 index 000000000..86b1fc81b --- /dev/null +++ b/packages/core/src/api/positionMapping.test.ts @@ -0,0 +1,184 @@ +import { Transaction } from "prosemirror-state"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { + absolutePositionToRelativePosition, + initProseMirrorDoc, + relativePositionToAbsolutePosition, + ySyncPluginKey, +} from "y-prosemirror"; +import * as Y from "yjs"; +import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { PositionStorage } from "./positionMapping.js"; +import { Mapping, StepMap } from "prosemirror-transform"; + +describe("PositionStorage", () => { + let editor: BlockNoteEditor; + let positionStorage: PositionStorage; + let ydoc: Y.Doc | undefined; + + beforeEach(() => { + ydoc = new Y.Doc(); + // Create a mock editor + editor = BlockNoteEditor.create({ + collaboration: { + fragment: ydoc.getXmlFragment("doc"), + user: { color: "#ff0000", name: "My Username" }, + provider: undefined, + }, + }); + + // Create a new PositionStorage instance + positionStorage = new PositionStorage(editor); + }); + + afterEach(() => { + if (ydoc) { + ydoc.destroy(); + ydoc = undefined; + } + }); + + describe("mount and unmount", () => { + it("should register transaction handler on mount", () => { + positionStorage.mount(); + + expect(editor._tiptapEditor.on).toHaveBeenCalledWith( + "transaction", + expect.any(Function) + ); + }); + + it("should unregister transaction handler on unmount", () => { + const unmount = positionStorage.mount(); + unmount(); + + expect(editor._tiptapEditor.off).toHaveBeenCalledWith( + "transaction", + expect.any(Function) + ); + }); + + it("should clear position mapping on unmount", () => { + const unmount = positionStorage.mount(); + + // Set a position + positionStorage.set("test-id", 10); + + // Unmount + unmount(); + + // Try to get the position (should throw) + expect(() => positionStorage.get("test-id")).toThrow(); + }); + }); + + describe("set and get positions", () => { + beforeEach(() => { + positionStorage.mount(); + }); + + it("should store and retrieve positions without Y.js", () => { + positionStorage.set("test-id", 10); + expect(positionStorage.get("test-id")).toBe(10); + }); + + it("should handle right side positions", () => { + positionStorage.set("test-id", 10, "right"); + expect(positionStorage.get("test-id")).toBe(10); + }); + + it("should throw when getting a non-existent position", () => { + expect(() => positionStorage.get("non-existent")).toThrow(); + }); + + it("should remove positions", () => { + positionStorage.set("test-id", 10); + positionStorage.remove("test-id"); + expect(() => positionStorage.get("test-id")).toThrow(); + }); + }); + + describe("transaction handling", () => { + beforeEach(() => { + positionStorage.mount(); + positionStorage.set("test-id", 10); + }); + + it("should update mapping for local transactions", () => { + // Create a mock transaction with mapping + const mockMapping = new Mapping(); + mockMapping.appendMap(new StepMap([0, 0, 5])); + const mockTransaction = { + getMeta: vi.fn().mockReturnValue(undefined), + mapping: mockMapping, + } as unknown as Transaction; + + // // Simulate transaction + // mockOnTransaction({ transaction: mockTransaction }); + + // Position should be updated according to mapping + expect(positionStorage.get("test-id")).toBe(15); + }); + + // it("should switch to relative positions after remote transaction", () => { + // const ydoc = new Y.Doc(); + // const type = ydoc.get("prosemirror", Y.XmlFragment); + // const { doc: pmDoc, mapping } = initProseMirrorDoc(type, schema); + // // Create a mock remote transaction + // const mockRemoteTransaction = { + // getMeta: vi.fn().mockReturnValue({ + // doc: ydoc, + // binding: { + // type: ydoc.getXmlFragment("doc"), + // mapping, + // }, + // } satisfies YSyncPluginState), + // } as unknown as Transaction; + + // // Simulate remote transaction + // mockOnTransaction({ transaction: mockRemoteTransaction }); + + // // Position should now be based on relative position + // expect(positionStorage.get("test-id")).toBe(21); // 20 + 1 for left side + // }); + }); + + describe("integration with editor", () => { + it("should track positions through document changes", () => { + // Create a real editor + const realEditor = BlockNoteEditor.create({ + initialContent: [ + { + type: "paragraph", + content: "Hello World", + }, + ], + }); + + const div = document.createElement("div"); + realEditor.mount(div); + + const storage = new PositionStorage(realEditor); + storage.mount(); + + // Store position at "Hello|World" + storage.set("cursor", 6); + storage.set("start", 3); + storage.set("after-start", 3, "right"); + storage.set("pos-after", 4); + + console.log(realEditor.document); + // Insert text at the beginning + realEditor._tiptapEditor.commands.insertContentAt(3, "Test "); + console.log(realEditor.document); + + // Position should be updated + expect(storage.get("cursor")).toBe(11); // 6 + 5 ("Test " length) + expect(storage.get("start")).toBe(3); // 3 + expect(storage.get("after-start")).toBe(8); // 3 + 5 ("Test " length) + expect(storage.get("pos-after")).toBe(9); // 4 + 5 ("Test " length) + // Clean up + storage.unmount(); + }); + }); +}); diff --git a/packages/core/src/api/positionMapping.ts b/packages/core/src/api/positionMapping.ts new file mode 100644 index 000000000..783b2899d --- /dev/null +++ b/packages/core/src/api/positionMapping.ts @@ -0,0 +1,175 @@ +import { Transaction } from "prosemirror-state"; +import { Mapping } from "prosemirror-transform"; +import { + absolutePositionToRelativePosition, + relativePositionToAbsolutePosition, + ySyncPluginKey, +} from "y-prosemirror"; +import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import * as Y from "yjs"; +import { ProsemirrorBinding } from "y-prosemirror"; + +export function isRemoteTransaction(tr: Transaction) { + return tr.getMeta(ySyncPluginKey) !== undefined; +} +type YSyncPluginState = { + doc: Y.Doc; + binding: Pick; +}; +type RelativePosition = symbol; + +/** + * This class 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. + */ +export class PositionStorage { + private readonly editor: BlockNoteEditor; + /** + * Whether the editor has had a remote transaction. + */ + private hadRemoteTransaction = false; + /** + * A map of an ID to the position mapping. + */ + private readonly positionMapping = new Map< + string, + { + position: number; + relativePosition: RelativePosition | undefined; + mapping: Mapping; + side: "left" | "right"; + } + >(); + + constructor(editor: BlockNoteEditor) { + this.editor = editor; + this.onTransactionHandler = this.onTransactionHandler.bind(this); + } + + /** + * Mounts the position storage. + */ + public mount() { + this.editor._tiptapEditor.on("transaction", this.onTransactionHandler); + + return this.unmount.bind(this); + } + + /** + * Unmounts the position storage. + */ + public unmount() { + this.positionMapping.clear(); + this.editor._tiptapEditor.off("transaction", this.onTransactionHandler); + } + + /** + * This will be called whenever a transaction is applied to the editor. + * + * It's used to update the position mapping or tell if there was a remote transaction. + */ + private onTransactionHandler({ transaction }: { transaction: Transaction }) { + console.log("onTransactionHandler", transaction); + if (this.hadRemoteTransaction) { + // If we have already had a remote transaction, we rely only on relative positions + return; + } + + if (isRemoteTransaction(transaction)) { + this.hadRemoteTransaction = true; + } else { + this.positionMapping.forEach(({ mapping }) => { + mapping.appendMapping(transaction.mapping); + }); + } + } + + /** + * Stores a position for a given ID. To consistently track the position of an element. + * + * @param id An ID to store the position of. + * @param position The position to store. + * @param side The side of the position to store. + */ + public set(id: string, position: number, side?: "left" | "right") { + const ySyncPluginState = ySyncPluginKey.getState( + this.editor._tiptapEditor.state + ) as YSyncPluginState; + + if (!ySyncPluginState) { + // TODO unsure if this works + this.positionMapping.set(id, { + position, + relativePosition: undefined, + mapping: new Mapping(), + side: side ?? "left", + }); + return this; + } + + const relativePosition = absolutePositionToRelativePosition( + // Track the position before the position + position + (side === "left" ? -1 : 0), + ySyncPluginState.binding.type, + ySyncPluginState.binding.mapping + ); + + this.positionMapping.set(id, { + position, + relativePosition, + mapping: new Mapping(), + side: side ?? "left", + }); + + return this; + } + + public get(id: string): number { + const storedPos = this.positionMapping.get(id); + + console.log(storedPos); + + if (!storedPos) { + throw new Error("No mapping found for id: " + id); + } + + if (this.hadRemoteTransaction) { + // If we have had a remote transaction, we need to rely on the relative position + if (!storedPos.relativePosition) { + throw new Error("No relative position found for id: " + id); + } + + const ystate = ySyncPluginKey.getState( + this.editor._tiptapEditor.state + ) as YSyncPluginState; + const rel = relativePositionToAbsolutePosition( + ystate.doc, + ystate.binding.type, + storedPos.relativePosition, + ystate.binding.mapping + ); + + if (rel === null) { + // TODO when does this happen? + return -1; + } + + return rel + (storedPos.side === "left" ? 1 : -1); + } + + return ( + storedPos.mapping.map( + storedPos.position - (storedPos.side === "left" ? 1 : 0), + storedPos.side === "left" ? -1 : 1 + ) + (storedPos.side === "left" ? 1 : 0) + ); + } + + public remove(id: string) { + this.positionMapping.delete(id); + + return this; + } +} From 7ac974940f511d8d9f0536357afd4d9a5cac87b5 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 28 Mar 2025 16:38:00 +0100 Subject: [PATCH 2/8] feat: working position mapping --- packages/core/src/api/positionMapping.test.ts | 448 +++++++++++++----- packages/core/src/api/positionMapping.ts | 85 ++-- packages/core/src/editor/BlockNoteEditor.ts | 11 + 3 files changed, 381 insertions(+), 163 deletions(-) diff --git a/packages/core/src/api/positionMapping.test.ts b/packages/core/src/api/positionMapping.test.ts index 86b1fc81b..d9eb6085d 100644 --- a/packages/core/src/api/positionMapping.test.ts +++ b/packages/core/src/api/positionMapping.test.ts @@ -1,82 +1,36 @@ -import { Transaction } from "prosemirror-state"; import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; -import { - absolutePositionToRelativePosition, - initProseMirrorDoc, - relativePositionToAbsolutePosition, - ySyncPluginKey, -} from "y-prosemirror"; import * as Y from "yjs"; import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; import { PositionStorage } from "./positionMapping.js"; -import { Mapping, StepMap } from "prosemirror-transform"; -describe("PositionStorage", () => { +describe("PositionStorage with local editor", () => { let editor: BlockNoteEditor; let positionStorage: PositionStorage; - let ydoc: Y.Doc | undefined; beforeEach(() => { - ydoc = new Y.Doc(); - // Create a mock editor - editor = BlockNoteEditor.create({ - collaboration: { - fragment: ydoc.getXmlFragment("doc"), - user: { color: "#ff0000", name: "My Username" }, - provider: undefined, - }, - }); - - // Create a new PositionStorage instance + editor = BlockNoteEditor.create(); + editor.mount(document.createElement("div")); positionStorage = new PositionStorage(editor); }); afterEach(() => { - if (ydoc) { - ydoc.destroy(); - ydoc = undefined; - } + editor.mount(undefined); + editor._tiptapEditor.destroy(); }); describe("mount and unmount", () => { - it("should register transaction handler on mount", () => { - positionStorage.mount(); + it("should register transaction handler on creation", () => { + editor._tiptapEditor.on = vi.fn(); + new PositionStorage(editor); expect(editor._tiptapEditor.on).toHaveBeenCalledWith( "transaction", expect.any(Function) ); }); - - it("should unregister transaction handler on unmount", () => { - const unmount = positionStorage.mount(); - unmount(); - - expect(editor._tiptapEditor.off).toHaveBeenCalledWith( - "transaction", - expect.any(Function) - ); - }); - - it("should clear position mapping on unmount", () => { - const unmount = positionStorage.mount(); - - // Set a position - positionStorage.set("test-id", 10); - - // Unmount - unmount(); - - // Try to get the position (should throw) - expect(() => positionStorage.get("test-id")).toThrow(); - }); }); describe("set and get positions", () => { - beforeEach(() => { - positionStorage.mount(); - }); - it("should store and retrieve positions without Y.js", () => { positionStorage.set("test-id", 10); expect(positionStorage.get("test-id")).toBe(10); @@ -87,98 +41,346 @@ describe("PositionStorage", () => { expect(positionStorage.get("test-id")).toBe(10); }); - it("should throw when getting a non-existent position", () => { - expect(() => positionStorage.get("non-existent")).toThrow(); + it("should be undefined when getting a non-existent position", () => { + expect(positionStorage.get("non-existent")).toBeUndefined(); }); it("should remove positions", () => { positionStorage.set("test-id", 10); positionStorage.remove("test-id"); - expect(() => positionStorage.get("test-id")).toThrow(); + expect(positionStorage.get("test-id")).toBeUndefined(); }); }); - describe("transaction handling", () => { + 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 + positionStorage.set("test-id", 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(positionStorage.get("test-id")).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 + positionStorage.set("test-id", 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(positionStorage.get("test-id")).toBe(10); + }); + + it("should track positions on each side", () => { + editor.replaceBlocks(editor.document, [ + { + type: "paragraph", + content: "Hello World", + }, + ]); + + // Store position at "Hello| World" + positionStorage.set("cursor", 6); + positionStorage.set("start", 3); + positionStorage.set("start-right", 3, "right"); + positionStorage.set("pos-after", 4); + positionStorage.set("pos-after-right", 4, "right"); + + // Insert text at the beginning + editor._tiptapEditor.commands.insertContentAt(3, "Test "); + + // Position should be updated + expect(positionStorage.get("cursor")).toBe(11); // 6 + 5 ("Test " length) + expect(positionStorage.get("start")).toBe(3); // 3 + expect(positionStorage.get("start-right")).toBe(8); // 3 + 5 ("Test " length) + expect(positionStorage.get("pos-after")).toBe(9); // 4 + 5 ("Test " length) + expect(positionStorage.get("pos-after-right")).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" + positionStorage.set("cursor", 6); + positionStorage.set("start", 3); + positionStorage.set("start-right", 3, "right"); + positionStorage.set("pos-after", 4); + positionStorage.set("pos-after-right", 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(positionStorage.get("cursor")).toBe(11); // 6 + 5 ("Test " length) + expect(positionStorage.get("start")).toBe(3); // 3 + expect(positionStorage.get("start-right")).toBe(8); // 3 + 5 ("Test " length) + expect(positionStorage.get("pos-after")).toBe(9); // 4 + 5 ("Test " length) + expect(positionStorage.get("pos-after-right")).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 localPositionStorage: PositionStorage; + let remoteEditor: BlockNoteEditor; + let remotePositionStorage: PositionStorage; + let ydoc: Y.Doc; + let remoteYdoc: Y.Doc; + beforeEach(() => { - positionStorage.mount(); - positionStorage.set("test-id", 10); + 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); + // Create a new PositionStorage instance + localPositionStorage = new PositionStorage(localEditor); + + remoteEditor = BlockNoteEditor.create({ + collaboration: { + fragment: remoteYdoc.getXmlFragment("doc"), + user: { color: "#ff0000", name: "Remote User" }, + provider: undefined, + }, + }); + + const remoteDiv = document.createElement("div"); + remoteEditor.mount(remoteDiv); + remotePositionStorage = new PositionStorage(remoteEditor); + setupTwoWaySync(ydoc, remoteYdoc); }); - it("should update mapping for local transactions", () => { - // Create a mock transaction with mapping - const mockMapping = new Mapping(); - mockMapping.appendMap(new StepMap([0, 0, 5])); - const mockTransaction = { - getMeta: vi.fn().mockReturnValue(undefined), - mapping: mockMapping, - } as unknown as Transaction; + 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", + }, + ]); - // // Simulate transaction - // mockOnTransaction({ transaction: mockTransaction }); + // Store position at "Hello| World" + localPositionStorage.set("cursor", 6); + // Store position at "|Hello World" + localPositionStorage.set("start", 3); + // Store position at "|Hello World" (but on the right side) + localPositionStorage.set("start-right", 3, "right"); + // Store position at "H|ello World" + localPositionStorage.set("pos-after", 4); + // Store position at "H|ello World" (but on the right side) + localPositionStorage.set("pos-after-right", 4, "right"); - // Position should be updated according to mapping - expect(positionStorage.get("test-id")).toBe(15); + // Insert text at the beginning + localEditor._tiptapEditor.commands.insertContentAt(3, "Test "); + + // Position should be updated + expect(localPositionStorage.get("cursor")).toBe(11); // 6 + 5 ("Test " length) + expect(localPositionStorage.get("start")).toBe(3); // 3 + expect(localPositionStorage.get("start-right")).toBe(8); // 3 + 5 ("Test " length) + expect(localPositionStorage.get("pos-after")).toBe(9); // 4 + 5 ("Test " length) + expect(localPositionStorage.get("pos-after-right")).toBe(9); // 4 + 5 ("Test " length) }); - // it("should switch to relative positions after remote transaction", () => { - // const ydoc = new Y.Doc(); - // const type = ydoc.get("prosemirror", Y.XmlFragment); - // const { doc: pmDoc, mapping } = initProseMirrorDoc(type, schema); - // // Create a mock remote transaction - // const mockRemoteTransaction = { - // getMeta: vi.fn().mockReturnValue({ - // doc: ydoc, - // binding: { - // type: ydoc.getXmlFragment("doc"), - // mapping, - // }, - // } satisfies YSyncPluginState), - // } as unknown as Transaction; - - // // Simulate remote transaction - // mockOnTransaction({ transaction: mockRemoteTransaction }); - - // // Position should now be based on relative position - // expect(positionStorage.get("test-id")).toBe(21); // 20 + 1 for left side - // }); - }); + it("should handle multiple transactions when collaborating", () => { + localEditor.replaceBlocks(localEditor.document, [ + { + type: "paragraph", + content: "Hello World", + }, + ]); - describe("integration with editor", () => { - it("should track positions through document changes", () => { - // Create a real editor - const realEditor = BlockNoteEditor.create({ - initialContent: [ - { - type: "paragraph", - content: "Hello World", - }, - ], - }); + // Store position at "Hello| World" + localPositionStorage.set("cursor", 6); + // Store position at "|Hello World" + localPositionStorage.set("start", 3); + // Store position at "|Hello World" (but on the right side) + localPositionStorage.set("start-right", 3, "right"); + // Store position at "H|ello World" + localPositionStorage.set("pos-after", 4); + // Store position at "H|ello World" (but on the right side) + localPositionStorage.set("pos-after-right", 4, "right"); - const div = document.createElement("div"); - realEditor.mount(div); + // 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(localPositionStorage.get("cursor")).toBe(11); // 6 + 5 ("Test " length) + expect(localPositionStorage.get("start")).toBe(3); // 3 + expect(localPositionStorage.get("start-right")).toBe(8); // 3 + 5 ("Test " length) + expect(localPositionStorage.get("pos-after")).toBe(9); // 4 + 5 ("Test " length) + expect(localPositionStorage.get("pos-after-right")).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" + localPositionStorage.set("cursor", 6); + // Store position at "|Hello World" + localPositionStorage.set("start", 3); + // Store position at "|Hello World" (but on the right side) + localPositionStorage.set("start-right", 3, "right"); + // Store position at "H|ello World" + localPositionStorage.set("pos-after", 4); + // Store position at "H|ello World" (but on the right side) + localPositionStorage.set("pos-after-right", 4, "right"); + + // Insert text at the beginning + localEditor._tiptapEditor.commands.insertContentAt(3, "Test "); + + // Position should be updated + expect(localPositionStorage.get("cursor")).toBe(11); // 6 + 5 ("Test " length) + expect(localPositionStorage.get("start")).toBe(3); // 3 + expect(localPositionStorage.get("start-right")).toBe(8); // 3 + 5 ("Test " length) + expect(localPositionStorage.get("pos-after")).toBe(9); // 4 + 5 ("Test " length) + expect(localPositionStorage.get("pos-after-right")).toBe(9); // 4 + 5 ("Test " length) + }); - const storage = new PositionStorage(realEditor); - storage.mount(); + it("should update the remote position from a remote transaction", () => { + remoteEditor.replaceBlocks(remoteEditor.document, [ + { + type: "paragraph", + content: "Hello World", + }, + ]); - // Store position at "Hello|World" - storage.set("cursor", 6); - storage.set("start", 3); - storage.set("after-start", 3, "right"); - storage.set("pos-after", 4); + // Store position at "Hello| World" + remotePositionStorage.set("cursor", 6); + // Store position at "|Hello World" + remotePositionStorage.set("start", 3); + // Store position at "|Hello World" (but on the right side) + remotePositionStorage.set("start-right", 3, "right"); + // Store position at "H|ello World" + remotePositionStorage.set("pos-after", 4); + // Store position at "H|ello World" (but on the right side) + remotePositionStorage.set("pos-after-right", 4, "right"); - console.log(realEditor.document); // Insert text at the beginning - realEditor._tiptapEditor.commands.insertContentAt(3, "Test "); - console.log(realEditor.document); + localEditor._tiptapEditor.commands.insertContentAt(3, "Test "); // Position should be updated - expect(storage.get("cursor")).toBe(11); // 6 + 5 ("Test " length) - expect(storage.get("start")).toBe(3); // 3 - expect(storage.get("after-start")).toBe(8); // 3 + 5 ("Test " length) - expect(storage.get("pos-after")).toBe(9); // 4 + 5 ("Test " length) - // Clean up - storage.unmount(); + expect(remotePositionStorage.get("cursor")).toBe(11); // 6 + 5 ("Test " length) + expect(remotePositionStorage.get("start")).toBe(3); // 3 + expect(remotePositionStorage.get("start-right")).toBe(8); // 3 + 5 ("Test " length) + expect(remotePositionStorage.get("pos-after")).toBe(9); // 4 + 5 ("Test " length) + expect(remotePositionStorage.get("pos-after-right")).toBe(9); // 4 + 5 ("Test " length) }); }); }); diff --git a/packages/core/src/api/positionMapping.ts b/packages/core/src/api/positionMapping.ts index 783b2899d..1b51bd27d 100644 --- a/packages/core/src/api/positionMapping.ts +++ b/packages/core/src/api/positionMapping.ts @@ -1,31 +1,47 @@ -import { Transaction } from "prosemirror-state"; +import type { Transaction } from "prosemirror-state"; import { Mapping } from "prosemirror-transform"; import { absolutePositionToRelativePosition, relativePositionToAbsolutePosition, ySyncPluginKey, } from "y-prosemirror"; -import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; import * as Y from "yjs"; -import { ProsemirrorBinding } from "y-prosemirror"; +import type { ProsemirrorBinding } from "y-prosemirror"; +import type { + DefaultInlineContentSchema, + DefaultStyleSchema, +} from "../blocks/defaultBlocks.js"; +import type { DefaultBlockSchema } from "../blocks/defaultBlocks.js"; +import type { + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../schema/index.js"; export function isRemoteTransaction(tr: Transaction) { return tr.getMeta(ySyncPluginKey) !== undefined; } + type YSyncPluginState = { doc: Y.Doc; binding: Pick; }; + type RelativePosition = symbol; /** - * This class is used to keep track of positions of elements in the editor. + * 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. */ -export class PositionStorage { - private readonly editor: BlockNoteEditor; +export class PositionStorage< + BSchema extends BlockSchema = DefaultBlockSchema, + ISchema extends InlineContentSchema = DefaultInlineContentSchema, + SSchema extends StyleSchema = DefaultStyleSchema +> { + private readonly editor: BlockNoteEditor; /** * Whether the editor has had a remote transaction. */ @@ -43,26 +59,21 @@ export class PositionStorage { } >(); - constructor(editor: BlockNoteEditor) { + constructor( + editor: BlockNoteEditor, + { shouldMount = true }: { shouldMount?: boolean } = {} + ) { this.editor = editor; this.onTransactionHandler = this.onTransactionHandler.bind(this); - } - - /** - * Mounts the position storage. - */ - public mount() { - this.editor._tiptapEditor.on("transaction", this.onTransactionHandler); - return this.unmount.bind(this); - } + if (!shouldMount) { + return; + } - /** - * Unmounts the position storage. - */ - public unmount() { - this.positionMapping.clear(); - this.editor._tiptapEditor.off("transaction", this.onTransactionHandler); + if (!this.editor._tiptapEditor) { + throw new Error("Editor not mounted"); + } + this.editor._tiptapEditor.on("transaction", this.onTransactionHandler); } /** @@ -71,7 +82,6 @@ export class PositionStorage { * It's used to update the position mapping or tell if there was a remote transaction. */ private onTransactionHandler({ transaction }: { transaction: Transaction }) { - console.log("onTransactionHandler", transaction); if (this.hadRemoteTransaction) { // If we have already had a remote transaction, we rely only on relative positions return; @@ -91,7 +101,7 @@ export class PositionStorage { * * @param id An ID to store the position of. * @param position The position to store. - * @param side The side of the position to store. + * @param side The side of the position to store. "left" is the default. "right" would move with the change if the change is in the right direction. */ public set(id: string, position: number, side?: "left" | "right") { const ySyncPluginState = ySyncPluginKey.getState( @@ -99,7 +109,6 @@ export class PositionStorage { ) as YSyncPluginState; if (!ySyncPluginState) { - // TODO unsure if this works this.positionMapping.set(id, { position, relativePosition: undefined, @@ -110,8 +119,8 @@ export class PositionStorage { } const relativePosition = absolutePositionToRelativePosition( - // Track the position before the position - position + (side === "left" ? -1 : 0), + // Track the position after the position if we are on the right side + position + (side === "right" ? 1 : 0), ySyncPluginState.binding.type, ySyncPluginState.binding.mapping ); @@ -126,19 +135,17 @@ export class PositionStorage { return this; } - public get(id: string): number { + public get(id: string): number | undefined { const storedPos = this.positionMapping.get(id); - console.log(storedPos); - if (!storedPos) { - throw new Error("No mapping found for id: " + id); + return undefined; } if (this.hadRemoteTransaction) { // If we have had a remote transaction, we need to rely on the relative position if (!storedPos.relativePosition) { - throw new Error("No relative position found for id: " + id); + return undefined; } const ystate = ySyncPluginKey.getState( @@ -151,19 +158,17 @@ export class PositionStorage { ystate.binding.mapping ); + // This can happen if the element is deleted if (rel === null) { - // TODO when does this happen? - return -1; + return undefined; } - return rel + (storedPos.side === "left" ? 1 : -1); + return rel + (storedPos.side === "right" ? -1 : 0); } - return ( - storedPos.mapping.map( - storedPos.position - (storedPos.side === "left" ? 1 : 0), - storedPos.side === "left" ? -1 : 1 - ) + (storedPos.side === "left" ? 1 : 0) + return storedPos.mapping.map( + storedPos.position, + storedPos.side === "left" ? -1 : 1 ); } diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index c0e918562..36f65b082 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -102,6 +102,7 @@ import type { ThreadStore, User } from "../comments/index.js"; import "../style.css"; import { EventEmitter } from "../util/EventEmitter.js"; import { CodeBlockOptions } from "../blocks/CodeBlockContent/CodeBlockContent.js"; +import { PositionStorage } from "../api/positionMapping.js"; export type BlockNoteExtensionFactory = ( editor: BlockNoteEditor @@ -395,6 +396,11 @@ export class BlockNoteEditor< public readonly inlineContentImplementations: InlineContentSpecs; public readonly styleImplementations: StyleSpecs; + /** + * Stores positions of elements in the editor. + */ + public readonly positionStorage: PositionStorage; + public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin; public readonly linkToolbar: LinkToolbarProsemirrorPlugin< BSchema, @@ -685,6 +691,11 @@ export class BlockNoteEditor< // but we still need the schema this.pmSchema = getSchema(tiptapOptions.extensions!); } + + this.positionStorage = new PositionStorage( + this, + { shouldMount: !this.headless } + ); this.emit("create"); } From 1d2906b773b95e6da9fcda550b28c8686c67ef03 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 31 Mar 2025 14:57:59 +0200 Subject: [PATCH 3/8] refactor: simplify the API further, implement SuggestionPlugin --- packages/core/src/api/positionMapping.test.ts | 147 ++++++++---------- packages/core/src/api/positionMapping.ts | 101 ++++-------- .../SuggestionMenu/SuggestionPlugin.ts | 22 ++- 3 files changed, 117 insertions(+), 153 deletions(-) diff --git a/packages/core/src/api/positionMapping.test.ts b/packages/core/src/api/positionMapping.test.ts index d9eb6085d..b0a841770 100644 --- a/packages/core/src/api/positionMapping.test.ts +++ b/packages/core/src/api/positionMapping.test.ts @@ -32,23 +32,15 @@ describe("PositionStorage with local editor", () => { describe("set and get positions", () => { it("should store and retrieve positions without Y.js", () => { - positionStorage.set("test-id", 10); - expect(positionStorage.get("test-id")).toBe(10); - }); + const getPos = positionStorage.track(10); - it("should handle right side positions", () => { - positionStorage.set("test-id", 10, "right"); - expect(positionStorage.get("test-id")).toBe(10); + expect(getPos()).toBe(10); }); - it("should be undefined when getting a non-existent position", () => { - expect(positionStorage.get("non-existent")).toBeUndefined(); - }); + it("should handle right side positions", () => { + const getPos = positionStorage.track(10, "right"); - it("should remove positions", () => { - positionStorage.set("test-id", 10); - positionStorage.remove("test-id"); - expect(positionStorage.get("test-id")).toBeUndefined(); + expect(getPos()).toBe(10); }); }); @@ -73,7 +65,7 @@ describe("PositionStorage with local editor", () => { ); // Start tracking - positionStorage.set("test-id", 10); + const getPos = positionStorage.track(10); // Move the cursor to the start of the document editor.setTextCursorPosition(editor.document[0], "start"); @@ -88,7 +80,7 @@ describe("PositionStorage with local editor", () => { ]); // Position should be updated according to mapping - expect(positionStorage.get("test-id")).toBe(14); + expect(getPos()).toBe(14); }); it("should not update mapping for local transactions after the position", () => { @@ -111,7 +103,7 @@ describe("PositionStorage with local editor", () => { "before" ); // Start tracking - positionStorage.set("test-id", 10); + const getPos = positionStorage.track(10); // Move the cursor to the end of the document editor.setTextCursorPosition(editor.document[0], "end"); @@ -126,7 +118,7 @@ describe("PositionStorage with local editor", () => { ]); // Position should not be updated - expect(positionStorage.get("test-id")).toBe(10); + expect(getPos()).toBe(10); }); it("should track positions on each side", () => { @@ -138,21 +130,20 @@ describe("PositionStorage with local editor", () => { ]); // Store position at "Hello| World" - positionStorage.set("cursor", 6); - positionStorage.set("start", 3); - positionStorage.set("start-right", 3, "right"); - positionStorage.set("pos-after", 4); - positionStorage.set("pos-after-right", 4, "right"); - + const getCursorPos = positionStorage.track(6); + const getStartPos = positionStorage.track(3); + const getStartRightPos = positionStorage.track(3, "right"); + const getPosAfterPos = positionStorage.track(4); + const getPosAfterRightPos = positionStorage.track(4, "right"); // Insert text at the beginning editor._tiptapEditor.commands.insertContentAt(3, "Test "); // Position should be updated - expect(positionStorage.get("cursor")).toBe(11); // 6 + 5 ("Test " length) - expect(positionStorage.get("start")).toBe(3); // 3 - expect(positionStorage.get("start-right")).toBe(8); // 3 + 5 ("Test " length) - expect(positionStorage.get("pos-after")).toBe(9); // 4 + 5 ("Test " length) - expect(positionStorage.get("pos-after-right")).toBe(9); // 4 + 5 ("Test " length) + 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", () => { @@ -164,11 +155,11 @@ describe("PositionStorage with local editor", () => { ]); // Store position at "Hello| World" - positionStorage.set("cursor", 6); - positionStorage.set("start", 3); - positionStorage.set("start-right", 3, "right"); - positionStorage.set("pos-after", 4); - positionStorage.set("pos-after-right", 4, "right"); + const getCursorPos = positionStorage.track(6); + const getStartPos = positionStorage.track(3); + const getStartRightPos = positionStorage.track(3, "right"); + const getPosAfterPos = positionStorage.track(4); + const getPosAfterRightPos = positionStorage.track(4, "right"); // Insert text at the beginning editor._tiptapEditor.commands.insertContentAt(3, "T"); @@ -178,11 +169,11 @@ describe("PositionStorage with local editor", () => { editor._tiptapEditor.commands.insertContentAt(7, " "); // Position should be updated - expect(positionStorage.get("cursor")).toBe(11); // 6 + 5 ("Test " length) - expect(positionStorage.get("start")).toBe(3); // 3 - expect(positionStorage.get("start-right")).toBe(8); // 3 + 5 ("Test " length) - expect(positionStorage.get("pos-after")).toBe(9); // 4 + 5 ("Test " length) - expect(positionStorage.get("pos-after-right")).toBe(9); // 4 + 5 ("Test " length) + 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) }); }); @@ -268,25 +259,25 @@ describe("PositionStorage with remote editor", () => { ]); // Store position at "Hello| World" - localPositionStorage.set("cursor", 6); + const getCursorPos = localPositionStorage.track(6); // Store position at "|Hello World" - localPositionStorage.set("start", 3); + const getStartPos = localPositionStorage.track(3); // Store position at "|Hello World" (but on the right side) - localPositionStorage.set("start-right", 3, "right"); + const getStartRightPos = localPositionStorage.track(3, "right"); // Store position at "H|ello World" - localPositionStorage.set("pos-after", 4); + const getPosAfterPos = localPositionStorage.track(4); // Store position at "H|ello World" (but on the right side) - localPositionStorage.set("pos-after-right", 4, "right"); + const getPosAfterRightPos = localPositionStorage.track(4, "right"); // Insert text at the beginning localEditor._tiptapEditor.commands.insertContentAt(3, "Test "); // Position should be updated - expect(localPositionStorage.get("cursor")).toBe(11); // 6 + 5 ("Test " length) - expect(localPositionStorage.get("start")).toBe(3); // 3 - expect(localPositionStorage.get("start-right")).toBe(8); // 3 + 5 ("Test " length) - expect(localPositionStorage.get("pos-after")).toBe(9); // 4 + 5 ("Test " length) - expect(localPositionStorage.get("pos-after-right")).toBe(9); // 4 + 5 ("Test " length) + 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", () => { @@ -298,15 +289,15 @@ describe("PositionStorage with remote editor", () => { ]); // Store position at "Hello| World" - localPositionStorage.set("cursor", 6); + const getCursorPos = localPositionStorage.track(6); // Store position at "|Hello World" - localPositionStorage.set("start", 3); + const getStartPos = localPositionStorage.track(3); // Store position at "|Hello World" (but on the right side) - localPositionStorage.set("start-right", 3, "right"); + const getStartRightPos = localPositionStorage.track(3, "right"); // Store position at "H|ello World" - localPositionStorage.set("pos-after", 4); + const getPosAfterPos = localPositionStorage.track(4); // Store position at "H|ello World" (but on the right side) - localPositionStorage.set("pos-after-right", 4, "right"); + const getPosAfterRightPos = localPositionStorage.track(4, "right"); // Insert text at the beginning localEditor._tiptapEditor.commands.insertContentAt(3, "T"); @@ -316,11 +307,11 @@ describe("PositionStorage with remote editor", () => { localEditor._tiptapEditor.commands.insertContentAt(7, " "); // Position should be updated - expect(localPositionStorage.get("cursor")).toBe(11); // 6 + 5 ("Test " length) - expect(localPositionStorage.get("start")).toBe(3); // 3 - expect(localPositionStorage.get("start-right")).toBe(8); // 3 + 5 ("Test " length) - expect(localPositionStorage.get("pos-after")).toBe(9); // 4 + 5 ("Test " length) - expect(localPositionStorage.get("pos-after-right")).toBe(9); // 4 + 5 ("Test " length) + 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", () => { @@ -332,25 +323,25 @@ describe("PositionStorage with remote editor", () => { ]); // Store position at "Hello| World" - localPositionStorage.set("cursor", 6); + const getCursorPos = localPositionStorage.track(6); // Store position at "|Hello World" - localPositionStorage.set("start", 3); + const getStartPos = localPositionStorage.track(3); // Store position at "|Hello World" (but on the right side) - localPositionStorage.set("start-right", 3, "right"); + const getStartRightPos = localPositionStorage.track(3, "right"); // Store position at "H|ello World" - localPositionStorage.set("pos-after", 4); + const getPosAfterPos = localPositionStorage.track(4); // Store position at "H|ello World" (but on the right side) - localPositionStorage.set("pos-after-right", 4, "right"); + const getPosAfterRightPos = localPositionStorage.track(4, "right"); // Insert text at the beginning localEditor._tiptapEditor.commands.insertContentAt(3, "Test "); // Position should be updated - expect(localPositionStorage.get("cursor")).toBe(11); // 6 + 5 ("Test " length) - expect(localPositionStorage.get("start")).toBe(3); // 3 - expect(localPositionStorage.get("start-right")).toBe(8); // 3 + 5 ("Test " length) - expect(localPositionStorage.get("pos-after")).toBe(9); // 4 + 5 ("Test " length) - expect(localPositionStorage.get("pos-after-right")).toBe(9); // 4 + 5 ("Test " length) + 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", () => { @@ -362,25 +353,25 @@ describe("PositionStorage with remote editor", () => { ]); // Store position at "Hello| World" - remotePositionStorage.set("cursor", 6); + const getCursorPos = remotePositionStorage.track(6); // Store position at "|Hello World" - remotePositionStorage.set("start", 3); + const getStartPos = remotePositionStorage.track(3); // Store position at "|Hello World" (but on the right side) - remotePositionStorage.set("start-right", 3, "right"); + const getStartRightPos = remotePositionStorage.track(3, "right"); // Store position at "H|ello World" - remotePositionStorage.set("pos-after", 4); + const getPosAfterPos = remotePositionStorage.track(4); // Store position at "H|ello World" (but on the right side) - remotePositionStorage.set("pos-after-right", 4, "right"); + const getPosAfterRightPos = remotePositionStorage.track(4, "right"); // Insert text at the beginning localEditor._tiptapEditor.commands.insertContentAt(3, "Test "); // Position should be updated - expect(remotePositionStorage.get("cursor")).toBe(11); // 6 + 5 ("Test " length) - expect(remotePositionStorage.get("start")).toBe(3); // 3 - expect(remotePositionStorage.get("start-right")).toBe(8); // 3 + 5 ("Test " length) - expect(remotePositionStorage.get("pos-after")).toBe(9); // 4 + 5 ("Test " length) - expect(remotePositionStorage.get("pos-after-right")).toBe(9); // 4 + 5 ("Test " length) + 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 index 1b51bd27d..1046cf705 100644 --- a/packages/core/src/api/positionMapping.ts +++ b/packages/core/src/api/positionMapping.ts @@ -28,8 +28,6 @@ type YSyncPluginState = { binding: Pick; }; -type RelativePosition = symbol; - /** * 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. @@ -46,18 +44,9 @@ export class PositionStorage< * Whether the editor has had a remote transaction. */ private hadRemoteTransaction = false; - /** - * A map of an ID to the position mapping. - */ - private readonly positionMapping = new Map< - string, - { - position: number; - relativePosition: RelativePosition | undefined; - mapping: Mapping; - side: "left" | "right"; - } - >(); + + private readonly mapping = new Mapping(); + private mappingLength = 0; constructor( editor: BlockNoteEditor, @@ -83,39 +72,48 @@ export class PositionStorage< */ private onTransactionHandler({ transaction }: { transaction: Transaction }) { if (this.hadRemoteTransaction) { - // If we have already had a remote transaction, we rely only on relative positions + // If we have already had a remote transaction, we rely only on relative positions, so no need to update the mapping. return; } if (isRemoteTransaction(transaction)) { this.hadRemoteTransaction = true; } else { - this.positionMapping.forEach(({ mapping }) => { - mapping.appendMapping(transaction.mapping); - }); + this.mapping.appendMapping(transaction.mapping); + this.mappingLength += transaction.mapping.maps.length; } } /** - * Stores a position for a given ID. To consistently track the position of an element. + * This is used to track a position in the editor. * - * @param id An ID to store the position of. - * @param position The position to store. - * @param side The side of the position to store. "left" is the default. "right" would move with the change if the change is in the right direction. + * @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. + * @param getOffset This allows you to offset the returned position from the tracked position. This is useful for cases where the tracked position is not the actual position of the element. */ - public set(id: string, position: number, side?: "left" | "right") { + public track( + /** + * 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( this.editor._tiptapEditor.state ) as YSyncPluginState; + const trackedMapLength = this.mappingLength; if (!ySyncPluginState) { - this.positionMapping.set(id, { - position, - relativePosition: undefined, - mapping: new Mapping(), - side: side ?? "left", - }); - return this; + return () => { + const pos = this.mapping + .slice(trackedMapLength) + .map(position, side === "left" ? -1 : 1); + + return pos; + }; } const relativePosition = absolutePositionToRelativePosition( @@ -125,56 +123,23 @@ export class PositionStorage< ySyncPluginState.binding.mapping ); - this.positionMapping.set(id, { - position, - relativePosition, - mapping: new Mapping(), - side: side ?? "left", - }); - - return this; - } - - public get(id: string): number | undefined { - const storedPos = this.positionMapping.get(id); - - if (!storedPos) { - return undefined; - } - - if (this.hadRemoteTransaction) { - // If we have had a remote transaction, we need to rely on the relative position - if (!storedPos.relativePosition) { - return undefined; - } - + return () => { const ystate = ySyncPluginKey.getState( this.editor._tiptapEditor.state ) as YSyncPluginState; const rel = relativePositionToAbsolutePosition( ystate.doc, ystate.binding.type, - storedPos.relativePosition, + relativePosition, ystate.binding.mapping ); // This can happen if the element is deleted if (rel === null) { - return undefined; + throw new Error("Element deleted"); } - return rel + (storedPos.side === "right" ? -1 : 0); - } - - return storedPos.mapping.map( - storedPos.position, - storedPos.side === "left" ? -1 : 1 - ); - } - - public remove(id: string) { - this.positionMapping.delete(id); - - return this; + return rel + (side === "right" ? -1 : 0); + }; } } diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts index 6564a1993..15f8319cf 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts @@ -127,7 +127,7 @@ class SuggestionMenuView< .focus() .deleteRange({ from: - this.pluginState.queryStartPos! - + this.pluginState.queryStartPos!() - (this.pluginState.deleteTriggerCharacter ? this.pluginState.triggerCharacter!.length : 0), @@ -141,7 +141,7 @@ type SuggestionPluginState = | { triggerCharacter: string; deleteTriggerCharacter: boolean; - queryStartPos: number; + queryStartPos: () => number; query: string; decorationId: string; ignoreQueryLength?: boolean; @@ -218,13 +218,21 @@ export class SuggestionMenuProseMirrorPlugin< suggestionPluginTransactionMeta !== null && prev === undefined ) { + const trackedPosition = editor.positionStorage.track( + 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: @@ -250,7 +258,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; } @@ -259,7 +267,7 @@ export class SuggestionMenuProseMirrorPlugin< // Updates the current query. next.query = newState.doc.textBetween( - prev.queryStartPos!, + prev.queryStartPos!(), newState.selection.from ); @@ -322,9 +330,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", From 98251169fe1356c753f6c3b80325575c5ee92bb7 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 31 Mar 2025 15:15:07 +0200 Subject: [PATCH 4/8] refactor: make into an internal API --- packages/core/src/api/positionMapping.ts | 4 +-- packages/core/src/editor/BlockNoteEditor.ts | 26 ++++++++++++------- .../SuggestionMenu/SuggestionPlugin.ts | 4 +-- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/core/src/api/positionMapping.ts b/packages/core/src/api/positionMapping.ts index 1046cf705..6a5431aef 100644 --- a/packages/core/src/api/positionMapping.ts +++ b/packages/core/src/api/positionMapping.ts @@ -100,7 +100,7 @@ export class PositionStorage< * 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 { + ): () => number | undefined { const ySyncPluginState = ySyncPluginKey.getState( this.editor._tiptapEditor.state ) as YSyncPluginState; @@ -136,7 +136,7 @@ export class PositionStorage< // This can happen if the element is deleted if (rel === null) { - throw new Error("Element deleted"); + return undefined; } return rel + (side === "right" ? -1 : 0); diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 36f65b082..f534676db 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -368,6 +368,17 @@ export class BlockNoteEditor< contentComponent: any; } = undefined as any; // TODO: Type should actually reflect that it can be `undefined` in headless mode + /** + * Internal properties that are not part of the public API and may change in the future. + * + * @internal + */ + public readonly _internal: { + /** + * Stores positions of elements in the editor. + */ + positionStorage: PositionStorage; + }; /** * Used by React to store a reference to an `ElementRenderer` helper utility to make sure we can render React elements * in the correct context (used by `ReactRenderUtil`) @@ -396,11 +407,6 @@ export class BlockNoteEditor< public readonly inlineContentImplementations: InlineContentSpecs; public readonly styleImplementations: StyleSpecs; - /** - * Stores positions of elements in the editor. - */ - public readonly positionStorage: PositionStorage; - public readonly formattingToolbar: FormattingToolbarProsemirrorPlugin; public readonly linkToolbar: LinkToolbarProsemirrorPlugin< BSchema, @@ -692,10 +698,12 @@ export class BlockNoteEditor< this.pmSchema = getSchema(tiptapOptions.extensions!); } - this.positionStorage = new PositionStorage( - this, - { shouldMount: !this.headless } - ); + this._internal = { + positionStorage: new PositionStorage(this, { + shouldMount: !this.headless, + }), + }; + this.emit("create"); } diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts index 15f8319cf..cbf7a1b1e 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts @@ -218,7 +218,7 @@ export class SuggestionMenuProseMirrorPlugin< suggestionPluginTransactionMeta !== null && prev === undefined ) { - const trackedPosition = editor.positionStorage.track( + const trackedPosition = editor._internal.positionStorage.track( 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 @@ -231,7 +231,7 @@ export class SuggestionMenuProseMirrorPlugin< false, // When reading the queryStartPos, we offset the result by the length of the trigger character, to make it easy on the caller queryStartPos: () => - trackedPosition() + + trackedPosition()! + suggestionPluginTransactionMeta.triggerCharacter.length, query: "", decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`, From c6fd9acac575a35eecca7c16e63879a86d6e0101 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 31 Mar 2025 15:18:01 +0200 Subject: [PATCH 5/8] refactor: make tilde prefixed --- packages/core/src/editor/BlockNoteEditor.ts | 4 ++-- .../core/src/extensions/SuggestionMenu/SuggestionPlugin.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index f534676db..6190c57cb 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -373,7 +373,7 @@ export class BlockNoteEditor< * * @internal */ - public readonly _internal: { + public readonly "~internal": { /** * Stores positions of elements in the editor. */ @@ -698,7 +698,7 @@ export class BlockNoteEditor< this.pmSchema = getSchema(tiptapOptions.extensions!); } - this._internal = { + this["~internal"] = { positionStorage: new PositionStorage(this, { shouldMount: !this.headless, }), diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts index cbf7a1b1e..210f5ad40 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts @@ -218,7 +218,7 @@ export class SuggestionMenuProseMirrorPlugin< suggestionPluginTransactionMeta !== null && prev === undefined ) { - const trackedPosition = editor._internal.positionStorage.track( + const trackedPosition = editor["~internal"].positionStorage.track( 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 From 4af971e7f03c3999be83dbb7950a93408160f199 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 1 Apr 2025 09:36:34 +0200 Subject: [PATCH 6/8] refactor: trackPosition is now separate from the editor instance --- packages/core/src/api/positionMapping.test.ts | 79 ++++---- packages/core/src/api/positionMapping.ts | 185 ++++++++---------- packages/core/src/editor/BlockNoteEditor.ts | 18 -- .../SuggestionMenu/SuggestionPlugin.ts | 16 +- 4 files changed, 129 insertions(+), 169 deletions(-) diff --git a/packages/core/src/api/positionMapping.test.ts b/packages/core/src/api/positionMapping.test.ts index b0a841770..6b45e1da2 100644 --- a/packages/core/src/api/positionMapping.test.ts +++ b/packages/core/src/api/positionMapping.test.ts @@ -1,16 +1,14 @@ import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import * as Y from "yjs"; import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; -import { PositionStorage } from "./positionMapping.js"; +import { trackPosition } from "./positionMapping.js"; describe("PositionStorage with local editor", () => { let editor: BlockNoteEditor; - let positionStorage: PositionStorage; beforeEach(() => { editor = BlockNoteEditor.create(); editor.mount(document.createElement("div")); - positionStorage = new PositionStorage(editor); }); afterEach(() => { @@ -21,7 +19,7 @@ describe("PositionStorage with local editor", () => { describe("mount and unmount", () => { it("should register transaction handler on creation", () => { editor._tiptapEditor.on = vi.fn(); - new PositionStorage(editor); + trackPosition(editor, 0); expect(editor._tiptapEditor.on).toHaveBeenCalledWith( "transaction", @@ -32,13 +30,13 @@ describe("PositionStorage with local editor", () => { describe("set and get positions", () => { it("should store and retrieve positions without Y.js", () => { - const getPos = positionStorage.track(10); + const getPos = trackPosition(editor, 10); expect(getPos()).toBe(10); }); it("should handle right side positions", () => { - const getPos = positionStorage.track(10, "right"); + const getPos = trackPosition(editor, 10, "right"); expect(getPos()).toBe(10); }); @@ -65,7 +63,7 @@ describe("PositionStorage with local editor", () => { ); // Start tracking - const getPos = positionStorage.track(10); + const getPos = trackPosition(editor, 10); // Move the cursor to the start of the document editor.setTextCursorPosition(editor.document[0], "start"); @@ -103,7 +101,7 @@ describe("PositionStorage with local editor", () => { "before" ); // Start tracking - const getPos = positionStorage.track(10); + const getPos = trackPosition(editor, 10); // Move the cursor to the end of the document editor.setTextCursorPosition(editor.document[0], "end"); @@ -130,11 +128,11 @@ describe("PositionStorage with local editor", () => { ]); // Store position at "Hello| World" - const getCursorPos = positionStorage.track(6); - const getStartPos = positionStorage.track(3); - const getStartRightPos = positionStorage.track(3, "right"); - const getPosAfterPos = positionStorage.track(4); - const getPosAfterRightPos = positionStorage.track(4, "right"); + 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 "); @@ -155,11 +153,11 @@ describe("PositionStorage with local editor", () => { ]); // Store position at "Hello| World" - const getCursorPos = positionStorage.track(6); - const getStartPos = positionStorage.track(3); - const getStartRightPos = positionStorage.track(3, "right"); - const getPosAfterPos = positionStorage.track(4); - const getPosAfterRightPos = positionStorage.track(4, "right"); + 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"); @@ -205,9 +203,7 @@ describe("PositionStorage with remote editor", () => { describe("remote editor", () => { let localEditor: BlockNoteEditor; - let localPositionStorage: PositionStorage; let remoteEditor: BlockNoteEditor; - let remotePositionStorage: PositionStorage; let ydoc: Y.Doc; let remoteYdoc: Y.Doc; @@ -224,8 +220,6 @@ describe("PositionStorage with remote editor", () => { }); const div = document.createElement("div"); localEditor.mount(div); - // Create a new PositionStorage instance - localPositionStorage = new PositionStorage(localEditor); remoteEditor = BlockNoteEditor.create({ collaboration: { @@ -237,7 +231,6 @@ describe("PositionStorage with remote editor", () => { const remoteDiv = document.createElement("div"); remoteEditor.mount(remoteDiv); - remotePositionStorage = new PositionStorage(remoteEditor); setupTwoWaySync(ydoc, remoteYdoc); }); @@ -259,15 +252,15 @@ describe("PositionStorage with remote editor", () => { ]); // Store position at "Hello| World" - const getCursorPos = localPositionStorage.track(6); + const getCursorPos = trackPosition(localEditor, 6); // Store position at "|Hello World" - const getStartPos = localPositionStorage.track(3); + const getStartPos = trackPosition(localEditor, 3); // Store position at "|Hello World" (but on the right side) - const getStartRightPos = localPositionStorage.track(3, "right"); + const getStartRightPos = trackPosition(localEditor, 3, "right"); // Store position at "H|ello World" - const getPosAfterPos = localPositionStorage.track(4); + const getPosAfterPos = trackPosition(localEditor, 4); // Store position at "H|ello World" (but on the right side) - const getPosAfterRightPos = localPositionStorage.track(4, "right"); + const getPosAfterRightPos = trackPosition(localEditor, 4, "right"); // Insert text at the beginning localEditor._tiptapEditor.commands.insertContentAt(3, "Test "); @@ -289,15 +282,15 @@ describe("PositionStorage with remote editor", () => { ]); // Store position at "Hello| World" - const getCursorPos = localPositionStorage.track(6); + const getCursorPos = trackPosition(localEditor, 6); // Store position at "|Hello World" - const getStartPos = localPositionStorage.track(3); + const getStartPos = trackPosition(localEditor, 3); // Store position at "|Hello World" (but on the right side) - const getStartRightPos = localPositionStorage.track(3, "right"); + const getStartRightPos = trackPosition(localEditor, 3, "right"); // Store position at "H|ello World" - const getPosAfterPos = localPositionStorage.track(4); + const getPosAfterPos = trackPosition(localEditor, 4); // Store position at "H|ello World" (but on the right side) - const getPosAfterRightPos = localPositionStorage.track(4, "right"); + const getPosAfterRightPos = trackPosition(localEditor, 4, "right"); // Insert text at the beginning localEditor._tiptapEditor.commands.insertContentAt(3, "T"); @@ -323,15 +316,15 @@ describe("PositionStorage with remote editor", () => { ]); // Store position at "Hello| World" - const getCursorPos = localPositionStorage.track(6); + const getCursorPos = trackPosition(localEditor, 6); // Store position at "|Hello World" - const getStartPos = localPositionStorage.track(3); + const getStartPos = trackPosition(localEditor, 3); // Store position at "|Hello World" (but on the right side) - const getStartRightPos = localPositionStorage.track(3, "right"); + const getStartRightPos = trackPosition(localEditor, 3, "right"); // Store position at "H|ello World" - const getPosAfterPos = localPositionStorage.track(4); + const getPosAfterPos = trackPosition(localEditor, 4); // Store position at "H|ello World" (but on the right side) - const getPosAfterRightPos = localPositionStorage.track(4, "right"); + const getPosAfterRightPos = trackPosition(localEditor, 4, "right"); // Insert text at the beginning localEditor._tiptapEditor.commands.insertContentAt(3, "Test "); @@ -353,15 +346,15 @@ describe("PositionStorage with remote editor", () => { ]); // Store position at "Hello| World" - const getCursorPos = remotePositionStorage.track(6); + const getCursorPos = trackPosition(remoteEditor, 6); // Store position at "|Hello World" - const getStartPos = remotePositionStorage.track(3); + const getStartPos = trackPosition(remoteEditor, 3); // Store position at "|Hello World" (but on the right side) - const getStartRightPos = remotePositionStorage.track(3, "right"); + const getStartRightPos = trackPosition(remoteEditor, 3, "right"); // Store position at "H|ello World" - const getPosAfterPos = remotePositionStorage.track(4); + const getPosAfterPos = trackPosition(remoteEditor, 4); // Store position at "H|ello World" (but on the right side) - const getPosAfterRightPos = remotePositionStorage.track(4, "right"); + const getPosAfterRightPos = trackPosition(remoteEditor, 4, "right"); // Insert text at the beginning localEditor._tiptapEditor.commands.insertContentAt(3, "Test "); diff --git a/packages/core/src/api/positionMapping.ts b/packages/core/src/api/positionMapping.ts index 6a5431aef..995106948 100644 --- a/packages/core/src/api/positionMapping.ts +++ b/packages/core/src/api/positionMapping.ts @@ -1,4 +1,3 @@ -import type { Transaction } from "prosemirror-state"; import { Mapping } from "prosemirror-transform"; import { absolutePositionToRelativePosition, @@ -19,127 +18,111 @@ import type { StyleSchema, } from "../schema/index.js"; -export function isRemoteTransaction(tr: Transaction) { - return tr.getMeta(ySyncPluginKey) !== undefined; -} +/** + * 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, Mapping>(); -type YSyncPluginState = { - doc: Y.Doc; - binding: Pick; -}; +/** + * This initializes a single mapping for an editor instance. + */ +function getMapping(editor: BlockNoteEditor) { + 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 class PositionStorage< +export function trackPosition< BSchema extends BlockSchema = DefaultBlockSchema, ISchema extends InlineContentSchema = DefaultInlineContentSchema, SSchema extends StyleSchema = DefaultStyleSchema -> { - private readonly editor: BlockNoteEditor; +>( /** - * Whether the editor has had a remote transaction. + * The editor to track the position of. */ - private hadRemoteTransaction = false; - - private readonly mapping = new Mapping(); - private mappingLength = 0; - - constructor( - editor: BlockNoteEditor, - { shouldMount = true }: { shouldMount?: boolean } = {} - ) { - this.editor = editor; - this.onTransactionHandler = this.onTransactionHandler.bind(this); - - if (!shouldMount) { - return; - } - - if (!this.editor._tiptapEditor) { - throw new Error("Editor not mounted"); - } - this.editor._tiptapEditor.on("transaction", this.onTransactionHandler); - } - + editor: BlockNoteEditor, /** - * This will be called whenever a transaction is applied to the editor. - * - * It's used to update the position mapping or tell if there was a remote transaction. + * The position to track. */ - private onTransactionHandler({ transaction }: { transaction: Transaction }) { - if (this.hadRemoteTransaction) { - // If we have already had a remote transaction, we rely only on relative positions, so no need to update the mapping. - return; - } - - if (isRemoteTransaction(transaction)) { - this.hadRemoteTransaction = true; - } else { - this.mapping.appendMapping(transaction.mapping); - this.mappingLength += transaction.mapping.maps.length; - } - } - + position: number, /** - * This is used to track a position in the editor. - * - * @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. - * @param getOffset This allows you to offset the returned position from the tracked position. This is useful for cases where the tracked position is not the actual position of the element. + * 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. */ - public track( - /** - * 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 | undefined { - const ySyncPluginState = ySyncPluginKey.getState( - this.editor._tiptapEditor.state - ) as YSyncPluginState; + side: "left" | "right" = "left" +): () => number { + const ySyncPluginState = ySyncPluginKey.getState( + editor._tiptapEditor.state + ) 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; - const trackedMapLength = this.mappingLength; - if (!ySyncPluginState) { - return () => { - const pos = this.mapping - .slice(trackedMapLength) - .map(position, side === "left" ? -1 : 1); + 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; - }; - } + 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 + 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._tiptapEditor.state + ) as typeof ySyncPluginState; + const pos = relativePositionToAbsolutePosition( + curYSyncPluginState.doc, + curYSyncPluginState.binding.type, + relativePosition, + curYSyncPluginState.binding.mapping ); - return () => { - const ystate = ySyncPluginKey.getState( - this.editor._tiptapEditor.state - ) as YSyncPluginState; - const rel = relativePositionToAbsolutePosition( - ystate.doc, - ystate.binding.type, - relativePosition, - ystate.binding.mapping - ); - - // This can happen if the element is deleted - if (rel === null) { - return undefined; - } + // This can happen if the element is garbage collected + if (pos === null) { + throw new Error("Position not found, cannot track positions"); + } - return rel + (side === "right" ? -1 : 0); - }; - } + return pos + (side === "right" ? -1 : 0); + }; } diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 1f7a55a1e..dc8a0a0be 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -106,7 +106,6 @@ import "../style.css"; import { EventEmitter } from "../util/EventEmitter.js"; import { CodeBlockOptions } from "../blocks/CodeBlockContent/CodeBlockContent.js"; import { nestedListsToBlockNoteStructure } from "../api/parsers/html/util/nestedLists.js"; -import { PositionStorage } from "../api/positionMapping.js"; export type BlockNoteExtensionFactory = ( editor: BlockNoteEditor @@ -405,17 +404,6 @@ export class BlockNoteEditor< contentComponent: any; } = undefined as any; // TODO: Type should actually reflect that it can be `undefined` in headless mode - /** - * Internal properties that are not part of the public API and may change in the future. - * - * @internal - */ - public readonly "~internal": { - /** - * Stores positions of elements in the editor. - */ - positionStorage: PositionStorage; - }; /** * Used by React to store a reference to an `ElementRenderer` helper utility to make sure we can render React elements * in the correct context (used by `ReactRenderUtil`) @@ -736,12 +724,6 @@ export class BlockNoteEditor< this.pmSchema = getSchema(tiptapOptions.extensions!); } - this["~internal"] = { - positionStorage: new PositionStorage(this, { - shouldMount: !this.headless, - }), - }; - this.emit("create"); } diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts index 210f5ad40..54834fa71 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"); @@ -127,7 +128,7 @@ class SuggestionMenuView< .focus() .deleteRange({ from: - this.pluginState.queryStartPos!() - + this.pluginState.queryStartPos() - (this.pluginState.deleteTriggerCharacter ? this.pluginState.triggerCharacter!.length : 0), @@ -218,7 +219,8 @@ export class SuggestionMenuProseMirrorPlugin< suggestionPluginTransactionMeta !== null && prev === undefined ) { - const trackedPosition = editor["~internal"].positionStorage.track( + 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 @@ -231,7 +233,7 @@ export class SuggestionMenuProseMirrorPlugin< false, // When reading the queryStartPos, we offset the result by the length of the trigger character, to make it easy on the caller queryStartPos: () => - trackedPosition()! + + trackedPosition() + suggestionPluginTransactionMeta.triggerCharacter.length, query: "", decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`, @@ -258,7 +260,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; } @@ -267,7 +269,7 @@ export class SuggestionMenuProseMirrorPlugin< // Updates the current query. next.query = newState.doc.textBetween( - prev.queryStartPos!(), + prev.queryStartPos(), newState.selection.from ); @@ -330,9 +332,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", From 0dcf9b77832d48d3c7e7c5acffd551f47ce9b759 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 1 Apr 2025 09:38:53 +0200 Subject: [PATCH 7/8] chore: use more blocknote API --- packages/core/src/api/positionMapping.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/core/src/api/positionMapping.ts b/packages/core/src/api/positionMapping.ts index 995106948..0f79c5bf5 100644 --- a/packages/core/src/api/positionMapping.ts +++ b/packages/core/src/api/positionMapping.ts @@ -75,9 +75,7 @@ export function trackPosition< */ side: "left" | "right" = "left" ): () => number { - const ySyncPluginState = ySyncPluginKey.getState( - editor._tiptapEditor.state - ) as { + const ySyncPluginState = ySyncPluginKey.getState(editor.prosemirrorState) as { doc: Y.Doc; binding: ProsemirrorBinding; }; @@ -109,7 +107,7 @@ export function trackPosition< return () => { const curYSyncPluginState = ySyncPluginKey.getState( - editor._tiptapEditor.state + editor.prosemirrorState ) as typeof ySyncPluginState; const pos = relativePositionToAbsolutePosition( curYSyncPluginState.doc, From dd6dfd997c01a60644419bbb312aca3078b4c1f3 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 7 Apr 2025 09:54:33 +0200 Subject: [PATCH 8/8] refactor: rm generic --- packages/core/src/api/positionMapping.ts | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/packages/core/src/api/positionMapping.ts b/packages/core/src/api/positionMapping.ts index 0f79c5bf5..1f89e7b32 100644 --- a/packages/core/src/api/positionMapping.ts +++ b/packages/core/src/api/positionMapping.ts @@ -7,16 +7,6 @@ import { import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; import * as Y from "yjs"; import type { ProsemirrorBinding } from "y-prosemirror"; -import type { - DefaultInlineContentSchema, - DefaultStyleSchema, -} from "../blocks/defaultBlocks.js"; -import type { DefaultBlockSchema } from "../blocks/defaultBlocks.js"; -import type { - BlockSchema, - InlineContentSchema, - StyleSchema, -} from "../schema/index.js"; /** * 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. @@ -57,15 +47,11 @@ function getMapping(editor: BlockNoteEditor) { * @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< - BSchema extends BlockSchema = DefaultBlockSchema, - ISchema extends InlineContentSchema = DefaultInlineContentSchema, - SSchema extends StyleSchema = DefaultStyleSchema ->( +export function trackPosition( /** * The editor to track the position of. */ - editor: BlockNoteEditor, + editor: BlockNoteEditor, /** * The position to track. */