Skip to content

Commit a8c74d5

Browse files
committed
Merge branch 'master' into u/nguyenvi/versionbump012425
2 parents c4bd4b4 + 54c7dbe commit a8c74d5

File tree

6 files changed

+239
-38
lines changed

6 files changed

+239
-38
lines changed

packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ export type EditOptions = {
1919
* Whether to handle Tab key in keyboard. @default true
2020
*/
2121
handleTabKey?: boolean;
22+
23+
/**
24+
* Whether expanded selection within a text node should be handled by CM when pressing Backspace/Delete key.
25+
* @default true
26+
*/
27+
handleExpandedSelectionOnDelete?: boolean;
2228
};
2329

2430
const BACKSPACE_KEY = 8;
@@ -33,6 +39,7 @@ const DEAD_KEY = 229;
3339

3440
const DefaultOptions: Partial<EditOptions> = {
3541
handleTabKey: true,
42+
handleExpandedSelectionOnDelete: true,
3643
};
3744

3845
/**
@@ -164,15 +171,19 @@ export class EditPlugin implements EditorPlugin {
164171
case 'Backspace':
165172
// Use our API to handle BACKSPACE/DELETE key.
166173
// No need to clear cache here since if we rely on browser's behavior, there will be Input event and its handler will reconcile cache
167-
keyboardDelete(editor, rawEvent);
174+
keyboardDelete(editor, rawEvent, this.options.handleExpandedSelectionOnDelete);
168175
break;
169176

170177
case 'Delete':
171178
// Use our API to handle BACKSPACE/DELETE key.
172179
// No need to clear cache here since if we rely on browser's behavior, there will be Input event and its handler will reconcile cache
173180
// And leave it to browser when shift key is pressed so that browser will trigger cut event
174181
if (!event.rawEvent.shiftKey) {
175-
keyboardDelete(editor, rawEvent);
182+
keyboardDelete(
183+
editor,
184+
rawEvent,
185+
this.options.handleExpandedSelectionOnDelete
186+
);
176187
}
177188
break;
178189

@@ -225,7 +236,8 @@ export class EditPlugin implements EditorPlugin {
225236
key: 'Backspace',
226237
keyCode: BACKSPACE_KEY,
227238
which: BACKSPACE_KEY,
228-
})
239+
}),
240+
this.options.handleExpandedSelectionOnDelete
229241
);
230242
break;
231243
case 'deleteContentForward':
@@ -235,7 +247,8 @@ export class EditPlugin implements EditorPlugin {
235247
key: 'Delete',
236248
keyCode: DELETE_KEY,
237249
which: DELETE_KEY,
238-
})
250+
}),
251+
this.options.handleExpandedSelectionOnDelete
239252
);
240253
break;
241254
}

packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,18 @@ import type { DOMSelection, DeleteSelectionStep, IEditor } from 'roosterjs-conte
2727
* Do keyboard event handling for DELETE/BACKSPACE key
2828
* @param editor The editor object
2929
* @param rawEvent DOM keyboard event
30+
* @param handleExpandedSelection Whether to handle expanded selection within a text node by CM
3031
* @returns True if the event is handled by content model, otherwise false
3132
*/
32-
export function keyboardDelete(editor: IEditor, rawEvent: KeyboardEvent) {
33+
export function keyboardDelete(
34+
editor: IEditor,
35+
rawEvent: KeyboardEvent,
36+
handleExpandedSelection: boolean = true
37+
) {
3338
let handled = false;
3439
const selection = editor.getDOMSelection();
3540

36-
if (shouldDeleteWithContentModel(selection, rawEvent)) {
41+
if (shouldDeleteWithContentModel(selection, rawEvent, handleExpandedSelection)) {
3742
editor.formatContentModel(
3843
(model, context) => {
3944
const result = deleteSelection(
@@ -80,11 +85,29 @@ function getDeleteSteps(rawEvent: KeyboardEvent, isMac: boolean): (DeleteSelecti
8085
];
8186
}
8287

83-
function shouldDeleteWithContentModel(selection: DOMSelection | null, rawEvent: KeyboardEvent) {
88+
function shouldDeleteWithContentModel(
89+
selection: DOMSelection | null,
90+
rawEvent: KeyboardEvent,
91+
handleExpandedSelection: boolean
92+
) {
8493
if (!selection) {
8594
return false; // Nothing to delete
86-
} else if (selection.type != 'range' || !selection.range.collapsed) {
87-
return true; // Selection is not collapsed, need to delete all selections
95+
} else if (selection.type != 'range') {
96+
return true;
97+
} else if (!selection.range.collapsed) {
98+
if (handleExpandedSelection) {
99+
return true; // Selection is not collapsed, need to delete all selections
100+
}
101+
102+
const range = selection.range;
103+
const { startContainer, endContainer } = selection.range;
104+
const isInSameTextNode =
105+
startContainer === endContainer && isNodeOfType(startContainer, 'TEXT_NODE');
106+
return !(
107+
isInSameTextNode &&
108+
!isModifierKey(rawEvent) &&
109+
range.endOffset - range.startOffset < (startContainer.nodeValue?.length ?? 0)
110+
);
88111
} else {
89112
const range = selection.range;
90113

packages/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import { addParser } from '../utils/addParser';
22
import { isNodeOfType, moveChildNodes } from 'roosterjs-content-model-dom';
33
import { setProcessor } from '../utils/setProcessor';
4-
import type { BeforePasteEvent, DOMCreator, ElementProcessor } from 'roosterjs-content-model-types';
4+
import type {
5+
BeforePasteEvent,
6+
ClipboardData,
7+
DOMCreator,
8+
ElementProcessor,
9+
} from 'roosterjs-content-model-types';
510

611
const LAST_TD_END_REGEX = /<\/\s*td\s*>((?!<\/\s*tr\s*>)[\s\S])*$/i;
712
const LAST_TR_END_REGEX = /<\/\s*tr\s*>((?!<\/\s*table\s*>)[\s\S])*$/i;
813
const LAST_TR_REGEX = /<tr[^>]*>[^<]*/i;
914
const LAST_TABLE_REGEX = /<table[^>]*>[^<]*/i;
1015
const DEFAULT_BORDER_STYLE = 'solid 1px #d4d4d4';
16+
const TABLE_SELECTOR = 'table';
1117

1218
/**
1319
* @internal
@@ -20,13 +26,9 @@ export function processPastedContentFromExcel(
2026
domCreator: DOMCreator,
2127
allowExcelNoBorderTable?: boolean
2228
) {
23-
const { fragment, htmlBefore, clipboardData } = event;
24-
const html = clipboardData.html ? excelHandler(clipboardData.html, htmlBefore) : undefined;
29+
const { fragment, htmlBefore, htmlAfter, clipboardData } = event;
2530

26-
if (html && clipboardData.html != html) {
27-
const doc = domCreator.htmlToDOM(html);
28-
moveChildNodes(fragment, doc?.body);
29-
}
31+
validateExcelFragment(fragment, domCreator, htmlBefore, clipboardData, htmlAfter);
3032

3133
// For Excel Online
3234
const firstChild = fragment.firstChild;
@@ -86,22 +88,63 @@ export const childProcessor: ElementProcessor<ParentNode> = (group, element, con
8688
}
8789
};
8890

91+
/**
92+
* @internal
93+
* Exported only for unit test
94+
*/
95+
export function validateExcelFragment(
96+
fragment: DocumentFragment,
97+
domCreator: DOMCreator,
98+
htmlBefore: string,
99+
clipboardData: ClipboardData,
100+
htmlAfter: string
101+
) {
102+
// Clipboard content of Excel may contain the <StartFragment> and EndFragment comment tags inside the table
103+
//
104+
// @example
105+
// <table>
106+
// <!--StartFragment-->
107+
// <tr>...</tr>
108+
// <!--EndFragment-->
109+
// </table>
110+
//
111+
// This causes that the fragment is not properly created and the table is not extracted.
112+
// The content that is before the StartFragment is htmlBefore and the content that is after the EndFragment is htmlAfter.
113+
// So attempt to create a new document fragment with the content of htmlBefore + clipboardData.html + htmlAfter
114+
// If a table is found, replace the fragment with the new fragment
115+
const result =
116+
!fragment.querySelector(TABLE_SELECTOR) &&
117+
domCreator.htmlToDOM(htmlBefore + clipboardData.html + htmlAfter);
118+
if (result && result.querySelector(TABLE_SELECTOR)) {
119+
moveChildNodes(fragment, result?.body);
120+
} else {
121+
// If the table is still not found, try to extract the table from the clipboard data using Regex
122+
const html = clipboardData.html ? excelHandler(clipboardData.html, htmlBefore) : undefined;
123+
124+
if (html && clipboardData.html != html) {
125+
const doc = domCreator.htmlToDOM(html);
126+
moveChildNodes(fragment, doc?.body);
127+
}
128+
}
129+
}
130+
89131
/**
90132
* @internal Export for test only
91133
* @param html Source html
92134
*/
93-
94135
export function excelHandler(html: string, htmlBefore: string): string {
95-
if (html.match(LAST_TD_END_REGEX)) {
96-
const trMatch = htmlBefore.match(LAST_TR_REGEX);
97-
const tr = trMatch ? trMatch[0] : '<TR>';
98-
html = tr + html + '</TR>';
99-
}
100-
if (html.match(LAST_TR_END_REGEX)) {
101-
const tableMatch = htmlBefore.match(LAST_TABLE_REGEX);
102-
const table = tableMatch ? tableMatch[0] : '<TABLE>';
103-
html = table + html + '</TABLE>';
136+
try {
137+
if (html.match(LAST_TD_END_REGEX)) {
138+
const trMatch = htmlBefore.match(LAST_TR_REGEX);
139+
const tr = trMatch ? trMatch[0] : '<TR>';
140+
html = tr + html + '</TR>';
141+
}
142+
if (html.match(LAST_TR_END_REGEX)) {
143+
const tableMatch = htmlBefore.match(LAST_TABLE_REGEX);
144+
const table = tableMatch ? tableMatch[0] : '<TABLE>';
145+
html = table + html + '</TABLE>';
146+
}
147+
} finally {
148+
return html;
104149
}
105-
106-
return html;
107150
}

packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe('EditPlugin', () => {
6666
rawEvent,
6767
});
6868

69-
expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent);
69+
expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent, true);
7070
expect(keyboardInputSpy).not.toHaveBeenCalled();
7171
expect(keyboardEnterSpy).not.toHaveBeenCalled();
7272
expect(keyboardTabSpy).not.toHaveBeenCalled();
@@ -83,7 +83,7 @@ describe('EditPlugin', () => {
8383
rawEvent,
8484
});
8585

86-
expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent);
86+
expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent, true);
8787
expect(keyboardInputSpy).not.toHaveBeenCalled();
8888
expect(keyboardEnterSpy).not.toHaveBeenCalled();
8989
expect(keyboardTabSpy).not.toHaveBeenCalled();
@@ -106,6 +106,20 @@ describe('EditPlugin', () => {
106106
expect(keyboardTabSpy).not.toHaveBeenCalled();
107107
});
108108

109+
it('handleExpandedSelectionOnDelete disabled', () => {
110+
plugin = new EditPlugin({ handleExpandedSelectionOnDelete: false });
111+
const rawEvent = { key: 'Delete' } as any;
112+
113+
plugin.initialize(editor);
114+
115+
plugin.onPluginEvent({
116+
eventType: 'keyDown',
117+
rawEvent,
118+
});
119+
120+
expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent, false);
121+
});
122+
109123
it('Tab', () => {
110124
plugin = new EditPlugin();
111125
const rawEvent = { key: 'Tab' } as any;
@@ -259,19 +273,27 @@ describe('EditPlugin', () => {
259273
rawEvent: { key: 'Delete' } as any,
260274
});
261275

262-
expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, {
263-
key: 'Delete',
264-
} as any);
276+
expect(keyboardDeleteSpy).toHaveBeenCalledWith(
277+
editor,
278+
{
279+
key: 'Delete',
280+
} as any,
281+
true
282+
);
265283

266284
plugin.onPluginEvent({
267285
eventType: 'keyDown',
268286
rawEvent: { key: 'Delete' } as any,
269287
});
270288

271289
expect(keyboardDeleteSpy).toHaveBeenCalledTimes(2);
272-
expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, {
273-
key: 'Delete',
274-
} as any);
290+
expect(keyboardDeleteSpy).toHaveBeenCalledWith(
291+
editor,
292+
{
293+
key: 'Delete',
294+
} as any,
295+
true
296+
);
275297
expect(keyboardInputSpy).not.toHaveBeenCalled();
276298
expect(keyboardEnterSpy).not.toHaveBeenCalled();
277299
expect(keyboardTabSpy).not.toHaveBeenCalled();
@@ -309,7 +331,8 @@ describe('EditPlugin', () => {
309331
key: 'Backspace',
310332
keyCode: 8,
311333
which: 8,
312-
})
334+
}),
335+
true
313336
);
314337
});
315338

