Skip to content

Commit c842afe

Browse files
committed
fix(usj): annotation-agnostic selection paths for base USJ
- Emit jsonPath/offset from base-USJ content runs in selection.utils - Update selection tests; add multi-annotation paragraph case - Platform demo: selection ref, Insert at selection, fix callback types Made-with: Cursor
1 parent 870bbd8 commit c842afe

File tree

3 files changed

+175
-11
lines changed

3 files changed

+175
-11
lines changed

demos/platform/src/app/app.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ import {
2323
HIDDEN_NOTE_CALLER,
2424
isInsertEmbedOpOfType,
2525
Marginal,
26-
MarginalProps,
2726
MarginalRef,
2827
MarkerMode,
2928
NoteMode,
29+
SelectionRange,
3030
TextDirection,
3131
UsjNodeOptions,
3232
ViewOptions,
@@ -48,6 +48,14 @@ interface Annotations {
4848
};
4949
}
5050

51+
type OnUsjChangeWithComments = (
52+
usj: Usj,
53+
comments: Comments | undefined,
54+
ops?: DeltaOp[],
55+
source?: DeltaSource,
56+
insertedNodeKey?: string,
57+
) => void;
58+
5159
const isTesting = process.env.NODE_ENV === "testing";
5260
const webUsj = usxStringToUsj(isTesting ? WEB_PSA_USX : WEB_PSA_CH1_USX);
5361
const editorUsj = webUsj; // isTesting ? webUsj : usj2Sa;
@@ -111,6 +119,7 @@ export default function App() {
111119
const marginalRef = useRef<MarginalRef | null>(null);
112120
const noteEditorRef = useRef<EditorRef | null>(null);
113121
const noteNodeKeyRef = useRef<string | undefined>();
122+
const currentSelectionRef = useRef<SelectionRange | undefined>(undefined);
114123
const [isNoteEditorVisible, setIsNoteEditorVisible] = useState(false);
115124
const [isOptionsDefined, setIsOptionsDefined] = useState(false);
116125
const [isReadonly, setIsReadonly] = useState(false);
@@ -215,7 +224,7 @@ export default function App() {
215224
[nodeOptions],
216225
);
217226

218-
const handleUsjChange = useCallback<NonNullable<MarginalProps<Console>["onUsjChange"]>>(
227+
const handleUsjChange = useCallback<OnUsjChangeWithComments>(
219228
(
220229
usj: Usj,
221230
comments: Comments | undefined,
@@ -394,6 +403,33 @@ export default function App() {
394403
>
395404
stand
396405
</button>
406+
<button
407+
id="insertAtSelection"
408+
onClick={() => {
409+
const selection = currentSelectionRef.current;
410+
if (
411+
selection?.start &&
412+
selection?.end &&
413+
marginalRef.current &&
414+
isUsjTextContentLocation(selection.start) &&
415+
isUsjTextContentLocation(selection.end)
416+
) {
417+
const id = `sel-${Date.now()}`;
418+
marginalRef.current.setAnnotation(
419+
{ start: selection.start, end: selection.end },
420+
annotationType,
421+
id,
422+
handleAnnotationOnClick,
423+
handleAnnotationOnRemove,
424+
);
425+
console.log("Inserted annotation at selection", { selection, id });
426+
} else {
427+
console.warn("No valid selection for annotation");
428+
}
429+
}}
430+
>
431+
Insert at selection
432+
</button>
397433
</div>
398434
</span>
399435
<pre title="contextMarker" style={{ color: "black" }}>
@@ -508,7 +544,10 @@ export default function App() {
508544
defaultUsj={EMPTY_USJ}
509545
scrRef={scrRef}
510546
onScrRefChange={setScrRef}
511-
onSelectionChange={(selection) => console.log({ selection })}
547+
onSelectionChange={(selection) => {
548+
currentSelectionRef.current = selection;
549+
console.log({ selection });
550+
}}
512551
onCommentChange={(comments) => console.log({ comments })}
513552
onUsjChange={handleUsjChange}
514553
onStateChange={({

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

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -918,8 +918,8 @@ describe("$getUsjSelectionFromEditor", () => {
918918

919919
if (!usjSelection) throw new Error("Expected usjSelection to be defined");
920920
expect(usjSelection.start).toEqual({
921-
jsonPath: "$.content[0].content[1]",
922-
offset: 0,
921+
jsonPath: "$.content[0].content[0]",
922+
offset: 4,
923923
});
924924
expect(usjSelection.end).toBeUndefined();
925925
});
@@ -955,8 +955,8 @@ describe("$getUsjSelectionFromEditor", () => {
955955

956956
if (!usjSelection) throw new Error("Expected usjSelection to be defined");
957957
expect(usjSelection.start).toEqual({
958-
jsonPath: "$.content[0].content[2]",
959-
offset: 0,
958+
jsonPath: "$.content[0].content[1]",
959+
offset: 17,
960960
});
961961
expect(usjSelection.end).toBeUndefined();
962962
});
@@ -1031,16 +1031,52 @@ describe("$getUsjSelectionFromEditor", () => {
10311031

10321032
if (!usjSelection) throw new Error("Expected usjSelection to be defined");
10331033
expect(usjSelection.start).toEqual({
1034-
jsonPath: "$.content[0].content[0].content[0]",
1034+
jsonPath: "$.content[0].content[0]",
10351035
offset: 3,
10361036
});
10371037
expect(usjSelection.end).toEqual({
1038-
jsonPath: "$.content[0].content[0].content[0]",
1038+
jsonPath: "$.content[0].content[0]",
10391039
offset: 9,
10401040
});
10411041
});
10421042
});
10431043

1044+
it("should report annotation-agnostic path when multiple annotations in same paragraph", () => {
1045+
let textBefore: TextNode;
1046+
let textAfter: TextNode;
1047+
const { editor } = createBasicTestEnvironment([ParaNode, TypedMarkNode], () => {
1048+
textBefore = $createTextNode("before ");
1049+
const markedText = $createTextNode("neva");
1050+
textAfter = $createTextNode(" sleep");
1051+
$getRoot().append(
1052+
$createParaNode().append(
1053+
textBefore,
1054+
$createTypedMarkNode({ testType: ["testId"] }).append(markedText),
1055+
textAfter,
1056+
),
1057+
);
1058+
});
1059+
// Select "sleep" - the text after the first annotation
1060+
// Non-null assertions are safe: textAfter is assigned during the test setup callback.
1061+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1062+
updateSelection(editor, textAfter!, 1, textAfter!, 6);
1063+
1064+
editor.getEditorState().read(() => {
1065+
const usjSelection = $getUsjSelectionFromEditor();
1066+
1067+
if (!usjSelection) throw new Error("Expected usjSelection to be defined");
1068+
// Path should be annotation-agnostic: content[0] (not content[2]) with offset 12-18
1069+
expect(usjSelection.start).toEqual({
1070+
jsonPath: "$.content[0].content[0]",
1071+
offset: 12,
1072+
});
1073+
expect(usjSelection.end).toEqual({
1074+
jsonPath: "$.content[0].content[0]",
1075+
offset: 17,
1076+
});
1077+
});
1078+
});
1079+
10441080
it("should return USJ selection of ImmutableVerseNode as an atomic unit", () => {
10451081
let paraNode: ParaNode;
10461082
let textNode: TextNode;

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

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -464,8 +464,11 @@ function $getLocationFromNode(node: LexicalNode, offset: number): UsjDocumentLoc
464464
return { jsonPath: usjJsonPathFromIndexes($getJsonPathIndexes(node)), offset: contentOffset };
465465
}
466466

467-
// Regular text node - UsjTextContentLocation
468-
return { jsonPath: usjJsonPathFromIndexes($getJsonPathIndexes(node)), offset };
467+
// Text node or node inside TypedMarkNode - use annotation-agnostic path (base USJ)
468+
return {
469+
jsonPath: usjJsonPathFromIndexes($getJsonPathIndexesForBaseUsj(node)),
470+
offset: $getOffsetInContentRun(node, offset),
471+
};
469472
}
470473

471474
function $getMarkerAnchorNode(markerNode: MarkerNode): LexicalNode | undefined {
@@ -512,6 +515,92 @@ function $getJsonPathIndexes(node: LexicalNode): number[] {
512515
return jsonPathIndexes;
513516
}
514517

518+
/**
519+
* Gets the jsonPath indexes for base USJ (ignoring annotations). TypedMarkNodes are collapsed
520+
* into the same content run as adjacent TextNodes, so paths are valid against USJ without
521+
* annotation milestones.
522+
* @param node - The node to get the path for (TextNode or node inside TypedMarkNode).
523+
* @returns An array of indexes representing the path from root to node.
524+
*/
525+
function $getJsonPathIndexesForBaseUsj(node: LexicalNode): number[] {
526+
const jsonPathIndexes: number[] = [];
527+
let current: LexicalNode | null = node;
528+
while (current?.getParent()) {
529+
const parent: ElementNode | null = current.getParent();
530+
if (parent) {
531+
if ($isTypedMarkNode(parent)) {
532+
const grandparent: LexicalNode | null = parent.getParent();
533+
if (grandparent && $isElementNode(grandparent)) {
534+
const index = $getContentIndexWithinParentForBaseUsj(grandparent, parent);
535+
if (index >= 0) jsonPathIndexes.unshift(index);
536+
current = grandparent;
537+
} else {
538+
current = parent;
539+
}
540+
} else {
541+
const index = $getContentIndexWithinParentForBaseUsj(parent, current);
542+
if (index >= 0) jsonPathIndexes.unshift(index);
543+
current = parent;
544+
}
545+
} else {
546+
current = parent;
547+
}
548+
}
549+
return jsonPathIndexes;
550+
}
551+
552+
/**
553+
* Content index for base USJ: TypedMarkNodes and consecutive TextNodes form one "content run"
554+
* and share the same content index. Only increment when we hit a non-run node (e.g. MarkerNode,
555+
* VerseNode, MilestoneNode). Ignored nodes are skipped.
556+
*/
557+
function $getContentIndexWithinParentForBaseUsj(parent: ElementNode, child: LexicalNode): number {
558+
const children = parent.getChildren();
559+
let contentIndex = 0;
560+
for (const sibling of children) {
561+
if (sibling === child) return contentIndex;
562+
563+
if ($shouldIgnoreNodeForContentIndexes(sibling)) {
564+
continue;
565+
}
566+
if ($isTextNode(sibling) || $isTypedMarkNode(sibling)) {
567+
continue;
568+
}
569+
contentIndex += 1;
570+
}
571+
return -1;
572+
}
573+
574+
/**
575+
* Gets the character offset within the content run (base USJ), summing text lengths of
576+
* preceding TextNodes and TypedMarkNode contents in the same run.
577+
*/
578+
function $getOffsetInContentRun(node: LexicalNode, offset: number): number {
579+
const runNode = $isTypedMarkNode(node.getParent()) ? (node.getParent() as LexicalNode) : node;
580+
const runContainer = runNode.getParent();
581+
if (!runContainer || !$isElementNode(runContainer)) return offset;
582+
583+
const children = runContainer.getChildren();
584+
let runOffset = 0;
585+
for (const sibling of children) {
586+
if (sibling === runNode) return runOffset + offset;
587+
588+
if ($shouldIgnoreNodeForContentIndexes(sibling)) {
589+
// End of run
590+
} else if ($isTextNode(sibling)) {
591+
runOffset += sibling.getTextContent().length;
592+
} else if ($isTypedMarkNode(sibling)) {
593+
const firstChild = sibling.getFirstChild();
594+
if (firstChild && $isTextNode(firstChild)) {
595+
runOffset += firstChild.getTextContent().length;
596+
}
597+
} else {
598+
// End of run
599+
}
600+
}
601+
return runOffset + offset;
602+
}
603+
515604
function $getContentIndexWithinParent(parent: ElementNode, child: LexicalNode): number {
516605
const children = parent.getChildren();
517606
let index = 0;

0 commit comments

Comments
 (0)