Skip to content

Commit 149806b

Browse files
authored
[lexical-table][lexical-clipboard] Bug Fix: Race condition in table CUT_COMMAND (#6550)
1 parent 365f91f commit 149806b

File tree

3 files changed

+108
-46
lines changed

3 files changed

+108
-46
lines changed

packages/lexical-clipboard/src/clipboard.ts

Lines changed: 93 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {objectKlassEquals} from '@lexical/utils';
1212
import {
1313
$cloneWithProperties,
1414
$createTabNode,
15+
$getEditor,
1516
$getRoot,
1617
$getSelection,
1718
$isElementNode,
@@ -34,18 +35,26 @@ import invariant from 'shared/invariant';
3435
const getDOMSelection = (targetWindow: Window | null): Selection | null =>
3536
CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
3637

38+
export interface LexicalClipboardData {
39+
'text/html'?: string | undefined;
40+
'application/x-lexical-editor'?: string | undefined;
41+
'text/plain': string;
42+
}
43+
3744
/**
3845
* Returns the *currently selected* Lexical content as an HTML string, relying on the
3946
* logic defined in the exportDOM methods on the LexicalNode classes. Note that
4047
* this will not return the HTML content of the entire editor (unless all the content is included
4148
* in the current selection).
4249
*
4350
* @param editor - LexicalEditor instance to get HTML content from
51+
* @param selection - The selection to use (default is $getSelection())
4452
* @returns a string of HTML content
4553
*/
46-
export function $getHtmlContent(editor: LexicalEditor): string {
47-
const selection = $getSelection();
48-
54+
export function $getHtmlContent(
55+
editor: LexicalEditor,
56+
selection = $getSelection(),
57+
): string {
4958
if (selection == null) {
5059
invariant(false, 'Expected valid LexicalSelection');
5160
}
@@ -68,11 +77,13 @@ export function $getHtmlContent(editor: LexicalEditor): string {
6877
* in the current selection).
6978
*
7079
* @param editor - LexicalEditor instance to get the JSON content from
80+
* @param selection - The selection to use (default is $getSelection())
7181
* @returns
7282
*/
73-
export function $getLexicalContent(editor: LexicalEditor): null | string {
74-
const selection = $getSelection();
75-
83+
export function $getLexicalContent(
84+
editor: LexicalEditor,
85+
selection = $getSelection(),
86+
): null | string {
7687
if (selection == null) {
7788
invariant(false, 'Expected valid LexicalSelection');
7889
}
@@ -383,6 +394,7 @@ let clipboardEventTimeout: null | number = null;
383394
export async function copyToClipboard(
384395
editor: LexicalEditor,
385396
event: null | ClipboardEvent,
397+
data?: LexicalClipboardData,
386398
): Promise<boolean> {
387399
if (clipboardEventTimeout !== null) {
388400
// Prevent weird race conditions that can happen when this function is run multiple times
@@ -392,7 +404,7 @@ export async function copyToClipboard(
392404
if (event !== null) {
393405
return new Promise((resolve, reject) => {
394406
editor.update(() => {
395-
resolve($copyToClipboardEvent(editor, event));
407+
resolve($copyToClipboardEvent(editor, event, data));
396408
});
397409
});
398410
}
@@ -423,7 +435,9 @@ export async function copyToClipboard(
423435
window.clearTimeout(clipboardEventTimeout);
424436
clipboardEventTimeout = null;
425437
}
426-
resolve($copyToClipboardEvent(editor, secondEvent as ClipboardEvent));
438+
resolve(
439+
$copyToClipboardEvent(editor, secondEvent as ClipboardEvent, data),
440+
);
427441
}
428442
// Block the entire copy flow while we wait for the next ClipboardEvent
429443
return true;
@@ -446,38 +460,83 @@ export async function copyToClipboard(
446460
function $copyToClipboardEvent(
447461
editor: LexicalEditor,
448462
event: ClipboardEvent,
463+
data?: LexicalClipboardData,
449464
): boolean {
450-
const domSelection = getDOMSelection(editor._window);
451-
if (!domSelection) {
452-
return false;
453-
}
454-
const anchorDOM = domSelection.anchorNode;
455-
const focusDOM = domSelection.focusNode;
456-
if (
457-
anchorDOM !== null &&
458-
focusDOM !== null &&
459-
!isSelectionWithinEditor(editor, anchorDOM, focusDOM)
460-
) {
461-
return false;
465+
if (data === undefined) {
466+
const domSelection = getDOMSelection(editor._window);
467+
if (!domSelection) {
468+
return false;
469+
}
470+
const anchorDOM = domSelection.anchorNode;
471+
const focusDOM = domSelection.focusNode;
472+
if (
473+
anchorDOM !== null &&
474+
focusDOM !== null &&
475+
!isSelectionWithinEditor(editor, anchorDOM, focusDOM)
476+
) {
477+
return false;
478+
}
479+
const selection = $getSelection();
480+
if (selection === null) {
481+
return false;
482+
}
483+
data = $getClipboardDataFromSelection(selection);
462484
}
463485
event.preventDefault();
464486
const clipboardData = event.clipboardData;
465-
const selection = $getSelection();
466-
if (clipboardData === null || selection === null) {
487+
if (clipboardData === null) {
467488
return false;
468489
}
469-
const htmlString = $getHtmlContent(editor);
470-
const lexicalString = $getLexicalContent(editor);
471-
let plainString = '';
472-
if (selection !== null) {
473-
plainString = selection.getTextContent();
474-
}
475-
if (htmlString !== null) {
476-
clipboardData.setData('text/html', htmlString);
490+
setLexicalClipboardDataTransfer(clipboardData, data);
491+
return true;
492+
}
493+
494+
const clipboardDataFunctions = [
495+
['text/html', $getHtmlContent],
496+
['application/x-lexical-editor', $getLexicalContent],
497+
] as const;
498+
499+
/**
500+
* Serialize the content of the current selection to strings in
501+
* text/plain, text/html, and application/x-lexical-editor (Lexical JSON)
502+
* formats (as available).
503+
*
504+
* @param selection the selection to serialize (defaults to $getSelection())
505+
* @returns LexicalClipboardData
506+
*/
507+
export function $getClipboardDataFromSelection(
508+
selection: BaseSelection | null = $getSelection(),
509+
): LexicalClipboardData {
510+
const clipboardData: LexicalClipboardData = {
511+
'text/plain': selection ? selection.getTextContent() : '',
512+
};
513+
if (selection) {
514+
const editor = $getEditor();
515+
for (const [mimeType, $editorFn] of clipboardDataFunctions) {
516+
const v = $editorFn(editor, selection);
517+
if (v !== null) {
518+
clipboardData[mimeType] = v;
519+
}
520+
}
477521
}
478-
if (lexicalString !== null) {
479-
clipboardData.setData('application/x-lexical-editor', lexicalString);
522+
return clipboardData;
523+
}
524+
525+
/**
526+
* Call setData on the given clipboardData for each MIME type present
527+
* in the given data (from {@link $getClipboardDataFromSelection})
528+
*
529+
* @param clipboardData the event.clipboardData to populate from data
530+
* @param data The lexical data
531+
*/
532+
export function setLexicalClipboardDataTransfer(
533+
clipboardData: DataTransfer,
534+
data: LexicalClipboardData,
535+
) {
536+
for (const k in data) {
537+
const v = data[k as keyof LexicalClipboardData];
538+
if (v !== undefined) {
539+
clipboardData.setData(k, v);
540+
}
480541
}
481-
clipboardData.setData('text/plain', plainString);
482-
return true;
483542
}

packages/lexical-clipboard/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99
export {
1010
$generateJSONFromSelectedNodes,
1111
$generateNodesFromSerializedNodes,
12+
$getClipboardDataFromSelection,
1213
$getHtmlContent,
1314
$getLexicalContent,
1415
$insertDataTransferForPlainText,
1516
$insertDataTransferForRichText,
1617
$insertGeneratedNodes,
1718
copyToClipboard,
19+
type LexicalClipboardData,
20+
setLexicalClipboardDataTransfer,
1821
} from './clipboard';

packages/lexical-table/src/LexicalTableSelectionHelpers.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ import type {
2424
TextFormatType,
2525
} from 'lexical';
2626

27-
import {copyToClipboard} from '@lexical/clipboard';
27+
import {
28+
$getClipboardDataFromSelection,
29+
copyToClipboard,
30+
} from '@lexical/clipboard';
2831
import {$findMatchingParent, objectKlassEquals} from '@lexical/utils';
2932
import {
3033
$createParagraphNode,
@@ -35,7 +38,6 @@ import {
3538
$getSelection,
3639
$isDecoratorNode,
3740
$isElementNode,
38-
$isNodeSelection,
3941
$isRangeSelection,
4042
$isRootOrShadowRoot,
4143
$isTextNode,
@@ -381,25 +383,23 @@ export function applyTableHandlers(
381383
(event) => {
382384
const selection = $getSelection();
383385
if (selection) {
384-
if ($isNodeSelection(selection)) {
386+
if (!($isTableSelection(selection) || $isRangeSelection(selection))) {
385387
return false;
386388
}
387-
388-
copyToClipboard(
389+
// Copying to the clipboard is async so we must capture the data
390+
// before we delete it
391+
void copyToClipboard(
389392
editor,
390393
objectKlassEquals(event, ClipboardEvent)
391394
? (event as ClipboardEvent)
392395
: null,
396+
$getClipboardDataFromSelection(selection),
393397
);
394-
395-
if ($isTableSelection(selection)) {
396-
$deleteCellHandler(event);
397-
return true;
398-
} else if ($isRangeSelection(selection)) {
399-
$deleteCellHandler(event);
398+
const intercepted = $deleteCellHandler(event);
399+
if ($isRangeSelection(selection)) {
400400
selection.removeText();
401-
return true;
402401
}
402+
return intercepted;
403403
}
404404
return false;
405405
},

0 commit comments

Comments
 (0)