@@ -337,7 +360,8 @@ describe('EditPlugin', () => {
337360
key: 'Delete',
338361
keyCode: 46,
339362
which: 46,
340-
})
363+
}),
364+
true
341365
);
342366
});
343367
});

packages/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,31 @@ describe('keyboardDelete', () => {
580580
expect(formatWithContentModelSpy).not.toHaveBeenCalled();
581581
});
582582

583+
it('No need to delete - handleExpandedSelection disabled', () => {
584+
const rawEvent = { key: 'Backspace' } as any;
585+
const formatWithContentModelSpy = jasmine.createSpy('formatContentModel');
586+
const node = document.createTextNode('test');
587+
const range: DOMSelection = {
588+
type: 'range',
589+
range: ({
590+
collapsed: false,
591+
startContainer: node,
592+
endContainer: node,
593+
startOffset: 1,
594+
endOffset: 3,
595+
} as any) as Range,
596+
isReverted: false,
597+
};
598+
const editor = {
599+
formatContentModel: formatWithContentModelSpy,
600+
getDOMSelection: () => range,
601+
} as any;
602+
603+
keyboardDelete(editor, rawEvent, false /* handleExpandedSelectionOnDelete */);
604+
605+
expect(formatWithContentModelSpy).not.toHaveBeenCalled();
606+
});
607+
583608
it('Backspace from the beginning', () => {
584609
const rawEvent = { key: 'Backspace' } as any;
585610
const formatWithContentModelSpy = jasmine.createSpy('formatContentModel');
@@ -625,4 +650,29 @@ describe('keyboardDelete', () => {
625650

626651
expect(formatWithContentModelSpy).toHaveBeenCalledTimes(1);
627652
});
653+
654+
it('Delete all the content of text node - handleExpandedSelection disabled', () => {
655+
const rawEvent = { key: 'Backspace' } as any;
656+
const formatWithContentModelSpy = jasmine.createSpy('formatContentModel');
657+
const node = document.createTextNode('test');
658+
const range: DOMSelection = {
659+
type: 'range',
660+
range: ({
661+
collapsed: false,
662+
startContainer: node,
663+
endContainer: node,
664+
startOffset: 0,
665+
endOffset: 4,
666+
} as any) as Range,
667+
isReverted: false,
668+
};
669+
const editor = {
670+
formatContentModel: formatWithContentModelSpy,
671+
getDOMSelection: () => range,
672+
} as any;
673+
674+
keyboardDelete(editor, rawEvent, false /* handleExpandedSelectionOnDelete */);
675+
676+
expect(formatWithContentModelSpy).toHaveBeenCalledTimes(1);
677+
});
628678
});

0 commit comments

Comments
 (0)