Context:
I am using Lexical Editor to create a custom implementation for assigning a code to a selection. The goal is to wrap selected nodes (which may include TranslateCommentNode and TranslateTextNode) into a new TranslateCommentNode, while preserving all properties of the original nodes, especially a custom property called marc.
The problem arises when nesting a TranslateCommentNode within another: the nested node’s marc property disappears or resets.
The problem arises when nesting a TranslateCommentNode within another: the nested node’s marc property disappears or resets.
Steps to Reproduce:
Select multiple nodes in the editor, including one or more TranslateCommentNode instances with the marc property set.
Run the assignCodeToSelection function.
Inspect the marc property of the nested TranslateCommentNode.
const assignCodeToSelection = (code) => {
editor.update(() => {
const selection = $getSelection();
if (!selection || !$isRangeSelection(selection)) {
console.warn("There is no valid range selection in the editor.");
return;
}
// Get selected nodes
let filteredSelection = selection.getNodes();
// Skip the last `TextNode` if the second-to-last is a `TranslateParagraphNode`
filteredSelection = filterLastInvalidNode(filteredSelection);
// Filter to include only `TranslateTextNode` or `TranslateCommentNode`
filteredSelection = filteredSelection.filter((node) => {
if ($isTranslateCommentNode(node)) {
if (node.getComment() === "This is not a comment, it’s a Verbatim") {
return true; // Include the entire node
}
}
return !$isTranslateCommentNode(node.getParent()); // Exclude child nodes of another `TranslateCommentNode`
});
if (filteredSelection.length === 0) {
console.warn("No valid text or comment nodes found in the selection.");
return;
}
const groups = groupByParagraphs(filteredSelection);
if (groups.length === 0) {
console.warn("No valid groups found to process.");
return;
}
const groupId = `group-${Date.now()}-${Math.random()}`;
const user = getUser();
const colorToUse = code.color;
let lastCommentNode = null;
groups.forEach((group) => {
const selectedNodes = group.filter(
(node) => $isTranslateTextNode(node) || $isTranslateCommentNode(node)
);
if (selectedNodes.length === 0) {
console.warn("No valid nodes found in the group.");
return;
}
const copiedNodes = selectedNodes
.map((node) => {
return cloneNode(node); // Clone text or comment nodes
})
.filter(Boolean);
const commentNode = createCommentNode(
copiedNodes,
user,
code,
colorToUse,
groupId
);
const firstNode = selectedNodes[0];
const lastNode = selectedNodes[selectedNodes.length - 1];
const parentParagraph = firstNode.getParent();
if (
parentParagraph &&
parentParagraph.getType() === "translate-paragraph"
) {
if (parentParagraph.getChildren().includes(lastNode)) {
lastNode.insertAfter(commentNode);
} else {
parentParagraph.append(commentNode); // Fallback
}
// Nest nodes inside the new TranslateCommentNode
selectedNodes.forEach((node) => {
if ($isTranslateCommentNode(node)) {
const originalMark = node.getMarca(); // Save the original mark
console.log(
"Nesting existing TranslateCommentNode:",
node.getKey(),
"With code:",
originalMark
);
commentNode.append(node); // Nest the node
// Reapply the mark to ensure it persists
if (originalMark !== null && originalMark !== undefined) {
node.setMarca(originalMark);
console.log(
"Mark reapplied after nesting:",
node.getKey(),
"Mark:",
node.getMarca()
);
}
} else {
commentNode.append(node);
console.log(
"Non-TranslateCommentNode nested:",
node.getKey(),
"Type:",
node.getType()
);
}
});
// Remove original nodes after nesting
selectedNodes.forEach((node) => {
node.remove();
});
// Log to verify child nodes of commentNode
const children = commentNode.getChildren(); // Get current children
console.log(
`Children of commentNode (${commentNode.getKey()}):`,
children.map((child) => ({
key: child.getKey(),
type: child.getType(),
mark: $isTranslateCommentNode(child)
? child.getLatest().getMarca() // Latest version of the node
: null,
}))
);
console.log(
`Comment node ${commentNode.getKey()} created and original nodes processed successfully.`
);
} else {
console.warn(
"The selected node does not belong to a valid TranslateParagraphNode."
);
}
});
if (lastCommentNode) {
lastCommentNode.select();
}
console.log("TranslateCommentNodes inserted successfully.");
});
};
After running the function, the marc property of nested TranslateCommentNode instances is null or undefined.
Before Assigning Code:
[
{
"key": "node1",
"type": "TranslateCommentNode",
"marc": "code1"
},
{
"key": "node2",
"type": "TranslateTextNode"
}
]
After Assigning Code:
[
{
"key": "node1",
"type": "TranslateCommentNode",
"marc": null
}
]
Questions:
Why does the marc property disappear when appending a TranslateCommentNode into a new parent node?
Should I manually copy or reset the marc property? If so, what is the best practice for handling this in Lexical?
Is there a known issue with how Lexical handles custom properties during node nesting, or am I missing a critical step?
TRANSLATECOMMENTNODE
import "./TranslateCommentNode.css";
import { ElementNode, $applyNodeReplacement } from "lexical";
import { useLexicalComposerContext } from "lexical";
import { FontIcon } from "@fluentui/react/lib/Icon";
import { HighContrastSelectorWhite } from "@fluentui/react";
import { SHOW_COMMENT_DIALOG_COMMAND } from "../EditorPlugins/CommentDialogPlugin.jsx";
export class TranslateCommentNode extends ElementNode {
#__id;
#__comment;
#__responses;
#__isAnonymized;
#__user;
#__isPublic;
#__color;
#__mainComment;
#__hideColor;
#__mark;
#__highlighted;
#__group;
constructor(
key,
{
id,
comment,
responses,
isAnonymized,
user,
isPublic,
color,
hideColor,
mark,
mainComment,
group,
}
) {
super(key);
this.#__id = id;
this.#__comment = comment;
this.#__responses = responses;
this.#__isAnonymized = isAnonymized;
this.#__user = user;
this.#__isPublic = isPublic;
this.#__color = color;
this.#__hideColor = hideColor;
this.#__mark = mark;
this.#__mainComment = mainComment; // Corrected property name
this.#__highlighted = false;
this.#__group = group;
}
static getType() {
return "translate-comment";
}
static clone(node) {
const properties = {
id: node.#__id,
comment: node.#__comment,
responses: node.#__responses,
isAnonymized: node.#__isAnonymized,
user: node.#__user,
isPublic: node.#__isPublic,
color: node.#__color,
mainComment: node.#__mainComment,
hideColor: node.#__hideColor,
mark: node.#__mark,
group: node.#__group,
};
return new TranslateCommentNode(node.__key, properties);
}
static importJSON(serializedNode) {
// Ensure `serializedNode` contains all required properties
const {
id,
comment,
responses,
isAnonymized,
user,
isPublic,
color,
hideColor,
mark,
mainComment,
group,
} = serializedNode;
return $createTranslateCommentNode(
comment,
responses,
id,
mainComment,
user,
isPublic,
color,
hideColor,
mark,
isAnonymized,
group
);
}
exportJSON() {
return {
...super.exportJSON(),
type: "translate-comment",
id: this.#__id,
comment: this.#__comment,
responses: this.#__responses,
isAnonymized: this.#__isAnonymized,
user: this.#__user,
isPublic: this.#__isPublic,
color: this.#__color,
mainComment: this.#__mainComment,
hideColor: this.#__hideColor,
mark: this.#__mark,
group: this.#__group,
};
}
addHighlight() {
const writableNode = this.getWritable();
writableNode.#__highlighted = true;
console.log(`Node ${writableNode.__key} highlighted`); // Log for confirmation
}
removeHighlight() {
const writableNode = this.getWritable();
writableNode.#__highlighted = false;
}
createDOM(config, editor) {
const rootSpan = document.createElement("span");
rootSpan.contentEditable = true;
// Apply background color if `hideColor` is false and a color is assigned
if (!this.#__hideColor && this.#__color) {
rootSpan.style.backgroundColor = this.#__color;
rootSpan.style.color = "black";
}
// Apply highlight style if `#highlighted` is true
if (this.#__highlighted) {
rootSpan.classList.add("highlighted-comment");
}
return rootSpan;
}
updateDOM(prevNode, dom, config) {
// Add or remove the highlight class based on the node's state
if (this.#__highlighted) {
dom.classList.add("highlighted-comment");
} else {
dom.classList.remove("highlighted-comment");
}
// Update the node's background color if not hidden
if (!this.#__hideColor && this.#__color) {
dom.style.backgroundColor = this.#__color;
} else {
dom.style.backgroundColor = "";
}
return false; // Do not replace the DOM node
}
// Getter and Setter methods
getId() {
return this.getLatest().#__id;
}
getUser() {
return this.getLatest().#__user;
}
isPublic() {
return this.getLatest().#__isPublic;
}
setIsPublic(isPublic) {
return (this.getWritable().#__isPublic = isPublic);
}
getColor() {
return this.getLatest().#__color;
}
setColor(color) {
return (this.getWritable().#__color = color);
}
setMark(mark) {
return (this.getWritable().#__mark = mark);
}
getComment() {
return this.getLatest().#__comment;
}
getMark() {
return this.getLatest().#__mark;
}
getResponses() {
return this.getLatest().#__responses;
}
getGroup() {
return this.getLatest().#__group;
}
setId(id) {
return (this.getWritable().#__id = id);
}
setUser(user) {
return (this.getWritable().#__user = user);
}
setComment(comment) {
return (this.getWritable().#__comment = comment);
}
setResponses(response) {
return (this.getWritable().#__responses = response);
}
setHideColor(hideColor) {
return (this.getWritable().#__hideColor = hideColor);
}
anonymize(isAnonymized) {
this.getWritable().#__isAnonymized = isAnonymized;
}
getIsAnonymized() {
return this.#__isAnonymized;
}
setMainComment(mainComment) {
this.getWritable().#__mainComment = mainComment;
}
getMainComment() {
return this.#__mainComment;
}
setGroup(group) {
this.getWritable().__.__group = group;
}
}
// Utility function to check if a node is a `TranslateCommentNode`
export function $isTranslateCommentNode(node) {
return node instanceof TranslateCommentNode;
}
export function $createTranslateCommentNode(
comment,
responses,
id,
mainComment = null,
user,
isPublic,
color,
hideColor,
mark,
isAnonymized = false,
group = null
) {
const properties = {
id: id,
comment: comment,
responses: responses,
isAnonymized: isAnonymized,
user: user,
isPublic: isPublic,
color: color,
hideColor: hideColor,
mark: mark,
mainComment: mainComment,
group: group,
};
console.log("TranslateCommentNode creation parameters:", properties);
return new TranslateCommentNode(undefined, properties);
}
TRANSLATETEXTNODE (TSX)
import "./TranslateTextNode.css";
import type {
EditorConfig,
LexicalEditor,
LexicalNode,
NodeKey,
SerializedTextNode,
Spread,
} from "lexical";
import { $applyNodeReplacement, TextNode } from "lexical";
import { SET_VIDEO_POSITION_COMMAND } from "../EditorPlugins/VideoJSPlugin";
import { SHOW_PREVIEW_COMMAND } from "../EditorPlugins/TranslateTextPlugin.jsx";
export type SerializedTranslateTextNode = Spread<
{
type: "translate-text";
start: number | null;
end: number | null;
isRead: boolean;
isAnonymized: boolean;
parent: string | null;
search: boolean;
},
SerializedTextNode
>;
export class TranslateTextNode extends TextNode {
#__start: number | null;
#__end: number | null;
#__isRead: boolean;
#__isAnonymized: boolean;
#__search: boolean;
#__selected: boolean;
#__highlighted: boolean; // New property for highlighting
static getType(): string {
return "translate-text";
}
getIsAnonymized(): boolean {
return this.#__isAnonymized;
}
static clone(node: TranslateTextNode): TranslateTextNode {
return new TranslateTextNode(
node.__text,
node.#__start,
node.#__end,
node.#__isRead,
node.#__isAnonymized,
node.__key
);
}
constructor(
text: string,
start: number | null,
end: number | null,
isRead: boolean,
isAnonymized: boolean,
key?: NodeKey
) {
super(text, key);
this.#__start = start;
this.#__end = end;
this.#__isRead = isRead;
this.#__isAnonymized = isAnonymized;
this.#__search = false;
this.#__selected = false;
this.#__highlighted = false; // Initially not highlighted
}
// Method to enable/disable highlighting
setHighlighted(isHighlighted) {
const writableNode = this.getWritable();
writableNode.#__highlighted = isHighlighted;
}
getStart() {
return this.getLatest().#__start;
}
getEnd() {
return this.getLatest().#__end;
}
getStartEnd() {
const latest = this.getLatest();
return {
start: latest.#__start,
end: latest.#__end,
};
}
isRead() {
return this.getLatest().#__isRead;
}
setRead(read) {
return (this.getWritable().#__isRead = read);
}
anonymize(isAnonymized) {
this.getWritable().#__isAnonymized = isAnonymized;
}
getReadElements() {
const self = this.getLatest();
return {
start: self.#__start,
end: self.#__end,
isRead: self.#__isRead,
};
}
setSearch(search) {
this.getWritable().#__search = search;
}
getSearch() {
return this.getLatest().#__search;
}
setSelected(selected) {
this.getWritable().#__selected = selected;
}
getSelected() {
return this.getLatest().#__selected;
}
createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
const dom = document.createElement("span");
if (this.#__isAnonymized) {
return dom;
}
dom.ondblclick = (event) => {
event.preventDefault();
event.stopPropagation();
editor.dispatchCommand(SET_VIDEO_POSITION_COMMAND, this.#__start);
};
dom.className = this.#__isRead ? "textread" : "";
dom.ondragenter = (event) => {
event.preventDefault();
event.stopPropagation();
editor.dispatchCommand(SHOW_PREVIEW_COMMAND, this);
};
dom.style.background = this.#__search ? "#d9b855" : "";
if (this.#__selected) {
dom.style.background = "#fd7e14";
}
const inner = super.createDOM(config);
dom.appendChild(inner);
return dom;
}
updateDOM(
prevNode: TextNode,
dom: HTMLElement,
config: EditorConfig
): boolean {
const inner = dom.firstChild;
console.log(`updateDOM for TranslateTextNode with key: ${this.getKey()}`);
console.log(
`State of selected: ${this.#__selected}, search: ${this.#__search}`
);
if (!inner || this.#__isAnonymized) {
return true;
}
// Update background if `search` or `selected` change
if (
prevNode.#__search !== this.#__search ||
prevNode.#__selected !== this.#__selected
) {
dom.style.background = this.#__search
? "#d9b855"
: this.#__selected
? "#ffecb3"
: "";
console.log(
`Updating background in the DOM for node ${this.getKey()} to ${
dom.style.background
}`
);
}
if (prevNode.#__isRead !== this.#__isRead) {
dom.className = this.#__isRead ? "textread" : "";
}
return super.updateDOM(prevNode, inner as HTMLElement, config);
}
static importJSON(
serializedNode: SerializedTranslateTextNode
): TranslateTextNode {
const node = $createTranslateTextNode(
serializedNode.text,
serializedNode.start,
serializedNode.end,
serializedNode.isAnonymized
);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
}
exportJSON(): SerializedTranslateTextNode {
return {
...super.exportJSON(),
type: "translate-text",
start: this.#__start,
end: this.#__end,
isRead: this.#__isRead,
isAnonymized: this.#__isAnonymized,
parent: this.__parent,
};
}
}
export function $isTranslateTextNode(
node: LexicalNode | null | undefined
): node is TranslateTextNode {
return node instanceof TranslateTextNode;
}
export function $createTranslateTextNode(
text: string,
start: number | null,
end: number | null,
isAnonymized: boolean
): TranslateTextNode {
const node = new TranslateTextNode(text, start, end, false, isAnonymized);
return $applyNodeReplacement(node);
}