Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: position storage WIP #1529

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 184 additions & 0 deletions packages/core/src/api/positionMapping.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
175 changes: 175 additions & 0 deletions packages/core/src/api/positionMapping.ts
Original file line number Diff line number Diff line change
@@ -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<ProsemirrorBinding, "type" | "mapping">;
};
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;
}
}
Loading