Skip to content

Commit

Permalink
Merge branch 'master' into u/juliaroldi/auto-format-margins
Browse files Browse the repository at this point in the history
  • Loading branch information
JiuqingSong authored Jan 28, 2025
2 parents ad542f4 + 11c9e3e commit 6a3be95
Show file tree
Hide file tree
Showing 8 changed files with 1,113 additions and 48 deletions.
17 changes: 13 additions & 4 deletions packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export type EditOptions = {
* Whether to handle Tab key in keyboard. @default true
*/
handleTabKey?: boolean;

/**
* Whether expanded selection within a text node should be handled by CM when pressing Backspace/Delete key.
* @default true
*/
handleExpandedSelectionOnDelete?: boolean;
};

const BACKSPACE_KEY = 8;
Expand All @@ -33,6 +39,7 @@ const DEAD_KEY = 229;

const DefaultOptions: Partial<EditOptions> = {
handleTabKey: true,
handleExpandedSelectionOnDelete: true,
};

/**
Expand Down Expand Up @@ -164,15 +171,15 @@ export class EditPlugin implements EditorPlugin {
case 'Backspace':
// Use our API to handle BACKSPACE/DELETE key.
// 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
keyboardDelete(editor, rawEvent);
keyboardDelete(editor, rawEvent, this.options.handleExpandedSelectionOnDelete);
break;

case 'Delete':
// Use our API to handle BACKSPACE/DELETE key.
// 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
// And leave it to browser when shift key is pressed so that browser will trigger cut event
if (!event.rawEvent.shiftKey) {
keyboardDelete(editor, rawEvent);
keyboardDelete(editor, rawEvent, this.options.handleExpandedSelectionOnDelete);
}
break;

Expand Down Expand Up @@ -225,7 +232,8 @@ export class EditPlugin implements EditorPlugin {
key: 'Backspace',
keyCode: BACKSPACE_KEY,
which: BACKSPACE_KEY,
})
}),
this.options.handleExpandedSelectionOnDelete
);
break;
case 'deleteContentForward':
Expand All @@ -235,7 +243,8 @@ export class EditPlugin implements EditorPlugin {
key: 'Delete',
keyCode: DELETE_KEY,
which: DELETE_KEY,
})
}),
this.options.handleExpandedSelectionOnDelete
);
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,14 @@ export const handleEnterOnList: DeleteSelectionStep = context => {
});

if (listItem.levels.length == 0) {
const index = findIndex(
const nextBlockIndex = findIndex(
listParent.blocks,
nextBlock.levels.length,
listIndex
nextBlock.levels.length
);

nextBlock.levels[
nextBlock.levels.length - 1
].format.startNumberOverride = index;
].format.startNumberOverride = nextBlockIndex;
}
}
}
Expand Down Expand Up @@ -148,24 +148,19 @@ const createNewListLevel = (listItem: ReadonlyContentModelListItem) => {
});
};

const findIndex = (
blocks: readonly ReadonlyContentModelBlock[],
levelLength: number,
index: number
) => {
const findIndex = (blocks: readonly ReadonlyContentModelBlock[], levelLength: number) => {
let counter = 1;
for (let i = index; i > -1; i--) {
for (let i = 0; i < blocks.length; i++) {
const listItem = blocks[i];

if (
isBlockGroupOfType<ContentModelListItem>(listItem, 'ListItem') &&
listItem.levels.length === levelLength
) {
counter++;
} else if (
!(
isBlockGroupOfType<ContentModelListItem>(listItem, 'ListItem') &&
listItem.levels.length == 0
)
isBlockGroupOfType<ContentModelListItem>(listItem, 'ListItem') &&
listItem.levels.length == 0
) {
return counter;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,14 @@ import type { DOMSelection, DeleteSelectionStep, IEditor } from 'roosterjs-conte
* Do keyboard event handling for DELETE/BACKSPACE key
* @param editor The editor object
* @param rawEvent DOM keyboard event
* @param handleExpandedSelection Whether to handle expanded selection within a text node by CM
* @returns True if the event is handled by content model, otherwise false
*/
export function keyboardDelete(editor: IEditor, rawEvent: KeyboardEvent) {
export function keyboardDelete(editor: IEditor, rawEvent: KeyboardEvent, handleExpandedSelection: boolean = true) {
let handled = false;
const selection = editor.getDOMSelection();

if (shouldDeleteWithContentModel(selection, rawEvent)) {
if (shouldDeleteWithContentModel(selection, rawEvent, handleExpandedSelection)) {
editor.formatContentModel(
(model, context) => {
const result = deleteSelection(
Expand Down Expand Up @@ -80,11 +81,20 @@ function getDeleteSteps(rawEvent: KeyboardEvent, isMac: boolean): (DeleteSelecti
];
}

function shouldDeleteWithContentModel(selection: DOMSelection | null, rawEvent: KeyboardEvent) {
function shouldDeleteWithContentModel(selection: DOMSelection | null, rawEvent: KeyboardEvent, handleExpandedSelection: boolean) {
if (!selection) {
return false; // Nothing to delete
} else if (selection.type != 'range' || !selection.range.collapsed) {
return true; // Selection is not collapsed, need to delete all selections
} else if (selection.type != 'range') {
return true;
} else if (!selection.range.collapsed) {
if (handleExpandedSelection) {
return true; // Selection is not collapsed, need to delete all selections
}

const range = selection.range;
const { startContainer, endContainer } = selection.range;
const isInSameTextNode = startContainer === endContainer && isNodeOfType(startContainer, 'TEXT_NODE');
return !(isInSameTextNode && !isModifierKey(rawEvent) && range.endOffset - range.startOffset < (startContainer.nodeValue?.length ?? 0));
} else {
const range = selection.range;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { addParser } from '../utils/addParser';
import { isNodeOfType, moveChildNodes } from 'roosterjs-content-model-dom';
import { setProcessor } from '../utils/setProcessor';
import type { BeforePasteEvent, DOMCreator, ElementProcessor } from 'roosterjs-content-model-types';
import type {
BeforePasteEvent,
ClipboardData,
DOMCreator,
ElementProcessor,
} from 'roosterjs-content-model-types';

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

/**
* @internal
Expand All @@ -20,13 +26,9 @@ export function processPastedContentFromExcel(
domCreator: DOMCreator,
allowExcelNoBorderTable?: boolean
) {
const { fragment, htmlBefore, clipboardData } = event;
const html = clipboardData.html ? excelHandler(clipboardData.html, htmlBefore) : undefined;
const { fragment, htmlBefore, htmlAfter, clipboardData } = event;

if (html && clipboardData.html != html) {
const doc = domCreator.htmlToDOM(html);
moveChildNodes(fragment, doc?.body);
}
validateExcelFragment(fragment, domCreator, htmlBefore, clipboardData, htmlAfter);

// For Excel Online
const firstChild = fragment.firstChild;
Expand Down Expand Up @@ -86,22 +88,63 @@ export const childProcessor: ElementProcessor<ParentNode> = (group, element, con
}
};

/**
* @internal
* Exported only for unit test
*/
export function validateExcelFragment(
fragment: DocumentFragment,
domCreator: DOMCreator,
htmlBefore: string,
clipboardData: ClipboardData,
htmlAfter: string
) {
// Clipboard content of Excel may contain the <StartFragment> and EndFragment comment tags inside the table
//
// @example
// <table>
// <!--StartFragment-->
// <tr>...</tr>
// <!--EndFragment-->
// </table>
//
// This causes that the fragment is not properly created and the table is not extracted.
// The content that is before the StartFragment is htmlBefore and the content that is after the EndFragment is htmlAfter.
// So attempt to create a new document fragment with the content of htmlBefore + clipboardData.html + htmlAfter
// If a table is found, replace the fragment with the new fragment
const result =
!fragment.querySelector(TABLE_SELECTOR) &&
domCreator.htmlToDOM(htmlBefore + clipboardData.html + htmlAfter);
if (result && result.querySelector(TABLE_SELECTOR)) {
moveChildNodes(fragment, result?.body);
} else {
// If the table is still not found, try to extract the table from the clipboard data using Regex
const html = clipboardData.html ? excelHandler(clipboardData.html, htmlBefore) : undefined;

if (html && clipboardData.html != html) {
const doc = domCreator.htmlToDOM(html);
moveChildNodes(fragment, doc?.body);
}
}
}

/**
* @internal Export for test only
* @param html Source html
*/

export function excelHandler(html: string, htmlBefore: string): string {
if (html.match(LAST_TD_END_REGEX)) {
const trMatch = htmlBefore.match(LAST_TR_REGEX);
const tr = trMatch ? trMatch[0] : '<TR>';
html = tr + html + '</TR>';
}
if (html.match(LAST_TR_END_REGEX)) {
const tableMatch = htmlBefore.match(LAST_TABLE_REGEX);
const table = tableMatch ? tableMatch[0] : '<TABLE>';
html = table + html + '</TABLE>';
try {
if (html.match(LAST_TD_END_REGEX)) {
const trMatch = htmlBefore.match(LAST_TR_REGEX);
const tr = trMatch ? trMatch[0] : '<TR>';
html = tr + html + '</TR>';
}
if (html.match(LAST_TR_END_REGEX)) {
const tableMatch = htmlBefore.match(LAST_TABLE_REGEX);
const table = tableMatch ? tableMatch[0] : '<TABLE>';
html = table + html + '</TABLE>';
}
} finally {
return html;
}

return html;
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe('EditPlugin', () => {
rawEvent,
});

expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent);
expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent, true);
expect(keyboardInputSpy).not.toHaveBeenCalled();
expect(keyboardEnterSpy).not.toHaveBeenCalled();
expect(keyboardTabSpy).not.toHaveBeenCalled();
Expand All @@ -83,7 +83,7 @@ describe('EditPlugin', () => {
rawEvent,
});

expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent);
expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent, true);
expect(keyboardInputSpy).not.toHaveBeenCalled();
expect(keyboardEnterSpy).not.toHaveBeenCalled();
expect(keyboardTabSpy).not.toHaveBeenCalled();
Expand All @@ -106,6 +106,20 @@ describe('EditPlugin', () => {
expect(keyboardTabSpy).not.toHaveBeenCalled();
});

it('handleExpandedSelectionOnDelete disabled', () => {
plugin = new EditPlugin({ handleExpandedSelectionOnDelete: false });
const rawEvent = { key: 'Delete' } as any;

plugin.initialize(editor);

plugin.onPluginEvent({
eventType: 'keyDown',
rawEvent,
});

expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent, false);
});

it('Tab', () => {
plugin = new EditPlugin();
const rawEvent = { key: 'Tab' } as any;
Expand Down Expand Up @@ -261,7 +275,7 @@ describe('EditPlugin', () => {

expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, {
key: 'Delete',
} as any);
} as any, true);

plugin.onPluginEvent({
eventType: 'keyDown',
Expand All @@ -271,7 +285,7 @@ describe('EditPlugin', () => {
expect(keyboardDeleteSpy).toHaveBeenCalledTimes(2);
expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, {
key: 'Delete',
} as any);
} as any, true);
expect(keyboardInputSpy).not.toHaveBeenCalled();
expect(keyboardEnterSpy).not.toHaveBeenCalled();
expect(keyboardTabSpy).not.toHaveBeenCalled();
Expand Down Expand Up @@ -309,7 +323,8 @@ describe('EditPlugin', () => {
key: 'Backspace',
keyCode: 8,
which: 8,
})
}),
true
);
});

Expand Down Expand Up @@ -337,7 +352,8 @@ describe('EditPlugin', () => {
key: 'Delete',
keyCode: 46,
which: 46,
})
}),
true
);
});
});
Expand Down
Loading

0 comments on commit 6a3be95

Please sign in to comment.