Skip to content

Commit c7b80a9

Browse files
committed
feat: position storage WIP
1 parent 37f0765 commit c7b80a9

File tree

2 files changed

+359
-0
lines changed

2 files changed

+359
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { Transaction } from "prosemirror-state";
2+
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
3+
import {
4+
absolutePositionToRelativePosition,
5+
initProseMirrorDoc,
6+
relativePositionToAbsolutePosition,
7+
ySyncPluginKey,
8+
} from "y-prosemirror";
9+
import * as Y from "yjs";
10+
import { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
11+
import { PositionStorage } from "./positionMapping.js";
12+
import { Mapping, StepMap } from "prosemirror-transform";
13+
14+
describe("PositionStorage", () => {
15+
let editor: BlockNoteEditor;
16+
let positionStorage: PositionStorage;
17+
let ydoc: Y.Doc | undefined;
18+
19+
beforeEach(() => {
20+
ydoc = new Y.Doc();
21+
// Create a mock editor
22+
editor = BlockNoteEditor.create({
23+
collaboration: {
24+
fragment: ydoc.getXmlFragment("doc"),
25+
user: { color: "#ff0000", name: "My Username" },
26+
provider: undefined,
27+
},
28+
});
29+
30+
// Create a new PositionStorage instance
31+
positionStorage = new PositionStorage(editor);
32+
});
33+
34+
afterEach(() => {
35+
if (ydoc) {
36+
ydoc.destroy();
37+
ydoc = undefined;
38+
}
39+
});
40+
41+
describe("mount and unmount", () => {
42+
it("should register transaction handler on mount", () => {
43+
positionStorage.mount();
44+
45+
expect(editor._tiptapEditor.on).toHaveBeenCalledWith(
46+
"transaction",
47+
expect.any(Function)
48+
);
49+
});
50+
51+
it("should unregister transaction handler on unmount", () => {
52+
const unmount = positionStorage.mount();
53+
unmount();
54+
55+
expect(editor._tiptapEditor.off).toHaveBeenCalledWith(
56+
"transaction",
57+
expect.any(Function)
58+
);
59+
});
60+
61+
it("should clear position mapping on unmount", () => {
62+
const unmount = positionStorage.mount();
63+
64+
// Set a position
65+
positionStorage.set("test-id", 10);
66+
67+
// Unmount
68+
unmount();
69+
70+
// Try to get the position (should throw)
71+
expect(() => positionStorage.get("test-id")).toThrow();
72+
});
73+
});
74+
75+
describe("set and get positions", () => {
76+
beforeEach(() => {
77+
positionStorage.mount();
78+
});
79+
80+
it("should store and retrieve positions without Y.js", () => {
81+
positionStorage.set("test-id", 10);
82+
expect(positionStorage.get("test-id")).toBe(10);
83+
});
84+
85+
it("should handle right side positions", () => {
86+
positionStorage.set("test-id", 10, "right");
87+
expect(positionStorage.get("test-id")).toBe(10);
88+
});
89+
90+
it("should throw when getting a non-existent position", () => {
91+
expect(() => positionStorage.get("non-existent")).toThrow();
92+
});
93+
94+
it("should remove positions", () => {
95+
positionStorage.set("test-id", 10);
96+
positionStorage.remove("test-id");
97+
expect(() => positionStorage.get("test-id")).toThrow();
98+
});
99+
});
100+
101+
describe("transaction handling", () => {
102+
beforeEach(() => {
103+
positionStorage.mount();
104+
positionStorage.set("test-id", 10);
105+
});
106+
107+
it("should update mapping for local transactions", () => {
108+
// Create a mock transaction with mapping
109+
const mockMapping = new Mapping();
110+
mockMapping.appendMap(new StepMap([0, 0, 5]));
111+
const mockTransaction = {
112+
getMeta: vi.fn().mockReturnValue(undefined),
113+
mapping: mockMapping,
114+
} as unknown as Transaction;
115+
116+
// // Simulate transaction
117+
// mockOnTransaction({ transaction: mockTransaction });
118+
119+
// Position should be updated according to mapping
120+
expect(positionStorage.get("test-id")).toBe(15);
121+
});
122+
123+
// it("should switch to relative positions after remote transaction", () => {
124+
// const ydoc = new Y.Doc();
125+
// const type = ydoc.get("prosemirror", Y.XmlFragment);
126+
// const { doc: pmDoc, mapping } = initProseMirrorDoc(type, schema);
127+
// // Create a mock remote transaction
128+
// const mockRemoteTransaction = {
129+
// getMeta: vi.fn().mockReturnValue({
130+
// doc: ydoc,
131+
// binding: {
132+
// type: ydoc.getXmlFragment("doc"),
133+
// mapping,
134+
// },
135+
// } satisfies YSyncPluginState),
136+
// } as unknown as Transaction;
137+
138+
// // Simulate remote transaction
139+
// mockOnTransaction({ transaction: mockRemoteTransaction });
140+
141+
// // Position should now be based on relative position
142+
// expect(positionStorage.get("test-id")).toBe(21); // 20 + 1 for left side
143+
// });
144+
});
145+
146+
describe("integration with editor", () => {
147+
it("should track positions through document changes", () => {
148+
// Create a real editor
149+
const realEditor = BlockNoteEditor.create({
150+
initialContent: [
151+
{
152+
type: "paragraph",
153+
content: "Hello World",
154+
},
155+
],
156+
});
157+
158+
const div = document.createElement("div");
159+
realEditor.mount(div);
160+
161+
const storage = new PositionStorage(realEditor);
162+
storage.mount();
163+
164+
// Store position at "Hello|World"
165+
storage.set("cursor", 6);
166+
storage.set("start", 3);
167+
storage.set("after-start", 3, "right");
168+
storage.set("pos-after", 4);
169+
170+
console.log(realEditor.document);
171+
// Insert text at the beginning
172+
realEditor._tiptapEditor.commands.insertContentAt(3, "Test ");
173+
console.log(realEditor.document);
174+
175+
// Position should be updated
176+
expect(storage.get("cursor")).toBe(11); // 6 + 5 ("Test " length)
177+
expect(storage.get("start")).toBe(3); // 3
178+
expect(storage.get("after-start")).toBe(8); // 3 + 5 ("Test " length)
179+
expect(storage.get("pos-after")).toBe(9); // 4 + 5 ("Test " length)
180+
// Clean up
181+
storage.unmount();
182+
});
183+
});
184+
});
+175
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { Transaction } from "prosemirror-state";
2+
import { Mapping } from "prosemirror-transform";
3+
import {
4+
absolutePositionToRelativePosition,
5+
relativePositionToAbsolutePosition,
6+
ySyncPluginKey,
7+
} from "y-prosemirror";
8+
import { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
9+
import * as Y from "yjs";
10+
import { ProsemirrorBinding } from "y-prosemirror";
11+
12+
export function isRemoteTransaction(tr: Transaction) {
13+
return tr.getMeta(ySyncPluginKey) !== undefined;
14+
}
15+
type YSyncPluginState = {
16+
doc: Y.Doc;
17+
binding: Pick<ProsemirrorBinding, "type" | "mapping">;
18+
};
19+
type RelativePosition = symbol;
20+
21+
/**
22+
* This class is used to keep track of positions of elements in the editor.
23+
* It is needed because y-prosemirror's sync plugin can disrupt normal prosemirror position mapping.
24+
*
25+
* 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.
26+
*/
27+
export class PositionStorage {
28+
private readonly editor: BlockNoteEditor;
29+
/**
30+
* Whether the editor has had a remote transaction.
31+
*/
32+
private hadRemoteTransaction = false;
33+
/**
34+
* A map of an ID to the position mapping.
35+
*/
36+
private readonly positionMapping = new Map<
37+
string,
38+
{
39+
position: number;
40+
relativePosition: RelativePosition | undefined;
41+
mapping: Mapping;
42+
side: "left" | "right";
43+
}
44+
>();
45+
46+
constructor(editor: BlockNoteEditor) {
47+
this.editor = editor;
48+
this.onTransactionHandler = this.onTransactionHandler.bind(this);
49+
}
50+
51+
/**
52+
* Mounts the position storage.
53+
*/
54+
public mount() {
55+
this.editor._tiptapEditor.on("transaction", this.onTransactionHandler);
56+
57+
return this.unmount.bind(this);
58+
}
59+
60+
/**
61+
* Unmounts the position storage.
62+
*/
63+
public unmount() {
64+
this.positionMapping.clear();
65+
this.editor._tiptapEditor.off("transaction", this.onTransactionHandler);
66+
}
67+
68+
/**
69+
* This will be called whenever a transaction is applied to the editor.
70+
*
71+
* It's used to update the position mapping or tell if there was a remote transaction.
72+
*/
73+
private onTransactionHandler({ transaction }: { transaction: Transaction }) {
74+
console.log("onTransactionHandler", transaction);
75+
if (this.hadRemoteTransaction) {
76+
// If we have already had a remote transaction, we rely only on relative positions
77+
return;
78+
}
79+
80+
if (isRemoteTransaction(transaction)) {
81+
this.hadRemoteTransaction = true;
82+
} else {
83+
this.positionMapping.forEach(({ mapping }) => {
84+
mapping.appendMapping(transaction.mapping);
85+
});
86+
}
87+
}
88+
89+
/**
90+
* Stores a position for a given ID. To consistently track the position of an element.
91+
*
92+
* @param id An ID to store the position of.
93+
* @param position The position to store.
94+
* @param side The side of the position to store.
95+
*/
96+
public set(id: string, position: number, side?: "left" | "right") {
97+
const ySyncPluginState = ySyncPluginKey.getState(
98+
this.editor._tiptapEditor.state
99+
) as YSyncPluginState;
100+
101+
if (!ySyncPluginState) {
102+
// TODO unsure if this works
103+
this.positionMapping.set(id, {
104+
position,
105+
relativePosition: undefined,
106+
mapping: new Mapping(),
107+
side: side ?? "left",
108+
});
109+
return this;
110+
}
111+
112+
const relativePosition = absolutePositionToRelativePosition(
113+
// Track the position before the position
114+
position + (side === "left" ? -1 : 0),
115+
ySyncPluginState.binding.type,
116+
ySyncPluginState.binding.mapping
117+
);
118+
119+
this.positionMapping.set(id, {
120+
position,
121+
relativePosition,
122+
mapping: new Mapping(),
123+
side: side ?? "left",
124+
});
125+
126+
return this;
127+
}
128+
129+
public get(id: string): number {
130+
const storedPos = this.positionMapping.get(id);
131+
132+
console.log(storedPos);
133+
134+
if (!storedPos) {
135+
throw new Error("No mapping found for id: " + id);
136+
}
137+
138+
if (this.hadRemoteTransaction) {
139+
// If we have had a remote transaction, we need to rely on the relative position
140+
if (!storedPos.relativePosition) {
141+
throw new Error("No relative position found for id: " + id);
142+
}
143+
144+
const ystate = ySyncPluginKey.getState(
145+
this.editor._tiptapEditor.state
146+
) as YSyncPluginState;
147+
const rel = relativePositionToAbsolutePosition(
148+
ystate.doc,
149+
ystate.binding.type,
150+
storedPos.relativePosition,
151+
ystate.binding.mapping
152+
);
153+
154+
if (rel === null) {
155+
// TODO when does this happen?
156+
return -1;
157+
}
158+
159+
return rel + (storedPos.side === "left" ? 1 : -1);
160+
}
161+
162+
return (
163+
storedPos.mapping.map(
164+
storedPos.position - (storedPos.side === "left" ? 1 : 0),
165+
storedPos.side === "left" ? -1 : 1
166+
) + (storedPos.side === "left" ? 1 : 0)
167+
);
168+
}
169+
170+
public remove(id: string) {
171+
this.positionMapping.delete(id);
172+
173+
return this;
174+
}
175+
}

0 commit comments

Comments
 (0)