Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Excel non-native paste event handling and related utilities #2950

Merged
Show file tree
Hide file tree
Changes from 5 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
15 changes: 5 additions & 10 deletions demo/scripts/controlsV2/demoButtons/pasteButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,14 @@ const createDataTransfer = (

const createDataTransferItems = (data: ClipboardItems) => {
const isTEXT = (type: string) => type.startsWith('text/');
const isIMAGE = (type: string) => type.startsWith('image/');
const dataTransferItems: Promise<DataTransferItem>[] = [];
data.forEach(item => {
item.types.forEach(type => {
if (isTEXT(type) || isIMAGE(type)) {
dataTransferItems.push(
item
.getType(type)
.then(blob =>
createDataTransfer(isTEXT(type) ? 'string' : 'file', type, blob)
)
);
}
dataTransferItems.push(
item
.getType(type)
.then(blob => createDataTransfer(isTEXT(type) ? 'string' : 'file', type, blob))
);
});
});
return dataTransferItems;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { setupExcelTableHandlers } from './setupExcelTableHandlers';
import type { BeforePasteEvent } from 'roosterjs-content-model-types';

/**
* @internal
*/
export function handleExcelContentFromNotNativeEvent(
event: BeforePasteEvent,
allowExcelNoBorderTable: boolean
) {
setupExcelTableHandlers(event, allowExcelNoBorderTable, false /* handleForNativeEvent */);
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
import { addParser } from '../utils/addParser';
import { isNodeOfType, moveChildNodes } from 'roosterjs-content-model-dom';
import { setProcessor } from '../utils/setProcessor';
import type {
BeforePasteEvent,
ClipboardData,
DOMCreator,
ElementProcessor,
} from 'roosterjs-content-model-types';
import { setupExcelTableHandlers } from './setupExcelTableHandlers';
import type { BeforePasteEvent, ClipboardData, DOMCreator } 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';

/**
Expand Down Expand Up @@ -54,40 +47,9 @@ export function processPastedContentFromExcel(
}
}

addParser(event.domToModelOption, 'tableCell', (format, element) => {
if (!allowExcelNoBorderTable && element.style.borderStyle === 'none') {
format.borderBottom = DEFAULT_BORDER_STYLE;
format.borderLeft = DEFAULT_BORDER_STYLE;
format.borderRight = DEFAULT_BORDER_STYLE;
format.borderTop = DEFAULT_BORDER_STYLE;
}
});

setProcessor(event.domToModelOption, 'child', childProcessor);
setupExcelTableHandlers(event, allowExcelNoBorderTable, true /* handleForNativeEvent */);
}

/**
* @internal
* Exported only for unit test
*/
export const childProcessor: ElementProcessor<ParentNode> = (group, element, context) => {
const segmentFormat = { ...context.segmentFormat };
if (
group.blockGroupType === 'TableCell' &&
group.format.textColor &&
!context.segmentFormat.textColor
) {
context.segmentFormat.textColor = group.format.textColor;
}

context.defaultElementProcessors.child(group, element, context);

if (group.blockGroupType === 'TableCell' && group.format.textColor) {
context.segmentFormat = segmentFormat;
delete group.format.textColor;
}
};

/**
* @internal
* Exported only for unit test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { addParser } from '../utils/addParser';
import { setProcessor } from '../utils/setProcessor';
import type { BeforePasteEvent, ElementProcessor } from 'roosterjs-content-model-types';

const DEFAULT_BORDER_STYLE = 'solid 1px #d4d4d4';

/**
* @internal
*/
export function setupExcelTableHandlers(
event: BeforePasteEvent,
allowExcelNoBorderTable: boolean | undefined,
handleForNativeEvent: boolean
) {
addParser(event.domToModelOption, 'tableCell', (format, element) => {
if (
!allowExcelNoBorderTable &&
(element.style.borderStyle === 'none' ||
(!handleForNativeEvent && element.style.borderStyle == ''))
) {
format.borderBottom = DEFAULT_BORDER_STYLE;
format.borderLeft = DEFAULT_BORDER_STYLE;
format.borderRight = DEFAULT_BORDER_STYLE;
format.borderTop = DEFAULT_BORDER_STYLE;
}
});

setProcessor(event.domToModelOption, 'child', childProcessor);
}

/**
* @internal
* Exported only for unit test
*/
export const childProcessor: ElementProcessor<ParentNode> = (group, element, context) => {
const segmentFormat = { ...context.segmentFormat };
if (
group.blockGroupType === 'TableCell' &&
group.format.textColor &&
!context.segmentFormat.textColor
) {
context.segmentFormat.textColor = group.format.textColor;
}

context.defaultElementProcessors.child(group, element, context);

if (group.blockGroupType === 'TableCell' && group.format.textColor) {
context.segmentFormat = segmentFormat;
delete group.format.textColor;
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { chainSanitizerCallback } from './utils/chainSanitizerCallback';
import { DefaultSanitizers } from './DefaultSanitizers';
import { deprecatedBorderColorParser } from './utils/deprecatedColorParser';
import { getPasteSource } from './pasteSourceValidations/getPasteSource';
import { handleExcelContentFromNotNativeEvent } from './Excel/handleExcelContentFromNotNativeEvent';
import { parseLink } from './utils/linkParser';
import { PastePropertyNames } from './pasteSourceValidations/constants';
import { processPastedContentFromExcel } from './Excel/processPastedContentFromExcel';
Expand Down Expand Up @@ -123,6 +124,9 @@ export class PastePlugin implements EditorPlugin {
case 'powerPointDesktop':
processPastedContentFromPowerPoint(event, this.editor.getDOMCreator());
break;
case 'excelNonNativeEvent':
handleExcelContentFromNotNativeEvent(event, !!this.allowExcelNoBorderTable);
break;
}

addParser(event.domToModelOption, 'link', parseLink);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { documentContainWacElements } from './documentContainWacElements';
import { isExcelDesktopDocument } from './isExcelDesktopDocument';
import { isExcelNotNativeEvent } from './isExcelNonNativeEvent';
import { isExcelOnlineDocument } from './isExcelOnlineDocument';
import { isGoogleSheetDocument } from './isGoogleSheetDocument';
import { isPowerPointDesktopDocument } from './isPowerPointDesktopDocument';
Expand Down Expand Up @@ -29,7 +30,8 @@ export type KnownPasteSourceType =
| 'googleSheets'
| 'wacComponents'
| 'default'
| 'singleImage';
| 'singleImage'
| 'excelNonNativeEvent';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this exist in all cases when copy from Excel? If so, do we still need to keep 'excelDesktop'? Maybe we just need to replace 'excelDesktop' and always use the new one.

Copy link
Contributor Author

@BryanValverdeU BryanValverdeU Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the shadow workbook type exists when copying from Excel. Should be available from ctrl + v and programatically pasting

The main difference is that when pasting wIth Ctrl + V we need the current approach, (clipboard fragment does not have a table element, so we do an special case).
But when getting the clipboard programatically, the fragment does contain a table but we need to add borders to the table.

Another option is to merge both logic in excel handling, but if fragment contains a table we do not need to build the table from the rawHtml

Copy link
Collaborator

@JiuqingSong JiuqingSong Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, then let's keep the change in this PR to keep it simple.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, just modified the logic. Now we just keep all the excel handling in a single block of the switch. I think it looks cleaner. Let me know if it is okay


/**
* @internal
Expand All @@ -44,6 +46,7 @@ const getSourceFunctions = new Map<KnownPasteSourceType, GetSourceFunction>([
['wacComponents', documentContainWacElements],
['googleSheets', isGoogleSheetDocument],
['singleImage', shouldConvertToSingleImage],
['excelNonNativeEvent', isExcelNotNativeEvent],
]);

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { GetSourceFunction, GetSourceInputParams } from './getPasteSource';

const ShadowWorkbookClipboardType = 'web data/shadow-workbook';

/**
* @internal
* When the clipboard content is retrieved programatically, the clipboard html does not contain the usual
* attributes we use to determine if the content is from Excel. This function is used to handle that case.
*/
export const isExcelNotNativeEvent: GetSourceFunction = (props: GetSourceInputParams) => {
const { clipboardData } = props;

return (
clipboardData.types.includes(ShadowWorkbookClipboardType) &&
clipboardData.htmlFirstLevelChildTags?.length == 1 &&
clipboardData.htmlFirstLevelChildTags[0] == 'TABLE'
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { BeforePasteEvent, ClipboardData } from 'roosterjs-content-model-types';

export function createBeforePasteEventMock(
fragment: DocumentFragment,
htmlBefore: string = ''
): BeforePasteEvent {
return {
eventType: 'beforePaste',
clipboardData: <ClipboardData>{},
fragment: fragment,
htmlBefore,
htmlAfter: '',
htmlAttributes: {},
pasteType: 'normal',
domToModelOption: {
additionalAllowedTags: [],
additionalDisallowedTags: [],
additionalFormatParsers: {},
attributeSanitizers: {},
formatParserOverride: {},
processorOverride: {},
styleSanitizers: {},
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as addParserFile from '../../../lib/paste/utils/addParser';
import * as setProcessorFile from '../../../lib/paste/utils/setProcessor';
import * as setupExcelTableHandlersFile from '../../../lib/paste/Excel/setupExcelTableHandlers';
import { BeforePasteEvent } from 'roosterjs-content-model-types';
import { handleExcelContentFromNotNativeEvent } from '../../../lib/paste/Excel/handleExcelContentFromNotNativeEvent';

describe('handleExcelContentFromNotNativeEvent', () => {
beforeEach(() => {
spyOn(setupExcelTableHandlersFile, 'setupExcelTableHandlers').and.callThrough();
spyOn(addParserFile, 'addParser').and.callFake(() => {});
spyOn(setProcessorFile, 'setProcessor').and.callFake(() => {});
});

it('should handle Excel content from non-native event', () => {
const fragment = document.createDocumentFragment();
const table = document.createElement('table');
fragment.appendChild(table);

const event: BeforePasteEvent = {
fragment,
clipboardData: {
types: ['web data/shadow-workbook'],
} as any,
} as any;

const allowExcelNoBorderTable = true;

handleExcelContentFromNotNativeEvent(event, allowExcelNoBorderTable);

expect(setupExcelTableHandlersFile.setupExcelTableHandlers).toHaveBeenCalledWith(
event,
allowExcelNoBorderTable,
false /* handleForNativeEvent */
);
expect(setProcessorFile.setProcessor).toHaveBeenCalledTimes(1);
expect(addParserFile.addParser).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as PastePluginFile from '../../lib/paste/Excel/processPastedContentFromExcel';
import * as PastePluginFile from '../../../lib/paste/Excel/processPastedContentFromExcel';
import * as setupExcelTableHandlersFile from '../../../lib/paste/Excel/setupExcelTableHandlers';
import { ContentModelDocument, DOMCreator } from 'roosterjs-content-model-types';
import { createBeforePasteEventMock } from './processPastedContentFromWordDesktopTest';
import { processPastedContentFromExcel } from '../../lib/paste/Excel/processPastedContentFromExcel';
import { createBeforePasteEventMock } from '../createBeforePasteEventMock';
import { processPastedContentFromExcel } from '../../../lib/paste/Excel/processPastedContentFromExcel';
import {
contentModelToDom,
createDomToModelContext,
Expand Down Expand Up @@ -397,7 +398,7 @@ describe('childProcessorTest', () => {
const tableCell = createTableCell();
tableCell.format.textColor = 'black';

PastePluginFile.childProcessor(tableCell, element, context);
setupExcelTableHandlersFile.childProcessor(tableCell, element, context);

expect(tableCell.format.textColor).toBeUndefined();
expect(context.segmentFormat.textColor).toBeUndefined();
Expand All @@ -417,7 +418,7 @@ describe('childProcessorTest', () => {
const tableCell = createTableCell();
tableCell.format.textColor = 'black';

PastePluginFile.childProcessor(tableCell, element, context);
setupExcelTableHandlersFile.childProcessor(tableCell, element, context);

expect(tableCell.format.textColor).toBeUndefined();
expect(context.segmentFormat.textColor).toEqual('blue');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ClipboardData, DOMCreator } from 'roosterjs-content-model-types';
import { validateExcelFragment } from '../../lib/paste/Excel/processPastedContentFromExcel';
import { validateExcelFragment } from '../../../lib/paste/Excel/processPastedContentFromExcel';

describe('validateExcelFragment', () => {
let domCreator: DOMCreator;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ describe('getPasteSourceTest | ', () => {
const result = getPasteSource(defaultParam(), false /* shouldConvertSingleImage */);
expect(result).toBe('default');
});
it('Is Excel Non-Native Event', () => {
const result = getPasteSource(
excelNonNativeEventParam(),
false /* shouldConvertSingleImage */
);
expect(result).toBe('excelNonNativeEvent');
});
});

function wacParam(): BeforePasteEvent {
Expand Down Expand Up @@ -78,8 +85,9 @@ function googleSheetParam(): BeforePasteEvent {

function converSingleImageParam(): BeforePasteEvent {
const fragment = document.createDocumentFragment();
const clipboardData = <ClipboardData>{
const clipboardData = <any>{
htmlFirstLevelChildTags: ['IMG'],
types: [],
};

return <BeforePasteEvent>{
Expand Down Expand Up @@ -111,5 +119,17 @@ function wordParam(): BeforePasteEvent {
function defaultParam(): BeforePasteEvent {
const fragment = document.createDocumentFragment();

return <BeforePasteEvent>{ htmlAttributes: {}, fragment, clipboardData: {} };
return <BeforePasteEvent>{ htmlAttributes: {}, fragment, clipboardData: <any>{ types: [] } };
}

function excelNonNativeEventParam(): BeforePasteEvent {
const fragment = document.createDocumentFragment();

const clipboardData: ClipboardData = {
types: ['web data/shadow-workbook'],
rawHtml: '',
htmlFirstLevelChildTags: ['TABLE'],
} as any;

return <BeforePasteEvent>{ fragment, clipboardData, htmlAttributes: {} };
}
Loading
Loading