Skip to content

Commit 79ef161

Browse files
authored
platform: fix adding a second annotation (#441)
1 parent efe700b commit 79ef161

File tree

2 files changed

+70
-4
lines changed

2 files changed

+70
-4
lines changed

libs/shared-react/src/plugins/usj/annotation/AnnotationPlugin.test.tsx

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { baseTestEnvironment } from "../react-test.utils";
22
import { AnnotationPlugin, AnnotationRef } from "./AnnotationPlugin";
33
import { AnnotationRange } from "./selection.model";
44
import { act } from "@testing-library/react";
5-
import { $createTextNode, $getRoot } from "lexical";
5+
import { $createTextNode, $getRoot, $isTextNode } from "lexical";
66
import { createRef } from "react";
7-
import { $createParaNode, $createTypedMarkNode } from "shared";
7+
import { $createParaNode, $createTypedMarkNode, $isParaNode, $isTypedMarkNode } from "shared";
88
import { vi } from "vitest";
99

1010
describe("AnnotationPlugin", () => {
@@ -30,6 +30,62 @@ describe("AnnotationPlugin", () => {
3030
// Check: spelling removal callback should not fire during internal rewrap.
3131
expect(spellingOnRemove).not.toHaveBeenCalled();
3232
});
33+
34+
it("correctly handles adding a second annotation at the same start position as the first", async () => {
35+
// Initial state: "the " + "man" (spelling annotation) + " who stands"
36+
const { annotationPlugin, editor } = await testEnvironment(() => {
37+
$getRoot().append(
38+
$createParaNode().append(
39+
$createTextNode("the "),
40+
$createTypedMarkNode({ spelling: ["spell-1"] }).append($createTextNode("man")),
41+
$createTextNode(" who stands"),
42+
),
43+
);
44+
});
45+
const jsonPath = "$.content[0].content[0]";
46+
// Second annotation: "man who" (starts at same position as first but extends further)
47+
const secondAnnotationRange: AnnotationRange = {
48+
start: { jsonPath, offset: 4 },
49+
end: { jsonPath, offset: 11 },
50+
};
51+
52+
// SUT: add second annotation that starts at the same position as the first
53+
await act(async () => {
54+
annotationPlugin.setAnnotation(secondAnnotationRange, "grammar", "grammar-1");
55+
});
56+
57+
// Verify the structure: both annotations should start at the same position
58+
editor.getEditorState().read(() => {
59+
const para = $getRoot().getFirstChild();
60+
if (!$isParaNode(para)) throw new Error("Expected a ParaNode");
61+
62+
// First child should be plain text "the "
63+
const firstChild = para.getFirstChild();
64+
if (!$isTextNode(firstChild)) throw new Error("Expected a TextNode");
65+
expect(firstChild.getTextContent()).toBe("the ");
66+
67+
// Second child should be a TypedMarkNode with both spelling AND grammar at "man"
68+
const secondChild = firstChild.getNextSibling();
69+
if (!$isTypedMarkNode(secondChild)) throw new Error("Expected a TypedMarkNode");
70+
const secondTypedIDs = secondChild.getTypedIDs();
71+
expect(secondTypedIDs.spelling).toContain("spell-1");
72+
expect(secondTypedIDs.grammar).toContain("grammar-1");
73+
expect(secondChild.getTextContent()).toBe("man");
74+
75+
// Third child should be a TypedMarkNode with only grammar for " who"
76+
const thirdChild = secondChild.getNextSibling();
77+
if (!$isTypedMarkNode(thirdChild)) throw new Error("Expected a TypedMarkNode");
78+
const thirdTypedIDs = thirdChild.getTypedIDs();
79+
expect(thirdTypedIDs.spelling).toBeUndefined();
80+
expect(thirdTypedIDs.grammar).toContain("grammar-1");
81+
expect(thirdChild.getTextContent()).toBe(" who");
82+
83+
// Fourth child should be plain text " stands"
84+
const fourthChild = thirdChild.getNextSibling();
85+
if (!$isTextNode(fourthChild)) throw new Error("Expected a TextNode");
86+
expect(fourthChild.getTextContent()).toBe(" stands");
87+
});
88+
});
3389
});
3490

3591
async function testEnvironment($initialEditorState: () => void) {

libs/shared-react/src/plugins/usj/annotation/selection.utils.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ function $getNodeFromLocation(
128128
currentNode = $getContentChildAtIndex(currentNode, index);
129129
}
130130

131+
// If we landed on a TypedMarkNode (annotation wrapper), unwrap it to get the actual content
132+
if (currentNode && $isTypedMarkNode(currentNode)) {
133+
currentNode = currentNode.getFirstChild() ?? undefined;
134+
}
135+
131136
// If the jsonPath resolves to an ElementNode (e.g. "$.content[0]"), interpret offset as a
132137
// content-based child boundary offset (what the editor can emit), and return an element point.
133138
if (currentNode && $isElementNode(currentNode)) {
@@ -306,15 +311,20 @@ function $findTextNodeInMarks(
306311
if (!node || !$isTextNode(node)) return [undefined, undefined];
307312

308313
const text = node.getTextContent();
309-
if (offset >= 0 && offset <= text.length) return [node, offset];
314+
// If offset is within the text (not at the very end), return this node.
315+
// If offset equals text.length exactly, continue to next sibling to find the start of next content.
316+
if (offset >= 0 && offset < text.length) return [node, offset];
310317

311318
let nextNode = node.getNextSibling();
312319
if (!nextNode) {
313320
const parent = node.getParent();
314321
if ($isTypedMarkNode(parent)) nextNode = parent.getNextSibling();
315322
}
316-
if (!nextNode || (!$isTypedMarkNode(nextNode) && !$isTextNode(nextNode)))
323+
if (!nextNode || (!$isTypedMarkNode(nextNode) && !$isTextNode(nextNode))) {
324+
// No more siblings - if offset equals text.length, return end of current node
325+
if (offset === text.length) return [node, offset];
317326
return [undefined, undefined];
327+
}
318328

319329
const nextOffset = offset - text.length;
320330
if (nextNode && $isTextNode(nextNode)) return $findTextNodeInMarks(nextNode, nextOffset);

0 commit comments

Comments
 (0)