Skip to content
Open
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
85 changes: 85 additions & 0 deletions libs/shared-react/src/nodes/usj/ImmutableVerseNode.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { baseTestEnvironment } from "../../plugins/usj/react-test.utils";
import {
$createImmutableVerseNode,
ImmutableVerseNode,
VERSE_SELECTED_CLASS_NAME,
} from "./ImmutableVerseNode";
import { act } from "@testing-library/react";
import {
$createNodeSelection,
$createPoint,
$createRangeSelection,
$createTextNode,
$getRoot,
$setSelection,
LexicalEditor,
} from "lexical";
import { $createParaNode } from "shared";

describe("ImmutableVerseNode selected-state rendering", () => {
it("renders no selected class when the verse is not in a NodeSelection", async () => {
const { editor } = await baseTestEnvironment(() => {
$getRoot().append(
$createParaNode("p").append($createImmutableVerseNode("1"), $createTextNode("text")),
);
});

expect(selectedVerseEl(editor)).toBeNull();
});

it("renders the selected class when its key is added to a NodeSelection, and removes it when cleared", async () => {
let verse: ImmutableVerseNode;
const { editor } = await baseTestEnvironment(() => {
verse = $createImmutableVerseNode("1");
$getRoot().append($createParaNode("p").append(verse, $createTextNode("text")));
});

// Arm: select the verse marker.
await act(async () => {
editor.update(
() => {
const ns = $createNodeSelection();
ns.add(verse.getKey());
$setSelection(ns);
},
{ discrete: true },
);
});
expect(selectedVerseEl(editor)).not.toBeNull();

// Clear: collapse back to a range caret in the text.
await act(async () => {
editor.update(() => $setSelection(null), { discrete: true });
});
expect(selectedVerseEl(editor)).toBeNull();
});

it("renders without throwing when an element-type RangeSelection point lands on the verse (cursor on verse number)", async () => {
let verse: ImmutableVerseNode;
const { editor } = await baseTestEnvironment(() => {
verse = $createImmutableVerseNode("1");
$getRoot().append($createParaNode("p").append(verse, $createTextNode("text")));
});

// Element point anchored on the DecoratorNode itself. The base `isSelected` calls
// `getNodes()`, which throws for this shape in Lexical; the decorator must not surface that.
await act(async () => {
editor.update(
() => {
const selection = $createRangeSelection();
selection.anchor = $createPoint(verse.getKey(), 0, "element");
selection.focus = $createPoint(verse.getKey(), 0, "element");
$setSelection(selection);
},
{ discrete: true },
);
});

expect(selectedVerseEl(editor)).toBeNull();
});
});

/** Query the rendered verse marker that carries the "selected" class, if any. */
function selectedVerseEl(editor: LexicalEditor): Element | null {
return editor.getRootElement()?.querySelector(`.${VERSE_SELECTED_CLASS_NAME}`) ?? null;
}
54 changes: 45 additions & 9 deletions libs/shared-react/src/nodes/usj/ImmutableVerseNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import {
$applyNodeReplacement,
BaseSelection,
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
Expand All @@ -14,12 +15,26 @@ import {
Spread,
isHTMLElement,
} from "lexical";
import { useLexicalNodeSelection } from "@lexical/react/useLexicalNodeSelection";
import { ReactElement } from "react";
import { getVisibleOpenMarkerText, UnknownAttributes, VERSE_CLASS_NAME, ZWSP } from "shared";
import {
getVisibleOpenMarkerText,
isSelectionStartNodeExpectedError,
UnknownAttributes,
VERSE_CLASS_NAME,
ZWSP,
} from "shared";

export const VERSE_MARKER = "v";
export const IMMUTABLE_VERSE_VERSION = 1;

/**
* Class applied to the rendered verse marker while it is in a NodeSelection (e.g. armed for the
* two-step intentional delete). Themeable by the host app; a default selection-style background
* ships in the platform stylesheet.
*/
export const VERSE_SELECTED_CLASS_NAME = "verse-selected";

type VerseMarker = typeof VERSE_MARKER;

export type SerializedImmutableVerseNode = Spread<
Expand Down Expand Up @@ -226,14 +241,11 @@ export class ImmutableVerseNode extends DecoratorNode<ReactElement> {
}

override decorate(): ReactElement {
return (
<span>
{this.getShowMarker()
? getVisibleOpenMarkerText(this.getMarker(), this.getNumber())
: // ZWSP added so double click word selection works without including this number.
ZWSP + this.getNumber() + ZWSP}
</span>
);
const text = this.getShowMarker()
? getVisibleOpenMarkerText(this.getMarker(), this.getNumber())
: // ZWSP added so double click word selection works without including this number.
ZWSP + this.getNumber() + ZWSP;
return <VerseDecorator nodeKey={this.getKey()} text={text} />;
}

override exportJSON(): SerializedImmutableVerseNode {
Expand All @@ -250,13 +262,37 @@ export class ImmutableVerseNode extends DecoratorNode<ReactElement> {
};
}

override isSelected(selection?: BaseSelection | null): boolean {
// The base implementation calls `selection.getNodes()`, which throws when a RangeSelection
// has an element-type point anchored on this DecoratorNode (the "cursor on the verse number"
// case). Treat that expected throw as "not selected", consistent with how
// `getSelectionStartNode` handles the same selection shape.
try {
return super.isSelected(selection);
} catch (err) {
if (isSelectionStartNodeExpectedError(err)) return false;
throw err;
}
}

// Mutation

override isKeyboardSelectable(): false {
return false;
}
}

/**
* Renders the verse marker text and reflects its node-selection state. DecoratorNodes do not get
* selection styling for free, so this subscribes to the node's selection and toggles
* {@link VERSE_SELECTED_CLASS_NAME}.
*/
function VerseDecorator({ nodeKey, text }: { nodeKey: NodeKey; text: string }): ReactElement {
const [isSelected] = useLexicalNodeSelection(nodeKey);
// ZWSPs stay inside this span so double-click word selection still excludes the number.
return <span className={isSelected ? VERSE_SELECTED_CLASS_NAME : undefined}>{text}</span>;
}

function $convertImmutableVerseElement(element: HTMLElement): DOMConversionOutput {
const verseNumber = element.getAttribute("data-number") ?? "0";
const node = $createImmutableVerseNode(verseNumber);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,12 @@

// Reaching inside only for tests.
// eslint-disable-next-line @nx/enforce-module-boundaries
import {
$expectSelectionToBe,
updateSelection,
} from "../../../../../libs/shared/src/nodes/usj/test.utils";
import { $expectSelectionToBe } from "../../../../../libs/shared/src/nodes/usj/test.utils";
import { $createImmutableNoteCallerNode, $createImmutableVerseNode } from "../../nodes/usj";
import { getDefaultViewOptions } from "../../views/view-options.utils";
import { ArrowNavigationPlugin } from "./ArrowNavigationPlugin";
import { TextDirectionPlugin } from "./TextDirectionPlugin";
import { baseTestEnvironment, pressKey } from "./react-test.utils";
import { baseTestEnvironment, pressKey, updateSelection } from "./react-test.utils";
import { $createLineBreakNode, $createTextNode, $getRoot, TextNode } from "lexical";
import {
$createCharNode,
Expand Down
Loading
Loading