From 3465a3115403422c5bfade44e6e3389b3a949f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 3 Jan 2024 16:25:56 -0300 Subject: [PATCH 1/4] justify-content-api --- .../contentModel/ContentModelRibbon.tsx | 2 + .../contentModel/alignJustifyButton.ts | 19 +++++ .../lib/modelApi/block/setModelAlignment.ts | 9 ++- .../lib/publicApi/block/setAlignment.ts | 2 +- .../modelApi/block/setModelAlignmentTest.ts | 69 +++++++++++++++++++ 5 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 demo/scripts/controls/ribbonButtons/contentModel/alignJustifyButton.ts diff --git a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx index 97ece1f2b28..fdd99c1f47c 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx +++ b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { alignCenterButton } from './alignCenterButton'; +import { alignJustifyButton } from './alignJustifyButton'; import { alignLeftButton } from './alignLeftButton'; import { alignRightButton } from './alignRightButton'; import { backgroundColorButton } from './backgroundColorButton'; @@ -83,6 +84,7 @@ const buttons = [ alignLeftButton, alignCenterButton, alignRightButton, + alignJustifyButton, insertLinkButton, removeLinkButton, insertTableButton, diff --git a/demo/scripts/controls/ribbonButtons/contentModel/alignJustifyButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/alignJustifyButton.ts new file mode 100644 index 00000000000..9b7803f26ff --- /dev/null +++ b/demo/scripts/controls/ribbonButtons/contentModel/alignJustifyButton.ts @@ -0,0 +1,19 @@ +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { RibbonButton } from 'roosterjs-react'; +import { setAlignment } from 'roosterjs-content-model-api'; + +/** + * @internal + * "Align justify" button on the format ribbon + */ +export const alignJustifyButton: RibbonButton<'buttonNameAlignJustify'> = { + key: 'buttonNameAlignJustify', + unlocalizedText: 'Align justify', + iconName: 'AlignJustify', + onClick: editor => { + if (isContentModelEditor(editor)) { + setAlignment(editor, 'justify'); + } + return true; + }, +}; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts index 7195148b4f1..9fc0ea21c2a 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts @@ -47,7 +47,7 @@ const TableAlignMap: Record< */ export function setModelAlignment( model: ContentModelDocument, - alignment: 'left' | 'center' | 'right' + alignment: 'left' | 'center' | 'right' | 'justify' ) { const paragraphOrListItemOrTable = getOperationalBlocks( model, @@ -56,8 +56,11 @@ export function setModelAlignment( ); paragraphOrListItemOrTable.forEach(({ block }) => { - const newAligment = ResultMap[alignment][block.format.direction == 'rtl' ? 'rtl' : 'ltr']; - if (block.blockType === 'Table') { + const newAligment = + alignment === 'justify' + ? 'justify' + : ResultMap[alignment][block.format.direction == 'rtl' ? 'rtl' : 'ltr']; + if (block.blockType === 'Table' && alignment !== 'justify') { alignTable( block, TableAlignMap[alignment][block.format.direction == 'rtl' ? 'rtl' : 'ltr'] diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setAlignment.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setAlignment.ts index 64e975c9493..9a9392477ed 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setAlignment.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setAlignment.ts @@ -8,7 +8,7 @@ import type { IStandaloneEditor } from 'roosterjs-content-model-types'; */ export default function setAlignment( editor: IStandaloneEditor, - alignment: 'left' | 'center' | 'right' + alignment: 'left' | 'center' | 'right' | 'justify' ) { editor.focus(); diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelAlignmentTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelAlignmentTest.ts index d7f14a394d0..e9f695a57fc 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelAlignmentTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelAlignmentTest.ts @@ -904,4 +904,73 @@ describe('align left', () => { }); expect(result).toBeTrue(); }); + + it('align justify paragraph', () => { + const group = createContentModelDocument(); + const para = createParagraph(); + const text = createText('test'); + text.isSelected = true; + para.segments.push(text); + + group.blocks.push(para); + + const result = setModelAlignment(group, 'justify'); + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { + textAlign: 'justify', + }, + segments: [text], + }, + ], + }); + expect(result).toBeTruthy(); + }); + + it('align justify list item', () => { + const group = createContentModelDocument(); + const listLevel = createListLevel('OL'); + const listItem = createListItem([listLevel]); + const para = createParagraph(); + const para2 = createParagraph(); + const text = createText('test'); + const text2 = createText('test2'); + text.isSelected = true; + text2.isSelected = true; + para.segments.push(text); + para2.segments.push(text2); + + listItem.blocks.push(para, para2); + + group.blocks.push(listItem); + + const result = setModelAlignment(group, 'justify'); + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockGroupType: 'ListItem', + blockType: 'BlockGroup', + levels: [ + { + listType: 'OL', + dataset: {}, + format: {}, + }, + ], + blocks: [para, para2], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { textAlign: 'justify' }, + }, + ], + }); + expect(result).toBeTruthy(); + }); }); From b9cd29b1d8a15d5ed64b07ad73ce8d731d662d1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 4 Jan 2024 13:56:18 -0300 Subject: [PATCH 2/4] aligment list item --- .../lib/modelApi/block/setModelAlignment.ts | 10 +- .../test/publicApi/block/setAlignmentTest.ts | 98 ++++++++++++++++++- 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts index 9fc0ea21c2a..5806dc00f8a 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts @@ -56,7 +56,7 @@ export function setModelAlignment( ); paragraphOrListItemOrTable.forEach(({ block }) => { - const newAligment = + const newAlignment = alignment === 'justify' ? 'justify' : ResultMap[alignment][block.format.direction == 'rtl' ? 'rtl' : 'ltr']; @@ -66,8 +66,14 @@ export function setModelAlignment( TableAlignMap[alignment][block.format.direction == 'rtl' ? 'rtl' : 'ltr'] ); } else if (block) { + if (block.blockType === 'BlockGroup' && block.blockGroupType === 'ListItem') { + block.blocks.forEach(b => { + const { format } = b; + format.textAlign = newAlignment; + }); + } const { format } = block; - format.textAlign = newAligment; + format.textAlign = newAlignment; } }); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setAlignmentTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setAlignmentTest.ts index c6fc09cedb2..ea10eb6806f 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setAlignmentTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setAlignmentTest.ts @@ -845,7 +845,7 @@ describe('setAlignment in list', () => { function runTest( list: ContentModelListItem, - alignment: 'left' | 'right' | 'center', + alignment: 'left' | 'right' | 'center' | 'justify', expectedList: ContentModelListItem | null ) { const model = createContentModelDocument(); @@ -916,7 +916,9 @@ describe('setAlignment in list', () => { blocks: [ { blockType: 'Paragraph', - format: {}, + format: { + textAlign: 'start', + }, segments: [ { segmentType: 'Text', @@ -948,7 +950,9 @@ describe('setAlignment in list', () => { blocks: [ { blockType: 'Paragraph', - format: {}, + format: { + textAlign: 'start', + }, segments: [ { segmentType: 'Text', @@ -1022,7 +1026,9 @@ describe('setAlignment in list', () => { blocks: [ { blockType: 'Paragraph', - format: {}, + format: { + textAlign: 'center', + }, segments: [ { segmentType: 'Text', @@ -1098,7 +1104,9 @@ describe('setAlignment in list', () => { blocks: [ { blockType: 'Paragraph', - format: {}, + format: { + textAlign: 'end', + }, segments: [ { segmentType: 'Text', @@ -1124,4 +1132,84 @@ describe('setAlignment in list', () => { } ); }); + + it('List - apply justify', () => { + runTest( + { + blockGroupType: 'ListItem', + blockType: 'BlockGroup', + levels: [ + { + listType: 'OL', + dataset: {}, + format: {}, + }, + ], + blocks: [ + { + blockType: 'Paragraph', + format: { + textAlign: 'end', + }, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + format: { + textAlign: 'end', + }, + }, + 'justify', + { + blockGroupType: 'ListItem', + blockType: 'BlockGroup', + levels: [ + { + listType: 'OL', + dataset: {}, + format: {}, + }, + ], + blocks: [ + { + blockType: 'Paragraph', + format: { + textAlign: 'justify', + }, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + textAlign: 'justify', + }, + } + ); + }); }); From 6283ab3a25162ae954a24747d81b7c8b1f4709d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 4 Jan 2024 15:44:50 -0300 Subject: [PATCH 3/4] add justify to map --- .../lib/modelApi/block/setModelAlignment.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts index 5806dc00f8a..5a46366499b 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts @@ -7,8 +7,8 @@ import type { } from 'roosterjs-content-model-types'; const ResultMap: Record< - 'left' | 'center' | 'right', - Record<'ltr' | 'rtl', 'start' | 'center' | 'end'> + 'left' | 'center' | 'right' | 'justify', + Record<'ltr' | 'rtl', 'start' | 'center' | 'end' | 'justify'> > = { left: { ltr: 'start', @@ -22,6 +22,10 @@ const ResultMap: Record< ltr: 'end', rtl: 'start', }, + justify: { + ltr: 'justify', + rtl: 'justify', + }, }; const TableAlignMap: Record< @@ -56,10 +60,7 @@ export function setModelAlignment( ); paragraphOrListItemOrTable.forEach(({ block }) => { - const newAlignment = - alignment === 'justify' - ? 'justify' - : ResultMap[alignment][block.format.direction == 'rtl' ? 'rtl' : 'ltr']; + const newAlignment = ResultMap[alignment][block.format.direction == 'rtl' ? 'rtl' : 'ltr']; if (block.blockType === 'Table' && alignment !== 'justify') { alignTable( block, From ccdb5e6b118c1b262194a865fc6fb556179bcb95 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 4 Jan 2024 13:54:23 -0800 Subject: [PATCH 4/4] Content Model: Improve paste and sanitization behavior (#2304) --- .../override/containerWidthFormatParser.ts | 11 +++ .../lib/override/pasteEntityProcessor.ts | 29 +++++--- .../lib/override/pasteGeneralProcessor.ts | 58 ++++++++------- .../lib/override/pasteTextProcessor.ts | 15 ++++ .../lib/publicApi/selection/deleteSegment.ts | 2 +- .../paste/generatePasteOptionFromPlugins.ts | 2 + .../lib/utils/paste/mergePasteContent.ts | 6 ++ .../lib/utils/sanitizeElement.ts | 63 ++++++++-------- .../containerWidthFormatParserTest.ts | 37 ++++++++++ .../overrides/pasteEntityProcessorTest.ts | 19 +++-- .../overrides/pasteGeneralProcessorTest.ts | 67 ++++++++++++++--- .../test/overrides/pasteTextProcessorTest.ts | 72 +++++++++++++++++++ .../generatePasteOptionFromPluginsTest.ts | 6 ++ .../test/utils/paste/mergePasteContentTest.ts | 6 ++ .../test/utils/sanitizeElementTest.ts | 52 ++++++++++++++ .../domToModel/processors/textProcessor.ts | 6 +- .../lib/domUtils/isWhiteSpacePreserved.ts | 10 +++ .../roosterjs-content-model-dom/lib/index.ts | 2 +- .../modelApi/common/isWhiteSpacePreserved.ts | 16 ----- .../lib/modelApi/common/normalizeParagraph.ts | 4 +- .../isWhiteSpacePreservedTest.ts | 15 +--- .../edit/deleteSteps/deleteWordSelection.ts | 2 +- .../lib/event/ContentModelBeforePasteEvent.ts | 11 +++ .../lib/index.ts | 1 + .../lib/parameter/ValueSanitizer.ts | 10 +++ 25 files changed, 402 insertions(+), 120 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/override/containerWidthFormatParser.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/override/pasteTextProcessor.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/overrides/containerWidthFormatParserTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/overrides/pasteTextProcessorTest.ts create mode 100644 packages-content-model/roosterjs-content-model-dom/lib/domUtils/isWhiteSpacePreserved.ts delete mode 100644 packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/isWhiteSpacePreserved.ts rename packages-content-model/roosterjs-content-model-dom/test/{modelApi/common => domUtils}/isWhiteSpacePreservedTest.ts (50%) create mode 100644 packages-content-model/roosterjs-content-model-types/lib/parameter/ValueSanitizer.ts diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/containerWidthFormatParser.ts b/packages-content-model/roosterjs-content-model-core/lib/override/containerWidthFormatParser.ts new file mode 100644 index 00000000000..6ea362ffbc1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/override/containerWidthFormatParser.ts @@ -0,0 +1,11 @@ +import type { FormatParser, SizeFormat } from 'roosterjs-content-model-types'; + +/** + * @internal Do not paste width for Format Containers since it may be generated by browser according to temp div width + */ +export const containerWidthFormatParser: FormatParser = (format, element) => { + // For pasted content, there may be existing width generated by browser from the temp DIV. So we need to remove it. + if (element.tagName == 'DIV') { + delete format.width; + } +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/pasteEntityProcessor.ts b/packages-content-model/roosterjs-content-model-core/lib/override/pasteEntityProcessor.ts index 67df4b70e71..8424d684dec 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/override/pasteEntityProcessor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/override/pasteEntityProcessor.ts @@ -1,10 +1,13 @@ -import { - AllowedTags, - DisallowedTags, - removeStyle, - sanitizeElement, -} from '../utils/sanitizeElement'; -import type { DomToModelOptionForPaste, ElementProcessor } from 'roosterjs-content-model-types'; +import { AllowedTags, DisallowedTags, sanitizeElement } from '../utils/sanitizeElement'; +import type { + DomToModelOptionForPaste, + ElementProcessor, + ValueSanitizer, +} from 'roosterjs-content-model-types'; + +const DefaultStyleSanitizers: Readonly> = { + position: false, +}; /** * @internal @@ -14,11 +17,17 @@ export function createPasteEntityProcessor( ): ElementProcessor { const allowedTags = AllowedTags.concat(options.additionalAllowedTags); const disallowedTags = DisallowedTags.concat(options.additionalDisallowedTags); + const styleSanitizers = Object.assign({}, DefaultStyleSanitizers, options.styleSanitizers); + const attrSanitizers = options.attributeSanitizers; return (group, element, context) => { - const sanitizedElement = sanitizeElement(element, allowedTags, disallowedTags, { - position: removeStyle, - }); + const sanitizedElement = sanitizeElement( + element, + allowedTags, + disallowedTags, + styleSanitizers, + attrSanitizers + ); if (sanitizedElement) { context.defaultElementProcessors.entity(group, sanitizedElement, context); diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/pasteGeneralProcessor.ts b/packages-content-model/roosterjs-content-model-core/lib/override/pasteGeneralProcessor.ts index 071726bd940..025601b625a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/override/pasteGeneralProcessor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/override/pasteGeneralProcessor.ts @@ -1,12 +1,22 @@ +import { AllowedTags, createSanitizedElement, DisallowedTags } from '../utils/sanitizeElement'; import { moveChildNodes } from 'roosterjs-content-model-dom'; -import { - AllowedTags, - createSanitizedElement, - DisallowedTags, - removeDisplayFlex, - removeStyle, -} from '../utils/sanitizeElement'; -import type { DomToModelOptionForPaste, ElementProcessor } from 'roosterjs-content-model-types'; +import type { + DomToModelOptionForPaste, + ElementProcessor, + ValueSanitizer, +} from 'roosterjs-content-model-types'; + +/** + * @internal Export for test only + */ +export const removeDisplayFlex: ValueSanitizer = value => { + return value == 'flex' ? null : value; +}; + +const DefaultStyleSanitizers: Readonly> = { + position: false, + display: removeDisplayFlex, +}; /** * @internal @@ -16,12 +26,25 @@ export function createPasteGeneralProcessor( ): ElementProcessor { const allowedTags = AllowedTags.concat(options.additionalAllowedTags); const disallowedTags = DisallowedTags.concat(options.additionalDisallowedTags); + const styleSanitizers = Object.assign({}, DefaultStyleSanitizers, options.styleSanitizers); + const attrSanitizers = options.attributeSanitizers; return (group, element, context) => { const tag = element.tagName.toLowerCase(); - const processor = + const processor: ElementProcessor | undefined = allowedTags.indexOf(tag) >= 0 - ? internalGeneralProcessor + ? (group, element, context) => { + const sanitizedElement = createSanitizedElement( + element.ownerDocument, + element.tagName, + element.attributes, + styleSanitizers, + attrSanitizers + ); + + moveChildNodes(sanitizedElement, element); + context.defaultElementProcessors['*']?.(group, sanitizedElement, context); + } : disallowedTags.indexOf(tag) >= 0 ? undefined // Ignore those disallowed tags : context.defaultElementProcessors.span; // For other unknown tags, treat them as SPAN @@ -29,18 +52,3 @@ export function createPasteGeneralProcessor( processor?.(group, element, context); }; } - -const internalGeneralProcessor: ElementProcessor = (group, element, context) => { - const sanitizedElement = createSanitizedElement( - element.ownerDocument, - element.tagName, - element.attributes, - { - position: removeStyle, - display: removeDisplayFlex, - } - ); - - moveChildNodes(sanitizedElement, element); - context.defaultElementProcessors['*']?.(group, sanitizedElement, context); -}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/pasteTextProcessor.ts b/packages-content-model/roosterjs-content-model-core/lib/override/pasteTextProcessor.ts new file mode 100644 index 00000000000..512a7f6b37b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/override/pasteTextProcessor.ts @@ -0,0 +1,15 @@ +import { isWhiteSpacePreserved } from 'roosterjs-content-model-dom'; +import type { ElementProcessor } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export const pasteTextProcessor: ElementProcessor = (group, text, context) => { + const whiteSpace = context.blockFormat.whiteSpace; + + if (isWhiteSpacePreserved(whiteSpace)) { + text.nodeValue = text.nodeValue?.replace(/\u0020\u0020/g, '\u0020\u00A0') ?? ''; + } + + context.defaultElementProcessors['#text'](group, text, context); +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts index 95ce0c57bba..87ffca8a863 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts @@ -24,7 +24,7 @@ export function deleteSegment( ): boolean { const segments = paragraph.segments; const index = segments.indexOf(segmentToDelete); - const preserveWhiteSpace = isWhiteSpacePreserved(paragraph); + const preserveWhiteSpace = isWhiteSpacePreserved(paragraph.format.whiteSpace); const isForward = direction == 'forward'; const isBackward = direction == 'backward'; diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/generatePasteOptionFromPlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/generatePasteOptionFromPlugins.ts index ec156e42079..a0f1a0c346b 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/generatePasteOptionFromPlugins.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/generatePasteOptionFromPlugins.ts @@ -33,6 +33,8 @@ export function generatePasteOptionFromPlugins( additionalFormatParsers: {}, formatParserOverride: {}, processorOverride: {}, + styleSanitizers: {}, + attributeSanitizers: {}, }; const event: ContentModelBeforePasteEvent = { diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts index ba1b6c5cde1..f63843cbbe6 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts @@ -1,3 +1,4 @@ +import { containerWidthFormatParser } from '../../override/containerWidthFormatParser'; import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; import { createPasteEntityProcessor } from '../../override/pasteEntityProcessor'; import { createPasteGeneralProcessor } from '../../override/pasteGeneralProcessor'; @@ -5,6 +6,7 @@ import { getSegmentTextFormat } from '../../publicApi/domUtils/getSegmentTextFor import { getSelectedSegments } from '../../publicApi/selection/collectSelections'; import { mergeModel } from '../../publicApi/model/mergeModel'; import { pasteDisplayFormatParser } from '../../override/pasteDisplayFormatParser'; +import { pasteTextProcessor } from '../../override/pasteTextProcessor'; import { PasteType } from 'roosterjs-editor-types'; import type { MergeModelOption } from '../../publicApi/model/mergeModel'; import type { @@ -45,12 +47,16 @@ export function mergePasteContent( defaultDomToModelOptions, { processorOverride: { + '#text': pasteTextProcessor, entity: createPasteEntityProcessor(domToModelOption), '*': createPasteGeneralProcessor(domToModelOption), }, formatParserOverride: { display: pasteDisplayFormatParser, }, + additionalFormatParsers: { + container: [containerWidthFormatParser], + }, }, domToModelOption ); diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/sanitizeElement.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/sanitizeElement.ts index a99dc177b53..20f75114fa3 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/sanitizeElement.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/sanitizeElement.ts @@ -1,4 +1,5 @@ import { isNodeOfType } from 'roosterjs-content-model-dom'; +import type { ValueSanitizer } from 'roosterjs-content-model-types'; /** * @internal @@ -269,7 +270,8 @@ export function sanitizeElement( element: HTMLElement, allowedTags: ReadonlyArray, disallowedTags: ReadonlyArray, - styleCallbacks?: Record string | null> + styleSanitizers?: Readonly>, + attributeSanitizers?: Readonly> ): HTMLElement | null { const tag = element.tagName.toLowerCase(); const sanitizedElement = @@ -279,13 +281,14 @@ export function sanitizeElement( element.ownerDocument, allowedTags.indexOf(tag) >= 0 ? tag : 'span', element.attributes, - styleCallbacks + styleSanitizers, + attributeSanitizers ); if (sanitizedElement) { for (let child = element.firstChild; child; child = child.nextSibling) { const newChild = isNodeOfType(child, 'ELEMENT_NODE') - ? sanitizeElement(child, allowedTags, disallowedTags, styleCallbacks) + ? sanitizeElement(child, allowedTags, disallowedTags, styleSanitizers) : isNodeOfType(child, 'TEXT_NODE') ? child.cloneNode() : null; @@ -306,7 +309,8 @@ export function createSanitizedElement( doc: Document, tag: string, attributes: NamedNodeMap, - styleCallbacks?: Record string | null> + styleSanitizers?: Readonly>, + attributeSanitizers?: Readonly> ): HTMLElement { const element = doc.createElement(tag); @@ -315,9 +319,16 @@ export function createSanitizedElement( const name = attribute.name.toLowerCase().trim(); const value = attribute.value; + const sanitizer = attributeSanitizers?.[name]; const newValue = name == 'style' - ? processStyles(tag, value, styleCallbacks) + ? processStyles(tag, value, styleSanitizers) + : typeof sanitizer == 'function' + ? sanitizer(value, tag) + : typeof sanitizer === 'boolean' + ? sanitizer + ? value + : null : AllowedAttributes.indexOf(name) >= 0 || name.indexOf('data-') == 0 ? value : null; @@ -334,24 +345,10 @@ export function createSanitizedElement( return element; } -/** - * @internal - */ -export function removeStyle(): string | null { - return null; -} - -/** - * @internal - */ -export function removeDisplayFlex(value: string) { - return value == 'flex' ? null : value; -} - function processStyles( tagName: string, value: string, - styleCallbacks?: Record string | null> + styleSanitizers?: Readonly> ) { const pairs = value.split(';'); const result: string[] = []; @@ -359,28 +356,30 @@ function processStyles( pairs.forEach(pair => { const valueIndex = pair.indexOf(':'); const name = pair.slice(0, valueIndex).trim(); - let value: string | null = pair.slice(valueIndex + 1).trim(); + let value: string = pair.slice(valueIndex + 1).trim(); if (name && value) { if (isCssVariable(value)) { value = processCssVariable(value); } - const callback = styleCallbacks?.[name]; - - if (callback) { - value = callback(value, tagName); - } + const sanitizer = styleSanitizers?.[name]; + const sanitizedValue = + typeof sanitizer == 'function' + ? sanitizer(value, tagName) + : sanitizer === false + ? null + : value; if ( - !!value && - value != 'inherit' && - value != 'initial' && - value.indexOf('expression') < 0 && + !!sanitizedValue && + sanitizedValue != 'inherit' && + sanitizedValue != 'initial' && + sanitizedValue.indexOf('expression') < 0 && !name.startsWith('-') && - DefaultStyleValue[name] != value + DefaultStyleValue[name] != sanitizedValue ) { - result.push(`${name}:${value}`); + result.push(`${name}:${sanitizedValue}`); } } }); diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/containerWidthFormatParserTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/containerWidthFormatParserTest.ts new file mode 100644 index 00000000000..14e5e65c9d4 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/containerWidthFormatParserTest.ts @@ -0,0 +1,37 @@ +import { containerWidthFormatParser } from '../../lib/override/containerWidthFormatParser'; +import { SizeFormat } from 'roosterjs-content-model-types'; + +describe('containerWidthFormatParser', () => { + it('DIV without width', () => { + const div = document.createElement('div'); + const format: SizeFormat = {}; + + containerWidthFormatParser(format, div, null!, {}); + + expect(format).toEqual({}); + }); + + it('DIV with width', () => { + const div = document.createElement('div'); + const format: SizeFormat = { + width: '10px', + }; + + containerWidthFormatParser(format, div, null!, {}); + + expect(format).toEqual({}); + }); + + it('SPAN with width', () => { + const div = document.createElement('span'); + const format: SizeFormat = { + width: '10px', + }; + + containerWidthFormatParser(format, div, null!, {}); + + expect(format).toEqual({ + width: '10px', + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/pasteEntityProcessorTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteEntityProcessorTest.ts index 5caf4aff1c7..e79751910f3 100644 --- a/packages-content-model/roosterjs-content-model-core/test/overrides/pasteEntityProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteEntityProcessorTest.ts @@ -32,6 +32,8 @@ describe('pasteEntityProcessor', () => { const pasteEntityProcessor = createPasteEntityProcessor({ additionalAllowedTags: [], additionalDisallowedTags: [], + styleSanitizers: {}, + attributeSanitizers: {}, } as any); sanitizeElementSpy.and.returnValue(element); @@ -45,8 +47,9 @@ describe('pasteEntityProcessor', () => { sanitizeElement.AllowedTags, sanitizeElement.DisallowedTags, { - position: sanitizeElement.removeStyle, - } + position: false, + }, + {} ); expect(entityProcessorSpy).toHaveBeenCalledTimes(1); expect(entityProcessorSpy).toHaveBeenCalledWith(group, sanitizedElement, context); @@ -58,6 +61,12 @@ describe('pasteEntityProcessor', () => { const pasteEntityProcessor = createPasteEntityProcessor({ additionalAllowedTags: ['allowed'], additionalDisallowedTags: ['disallowed'], + styleSanitizers: { + color: true, + }, + attributeSanitizers: { + id: true, + }, } as any); sanitizeElementSpy.and.returnValue(element); @@ -71,8 +80,10 @@ describe('pasteEntityProcessor', () => { sanitizeElement.AllowedTags.concat('allowed'), sanitizeElement.DisallowedTags.concat('disallowed'), { - position: sanitizeElement.removeStyle, - } + position: false, + color: true, + }, + { id: true } ); expect(entityProcessorSpy).toHaveBeenCalledTimes(1); expect(entityProcessorSpy).toHaveBeenCalledWith(group, sanitizedElement, context); diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/pasteGeneralProcessorTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteGeneralProcessorTest.ts index dc4064577f9..7053cf21809 100644 --- a/packages-content-model/roosterjs-content-model-core/test/overrides/pasteGeneralProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteGeneralProcessorTest.ts @@ -1,7 +1,10 @@ import * as sanitizeElement from '../../lib/utils/sanitizeElement'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; -import { createPasteGeneralProcessor } from '../../lib/override/pasteGeneralProcessor'; import { DomToModelContext } from 'roosterjs-content-model-types'; +import { + createPasteGeneralProcessor, + removeDisplayFlex, +} from '../../lib/override/pasteGeneralProcessor'; describe('pasteGeneralProcessor', () => { let createSanitizedElementSpy: jasmine.Spy; @@ -11,7 +14,7 @@ describe('pasteGeneralProcessor', () => { beforeEach(() => { createSanitizedElementSpy = spyOn(sanitizeElement, 'createSanitizedElement'); - generalProcessorSpy = jasmine.createSpy('entityProcessor'); + generalProcessorSpy = jasmine.createSpy('generalProcessor'); spanProcessorSpy = jasmine.createSpy('spanProcessorSpy'); context = { @@ -28,6 +31,8 @@ describe('pasteGeneralProcessor', () => { const pasteGeneralProcessor = createPasteGeneralProcessor({ additionalAllowedTags: [], additionalDisallowedTags: [], + styleSanitizers: {}, + attributeSanitizers: {}, } as any); createSanitizedElementSpy.and.returnValue(element); @@ -40,9 +45,10 @@ describe('pasteGeneralProcessor', () => { 'DIV', element.attributes, { - position: sanitizeElement.removeStyle, - display: sanitizeElement.removeDisplayFlex, - } + position: false, + display: removeDisplayFlex, + }, + {} ); expect(generalProcessorSpy).toHaveBeenCalledTimes(1); expect(generalProcessorSpy).toHaveBeenCalledWith(group, element, context); @@ -73,6 +79,8 @@ describe('pasteGeneralProcessor', () => { const pasteGeneralProcessor = createPasteGeneralProcessor({ additionalAllowedTags: ['test'], additionalDisallowedTags: [], + styleSanitizers: {}, + attributeSanitizers: {}, } as any); createSanitizedElementSpy.and.returnValue(element); @@ -85,9 +93,10 @@ describe('pasteGeneralProcessor', () => { 'TEST', element.attributes, { - position: sanitizeElement.removeStyle, - display: sanitizeElement.removeDisplayFlex, - } + position: false, + display: removeDisplayFlex, + }, + {} ); expect(generalProcessorSpy).toHaveBeenCalledTimes(1); expect(generalProcessorSpy).toHaveBeenCalledWith(group, element, context); @@ -111,6 +120,39 @@ describe('pasteGeneralProcessor', () => { expect(spanProcessorSpy).toHaveBeenCalledTimes(0); }); + it('Empty element with sanitizers', () => { + const element = document.createElement('div'); + const group = createContentModelDocument(); + const pasteGeneralProcessor = createPasteGeneralProcessor({ + additionalAllowedTags: [], + styleSanitizers: { + color: true, + }, + attributeSanitizers: { + id: true, + }, + } as any); + + createSanitizedElementSpy.and.returnValue(element); + + pasteGeneralProcessor(group, element, context); + + expect(createSanitizedElementSpy).toHaveBeenCalledTimes(1); + expect(createSanitizedElementSpy).toHaveBeenCalledWith( + document, + 'DIV', + element.attributes, + { + position: false, + display: removeDisplayFlex, + color: true, + }, + { id: true } + ); + expect(generalProcessorSpy).toHaveBeenCalledTimes(1); + expect(spanProcessorSpy).toHaveBeenCalledTimes(0); + }); + it('Element with display:flex', () => { const element = document.createElement('div'); @@ -120,6 +162,8 @@ describe('pasteGeneralProcessor', () => { const pasteGeneralProcessor = createPasteGeneralProcessor({ additionalAllowedTags: [], additionalDisallowedTags: ['test'], + styleSanitizers: {}, + attributeSanitizers: {}, } as any); createSanitizedElementSpy.and.callThrough(); @@ -132,9 +176,10 @@ describe('pasteGeneralProcessor', () => { 'DIV', element.attributes, { - position: sanitizeElement.removeStyle, - display: sanitizeElement.removeDisplayFlex, - } + position: false, + display: removeDisplayFlex, + }, + {} ); expect(generalProcessorSpy).toHaveBeenCalledTimes(1); expect((generalProcessorSpy.calls.argsFor(0)[1] as any).outerHTML).toEqual( diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/pasteTextProcessorTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteTextProcessorTest.ts new file mode 100644 index 00000000000..4ded1653556 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteTextProcessorTest.ts @@ -0,0 +1,72 @@ +import * as isWhiteSpacePreserved from 'roosterjs-content-model-dom/lib/domUtils/isWhiteSpacePreserved'; +import { DomToModelContext } from 'roosterjs-content-model-types'; +import { pasteTextProcessor } from '../../lib/override/pasteTextProcessor'; + +describe('pasteTextProcessor', () => { + let isWhiteSpacePreservedSpy: jasmine.Spy; + let defaultProcessorSpy: jasmine.Spy; + let mockedContext: DomToModelContext; + const mockedGroup = 'GROUP' as any; + const mockedWhiteSpace = 'WHITESPACE' as any; + + beforeEach(() => { + isWhiteSpacePreservedSpy = spyOn(isWhiteSpacePreserved, 'isWhiteSpacePreserved'); + defaultProcessorSpy = jasmine.createSpy('#text'); + mockedContext = { + blockFormat: { + whiteSpace: mockedWhiteSpace, + }, + defaultElementProcessors: { + '#text': defaultProcessorSpy, + }, + } as any; + }); + + it('empty text node, isWhiteSpacePreserved=false', () => { + const text = document.createTextNode(''); + + isWhiteSpacePreservedSpy.and.returnValue(false); + + pasteTextProcessor(mockedGroup, text, mockedContext); + + expect(isWhiteSpacePreservedSpy).toHaveBeenCalledWith(mockedWhiteSpace); + expect(defaultProcessorSpy).toHaveBeenCalledWith(mockedGroup, text, mockedContext); + expect(text.nodeValue).toBe(''); + }); + + it('empty text node, isWhiteSpacePreserved=true', () => { + const text = document.createTextNode(''); + + isWhiteSpacePreservedSpy.and.returnValue(true); + + pasteTextProcessor(mockedGroup, text, mockedContext); + + expect(isWhiteSpacePreservedSpy).toHaveBeenCalledWith(mockedWhiteSpace); + expect(defaultProcessorSpy).toHaveBeenCalledWith(mockedGroup, text, mockedContext); + expect(text.nodeValue).toBe(''); + }); + + it('text node with space, isWhiteSpacePreserved=false', () => { + const text = document.createTextNode(' '); + + isWhiteSpacePreservedSpy.and.returnValue(false); + + pasteTextProcessor(mockedGroup, text, mockedContext); + + expect(isWhiteSpacePreservedSpy).toHaveBeenCalledWith(mockedWhiteSpace); + expect(defaultProcessorSpy).toHaveBeenCalledWith(mockedGroup, text, mockedContext); + expect(text.nodeValue).toBe(' '); + }); + + it('text node with space, isWhiteSpacePreserved=true', () => { + const text = document.createTextNode(' '); + + isWhiteSpacePreservedSpy.and.returnValue(true); + + pasteTextProcessor(mockedGroup, text, mockedContext); + + expect(isWhiteSpacePreservedSpy).toHaveBeenCalledWith(mockedWhiteSpace); + expect(defaultProcessorSpy).toHaveBeenCalledWith(mockedGroup, text, mockedContext); + expect(text.nodeValue).toBe(' \u00A0 \u00A0'); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/paste/generatePasteOptionFromPluginsTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/paste/generatePasteOptionFromPluginsTest.ts index 071277027e8..1b0c8eb33ff 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/paste/generatePasteOptionFromPluginsTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/paste/generatePasteOptionFromPluginsTest.ts @@ -86,6 +86,8 @@ describe('generatePasteOptionFromPlugins', () => { additionalFormatParsers: {}, formatParserOverride: {}, processorOverride: {}, + styleSanitizers: {}, + attributeSanitizers: {}, }, sanitizingOption, }); @@ -219,6 +221,8 @@ describe('generatePasteOptionFromPlugins', () => { additionalFormatParsers: {}, formatParserOverride: {}, processorOverride: {}, + styleSanitizers: {}, + attributeSanitizers: {}, }, sanitizingOption, }); @@ -249,6 +253,8 @@ describe('generatePasteOptionFromPlugins', () => { additionalFormatParsers: {}, formatParserOverride: {}, processorOverride: {}, + styleSanitizers: {}, + attributeSanitizers: {}, }, pasteType: PasteType.AsPlainText, eventType: PluginEventType.BeforePaste, diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/paste/mergePasteContentTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/paste/mergePasteContentTest.ts index dd2314aa5d7..bb4e68be828 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/paste/mergePasteContentTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/paste/mergePasteContentTest.ts @@ -3,9 +3,11 @@ import * as createPasteEntityProcessor from '../../../lib/override/pasteEntityPr import * as createPasteGeneralProcessor from '../../../lib/override/pasteGeneralProcessor'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as mergeModelFile from '../../../lib/publicApi/model/mergeModel'; +import { containerWidthFormatParser } from '../../../lib/override/containerWidthFormatParser'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { mergePasteContent } from '../../../lib/utils/paste/mergePasteContent'; import { pasteDisplayFormatParser } from '../../../lib/override/pasteDisplayFormatParser'; +import { pasteTextProcessor } from '../../../lib/override/pasteTextProcessor'; import { PasteType } from 'roosterjs-editor-types'; import { ContentModelDocument, @@ -383,12 +385,16 @@ describe('mergePasteContent', () => { mockedDomToModelOptions, { processorOverride: { + '#text': pasteTextProcessor, entity: mockedPasteEntityProcessor, '*': mockedPasteGeneralProcessor, }, formatParserOverride: { display: pasteDisplayFormatParser, }, + additionalFormatParsers: { + container: [containerWidthFormatParser], + }, }, mockedDefaultDomToModelOptions ); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/sanitizeElementTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/sanitizeElementTest.ts index a97945f5ccd..c24c35e6749 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/sanitizeElementTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/sanitizeElementTest.ts @@ -105,6 +105,58 @@ describe('sanitizeElement', () => { expect(element.outerHTML).toBe('
'); expect(result!.outerHTML).toBe('
'); }); + + it('styleCallbacks', () => { + const element = document.createElement('div'); + const sanitizerSpy = jasmine.createSpy('sanitizer').and.returnValue('green'); + + element.style.color = 'red'; + + const result = sanitizeElement(element, AllowedTags, DisallowedTags, { + color: sanitizerSpy, + }); + + expect(result!.outerHTML).toBe('
'); + expect(sanitizerSpy).toHaveBeenCalledWith('red', 'div'); + }); + + it('styleCallbacks with boolean', () => { + const element = document.createElement('div'); + + element.style.color = 'red'; + + const result = sanitizeElement(element, AllowedTags, DisallowedTags, { + color: false, + }); + + expect(result!.outerHTML).toBe('
'); + }); + + it('attributeCallbacks', () => { + const element = document.createElement('div'); + const sanitizerSpy = jasmine.createSpy('sanitizer').and.returnValue('b'); + + element.id = 'a'; + + const result = sanitizeElement(element, AllowedTags, DisallowedTags, undefined, { + id: sanitizerSpy, + }); + + expect(result!.outerHTML).toBe('
'); + expect(sanitizerSpy).toHaveBeenCalledWith('a', 'div'); + }); + + it('attributeCallbacks with boolean', () => { + const element = document.createElement('div'); + + element.id = 'a'; + + const result = sanitizeElement(element, AllowedTags, DisallowedTags, undefined, { + id: false, + }); + + expect(result!.outerHTML).toBe('
'); + }); }); describe('sanitizeHtml', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts index e2de0dd87e9..29b78ef4637 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts @@ -5,6 +5,7 @@ import { createText } from '../../modelApi/creators/createText'; import { ensureParagraph } from '../../modelApi/common/ensureParagraph'; import { getRegularSelectionOffsets } from '../utils/getRegularSelectionOffsets'; import { hasSpacesOnly } from '../../modelApi/common/hasSpacesOnly'; +import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; import type { ContentModelBlockGroup, ContentModelParagraph, @@ -62,9 +63,6 @@ export const textProcessor: ElementProcessor = ( ); }; -// When we see these values of white-space style, need to preserve spaces and line-breaks and let browser handle it for us. -const WhiteSpaceValuesNeedToHandle = ['pre', 'pre-wrap', 'pre-line', 'break-spaces']; - function addTextSegment( group: ContentModelBlockGroup, text: string, @@ -77,7 +75,7 @@ function addTextSegment( if ( !hasSpacesOnly(text) || (paragraph?.segments.length ?? 0) > 0 || - WhiteSpaceValuesNeedToHandle.indexOf(paragraph?.format.whiteSpace || '') >= 0 + isWhiteSpacePreserved(paragraph?.format.whiteSpace) ) { textModel = createText(text, context.segmentFormat); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/isWhiteSpacePreserved.ts b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/isWhiteSpacePreserved.ts new file mode 100644 index 00000000000..21f30644ea0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/isWhiteSpacePreserved.ts @@ -0,0 +1,10 @@ +// According to https://developer.mozilla.org/en-US/docs/Web/CSS/white-space, these style values will need to preserve white spaces +const WHITESPACE_PRE_VALUES = ['pre', 'pre-wrap', 'break-spaces']; + +/** + * Check if the given white-space style value will cause preserving white space + * @param whiteSpace The white-space style value to check + */ +export function isWhiteSpacePreserved(whiteSpace: string | undefined): boolean { + return !!whiteSpace && WHITESPACE_PRE_VALUES.indexOf(whiteSpace) >= 0; +} diff --git a/packages-content-model/roosterjs-content-model-dom/lib/index.ts b/packages-content-model/roosterjs-content-model-dom/lib/index.ts index d0c355cbb9e..e8d31ce0df8 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -28,6 +28,7 @@ export { addDelimiters, } from './domUtils/entityUtils'; export { reuseCachedElement } from './domUtils/reuseCachedElement'; +export { isWhiteSpacePreserved } from './domUtils/isWhiteSpacePreserved'; export { createBr } from './modelApi/creators/createBr'; export { createListItem } from './modelApi/creators/createListItem'; @@ -54,7 +55,6 @@ export { normalizeContentModel } from './modelApi/common/normalizeContentModel'; export { isGeneralSegment } from './modelApi/common/isGeneralSegment'; export { unwrapBlock } from './modelApi/common/unwrapBlock'; export { addSegment } from './modelApi/common/addSegment'; -export { isWhiteSpacePreserved } from './modelApi/common/isWhiteSpacePreserved'; export { normalizeSingleSegment } from './modelApi/common/normalizeSegment'; export { setParagraphNotImplicit } from './modelApi/block/setParagraphNotImplicit'; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/isWhiteSpacePreserved.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/isWhiteSpacePreserved.ts deleted file mode 100644 index b3122e11d17..00000000000 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/isWhiteSpacePreserved.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ContentModelParagraph } from 'roosterjs-content-model-types'; - -// According to https://developer.mozilla.org/en-US/docs/Web/CSS/white-space, these style values will need to preserve white spaces -const WHITESPACE_PRE_VALUES = ['pre', 'pre-wrap', 'break-spaces']; - -/** - * Check if we have white-space to be preserved for a given paragraph - * @param paragraph The paragraph to check - */ -export function isWhiteSpacePreserved(paragraph: ContentModelParagraph): boolean { - return ( - (paragraph.format.whiteSpace && - WHITESPACE_PRE_VALUES.indexOf(paragraph.format.whiteSpace) >= 0) || - false - ); -} diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts index 7853f801ce0..1edd90acb8b 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts @@ -1,7 +1,7 @@ import { areSameFormats } from '../../domToModel/utils/areSameFormats'; import { createBr } from '../creators/createBr'; import { isSegmentEmpty } from './isEmpty'; -import { isWhiteSpacePreserved } from './isWhiteSpacePreserved'; +import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; import { normalizeAllSegments } from './normalizeSegment'; import type { ContentModelParagraph } from 'roosterjs-content-model-types'; /** @@ -33,7 +33,7 @@ export function normalizeParagraph(paragraph: ContentModelParagraph) { } } - if (!isWhiteSpacePreserved(paragraph)) { + if (!isWhiteSpacePreserved(paragraph.format.whiteSpace)) { normalizeAllSegments(paragraph); } diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelApi/common/isWhiteSpacePreservedTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domUtils/isWhiteSpacePreservedTest.ts similarity index 50% rename from packages-content-model/roosterjs-content-model-dom/test/modelApi/common/isWhiteSpacePreservedTest.ts rename to packages-content-model/roosterjs-content-model-dom/test/domUtils/isWhiteSpacePreservedTest.ts index 06fd459b37b..fc798b2d612 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelApi/common/isWhiteSpacePreservedTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domUtils/isWhiteSpacePreservedTest.ts @@ -1,19 +1,8 @@ -import { ContentModelParagraph } from 'roosterjs-content-model-types'; -import { isWhiteSpacePreserved } from '../../../lib/modelApi/common/isWhiteSpacePreserved'; +import { isWhiteSpacePreserved } from '../../lib/domUtils/isWhiteSpacePreserved'; describe('isWhiteSpacePreserved', () => { function runTest(style: string | undefined, expected: boolean) { - const paragraph: ContentModelParagraph = { - blockType: 'Paragraph', - format: {}, - segments: [], - }; - - if (style) { - paragraph.format.whiteSpace = style; - } - - const result = isWhiteSpacePreserved(paragraph); + const result = isWhiteSpacePreserved(style); expect(result).toBe(expected); } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts index 261f93036d8..1217a6648d1 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts @@ -104,7 +104,7 @@ function* iterateSegments( ): Generator { const step = forward ? 1 : -1; const segments = paragraph.segments; - const preserveWhiteSpace = isWhiteSpacePreserved(paragraph); + const preserveWhiteSpace = isWhiteSpacePreserved(paragraph.format.whiteSpace); for (let i = markerIndex + step; i >= 0 && i < segments.length; i += step) { const segment = segments[i]; diff --git a/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelBeforePasteEvent.ts b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelBeforePasteEvent.ts index a85198aac95..3a4f8d90711 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelBeforePasteEvent.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelBeforePasteEvent.ts @@ -1,3 +1,4 @@ +import type { ValueSanitizer } from '../parameter/ValueSanitizer'; import type { DomToModelOption } from '../context/DomToModelOption'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { InsertPoint } from '../selection/InsertPoint'; @@ -20,6 +21,16 @@ export interface DomToModelOptionForPaste extends Required { * Additional disallowed HTML tags in lower case. Elements with these tags will be dropped */ additionalDisallowedTags: Lowercase[]; + + /** + * Additional sanitizers for CSS styles + */ + styleSanitizers: Record; + + /** + * Additional sanitizers for CSS styles + */ + attributeSanitizers: Record; } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index 4a6ee232753..01953755fbb 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -264,6 +264,7 @@ export { SnapshotsManager } from './parameter/SnapshotsManager'; export { DOMEventHandlerFunction, DOMEventRecord } from './parameter/DOMEventRecord'; export { EdgeLinkPreview } from './parameter/EdgeLinkPreview'; export { ClipboardData } from './parameter/ClipboardData'; +export { ValueSanitizer } from './parameter/ValueSanitizer'; export { MergePastedContentFunc, diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/ValueSanitizer.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/ValueSanitizer.ts new file mode 100644 index 00000000000..5a4a6099f44 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/ValueSanitizer.ts @@ -0,0 +1,10 @@ +/** + * Specify how to sanitize a value, can be a callback function or a boolean value. + * True: Keep this value + * False: Remove this value + * A callback: Let the callback function to decide how to deal this value. + * @param value The original value + * @param tagName Tag name of the element of this value + * @returns Return a non-empty string means use this value to replace the original value. Otherwise remove this value + */ +export type ValueSanitizer = ((value: string, tagName: string) => string | null) | boolean;