Skip to content

Commit 864cf54

Browse files
committed
fix: round trip position resolution
1 parent 8621163 commit 864cf54

File tree

2 files changed

+107
-107
lines changed

2 files changed

+107
-107
lines changed

packages/core/src/locations/location.test.ts

Lines changed: 71 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,30 @@ describe("Location Resolution", () => {
188188
`);
189189
});
190190

191+
it("should resolve point with offset 0", () => {
192+
const point: Point = { id: "block1", offset: 0 };
193+
const result = resolvePointToPM(doc, point);
194+
195+
expect(result).toMatchInlineSnapshot(`
196+
{
197+
"anchor": 3,
198+
"head": 3,
199+
}
200+
`);
201+
});
202+
203+
it("should resolve point with offset 1", () => {
204+
const point: Point = { id: "block1", offset: 1 };
205+
const result = resolvePointToPM(doc, point);
206+
207+
expect(result).toMatchInlineSnapshot(`
208+
{
209+
"anchor": 4,
210+
"head": 4,
211+
}
212+
`);
213+
});
214+
191215
it("should resolve point with specific offset", () => {
192216
const point: Point = { id: "block1", offset: 5 };
193217
const result = resolvePointToPM(doc, point);
@@ -500,8 +524,8 @@ describe("Location Resolution", () => {
500524

501525
expect(result).toMatchInlineSnapshot(`
502526
{
503-
"anchor": 9,
504-
"head": 9,
527+
"anchor": 8,
528+
"head": 8,
505529
}
506530
`);
507531
});
@@ -592,7 +616,7 @@ describe("Location Resolution", () => {
592616

593617
expect(result).toMatchInlineSnapshot(`
594618
{
595-
"anchor": 4,
619+
"anchor": 3,
596620
"head": 88,
597621
}
598622
`);
@@ -622,7 +646,7 @@ describe("Location Resolution", () => {
622646

623647
expect(result).toMatchInlineSnapshot(`
624648
{
625-
"anchor": 4,
649+
"anchor": 3,
626650
"head": 167,
627651
}
628652
`);
@@ -744,7 +768,7 @@ describe("Location Resolution", () => {
744768

745769
expect(result).toMatchInlineSnapshot(`
746770
{
747-
"anchor": 4,
771+
"anchor": 3,
748772
"head": 94,
749773
}
750774
`);
@@ -1085,7 +1109,7 @@ describe("Location Resolution", () => {
10851109

10861110
it("should handle whole block selections in nested structures", () => {
10871111
// Whole parent block
1088-
const parentWholePM = { anchor: 2, head: 92 };
1112+
const parentWholePM = { anchor: 2, head: 22 };
10891113
const parentWholeResult = resolvePMToLocation(nestedDoc, parentWholePM);
10901114

10911115
expect(parentWholeResult).toMatchInlineSnapshot(`
@@ -1113,18 +1137,15 @@ describe("Location Resolution", () => {
11131137
// Test Point -> PMLocation -> Point
11141138
const originalPoint: Point = { id: "block1", offset: 5 };
11151139
const pmLocation = resolvePointToPM(doc, originalPoint);
1116-
const convertedLocation = resolvePMToLocation(doc, pmLocation);
1117-
1118-
// The conversion may not be perfectly symmetric due to ProseMirror position handling
1119-
// but the converted point should be valid and within the same block
1120-
expect("id" in convertedLocation).toBe(true);
1121-
1122-
expect(convertedLocation).toMatchInlineSnapshot(`
1140+
expect(pmLocation).toMatchInlineSnapshot(`
11231141
{
1124-
"id": "block1",
1125-
"offset": 5,
1142+
"anchor": 8,
1143+
"head": 8,
11261144
}
11271145
`);
1146+
const convertedLocation = resolvePMToLocation(doc, pmLocation);
1147+
1148+
expect(convertedLocation).toEqual(originalPoint);
11281149
});
11291150

11301151
it("should maintain consistency for ranges in round-trip conversions", () => {
@@ -1136,22 +1157,7 @@ describe("Location Resolution", () => {
11361157
const pmLocation = resolveRangeToPM(doc, originalRange);
11371158
const convertedLocation = resolvePMToLocation(doc, pmLocation);
11381159

1139-
// The conversion may not be perfectly symmetric, but should maintain block relationships
1140-
expect("anchor" in convertedLocation).toBe(true);
1141-
1142-
// Type assertion since we know it's a Range from the test above
1143-
expect(convertedLocation).toMatchInlineSnapshot(`
1144-
{
1145-
"anchor": {
1146-
"id": "block1",
1147-
"offset": 2,
1148-
},
1149-
"head": {
1150-
"id": "block3",
1151-
"offset": 5,
1152-
},
1153-
}
1154-
`);
1160+
expect(convertedLocation).toEqual(originalRange);
11551161
});
11561162

11571163
it("should handle nested block round-trip conversions", () => {
@@ -1179,44 +1185,52 @@ describe("Location Resolution", () => {
11791185
const pmLocation = resolvePointToPM(nestedDoc, originalPoint);
11801186
const convertedLocation = resolvePMToLocation(nestedDoc, pmLocation);
11811187

1182-
// The conversion may not be perfectly symmetric, but should maintain block relationships
1183-
expect("id" in convertedLocation).toBe(true);
1188+
expect(convertedLocation).toEqual(originalPoint);
11841189

1185-
// Type assertion since we know it's a Point from the test above
1186-
const convertedPoint = convertedLocation as Point;
1187-
expect(convertedPoint).toMatchInlineSnapshot(`
1190+
// Test nested Point round-trip
1191+
const originalPoint2: Point = { id: "child", offset: 0 };
1192+
const pmLocation2 = resolvePointToPM(nestedDoc, originalPoint2);
1193+
const convertedLocation2 = resolvePMToLocation(nestedDoc, pmLocation2);
1194+
1195+
expect(convertedLocation2).toEqual(originalPoint2);
1196+
1197+
// Test nested Point round-trip
1198+
const originalPoint3: Point = { id: "child", offset: 5 };
1199+
const pmLocation3 = resolvePointToPM(nestedDoc, originalPoint3);
1200+
const convertedLocation3 = resolvePMToLocation(nestedDoc, pmLocation3);
1201+
1202+
expect(convertedLocation3).toEqual(originalPoint3);
1203+
1204+
// Test nested Point round-trip
1205+
const originalPoint4: Point = { id: "child", offset: -1 };
1206+
const pmLocation4 = resolvePointToPM(nestedDoc, originalPoint4);
1207+
expect(pmLocation4).toMatchInlineSnapshot(`
11881208
{
1189-
"id": "child",
1190-
"offset": 2,
1209+
"anchor": 12,
1210+
"head": 19,
11911211
}
11921212
`);
1213+
const convertedLocation4 = resolvePMToLocation(nestedDoc, pmLocation4);
1214+
1215+
expect(convertedLocation4).toEqual(originalPoint4);
11931216

11941217
// Test nested Range round-trip
11951218
const originalRange: Range = {
11961219
anchor: { id: "parent", offset: 0 },
1197-
head: { id: "child", offset: -1 },
1220+
head: { id: "child", offset: 5 },
11981221
};
11991222
const pmRange = resolveRangeToPM(nestedDoc, originalRange);
1200-
const convertedRangeLocation = resolvePMToLocation(nestedDoc, pmRange);
1201-
1202-
// The conversion may not be perfectly symmetric, but should maintain block relationships
1203-
expect("anchor" in convertedRangeLocation).toBe(true);
1204-
1205-
// Type assertion since we know it's a Range from the test above
1206-
const convertedRange = convertedRangeLocation as Range;
1207-
expect(convertedRange).toMatchInlineSnapshot(`
1223+
expect(pmRange).toMatchInlineSnapshot(`
12081224
{
1209-
"anchor": {
1210-
"id": "parent",
1211-
"offset": 1,
1212-
},
1213-
"head": {
1214-
"id": "child",
1215-
"offset": 6,
1216-
},
1225+
"anchor": 3,
1226+
"head": 18,
12171227
}
12181228
`);
12191229

1230+
const convertedRangeLocation = resolvePMToLocation(nestedDoc, pmRange);
1231+
1232+
expect(convertedRangeLocation).toEqual(originalRange);
1233+
12201234
nestedEditor._tiptapEditor.destroy();
12211235
});
12221236
});
@@ -1238,7 +1252,7 @@ describe("Location Resolution", () => {
12381252
expect(endResult).toMatchInlineSnapshot(`
12391253
{
12401254
"id": "block5",
1241-
"offset": 16,
1255+
"offset": 15,
12421256
}
12431257
`);
12441258
});
@@ -1421,7 +1435,7 @@ describe("Location Resolution", () => {
14211435
expect(deepResult).toMatchInlineSnapshot(`
14221436
{
14231437
"id": "level1",
1424-
"offset": 34,
1438+
"offset": 9,
14251439
}
14261440
`);
14271441

packages/core/src/locations/location.ts

Lines changed: 36 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import type { Node } from "prosemirror-model";
2-
import { getNodeById } from "../api/nodeUtil.js";
3-
import type { BlockId, Location, PMLocation, Point, Range } from "./types.js";
4-
import { isBlockId, isBlockIdentifier, isPoint, isRange } from "./utils.js";
52
import {
63
getBlockInfo,
74
getNearestBlockPos,
85
} from "../api/getBlockInfoFromPos.js";
6+
import { getNodeById } from "../api/nodeUtil.js";
7+
import type { BlockId, Location, PMLocation, Point, Range } from "./types.js";
8+
import { isBlockId, isBlockIdentifier, isPoint, isRange } from "./utils.js";
99

1010
export function resolveLocation(
1111
doc: Node,
@@ -62,48 +62,19 @@ export function resolvePointToPM(doc: Node, point: Point): PMLocation {
6262
head: block.head,
6363
};
6464
}
65-
if (point.offset > block.head - block.anchor) {
65+
66+
const head = block.anchor + point.offset + 1;
67+
68+
if (head > block.head) {
69+
// TODO should this just clamp?
6670
throw new Error(
6771
`Offset ${point.offset} exceeds block length ${block.head - block.anchor}`,
6872
);
6973
}
7074

71-
/**
72-
* This counts characters & leaf inline nodes within the block as the unit of measurement.
73-
* This is slightly different than what ProseMirror does, but it's more intuitive for users.
74-
*/
75-
76-
let curOffset = 0;
77-
let foundPos = block.anchor;
78-
79-
doc.nodesBetween(block.anchor, block.head, (node, pos) => {
80-
if (pos <= block.anchor || pos >= block.head) {
81-
return true;
82-
}
83-
84-
let nodeLength = 0;
85-
if (node.type.isText) {
86-
// If the node is a text node, we can just use the length of the text
87-
nodeLength = node.text!.length;
88-
} else if (node.type.isInline && node.isLeaf) {
89-
// If the node is an inline leaf node, we can just use 1 to represent the node's position
90-
nodeLength = 1;
91-
}
92-
curOffset += nodeLength;
93-
94-
if (curOffset <= point.offset) {
95-
return true;
96-
}
97-
98-
// We've found the position within the node, but we may have overshot the target offset
99-
// So we need to subtract the amount we overshot from the found position
100-
foundPos = pos + (point.offset - (curOffset - nodeLength));
101-
return false;
102-
});
103-
10475
return {
105-
anchor: foundPos,
106-
head: foundPos,
76+
anchor: head,
77+
head,
10778
};
10879
}
10980

@@ -151,21 +122,36 @@ export function resolvePMToLocation(
151122
? anchorBlockPos
152123
: getNearestBlockPos(doc, pmLocation.head);
153124

154-
const anchorOffset = Math.max(
155-
pmLocation.anchor - anchorBlockPos.posBeforeNode - 2,
156-
-1,
125+
// clamp the offsets to be within -1 (the block itself) and the block's node size
126+
const anchorOffset = Math.min(
127+
Math.max(pmLocation.anchor - anchorBlockPos.posBeforeNode - 2, -1),
128+
anchorBlockPos.node.firstChild?.nodeSize ?? Number.MAX_SAFE_INTEGER,
157129
);
158-
const headOffset = Math.max(
159-
pmLocation.head - headBlockPos.posBeforeNode - 2,
160-
-1,
130+
const headOffset = Math.min(
131+
Math.max(pmLocation.head - headBlockPos.posBeforeNode - 2, -1),
132+
headBlockPos.node.firstChild?.nodeSize ?? Number.MAX_SAFE_INTEGER,
161133
);
162134

163135
if (anchorBlockPos.node === headBlockPos.node) {
164-
// pointing to the same block, return a point
165-
return {
166-
id: anchorBlockPos.node.attrs.id,
167-
offset: anchorOffset,
168-
};
136+
// pointing to the same block, so we will return a point
137+
if (
138+
anchorOffset === -1 &&
139+
headOffset === (headBlockPos.node.firstChild?.nodeSize ?? 0) - 1
140+
) {
141+
// We may be pointing to the whole block, so we need to check the block size
142+
return {
143+
id: anchorBlockPos.node.attrs.id,
144+
offset: -1,
145+
};
146+
}
147+
148+
// Only if the offsets are the same, we can return a point
149+
if (headOffset === anchorOffset) {
150+
return {
151+
id: anchorBlockPos.node.attrs.id,
152+
offset: headOffset,
153+
};
154+
}
169155
}
170156

171157
// pointing to different blocks, return a range

0 commit comments

Comments
 (0)