Skip to content
Merged
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
23 changes: 3 additions & 20 deletions packages/lexical-clipboard/src/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {$addNodeStyle, $sliceSelectedTextNodeContent} from '@lexical/selection';
import {objectKlassEquals} from '@lexical/utils';
import {
$caretFromPoint,
$cloneWithProperties,
$createTabNode,
$getCaretRange,
$getChildCaret,
Expand All @@ -32,7 +31,6 @@ import {
LexicalNode,
SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
SerializedElementNode,
SerializedTextNode,
} from 'lexical';
import invariant from 'shared/invariant';

Expand Down Expand Up @@ -321,31 +319,16 @@ function $appendNodesToJSON(
let target = currentNode;

if (selection !== null && $isTextNode(target)) {
target = $sliceSelectedTextNodeContent(
selection,
$cloneWithProperties(target),
);
target = $sliceSelectedTextNodeContent(selection, target, 'clone');
}
const children = $isElementNode(target) ? target.getChildren() : [];

const serializedNode = exportNodeToJSON(target);

// TODO: TextNode calls getTextContent() (NOT node.__text) within its exportJSON method
// which uses getLatest() to get the text from the original node with the same key.
// This is a deeper issue with the word "clone" here, it's still a reference to the
// same node as far as the LexicalEditor is concerned since it shares a key.
// We need a way to create a clone of a Node in memory with its own key, but
// until then this hack will work for the selected text extract use case.
if ($isTextNode(target)) {
const text = target.__text;
if ($isTextNode(target) && target.getTextContentSize() === 0) {
// If an uncollapsed selection ends or starts at the end of a line of specialized,
// TextNodes, such as code tokens, we will get a 'blank' TextNode here, i.e., one
// with text of length 0. We don't want this, it makes a confusing mess. Reset!
if (text.length > 0) {
(serializedNode as SerializedTextNode).text = text;
} else {
shouldInclude = false;
}
shouldInclude = false;
}

for (let i = 0; i < children.length; i++) {
Expand Down
10 changes: 2 additions & 8 deletions packages/lexical-html/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import type {
import {$sliceSelectedTextNodeContent} from '@lexical/selection';
import {isBlockDomNode, isHTMLElement} from '@lexical/utils';
import {
$cloneWithProperties,
$createLineBreakNode,
$createParagraphNode,
$getRoot,
Expand Down Expand Up @@ -104,13 +103,8 @@ function $appendNodesToHTML(
$isElementNode(currentNode) && currentNode.excludeFromCopy('html');
let target = currentNode;

if (selection !== null) {
let clone = $cloneWithProperties(currentNode);
clone =
$isTextNode(clone) && selection !== null
? $sliceSelectedTextNodeContent(selection, clone)
: clone;
target = clone;
if (selection !== null && $isTextNode(currentNode)) {
target = $sliceSelectedTextNodeContent(selection, currentNode, 'clone');
}
const children = $isElementNode(target) ? target.getChildren() : [];
const registeredNode = getRegisteredNode(editor, target.getType());
Expand Down
7 changes: 4 additions & 3 deletions packages/lexical-selection/flow/LexicalSelection.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,11 @@ declare export function $forEachSelectedTextNode(
fn: (textNode: TextNode) => void,
): void;

declare export function $sliceSelectedTextNodeContent(
declare export function $sliceSelectedTextNodeContent<T: TextNode>(
selection: BaseSelection,
textNode: TextNode,
): LexicalNode;
textNode: T,
mutates?: 'self' | 'clone',
): T;

declare export function $copyBlockFormatIndent(
srcNode: ElementNode,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import {$sliceSelectedTextNodeContent} from '@lexical/selection';
import {
$createParagraphNode,
$createRangeSelection,
$createTextNode,
$getNodeByKey,
$getRoot,
} from 'lexical';
import {createTestEditor} from 'lexical/src/__tests__/utils';
import {describe, expect, test} from 'vitest';

// This primarily verifies that ephemeral TextNodes work correctly
describe('$sliceSelectedTextNodeContent', () => {
function createInitializedEditor() {
const editor = createTestEditor();
editor.update(
() => {
$getRoot()
.clear()
.append(
$createParagraphNode().append(
$createTextNode('01234'),
$createTextNode('56789').setFormat('bold'),
),
);
},
{discrete: true},
);
return editor;
}
function $createTextSelection(start: number, end: number) {
let i = 0;
const selection = $createRangeSelection();
for (const node of $getRoot().getAllTextNodes()) {
const len = node.getTextContentSize();
const j = i + len;
if (start >= i && start <= j) {
selection.anchor.set(node.getKey(), start - i, 'text');
}
if (end >= i && end <= j) {
selection.focus.set(node.getKey(), end - i, 'text');
}
i = j;
}
return selection;
}
describe('clone', () => {
test('does not clone with full selection (both nodes)', () => {
const editor = createInitializedEditor();
editor.read(() => {
const textNodes = $getRoot().getAllTextNodes();
const fullSize = textNodes.reduce(
(acc, n) => acc + n.getTextContentSize(),
0,
);
const selection = $createTextSelection(0, fullSize);
for (const node of textNodes) {
expect($sliceSelectedTextNodeContent(selection, node, 'clone')).toBe(
node,
);
}
});
});
test('clones only with partial selection (last node)', () => {
const editor = createInitializedEditor();
editor.read(() => {
const textNodes = $getRoot().getAllTextNodes();
const fullSize = textNodes.reduce(
(acc, n) => acc + n.getTextContentSize(),
0,
);
const selection = $createTextSelection(0, fullSize - 1);
const lastNode = textNodes.at(-1);
for (const node of textNodes) {
const slice = $sliceSelectedTextNodeContent(selection, node, 'clone');
if (node === lastNode) {
expect(slice).not.toBe(node);
expect(slice.getTextContent()).toBe(
node.getTextContent().slice(0, -1),
);
} else {
expect(slice).toBe(node);
}
}
});
});
test('clones only with partial selection (first node)', () => {
const editor = createInitializedEditor();
editor.read(() => {
const textNodes = $getRoot().getAllTextNodes();
const fullSize = textNodes.reduce(
(acc, n) => acc + n.getTextContentSize(),
0,
);
const selection = $createTextSelection(1, fullSize);
const firstNode = textNodes.at(0);
for (const node of textNodes) {
const slice = $sliceSelectedTextNodeContent(selection, node, 'clone');
if (node === firstNode) {
expect(slice).not.toBe(node);
expect(slice.getTextContent()).toBe(node.getTextContent().slice(1));
} else {
expect(slice).toBe(node);
}
}
});
});
test('can slice a node from both sides', () => {
const editor = createInitializedEditor();
editor.read(() => {
const node = $getRoot().getAllTextNodes().at(0)!;
const originalText = node.getTextContent();
const selection = $createTextSelection(1, 3);
const slice = $sliceSelectedTextNodeContent(selection, node, 'clone');
expect(slice).not.toBe(node);
expect(slice.getTextContent()).toBe(node.getTextContent().slice(1, 3));
slice.setTextContent('different');
// no side-effects are observed
expect(node.getTextContent()).toBe(originalText);
expect(slice.getTextContent()).toBe('different');
expect($getNodeByKey(node.getKey())).toBe(node);
});
});
});
});
19 changes: 14 additions & 5 deletions packages/lexical-selection/src/lexical-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
import {
$caretRangeFromSelection,
$cloneWithPropertiesEphemeral,
$createTextNode,
$getCharacterOffsets,
$getNodeByKey,
Expand Down Expand Up @@ -40,12 +41,14 @@ import {
* it to be generated into the new TextNode.
* @param selection - The selection containing the node whose TextNode is to be edited.
* @param textNode - The TextNode to be edited.
* @returns The updated TextNode.
* @param mutates - 'clone' to return a clone before mutating, 'self' to update in-place
* @returns The updated TextNode or clone.
*/
export function $sliceSelectedTextNodeContent(
export function $sliceSelectedTextNodeContent<T extends TextNode>(
selection: BaseSelection,
textNode: TextNode,
): LexicalNode {
textNode: T,
mutates: 'clone' | 'self' = 'self',
): T {
const anchorAndFocus = selection.getStartEndPoints();
if (
textNode.isSelected(selection) &&
Expand Down Expand Up @@ -83,7 +86,13 @@ export function $sliceSelectedTextNodeContent(
// NOTE: This mutates __text directly because the primary use case is to
// modify a $cloneWithProperties node that should never be added
// to the EditorState so we must not call getWritable via setTextContent
textNode.__text = textNode.__text.slice(startOffset, endOffset);
const text = textNode.__text.slice(startOffset, endOffset);
if (text !== textNode.__text) {
if (mutates === 'clone') {
textNode = $cloneWithPropertiesEphemeral(textNode);
}
textNode.__text = text;
}
}
}
return textNode;
Expand Down
30 changes: 30 additions & 0 deletions packages/lexical/src/LexicalNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,30 @@ export type DOMExportOutput = {

export type NodeKey = string;

const EPHEMERAL = Symbol.for('ephemeral');

/**
* @internal
* @param node any LexicalNode
* @returns true if the node was created with {@link $cloneWithPropertiesEphemeral}
*/
export function $isEphemeral(
node: LexicalNode & {readonly [EPHEMERAL]?: boolean},
): boolean {
return node[EPHEMERAL] || false;
}
/**
* @internal
* Mark this node as ephemeral, its instance always returns this
* for getLatest and getWritable. It must not be added to an EditorState.
*/
export function $markEphemeral<T extends LexicalNode>(
node: T & {[EPHEMERAL]?: boolean},
): T {
node[EPHEMERAL] = true;
return node;
}

export class LexicalNode {
/** @internal Allow us to look up the type including static props */
declare ['constructor']: KlassConstructor<typeof LexicalNode>;
Expand Down Expand Up @@ -975,6 +999,9 @@ export class LexicalNode {
*
*/
getLatest(): this {
if ($isEphemeral(this)) {
return this;
}
const latest = $getNodeByKey<this>(this.__key);
if (latest === null) {
invariant(
Expand All @@ -992,6 +1019,9 @@ export class LexicalNode {
*
*/
getWritable(): this {
if ($isEphemeral(this)) {
return this;
}
errorOnReadOnly();
const editorState = getActiveEditorState();
const editor = getActiveEditor();
Expand Down
26 changes: 26 additions & 0 deletions packages/lexical/src/LexicalUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ import {
import {LexicalEditor} from './LexicalEditor';
import {flushRootMutations} from './LexicalMutations';
import {
$isEphemeral,
$markEphemeral,
LexicalNode,
type LexicalPrivateDOM,
type NodeKey,
Expand Down Expand Up @@ -446,6 +448,12 @@ export function removeFromParent(node: LexicalNode): void {
// the cloning heuristic. Instead use node.getWritable().
export function internalMarkNodeAsDirty(node: LexicalNode): void {
errorOnInfiniteTransforms();
invariant(
!$isEphemeral(node),
'internalMarkNodeAsDirty: Ephemeral nodes must not be marked as dirty (key %s type %s)',
node.__key,
node.__type,
);
const latest = node.getLatest();
const parent = latest.__parent;
const editorState = getActiveEditorState();
Expand Down Expand Up @@ -1951,6 +1959,24 @@ export function $cloneWithProperties<T extends LexicalNode>(latestNode: T): T {
return mutableNode;
}

/**
* Returns a clone with {@link $cloneWithProperties} and then "detaches"
* it from the state by overriding its getLatest and getWritable to always
* return this. This node can not be added to an EditorState or become the
* parent, child, or sibling of another node. It is primarily only useful
* for making in-place temporary modifications to a TextNode when
* serializing a partial slice.
*
* Does not mutate the EditorState.
* @param latestNode - The node to be cloned.
* @returns The clone of the node.
*/
export function $cloneWithPropertiesEphemeral<T extends LexicalNode>(
latestNode: T,
): T {
return $markEphemeral($cloneWithProperties(latestNode));
}

export function setNodeIndentFromDOM(
elementDom: HTMLElement,
elementNode: ElementNode,
Expand Down
1 change: 1 addition & 0 deletions packages/lexical/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ export {
$addUpdateTag,
$applyNodeReplacement,
$cloneWithProperties,
$cloneWithPropertiesEphemeral,
$copyNode,
$create,
$findMatchingParent,
Expand Down
Loading