From c7b60faaad6acc2d6925583fce42d6b0070da721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 10 Jan 2024 14:02:36 -0300 Subject: [PATCH 001/112] wip --- .../lib/edit/ContentModelEditPlugin.ts | 9 +- .../lib/edit/keyboardListTrigger.ts | 33 ++++ .../convertAlphaToDecimals.ts | 15 ++ .../lib/edit/listFeaturesUtils/getIndex.ts | 7 + .../lib/edit/listFeaturesUtils/getListType.ts | 92 +++++++++ .../getNumberingListStyle.ts | 180 ++++++++++++++++++ 6 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/convertAlphaToDecimals.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getIndex.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getNumberingListStyle.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts index 0581a9d4b39..0722bf0cbcf 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts @@ -1,5 +1,7 @@ +import { getListStyleType } from './listFeaturesUtils/getListType'; import { keyboardDelete } from './keyboardDelete'; import { keyboardInput } from './keyboardInput'; +import { keyboardListTrigger } from './keyboardListTrigger'; import { PluginEventType } from 'roosterjs-editor-types'; import type { IContentModelEditor } from 'roosterjs-content-model-editor'; import type { @@ -73,7 +75,12 @@ export class ContentModelEditPlugin implements EditorPlugin { // 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); break; - + case ' ': + const listStyleType = getListStyleType(editor); + if (listStyleType) { + keyboardListTrigger(editor, listStyleType); + break; + } case 'Enter': default: keyboardInput(editor, rawEvent); diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts new file mode 100644 index 00000000000..9a437455152 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts @@ -0,0 +1,33 @@ +import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core/lib/publicApi/selection/collectSelections'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { + setListStartNumber, + setListStyle, + toggleBullet, + toggleNumbering, +} from 'roosterjs-content-model-api'; + +export const keyboardListTrigger = ( + editor: IStandaloneEditor, + listStyleType: { listType: 'UL' | 'OL'; styleType: number; index?: number } +) => { + editor.formatContentModel((model, context) => { + if (listStyleType.listType === 'UL') { + toggleBullet(editor); + setListStyle(editor, { unorderedStyleType: listStyleType.styleType }); + } else { + toggleNumbering(editor); + setListStyle(editor, { orderedStyleType: listStyleType.styleType }); + if (listStyleType.index) { + setListStartNumber(editor, listStyleType.index); + } + } + const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs(model, false); + const listMarker = selectedSegmentsAndParagraphs[0][1]?.segments[0]; + if (listMarker && listMarker.segmentType === 'Text') { + listMarker.text = ''; + } + context.skipUndoSnapshot = true; + return true; + }); +}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/convertAlphaToDecimals.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/convertAlphaToDecimals.ts new file mode 100644 index 00000000000..08acacc971b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/convertAlphaToDecimals.ts @@ -0,0 +1,15 @@ +/** + * @internal + * Convert english alphabet numbers into decimal numbers + * @param letter The letter that needs to be converted + * @returns + */ +export default function convertAlphaToDecimals(letter: string): number | undefined { + const alpha = letter.toLocaleLowerCase(); + if (alpha) { + const size = alpha.length - 1; + const number = 26 * size + alpha.charCodeAt(size) - 96; + return number; + } + return undefined; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getIndex.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getIndex.ts new file mode 100644 index 00000000000..de0c467acb9 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getIndex.ts @@ -0,0 +1,7 @@ +import convertAlphaToDecimals from './convertAlphaToDecimals'; + +export function getIndex(listIndex: string) { + const index = listIndex.replace(/[^a-zA-Z0-9 ]/g, ''); + const indexNumber = parseInt(index); + return !isNaN(indexNumber) ? indexNumber : convertAlphaToDecimals(index); +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts new file mode 100644 index 00000000000..a28475aca8b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts @@ -0,0 +1,92 @@ +import getNumberingListStyle from './getNumberingListStyle'; +import { getIndex } from './getIndex'; +import { IStandaloneEditor } from 'roosterjs-content-model-types/lib/editor/IStandaloneEditor'; +import { + BulletListType, + isBlockGroupOfType, + updateListMetadata, +} from 'roosterjs-content-model-core'; +import { + ContentModelDocument, + ContentModelListItem, + ContentModelParagraph, +} from 'roosterjs-content-model-types/lib'; +import { + getOperationalBlocks, + getSelectedSegmentsAndParagraphs, +} from 'roosterjs-content-model-core/lib/publicApi/selection/collectSelections'; + +export function getListStyleType( + editor: IStandaloneEditor +): { listType: 'UL' | 'OL'; styleType: number; index?: number } | undefined { + const model = editor.createContentModel(); + const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs(model, true); + const marker = selectedSegmentsAndParagraphs[0][0]; + const paragraph = selectedSegmentsAndParagraphs[0][1]; + const listMarkerSegment = paragraph?.segments[0]; + if ( + marker && + marker.segmentType == 'SelectionMarker' && + listMarkerSegment && + listMarkerSegment.segmentType == 'Text' + ) { + const listMarker = listMarkerSegment.text; + const bulletType = bulletListType[listMarker]; + + if (bulletType) { + return { listType: 'UL', styleType: bulletType }; + } else { + const previousList = getPreviousListLevel(model, paragraph); + const previousListStyle = getPreviousListStyle(previousList); + const numberingType = getNumberingListStyle( + listMarker, + previousList?.format?.listStyleType, + previousListStyle + ); + if (numberingType) { + return { + listType: 'OL', + styleType: numberingType, + index: previousList ? getIndex(listMarker) : undefined, + }; + } + } + } + return undefined; +} + +const getPreviousListLevel = (model: ContentModelDocument, paragraph: ContentModelParagraph) => { + const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); + let listItem: ContentModelListItem | undefined = undefined; + const listBlock = blocks.filter(({ block, parent }) => { + return parent.blocks.indexOf(paragraph) > -1; + })[0]; + if (listBlock) { + const length = listBlock.parent.blocks.length; + for (let i = length - 1; i > -1; i--) { + const item = listBlock.parent.blocks[i]; + if (isBlockGroupOfType(item, 'ListItem')) { + listItem = item; + break; + } + } + } + return listItem; +}; + +const getPreviousListStyle = (list?: ContentModelListItem) => { + if (list?.levels[0].dataset) { + return updateListMetadata(list.levels[0])?.orderedStyleType; + } +}; + +const bulletListType: Record = { + '*': BulletListType.Disc, + '-': BulletListType.Dash, + '--': BulletListType.Square, + '->': BulletListType.LongArrow, + '-->': BulletListType.DoubleLongArrow, + '=>': BulletListType.UnfilledArrow, + '>': BulletListType.ShortArrow, + '—': BulletListType.Hyphen, +}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getNumberingListStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getNumberingListStyle.ts new file mode 100644 index 00000000000..46913a17e9f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getNumberingListStyle.ts @@ -0,0 +1,180 @@ +import { getIndex } from './getIndex'; +import { NumberingListType } from 'roosterjs-content-model-core'; + +const enum NumberingTypes { + Decimal = 1, + LowerAlpha = 2, + UpperAlpha = 3, + LowerRoman = 4, + UpperRoman = 5, +} + +const enum Character { + Dot = 1, + Dash = 2, + Parenthesis = 3, + DoubleParenthesis = 4, +} + +const characters: Record = { + '.': Character.Dot, + '-': Character.Dash, + ')': Character.Parenthesis, +}; + +const lowerRomanTypes = [ + NumberingListType.LowerRoman, + NumberingListType.LowerRomanDash, + NumberingListType.LowerRomanDoubleParenthesis, + NumberingListType.LowerRomanParenthesis, +]; +const upperRomanTypes = [ + NumberingListType.UpperRoman, + NumberingListType.UpperRomanDash, + NumberingListType.UpperRomanDoubleParenthesis, + NumberingListType.UpperRomanParenthesis, +]; +const numberingTriggers = ['1', 'a', 'A', 'I', 'i']; +const lowerRomanNumbers = ['i', 'v', 'x', 'l', 'c', 'd', 'm']; +const upperRomanNumbers = ['I', 'V', 'X', 'L', 'C', 'D', 'M']; + +const identifyNumberingType = (text: string, previousListStyle?: number) => { + if (!isNaN(parseInt(text))) { + return NumberingTypes.Decimal; + } else if (/[a-z]+/g.test(text)) { + if ( + (previousListStyle != undefined && + lowerRomanTypes.indexOf(previousListStyle) > -1 && + lowerRomanNumbers.indexOf(text[0]) > -1) || + (!previousListStyle && text === 'i') + ) { + return NumberingTypes.LowerRoman; + } else if (previousListStyle || (!previousListStyle && text === 'a')) { + return NumberingTypes.LowerAlpha; + } + } else if (/[A-Z]+/g.test(text)) { + if ( + (previousListStyle != undefined && + upperRomanTypes.indexOf(previousListStyle) > -1 && + upperRomanNumbers.indexOf(text[0]) > -1) || + (!previousListStyle && text === 'I') + ) { + return NumberingTypes.UpperRoman; + } else if (previousListStyle || (!previousListStyle && text === 'A')) { + return NumberingTypes.UpperAlpha; + } + } +}; + +const numberingListTypes: Record number | undefined> = { + [NumberingTypes.Decimal]: char => DecimalsTypes[char] || undefined, + [NumberingTypes.LowerAlpha]: char => LowerAlphaTypes[char] || undefined, + [NumberingTypes.UpperAlpha]: char => UpperAlphaTypes[char] || undefined, + [NumberingTypes.LowerRoman]: char => LowerRomanTypes[char] || undefined, + [NumberingTypes.UpperRoman]: char => UpperRomanTypes[char] || undefined, +}; + +const UpperRomanTypes: Record = { + [Character.Dot]: NumberingListType.UpperRoman, + [Character.Dash]: NumberingListType.UpperRomanDash, + [Character.Parenthesis]: NumberingListType.UpperRomanParenthesis, + [Character.DoubleParenthesis]: NumberingListType.UpperRomanDoubleParenthesis, +}; + +const LowerRomanTypes: Record = { + [Character.Dot]: NumberingListType.LowerRoman, + [Character.Dash]: NumberingListType.LowerRomanDash, + [Character.Parenthesis]: NumberingListType.LowerRomanParenthesis, + [Character.DoubleParenthesis]: NumberingListType.LowerRomanDoubleParenthesis, +}; + +const UpperAlphaTypes: Record = { + [Character.Dot]: NumberingListType.UpperAlpha, + [Character.Dash]: NumberingListType.UpperAlphaDash, + [Character.Parenthesis]: NumberingListType.UpperAlphaParenthesis, + [Character.DoubleParenthesis]: NumberingListType.UpperAlphaDoubleParenthesis, +}; + +const LowerAlphaTypes: Record = { + [Character.Dot]: NumberingListType.LowerAlpha, + [Character.Dash]: NumberingListType.LowerAlphaDash, + [Character.Parenthesis]: NumberingListType.LowerAlphaParenthesis, + [Character.DoubleParenthesis]: NumberingListType.LowerAlphaDoubleParenthesis, +}; + +const DecimalsTypes: Record = { + [Character.Dot]: NumberingListType.Decimal, + [Character.Dash]: NumberingListType.DecimalDash, + [Character.Parenthesis]: NumberingListType.DecimalParenthesis, + [Character.DoubleParenthesis]: NumberingListType.DecimalDoubleParenthesis, +}; + +const identifyNumberingListType = ( + numbering: string, + isDoubleParenthesis: boolean, + previousListStyle?: number +): number | undefined => { + const separatorCharacter = isDoubleParenthesis + ? Character.DoubleParenthesis + : characters[numbering[numbering.length - 1]]; + // if separator is not valid, no need to check if the number is valid. + if (separatorCharacter) { + const number = isDoubleParenthesis ? numbering.slice(1, -1) : numbering.slice(0, -1); + const numberingType = identifyNumberingType(number, previousListStyle); + return numberingType ? numberingListTypes[numberingType](separatorCharacter) : undefined; + } + return undefined; +}; + +/** + * @internal + * @param textBeforeCursor The trigger character + * @param previousListChain @optional This parameters is used to keep the list chain, if the is not a new list + * @param previousListStyle @optional The list style of the previous list + * @returns The style of a numbering list triggered by a string + */ +export default function getNumberingListStyle( + textBeforeCursor: string, + previousListStyleType?: string, + previousListStyle?: number +): number | undefined { + const trigger = textBeforeCursor.trim(); + const isDoubleParenthesis = trigger[0] === '(' && trigger[trigger.length - 1] === ')'; + //Only the staring items ['1', 'a', 'A', 'I', 'i'] must trigger a new list. All the other triggers is used to keep the list chain. + //The index is always the characters before the last character + const listIndex = isDoubleParenthesis ? trigger.slice(1, -1) : trigger.slice(0, -1); + const index = getIndex(listIndex); + + if ( + !index || + index < 1 || + (!previousListStyleType && numberingTriggers.indexOf(listIndex) < 0) || + (previousListStyleType && + numberingTriggers.indexOf(listIndex) < 0 && + !canAppendList(index, previousListStyleType)) + ) { + return undefined; + } + + const numberingType = isValidNumbering(listIndex) + ? identifyNumberingListType(trigger, isDoubleParenthesis, previousListStyle) + : undefined; + return numberingType; +} + +/** + * Check if index has only numbers or only letters to avoid sequence of character such 1:1. trigger a list. + * @param index + * @returns + */ +function isValidNumbering(index: string) { + return Number(index) || /^[A-Za-z\s]*$/.test(index); +} + +function canAppendList(index: number | null, listStyleType?: string) { + if (index && listStyleType) { + const previousIndex = getIndex(listStyleType); + return previousIndex && previousIndex + 1 === index; + } + return false; +} From 6639cd8d550ab217ff33274ea9a91e2362f0c458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 10 Jan 2024 14:05:33 -0300 Subject: [PATCH 002/112] wip --- .../lib/edit/ContentModelEditPlugin.ts | 1 - .../lib/edit/listFeaturesUtils/getListType.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts index 0722bf0cbcf..97cfc780778 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts @@ -79,7 +79,6 @@ export class ContentModelEditPlugin implements EditorPlugin { const listStyleType = getListStyleType(editor); if (listStyleType) { keyboardListTrigger(editor, listStyleType); - break; } case 'Enter': default: diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts index a28475aca8b..7b0bac18b4e 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts @@ -1,12 +1,12 @@ import getNumberingListStyle from './getNumberingListStyle'; import { getIndex } from './getIndex'; -import { IStandaloneEditor } from 'roosterjs-content-model-types/lib/editor/IStandaloneEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types/lib/editor/IStandaloneEditor'; import { BulletListType, isBlockGroupOfType, updateListMetadata, } from 'roosterjs-content-model-core'; -import { +import type { ContentModelDocument, ContentModelListItem, ContentModelParagraph, From 9e8f2e35ab7c63915d5b436db8609339d6294c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 10 Jan 2024 14:30:48 -0300 Subject: [PATCH 003/112] refactor --- .../lib/edit/ContentModelEditPlugin.ts | 8 +-- .../lib/edit/keyboardListTrigger.ts | 53 +++++++++++-------- .../lib/edit/listFeaturesUtils/getListType.ts | 6 ++- .../getNumberingListStyle.ts | 16 +++--- 4 files changed, 42 insertions(+), 41 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts index 97cfc780778..4aa69cc3934 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts @@ -1,4 +1,3 @@ -import { getListStyleType } from './listFeaturesUtils/getListType'; import { keyboardDelete } from './keyboardDelete'; import { keyboardInput } from './keyboardInput'; import { keyboardListTrigger } from './keyboardListTrigger'; @@ -75,13 +74,10 @@ export class ContentModelEditPlugin implements EditorPlugin { // 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); break; - case ' ': - const listStyleType = getListStyleType(editor); - if (listStyleType) { - keyboardListTrigger(editor, listStyleType); - } + case ' ': // Space case 'Enter': default: + keyboardListTrigger(editor, rawEvent); keyboardInput(editor, rawEvent); break; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts index 9a437455152..9df75ac8504 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts @@ -1,5 +1,6 @@ +import { getListStyleType } from './listFeaturesUtils/getListType'; import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core/lib/publicApi/selection/collectSelections'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; import { setListStartNumber, setListStyle, @@ -7,27 +8,33 @@ import { toggleNumbering, } from 'roosterjs-content-model-api'; -export const keyboardListTrigger = ( - editor: IStandaloneEditor, - listStyleType: { listType: 'UL' | 'OL'; styleType: number; index?: number } -) => { - editor.formatContentModel((model, context) => { - if (listStyleType.listType === 'UL') { - toggleBullet(editor); - setListStyle(editor, { unorderedStyleType: listStyleType.styleType }); - } else { - toggleNumbering(editor); - setListStyle(editor, { orderedStyleType: listStyleType.styleType }); - if (listStyleType.index) { - setListStartNumber(editor, listStyleType.index); +export const keyboardListTrigger = (editor: IStandaloneEditor, rawEvent: KeyboardEvent) => { + if (rawEvent.key === ' ') { + editor.formatContentModel((model, context) => { + const listStyleType = getListStyleType(editor); + if (listStyleType) { + if (listStyleType.listType === 'UL') { + toggleBullet(editor); + setListStyle(editor, { unorderedStyleType: listStyleType.styleType }); + } else { + toggleNumbering(editor); + setListStyle(editor, { orderedStyleType: listStyleType.styleType }); + if (listStyleType.index) { + setListStartNumber(editor, listStyleType.index); + } + } + const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs( + model, + false + ); + const listMarker = selectedSegmentsAndParagraphs[0][1]?.segments[0]; + if (listMarker && listMarker.segmentType === 'Text') { + listMarker.text = ''; + } + context.skipUndoSnapshot = true; + return true; } - } - const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs(model, false); - const listMarker = selectedSegmentsAndParagraphs[0][1]?.segments[0]; - if (listMarker && listMarker.segmentType === 'Text') { - listMarker.text = ''; - } - context.skipUndoSnapshot = true; - return true; - }); + return false; + }); + } }; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts index 7b0bac18b4e..02dee5d9308 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts @@ -40,14 +40,16 @@ export function getListStyleType( const previousListStyle = getPreviousListStyle(previousList); const numberingType = getNumberingListStyle( listMarker, - previousList?.format?.listStyleType, + previousList?.format?.listStyleType + ? getIndex(previousList.format.listStyleType) + : undefined, previousListStyle ); if (numberingType) { return { listType: 'OL', styleType: numberingType, - index: previousList ? getIndex(listMarker) : undefined, + index: getIndex(listMarker), }; } } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getNumberingListStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getNumberingListStyle.ts index 46913a17e9f..e2a3242cbc4 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getNumberingListStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getNumberingListStyle.ts @@ -135,7 +135,7 @@ const identifyNumberingListType = ( */ export default function getNumberingListStyle( textBeforeCursor: string, - previousListStyleType?: string, + previousListIndex?: number, previousListStyle?: number ): number | undefined { const trigger = textBeforeCursor.trim(); @@ -148,10 +148,10 @@ export default function getNumberingListStyle( if ( !index || index < 1 || - (!previousListStyleType && numberingTriggers.indexOf(listIndex) < 0) || - (previousListStyleType && + (!previousListIndex && numberingTriggers.indexOf(listIndex) < 0) || + (previousListIndex && numberingTriggers.indexOf(listIndex) < 0 && - !canAppendList(index, previousListStyleType)) + !canAppendList(index, previousListIndex)) ) { return undefined; } @@ -171,10 +171,6 @@ function isValidNumbering(index: string) { return Number(index) || /^[A-Za-z\s]*$/.test(index); } -function canAppendList(index: number | null, listStyleType?: string) { - if (index && listStyleType) { - const previousIndex = getIndex(listStyleType); - return previousIndex && previousIndex + 1 === index; - } - return false; +function canAppendList(index?: number, previousListIndex?: number) { + return previousListIndex && index && previousListIndex + 1 === index; } From 7c6288ef5ba6184641fd74dc17dc99ebca2ccc14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 10 Jan 2024 14:42:09 -0300 Subject: [PATCH 004/112] fix dependecies --- .../lib/edit/keyboardListTrigger.ts | 6 +++--- .../lib/edit/listFeaturesUtils/getListType.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts index 9df75ac8504..764d523e9e4 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts @@ -1,5 +1,5 @@ -import { getListStyleType } from './listFeaturesUtils/getListType'; -import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core/lib/publicApi/selection/collectSelections'; +import { getListType } from './listFeaturesUtils/getListType'; +import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core'; import type { IStandaloneEditor } from 'roosterjs-content-model-types'; import { setListStartNumber, @@ -11,7 +11,7 @@ import { export const keyboardListTrigger = (editor: IStandaloneEditor, rawEvent: KeyboardEvent) => { if (rawEvent.key === ' ') { editor.formatContentModel((model, context) => { - const listStyleType = getListStyleType(editor); + const listStyleType = getListType(editor); if (listStyleType) { if (listStyleType.listType === 'UL') { toggleBullet(editor); diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts index 02dee5d9308..c79212dbdd3 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts @@ -1,6 +1,6 @@ import getNumberingListStyle from './getNumberingListStyle'; import { getIndex } from './getIndex'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types/lib/editor/IStandaloneEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; import { BulletListType, isBlockGroupOfType, @@ -10,13 +10,13 @@ import type { ContentModelDocument, ContentModelListItem, ContentModelParagraph, -} from 'roosterjs-content-model-types/lib'; +} from 'roosterjs-content-model-types'; import { getOperationalBlocks, getSelectedSegmentsAndParagraphs, -} from 'roosterjs-content-model-core/lib/publicApi/selection/collectSelections'; +} from 'roosterjs-content-model-core'; -export function getListStyleType( +export function getListType( editor: IStandaloneEditor ): { listType: 'UL' | 'OL'; styleType: number; index?: number } | undefined { const model = editor.createContentModel(); From ce87bffb66153066d5dba85de1c9c4c5d2ccfc69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 10 Jan 2024 15:09:02 -0300 Subject: [PATCH 005/112] fix dependecies --- .../lib/edit/listFeaturesUtils/getListType.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts index c79212dbdd3..2bf4f538744 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts @@ -1,12 +1,12 @@ import getNumberingListStyle from './getNumberingListStyle'; import { getIndex } from './getIndex'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; import { BulletListType, isBlockGroupOfType, updateListMetadata, } from 'roosterjs-content-model-core'; import type { + IStandaloneEditor, ContentModelDocument, ContentModelListItem, ContentModelParagraph, From e0982f2b67483b081486144874c22cc248101427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 10 Jan 2024 15:34:11 -0300 Subject: [PATCH 006/112] fix dependecies --- .../lib/edit/listFeaturesUtils/getListType.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts index 2bf4f538744..49a9b7d5708 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts @@ -1,10 +1,5 @@ import getNumberingListStyle from './getNumberingListStyle'; import { getIndex } from './getIndex'; -import { - BulletListType, - isBlockGroupOfType, - updateListMetadata, -} from 'roosterjs-content-model-core'; import type { IStandaloneEditor, ContentModelDocument, @@ -12,6 +7,9 @@ import type { ContentModelParagraph, } from 'roosterjs-content-model-types'; import { + BulletListType, + isBlockGroupOfType, + updateListMetadata, getOperationalBlocks, getSelectedSegmentsAndParagraphs, } from 'roosterjs-content-model-core'; From bdee8ed6b5fe8a43897ec0f300dab644dd1a557a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 11 Jan 2024 13:14:46 -0300 Subject: [PATCH 007/112] WIP --- .../controls/ContentModelEditorMainPane.tsx | 9 +- .../ContentModelAutoFormatPlugin.ts | 85 +++++++++++++++++++ .../lib/autoFormat/keyboardListTrigger.ts | 54 ++++++++++++ .../convertAlphaToDecimals.ts | 0 .../listFeaturesUtils/getIndex.ts | 0 .../listFeaturesUtils/getListType.ts | 8 +- .../getNumberingListStyle.ts | 0 .../lib/edit/ContentModelEditPlugin.ts | 4 +- .../lib/edit/keyboardListTrigger.ts | 40 --------- .../lib/index.ts | 1 + .../lib/createContentModelEditor.ts | 4 +- 11 files changed, 157 insertions(+), 48 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts rename packages-content-model/roosterjs-content-model-plugins/lib/{edit => autoFormat}/listFeaturesUtils/convertAlphaToDecimals.ts (100%) rename packages-content-model/roosterjs-content-model-plugins/lib/{edit => autoFormat}/listFeaturesUtils/getIndex.ts (100%) rename packages-content-model/roosterjs-content-model-plugins/lib/{edit => autoFormat}/listFeaturesUtils/getListType.ts (93%) rename packages-content-model/roosterjs-content-model-plugins/lib/{edit => autoFormat}/listFeaturesUtils/getNumberingListStyle.ts (100%) delete mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 73561fd54c5..70c4243a4ff 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -16,7 +16,6 @@ import SampleEntityPlugin from './sampleEntity/SampleEntityPlugin'; import SidePane from './sidePane/SidePane'; import TitleBar from './titleBar/TitleBar'; import { arrayPush } from 'roosterjs-editor-dom'; -import { ContentModelEditPlugin, EntityDelimiterPlugin } from 'roosterjs-content-model-plugins'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; import { ContentModelSegmentFormat, Snapshot } from 'roosterjs-content-model-types'; import { createEmojiPlugin, createPasteOptionPlugin } from 'roosterjs-react'; @@ -24,6 +23,11 @@ import { EditorPlugin, Snapshots } from 'roosterjs-editor-types'; import { getDarkColor } from 'roosterjs-color-utils'; import { PartialTheme } from '@fluentui/react/lib/Theme'; import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; +import { + ContentModelAutoFormatPlugin, + ContentModelEditPlugin, + EntityDelimiterPlugin, +} from 'roosterjs-content-model-plugins'; import { ContentModelEditor, ContentModelEditorOptions, @@ -97,6 +101,7 @@ class ContentModelEditorMainPane extends MainPaneBase private apiPlaygroundPlugin: ApiPlaygroundPlugin; private contentModelPanePlugin: ContentModelPanePlugin; private contentModelEditPlugin: ContentModelEditPlugin; + private contentModelAutoFormatPlugin: ContentModelAutoFormatPlugin; private contentModelRibbonPlugin: RibbonPlugin; private pasteOptionPlugin: EditorPlugin; private emojiPlugin: EditorPlugin; @@ -125,6 +130,7 @@ class ContentModelEditorMainPane extends MainPaneBase this.snapshotPlugin = new ContentModelSnapshotPlugin(this.snapshots); this.contentModelPanePlugin = new ContentModelPanePlugin(); this.contentModelEditPlugin = new ContentModelEditPlugin(); + this.contentModelAutoFormatPlugin = new ContentModelAutoFormatPlugin(); this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.pasteOptionPlugin = createPasteOptionPlugin(); this.emojiPlugin = createEmojiPlugin(); @@ -188,6 +194,7 @@ class ContentModelEditorMainPane extends MainPaneBase ...this.toggleablePlugins, this.contentModelPanePlugin.getInnerRibbonPlugin(), this.contentModelEditPlugin, + this.contentModelAutoFormatPlugin, this.pasteOptionPlugin, this.emojiPlugin, this.entityDelimiterPlugin, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts new file mode 100644 index 00000000000..2fdd5d1a3fd --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -0,0 +1,85 @@ +import { keyboardListTrigger } from './keyboardListTrigger'; +import { PluginEventType } from 'roosterjs-editor-types'; +import type { IContentModelEditor } from 'roosterjs-content-model-editor'; +import type { + EditorPlugin, + IEditor, + PluginEvent, + PluginKeyDownEvent, +} from 'roosterjs-editor-types'; + +interface AutoFormatOptions { + autoBullet: boolean; + autoNumbering: boolean; +} + +export class ContentModelAutoFormatPlugin implements EditorPlugin { + private editor: IContentModelEditor | null = null; + private options: AutoFormatOptions = { + autoBullet: true, + autoNumbering: true, + }; + + constructor(options?: AutoFormatOptions) { + if (options) { + this.options = options; + } + } + + /** + * Get name of this plugin + */ + getName() { + return 'ContentModelAutoFormat'; + } + + /** + * The first method that editor will call to a plugin when editor is initializing. + * It will pass in the editor instance, plugin should take this chance to save the + * editor reference so that it can call to any editor method or format API later. + * @param editor The editor object + */ + initialize(editor: IEditor) { + // TODO: Later we may need a different interface for Content Model editor plugin + this.editor = editor as IContentModelEditor; + } + + /** + * The last method that editor will call to a plugin before it is disposed. + * Plugin can take this chance to clear the reference to editor. After this method is + * called, plugin should not call to any editor method since it will result in error. + */ + dispose() { + this.editor = null; + } + + /** + * Core method for a plugin. Once an event happens in editor, editor will call this + * method of each plugin to handle the event as long as the event is not handled + * exclusively by another plugin. + * @param event The event to handle: + */ + onPluginEvent(event: PluginEvent) { + if (this.editor) { + switch (event.eventType) { + case PluginEventType.KeyDown: + this.handleKeyDownEvent(this.editor, event); + break; + } + } + } + + private handleKeyDownEvent(editor: IContentModelEditor, event: PluginKeyDownEvent) { + const rawEvent = event.rawEvent; + if (!rawEvent.defaultPrevented && !event.handledByEditFeature) { + switch (rawEvent.key) { + case ' ': + if (this.options.autoBullet || this.options.autoNumbering) { + keyboardListTrigger(editor, rawEvent); + } + + break; + } + } + } +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts new file mode 100644 index 00000000000..ebb87ff7354 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -0,0 +1,54 @@ +import { createListItem, createListLevel } from 'roosterjs-content-model-dom'; +import { getListType } from './listFeaturesUtils/getListType'; +import { + getOperationalBlocks, + isBlockGroupOfType, + updateListMetadata, +} from 'roosterjs-content-model-core'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; + +export const keyboardListTrigger = ( + editor: IStandaloneEditor, + rawEvent: KeyboardEvent, + shouldSearchForBullet: boolean = true, + shouldSearchForNumbering: boolean = true +) => { + if (rawEvent.key === ' ') { + editor.formatContentModel((model, context) => { + const listStyleType = getListType( + editor, + shouldSearchForBullet, + shouldSearchForNumbering + ); + if (listStyleType) { + const { listType, styleType, index } = listStyleType; + + const paragraphOrListItems = getOperationalBlocks(model, ['ListItem'], []); + paragraphOrListItems.forEach(({ block, parent }, itemIndex) => { + if (!isBlockGroupOfType(block, 'General')) { + const blockIndex = parent.blocks.indexOf(block); + const listLevel = createListLevel(listType, { + startNumberOverride: index, + }); + updateListMetadata( + listLevel, + metadata => + (metadata = + listType == 'UL' + ? { + unorderedStyleType: styleType, + } + : { + orderedStyleType: styleType, + }) + ); + const listItem = createListItem([listLevel]); + parent.blocks.splice(blockIndex, 1, listItem); + } + }); + return true; + } + return false; + }); + } +}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/convertAlphaToDecimals.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/convertAlphaToDecimals.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/convertAlphaToDecimals.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/convertAlphaToDecimals.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getIndex.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/getIndex.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getIndex.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/getIndex.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/getListType.ts similarity index 93% rename from packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/getListType.ts index 49a9b7d5708..c7e3a9317c0 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getListType.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/getListType.ts @@ -15,7 +15,9 @@ import { } from 'roosterjs-content-model-core'; export function getListType( - editor: IStandaloneEditor + editor: IStandaloneEditor, + shouldSearchForBullet: boolean = true, + shouldSearchForNumbering: boolean = true ): { listType: 'UL' | 'OL'; styleType: number; index?: number } | undefined { const model = editor.createContentModel(); const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs(model, true); @@ -31,9 +33,9 @@ export function getListType( const listMarker = listMarkerSegment.text; const bulletType = bulletListType[listMarker]; - if (bulletType) { + if (bulletType && shouldSearchForBullet) { return { listType: 'UL', styleType: bulletType }; - } else { + } else if (shouldSearchForNumbering) { const previousList = getPreviousListLevel(model, paragraph); const previousListStyle = getPreviousListStyle(previousList); const numberingType = getNumberingListStyle( diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getNumberingListStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/getNumberingListStyle.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-plugins/lib/edit/listFeaturesUtils/getNumberingListStyle.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/getNumberingListStyle.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts index 4aa69cc3934..0581a9d4b39 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts @@ -1,6 +1,5 @@ import { keyboardDelete } from './keyboardDelete'; import { keyboardInput } from './keyboardInput'; -import { keyboardListTrigger } from './keyboardListTrigger'; import { PluginEventType } from 'roosterjs-editor-types'; import type { IContentModelEditor } from 'roosterjs-content-model-editor'; import type { @@ -74,10 +73,9 @@ export class ContentModelEditPlugin implements EditorPlugin { // 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); break; - case ' ': // Space + case 'Enter': default: - keyboardListTrigger(editor, rawEvent); keyboardInput(editor, rawEvent); break; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts deleted file mode 100644 index 764d523e9e4..00000000000 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardListTrigger.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { getListType } from './listFeaturesUtils/getListType'; -import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; -import { - setListStartNumber, - setListStyle, - toggleBullet, - toggleNumbering, -} from 'roosterjs-content-model-api'; - -export const keyboardListTrigger = (editor: IStandaloneEditor, rawEvent: KeyboardEvent) => { - if (rawEvent.key === ' ') { - editor.formatContentModel((model, context) => { - const listStyleType = getListType(editor); - if (listStyleType) { - if (listStyleType.listType === 'UL') { - toggleBullet(editor); - setListStyle(editor, { unorderedStyleType: listStyleType.styleType }); - } else { - toggleNumbering(editor); - setListStyle(editor, { orderedStyleType: listStyleType.styleType }); - if (listStyleType.index) { - setListStartNumber(editor, listStyleType.index); - } - } - const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs( - model, - false - ); - const listMarker = selectedSegmentsAndParagraphs[0][1]?.segments[0]; - if (listMarker && listMarker.segmentType === 'Text') { - listMarker.text = ''; - } - context.skipUndoSnapshot = true; - return true; - } - return false; - }); - } -}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts index d479dec6ddf..59bf98f4478 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts @@ -1,3 +1,4 @@ export { ContentModelPastePlugin } from './paste/ContentModelPastePlugin'; export { ContentModelEditPlugin } from './edit/ContentModelEditPlugin'; export { EntityDelimiterPlugin } from './entityDelimiter/EntityDelimiterPlugin'; +export { ContentModelAutoFormatPlugin } from './autoFormat/ContentModelAutoFormatPlugin'; diff --git a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts index 4c8242a6639..71410bad6f1 100644 --- a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts @@ -1,5 +1,6 @@ import { ContentModelEditor } from 'roosterjs-content-model-editor'; import { + ContentModelAutoFormatPlugin, ContentModelEditPlugin, ContentModelPastePlugin, EntityDelimiterPlugin, @@ -27,7 +28,8 @@ export function createContentModelEditor( plugins.push( new ContentModelPastePlugin(), new ContentModelEditPlugin(), - new EntityDelimiterPlugin() + new EntityDelimiterPlugin(), + new ContentModelAutoFormatPlugin() ); const options: ContentModelEditorOptions = { From dc2d7843c36d288cdabff8aa58e5ff8bedb9733e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 11 Jan 2024 19:43:04 -0300 Subject: [PATCH 008/112] add test --- .../lib/autoFormat/keyboardListTrigger.ts | 9 +- .../convertAlphaToDecimals.ts | 12 +- .../{listFeaturesUtils => utils}/getIndex.ts | 0 .../getListTypeStyle.ts} | 14 +- .../getNumberingListStyle.ts | 0 .../ContentModelAutoFormatPluginTest.ts | 0 .../autoFormat/keyboardListTriggerTest.ts | 0 .../utils/convertAlphaToDecimalsTest.ts | 24 + .../test/autoFormat/utils/getIndexTest.ts | 32 + .../autoFormat/utils/getListTypeStyleTest.ts | 995 ++++++++++++++++++ .../utils/getNumberingListStyleTest.ts | 114 ++ 11 files changed, 1191 insertions(+), 9 deletions(-) rename packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/{listFeaturesUtils => utils}/convertAlphaToDecimals.ts (52%) rename packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/{listFeaturesUtils => utils}/getIndex.ts (100%) rename packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/{listFeaturesUtils/getListType.ts => utils/getListTypeStyle.ts} (92%) rename packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/{listFeaturesUtils => utils}/getNumberingListStyle.ts (100%) create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/convertAlphaToDecimalsTest.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getListTypeStyleTest.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getNumberingListStyleTest.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts index ebb87ff7354..ed36b68e442 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -1,5 +1,5 @@ import { createListItem, createListLevel } from 'roosterjs-content-model-dom'; -import { getListType } from './listFeaturesUtils/getListType'; +import { getListTypeStyle } from './utils/getListTypeStyle'; import { getOperationalBlocks, isBlockGroupOfType, @@ -15,7 +15,7 @@ export const keyboardListTrigger = ( ) => { if (rawEvent.key === ' ') { editor.formatContentModel((model, context) => { - const listStyleType = getListType( + const listStyleType = getListTypeStyle( editor, shouldSearchForBullet, shouldSearchForNumbering @@ -29,6 +29,11 @@ export const keyboardListTrigger = ( const blockIndex = parent.blocks.indexOf(block); const listLevel = createListLevel(listType, { startNumberOverride: index, + direction: block.format.direction, + textAlign: block.format.textAlign, + marginTop: '0', + marginBlockEnd: '0px', + marginBlockStart: '0px', }); updateListMetadata( listLevel, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/convertAlphaToDecimals.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/convertAlphaToDecimals.ts similarity index 52% rename from packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/convertAlphaToDecimals.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/convertAlphaToDecimals.ts index 08acacc971b..c29583b92c9 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/convertAlphaToDecimals.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/convertAlphaToDecimals.ts @@ -5,11 +5,15 @@ * @returns */ export default function convertAlphaToDecimals(letter: string): number | undefined { - const alpha = letter.toLocaleLowerCase(); + const alpha = letter.toUpperCase(); if (alpha) { - const size = alpha.length - 1; - const number = 26 * size + alpha.charCodeAt(size) - 96; - return number; + let result = 0; + for (let i = 0; i < alpha.length; i++) { + const charCode = alpha.charCodeAt(i) - 65 + 1; + result = result * 26 + charCode; + } + + return result; } return undefined; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/getIndex.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/getIndex.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/getListType.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts similarity index 92% rename from packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/getListType.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts index c7e3a9317c0..b4ba0f680b0 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/getListType.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts @@ -1,5 +1,6 @@ import getNumberingListStyle from './getNumberingListStyle'; import { getIndex } from './getIndex'; + import type { IStandaloneEditor, ContentModelDocument, @@ -14,16 +15,23 @@ import { getSelectedSegmentsAndParagraphs, } from 'roosterjs-content-model-core'; -export function getListType( +interface ListTypeStyle { + listType: 'UL' | 'OL'; + styleType: number; + index?: number; +} + +export function getListTypeStyle( editor: IStandaloneEditor, shouldSearchForBullet: boolean = true, shouldSearchForNumbering: boolean = true -): { listType: 'UL' | 'OL'; styleType: number; index?: number } | undefined { +): ListTypeStyle | undefined { const model = editor.createContentModel(); const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs(model, true); const marker = selectedSegmentsAndParagraphs[0][0]; const paragraph = selectedSegmentsAndParagraphs[0][1]; const listMarkerSegment = paragraph?.segments[0]; + if ( marker && marker.segmentType == 'SelectionMarker' && @@ -49,7 +57,7 @@ export function getListType( return { listType: 'OL', styleType: numberingType, - index: getIndex(listMarker), + index: previousList?.format?.listStyleType ? getIndex(listMarker) : undefined, }; } } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/getNumberingListStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/listFeaturesUtils/getNumberingListStyle.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/convertAlphaToDecimalsTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/convertAlphaToDecimalsTest.ts new file mode 100644 index 00000000000..92fda526128 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/convertAlphaToDecimalsTest.ts @@ -0,0 +1,24 @@ +import convertAlphaToDecimals from '../../../lib/autoFormat/utils/convertAlphaToDecimals'; + +describe('convertAlphaToDecimals', () => { + function runTest(alpha: string, expectedResult: number) { + const decimal = convertAlphaToDecimals(alpha); + expect(decimal).toBe(expectedResult); + } + + it('should convert a to 1', () => { + runTest('a', 1); + }); + + it('should convert G to 6', () => { + runTest('G', 7); + }); + + it('should convert AA to 27', () => { + runTest('AA', 27); + }); + + it('should convert ba to 52', () => { + runTest('ba', 53); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts new file mode 100644 index 00000000000..9a5e006100d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts @@ -0,0 +1,32 @@ +import { getIndex } from '../../../lib/autoFormat/utils/getIndex'; + +describe('getIndex', () => { + function runTest(listMarker: string, expectedResult: number) { + const index = getIndex(listMarker); + expect(index).toBe(expectedResult); + } + + it('should convert a. to 1', () => { + runTest('a.', 1); + }); + + it('should convert 4. to 4', () => { + runTest('4.', 4); + }); + + it('should convert (5) to 5', () => { + runTest('(5)', 5); + }); + + it('should convert (B) to 2', () => { + runTest('(B)', 2); + }); + + it('should convert g) to 7', () => { + runTest('g)', 7); + }); + + it('should convert C) to 3', () => { + runTest('C)', 3); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getListTypeStyleTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getListTypeStyleTest.ts new file mode 100644 index 00000000000..1d1d84d5149 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getListTypeStyleTest.ts @@ -0,0 +1,995 @@ +import { BulletListType, NumberingListType } from 'roosterjs-content-model-core'; +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { getListTypeStyle } from '../../../lib/autoFormat/utils/getListTypeStyle'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; + +describe('getListTypeStyle', () => { + function runTest( + model: ContentModelDocument, + expectedResult: + | { + listType: 'UL' | 'OL'; + styleType: number; + index?: number; + } + | undefined, + shouldSearchForBullet?: boolean, + shouldSearchForNumbering?: boolean + ) { + const createContentModel = jasmine.createSpy('createContentModel').and.returnValue(model); + + const editor = ({ + createContentModel, + getEnvironment: () => ({}), + } as any) as IContentModelEditor; + + const listTypeStyle = getListTypeStyle( + editor, + shouldSearchForBullet, + shouldSearchForNumbering + ); + expect(listTypeStyle).toEqual(expectedResult); + } + + it('should identify Decimal Parenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.DecimalParenthesis, + index: undefined, + }); + }); + + it('should not identify Decimal Parenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined, true, false); + }); + + it('should identify Disc', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.Disc, + }); + }); + + it('should not identify Disc', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined, false); + }); + + it('should identify Decimal', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1.', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.Decimal, + index: undefined, + }); + }); + + it('should identify DecimalDash', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1-', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.DecimalDash, + index: undefined, + }); + }); + + it('should identify DecimalDoubleParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '(1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.DecimalDoubleParenthesis, + index: undefined, + }); + }); + + it('should identify LowerAlpha', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a.', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerAlpha, + index: undefined, + }); + }); + + it('should identify LowerAlphaParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerAlphaParenthesis, + index: undefined, + }); + }); + + it('should identify LowerAlphaDoubleParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '(a)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerAlphaDoubleParenthesis, + index: undefined, + }); + }); + + it('should identify LowerAlphaDash', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a-', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerAlphaDash, + index: undefined, + }); + }); + + it('should identify UpperAlpha', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'A.', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperAlpha, + index: undefined, + }); + }); + + it('should identify UpperAlphaParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'A)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperAlphaParenthesis, + index: undefined, + }); + }); + + it('should identify UpperAlphaDoubleParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '(A)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperAlphaDoubleParenthesis, + index: undefined, + }); + }); + + it('should identify UpperAlphaDash', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'A-', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperAlphaDash, + index: undefined, + }); + }); + + it('should identify LowerRoman', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'i.', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerRoman, + index: undefined, + }); + }); + + it('should identify LowerRomanParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'i)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerRomanParenthesis, + index: undefined, + }); + }); + + it('should identify LowerRomanDoubleParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '(i)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerRomanDoubleParenthesis, + index: undefined, + }); + }); + + it('should identify LowerRomanDash', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'i-', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.LowerRomanDash, + index: undefined, + }); + }); + + it('should identify UpperRoman', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'I.', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperRoman, + index: undefined, + }); + }); + + it('should identify UpperRomanParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'I)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperRomanParenthesis, + index: undefined, + }); + }); + + it('should identify UpperRomanDoubleParenthesis', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '(I)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperRomanDoubleParenthesis, + index: undefined, + }); + }); + + it('should identify UpperRomanDash', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'I-', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'OL', + styleType: NumberingListType.UpperRomanDash, + index: undefined, + }); + }); + + it('should identify Dash', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '-', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.Dash, + }); + }); + + it('should identify Square', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '--', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.Square, + }); + }); + + it('should identify ShortArrow', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '>', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.ShortArrow, + }); + }); + + it('should identify LongArrow', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '->', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.LongArrow, + }); + }); + + it('should identify UnfilledArrow', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '=>', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.UnfilledArrow, + }); + }); + + it('should identify Hyphen', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '—', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.Hyphen, + }); + }); + + it('should identify DoubleLongArrow', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '-->', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, { + listType: 'UL', + styleType: BulletListType.DoubleLongArrow, + }); + }); + + it('should not identify invalid character', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1:', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined); + }); + + it('should not identify invalid character - 2', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'a', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined); + }); + + it('should not identify invalid character - 3', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '>)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getNumberingListStyleTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getNumberingListStyleTest.ts new file mode 100644 index 00000000000..2675a2f1e50 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getNumberingListStyleTest.ts @@ -0,0 +1,114 @@ +import getNumberingListStyle from '../../../lib/autoFormat/utils/getNumberingListStyle'; +import { NumberingListType } from 'roosterjs-content-model-core'; + +describe('getNumberingListStyle', () => { + function runTest( + listMarker: string, + expectedResult?: number, + previousListIndex?: number | undefined, + previousListStyle?: number | undefined + ) { + const index = getNumberingListStyle(listMarker, previousListIndex, previousListStyle); + expect(index).toBe(expectedResult); + } + + it('1. ', () => { + runTest('1.', NumberingListType.Decimal); + }); + + it('1- ', () => { + runTest('1- ', NumberingListType.DecimalDash); + }); + + it('1) ', () => { + runTest('1) ', NumberingListType.DecimalParenthesis); + }); + + it('(1) ', () => { + runTest('(1) ', NumberingListType.DecimalDoubleParenthesis); + }); + + it('A.', () => { + runTest('A. ', NumberingListType.UpperAlpha); + }); + + it('A- ', () => { + runTest('A- ', NumberingListType.UpperAlphaDash); + }); + + it('A) ', () => { + runTest('A) ', NumberingListType.UpperAlphaParenthesis); + }); + + it('(A) ', () => { + runTest('(A) ', NumberingListType.UpperAlphaDoubleParenthesis); + }); + + it('a. ', () => { + runTest('a. ', NumberingListType.LowerAlpha); + }); + + it('a- ', () => { + runTest('a- ', NumberingListType.LowerAlphaDash); + }); + + it('a) ', () => { + runTest('a) ', NumberingListType.LowerAlphaParenthesis); + }); + + it('(a) ', () => { + runTest('(a) ', NumberingListType.LowerAlphaDoubleParenthesis); + }); + + it('i. ', () => { + runTest('i. ', NumberingListType.LowerRoman); + }); + + it('i- ', () => { + runTest('i- ', NumberingListType.LowerRomanDash); + }); + + it('i) ', () => { + runTest('i) ', NumberingListType.LowerRomanParenthesis); + }); + + it('(i) ', () => { + runTest('(i) ', NumberingListType.LowerRomanDoubleParenthesis); + }); + + it('I. ', () => { + runTest('I. ', NumberingListType.UpperRoman); + }); + + it('I- ', () => { + runTest('I- ', NumberingListType.UpperRomanDash); + }); + + it('I) ', () => { + runTest('I) ', NumberingListType.UpperRomanParenthesis); + }); + + it('(I) ', () => { + runTest('(I) ', NumberingListType.UpperRomanDoubleParenthesis); + }); + + it('4) ', () => { + runTest('4) ', 3, NumberingListType.DecimalParenthesis); + }); + + it('1:1. ', () => { + runTest('1:1. ', undefined); + }); + + it('30%). ', () => { + runTest('30%). ', undefined); + }); + + it('4th. ', () => { + runTest('4th. ', undefined); + }); + + it('30%) ', () => { + runTest('30%) ', undefined); + }); +}); From f9a490a3928883975ce04ccaf08301da859465a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 15 Jan 2024 17:36:49 -0300 Subject: [PATCH 009/112] add tests --- .../controls/ContentModelEditorMainPane.tsx | 2 +- .../ContentModelAutoFormatPlugin.ts | 24 +- .../lib/autoFormat/keyboardListTrigger.ts | 89 ++-- .../lib/autoFormat/utils/getIndex.ts | 2 +- .../lib/autoFormat/utils/getListTypeStyle.ts | 6 +- .../autoFormat/utils/getNumberingListStyle.ts | 2 +- .../ContentModelAutoFormatPluginTest.ts | 113 +++++ .../autoFormat/keyboardListTriggerTest.ts | 396 ++++++++++++++++++ .../test/autoFormat/utils/getIndexTest.ts | 2 +- .../autoFormat/utils/getListTypeStyleTest.ts | 10 +- .../lib/createContentModelEditor.ts | 7 +- 11 files changed, 572 insertions(+), 81 deletions(-) diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 1389949a94a..44d6d7318e8 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -197,7 +197,6 @@ class ContentModelEditorMainPane extends MainPaneBase ...this.toggleablePlugins, this.contentModelPanePlugin.getInnerRibbonPlugin(), this.contentModelEditPlugin, - this.contentModelAutoFormatPlugin, this.pasteOptionPlugin, this.emojiPlugin, this.entityDelimiterPlugin, @@ -259,6 +258,7 @@ class ContentModelEditorMainPane extends MainPaneBase this.contentModelRibbonPlugin, this.formatPainterPlugin, this.pastePlugin, + this.contentModelAutoFormatPlugin, ]} defaultSegmentFormat={defaultFormat} inDarkMode={this.state.isDarkMode} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts index 2fdd5d1a3fd..8a94512167d 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -1,12 +1,11 @@ -import { keyboardListTrigger } from './keyboardListTrigger'; -import { PluginEventType } from 'roosterjs-editor-types'; -import type { IContentModelEditor } from 'roosterjs-content-model-editor'; -import type { +import keyboardListTrigger from './keyboardListTrigger'; +import { EditorPlugin, - IEditor, + IStandaloneEditor, + KeyDownEvent, PluginEvent, - PluginKeyDownEvent, -} from 'roosterjs-editor-types'; +} from 'roosterjs-content-model-types'; +import type { IContentModelEditor } from 'roosterjs-content-model-editor'; interface AutoFormatOptions { autoBullet: boolean; @@ -39,7 +38,7 @@ export class ContentModelAutoFormatPlugin implements EditorPlugin { * editor reference so that it can call to any editor method or format API later. * @param editor The editor object */ - initialize(editor: IEditor) { + initialize(editor: IStandaloneEditor) { // TODO: Later we may need a different interface for Content Model editor plugin this.editor = editor as IContentModelEditor; } @@ -62,20 +61,21 @@ export class ContentModelAutoFormatPlugin implements EditorPlugin { onPluginEvent(event: PluginEvent) { if (this.editor) { switch (event.eventType) { - case PluginEventType.KeyDown: + case 'keyDown': this.handleKeyDownEvent(this.editor, event); break; } } } - private handleKeyDownEvent(editor: IContentModelEditor, event: PluginKeyDownEvent) { + private handleKeyDownEvent(editor: IContentModelEditor, event: KeyDownEvent) { const rawEvent = event.rawEvent; if (!rawEvent.defaultPrevented && !event.handledByEditFeature) { switch (rawEvent.key) { case ' ': - if (this.options.autoBullet || this.options.autoNumbering) { - keyboardListTrigger(editor, rawEvent); + const { autoBullet, autoNumbering } = this.options; + if (autoBullet || autoNumbering) { + keyboardListTrigger(editor, autoBullet, autoNumbering); } break; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts index ed36b68e442..6a6af219d43 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -7,53 +7,50 @@ import { } from 'roosterjs-content-model-core'; import type { IStandaloneEditor } from 'roosterjs-content-model-types'; -export const keyboardListTrigger = ( +export default function keyboardListTrigger( editor: IStandaloneEditor, - rawEvent: KeyboardEvent, shouldSearchForBullet: boolean = true, shouldSearchForNumbering: boolean = true -) => { - if (rawEvent.key === ' ') { - editor.formatContentModel((model, context) => { - const listStyleType = getListTypeStyle( - editor, - shouldSearchForBullet, - shouldSearchForNumbering - ); - if (listStyleType) { - const { listType, styleType, index } = listStyleType; +) { + editor.formatContentModel((model, _) => { + const listStyleType = getListTypeStyle( + model, + shouldSearchForBullet, + shouldSearchForNumbering + ); + if (listStyleType) { + const { listType, styleType, index } = listStyleType; - const paragraphOrListItems = getOperationalBlocks(model, ['ListItem'], []); - paragraphOrListItems.forEach(({ block, parent }, itemIndex) => { - if (!isBlockGroupOfType(block, 'General')) { - const blockIndex = parent.blocks.indexOf(block); - const listLevel = createListLevel(listType, { - startNumberOverride: index, - direction: block.format.direction, - textAlign: block.format.textAlign, - marginTop: '0', - marginBlockEnd: '0px', - marginBlockStart: '0px', - }); - updateListMetadata( - listLevel, - metadata => - (metadata = - listType == 'UL' - ? { - unorderedStyleType: styleType, - } - : { - orderedStyleType: styleType, - }) - ); - const listItem = createListItem([listLevel]); - parent.blocks.splice(blockIndex, 1, listItem); - } - }); - return true; - } - return false; - }); - } -}; + const paragraphOrListItems = getOperationalBlocks(model, ['ListItem'], []); + paragraphOrListItems.forEach(({ block, parent }, itemIndex) => { + if (!isBlockGroupOfType(block, 'General')) { + const blockIndex = parent.blocks.indexOf(block); + const listLevel = createListLevel(listType, { + startNumberOverride: index, + direction: block.format.direction, + textAlign: block.format.textAlign, + marginTop: '0', + marginBlockEnd: '0px', + marginBlockStart: '0px', + }); + updateListMetadata(listLevel, metadata => { + return (metadata = + listType == 'UL' + ? { + unorderedStyleType: styleType, + } + : { + orderedStyleType: styleType, + }); + }); + + const listItem = createListItem([listLevel]); + parent.blocks.splice(blockIndex, 1, listItem); + } + }); + + return true; + } + return false; + }); +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts index de0c467acb9..b218f041330 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts @@ -1,6 +1,6 @@ import convertAlphaToDecimals from './convertAlphaToDecimals'; -export function getIndex(listIndex: string) { +export default function getIndex(listIndex: string) { const index = listIndex.replace(/[^a-zA-Z0-9 ]/g, ''); const indexNumber = parseInt(index); return !isNaN(indexNumber) ? indexNumber : convertAlphaToDecimals(index); diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts index b4ba0f680b0..6877c0ed2ea 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts @@ -1,8 +1,7 @@ +import getIndex from './getIndex'; import getNumberingListStyle from './getNumberingListStyle'; -import { getIndex } from './getIndex'; import type { - IStandaloneEditor, ContentModelDocument, ContentModelListItem, ContentModelParagraph, @@ -22,11 +21,10 @@ interface ListTypeStyle { } export function getListTypeStyle( - editor: IStandaloneEditor, + model: ContentModelDocument, shouldSearchForBullet: boolean = true, shouldSearchForNumbering: boolean = true ): ListTypeStyle | undefined { - const model = editor.createContentModel(); const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs(model, true); const marker = selectedSegmentsAndParagraphs[0][0]; const paragraph = selectedSegmentsAndParagraphs[0][1]; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts index e2a3242cbc4..f1c1b1108e6 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts @@ -1,4 +1,4 @@ -import { getIndex } from './getIndex'; +import getIndex from './getIndex'; import { NumberingListType } from 'roosterjs-content-model-core'; const enum NumberingTypes { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts index e69de29bb2d..22ee09b0f4f 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts @@ -0,0 +1,113 @@ +import * as keyboardListTrigger from '../../lib/autoFormat/keyboardListTrigger'; +import { ContentModelAutoFormatPlugin } from '../../lib/autoFormat/ContentModelAutoFormatPlugin'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; +import { KeyDownEvent } from 'roosterjs-content-model-types'; + +describe('Content Model Auto Format Plugin Test', () => { + let editor: IContentModelEditor; + + beforeEach(() => { + editor = ({ + getDOMSelection: () => + ({ + type: -1, + } as any), // Force return invalid range to go through content model code + } as any) as IContentModelEditor; + }); + + describe('onPluginEvent', () => { + let keyboardListTriggerSpy: jasmine.Spy; + + beforeEach(() => { + keyboardListTriggerSpy = spyOn(keyboardListTrigger, 'default'); + }); + + function runTest( + event: KeyDownEvent, + shouldCallTrigger: boolean, + options?: { autoBullet: boolean; autoNumbering: boolean } + ) { + const plugin = new ContentModelAutoFormatPlugin(options); + plugin.initialize(editor); + + plugin.onPluginEvent(event); + + if (shouldCallTrigger) { + expect(keyboardListTriggerSpy).toHaveBeenCalledWith( + editor, + options?.autoBullet ?? true, + options?.autoNumbering ?? true + ); + } else { + expect(keyboardListTriggerSpy).not.toHaveBeenCalled(); + } + } + + it('should trigger keyboardListTrigger', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { key: ' ', defaultPrevented: false } as any, + handledByEditFeature: false, + }; + runTest(event, true); + }); + + it('should not trigger keyboardListTrigger', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { key: '*', defaultPrevented: false } as any, + handledByEditFeature: false, + }; + + runTest(event, false); + }); + + it('should not trigger keyboardListTrigger', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { key: ' ', defaultPrevented: false } as any, + handledByEditFeature: false, + }; + + runTest(event, false, { autoBullet: false, autoNumbering: false }); + }); + + it('should trigger keyboardListTrigger with auto bullet only', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { key: ' ', defaultPrevented: false } as any, + handledByEditFeature: false, + }; + runTest(event, true, { autoBullet: true, autoNumbering: false }); + }); + + it('should trigger keyboardListTrigger with auto numbering only', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { key: ' ', defaultPrevented: false } as any, + handledByEditFeature: false, + }; + runTest(event, true, { autoBullet: false, autoNumbering: true }); + }); + + it('should not trigger keyboardListTrigger, because handledByEditFeature', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { key: ' ', defaultPrevented: false } as any, + handledByEditFeature: true, + }; + + runTest(event, false); + }); + + it('should not trigger keyboardListTrigger, because defaultPrevented', () => { + const event: KeyDownEvent = { + eventType: 'keyDown', + rawEvent: { key: ' ', defaultPrevented: true } as any, + handledByEditFeature: false, + }; + + runTest(event, false); + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts index e69de29bb2d..80b8cc91ed1 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts @@ -0,0 +1,396 @@ +import keyboardListTrigger from '../../lib/autoFormat/keyboardListTrigger'; +import { ContentModelDocument } from 'roosterjs-content-model-types'; + +describe('keyboardListTrigger', () => { + function runTest( + input: ContentModelDocument, + expectedModel: ContentModelDocument, + expectedResult: boolean, + shouldSearchForBullet: boolean = true, + shouldSearchForNumbering: boolean = true + ) { + const formatWithContentModelSpy = jasmine + .createSpy('formatWithContentModel') + .and.callFake((callback, options) => { + const result = callback(input, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + expect(result).toBe(expectedResult); + }); + + keyboardListTrigger( + { + formatContentModel: formatWithContentModelSpy, + } as any, + shouldSearchForBullet, + shouldSearchForNumbering + ); + + expect(formatWithContentModelSpy).toHaveBeenCalledTimes(1); + expect(input).toEqual(expectedModel); + } + + it('trigger numbering list', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0', + marginBlockEnd: '0px', + startNumberOverride: undefined, + direction: undefined, + textAlign: undefined, + marginBlockStart: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }, + true + ); + }); + + it('trigger continued numbering list', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: ' test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBlockEnd: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '2)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: ' test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBlockEnd: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 2, + direction: undefined, + textAlign: undefined, + marginBlockStart: '0px', + marginTop: '0', + marginBlockEnd: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }, + true + ); + }); + + it('should not trigger numbering list', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '1)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + false, + undefined, + false + ); + }); + + it('should trigger bullet list', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [], + levels: [ + { + listType: 'UL', + format: { + marginTop: '0', + marginBlockEnd: '0px', + startNumberOverride: undefined, + direction: undefined, + textAlign: undefined, + marginBlockStart: '0px', + }, + dataset: { + editingInfo: '{"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }, + true + ); + }); + + it('should not trigger bullet list', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '*', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + false, + false + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts index 9a5e006100d..9ba5e811be0 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts @@ -1,4 +1,4 @@ -import { getIndex } from '../../../lib/autoFormat/utils/getIndex'; +import getIndex from '../../../lib/autoFormat/utils/getIndex'; describe('getIndex', () => { function runTest(listMarker: string, expectedResult: number) { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getListTypeStyleTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getListTypeStyleTest.ts index 1d1d84d5149..63c1d036247 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getListTypeStyleTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getListTypeStyleTest.ts @@ -1,7 +1,6 @@ import { BulletListType, NumberingListType } from 'roosterjs-content-model-core'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { getListTypeStyle } from '../../../lib/autoFormat/utils/getListTypeStyle'; -import { IContentModelEditor } from 'roosterjs-content-model-editor'; describe('getListTypeStyle', () => { function runTest( @@ -16,15 +15,8 @@ describe('getListTypeStyle', () => { shouldSearchForBullet?: boolean, shouldSearchForNumbering?: boolean ) { - const createContentModel = jasmine.createSpy('createContentModel').and.returnValue(model); - - const editor = ({ - createContentModel, - getEnvironment: () => ({}), - } as any) as IContentModelEditor; - const listTypeStyle = getListTypeStyle( - editor, + model, shouldSearchForBullet, shouldSearchForNumbering ); diff --git a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts index fbacbf0c096..fc4437d9380 100644 --- a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts @@ -1,6 +1,5 @@ import { ContentModelEditor } from 'roosterjs-content-model-editor'; import { - ContentModelAutoFormatPlugin, ContentModelEditPlugin, ContentModelPastePlugin, EntityDelimiterPlugin, @@ -24,11 +23,7 @@ export function createContentModelEditor( additionalPlugins?: EditorPlugin[], initialContent?: string ): IContentModelEditor { - const legacyPlugins = [ - new ContentModelEditPlugin(), - new EntityDelimiterPlugin(), - new ContentModelAutoFormatPlugin(), - ]; + const legacyPlugins = [new ContentModelEditPlugin(), new EntityDelimiterPlugin()]; const plugins = [new ContentModelPastePlugin(), ...(additionalPlugins ?? [])]; const options: ContentModelEditorOptions = { From b3d3391d2af56f5205cf7455833d17df0e596b6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 15 Jan 2024 17:43:46 -0300 Subject: [PATCH 010/112] types --- .../lib/autoFormat/ContentModelAutoFormatPlugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts index 8a94512167d..c1a23fe6188 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -1,5 +1,5 @@ import keyboardListTrigger from './keyboardListTrigger'; -import { +import type { EditorPlugin, IStandaloneEditor, KeyDownEvent, From be0d2754e57bbe9024f93d0a691c446df8e0d7e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 15 Jan 2024 18:14:26 -0300 Subject: [PATCH 011/112] add comment --- .../lib/autoFormat/ContentModelAutoFormatPlugin.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts index c1a23fe6188..f6f6a009559 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -11,7 +11,13 @@ interface AutoFormatOptions { autoBullet: boolean; autoNumbering: boolean; } - +/** + * Auto Format plugin handles auto formatting, such as transforming * characters into a bullet list. + * It can be customized with options to enable or disable auto list features. + * @param options An optional parameter that takes in an object of type AutoFormatOptions, which includes the following properties: + * - autoBullet: A boolean that enables or disables automatic bullet list formatting. Defaults to true. + * - autoNumbering: A boolean that enables or disables automatic numbering formatting. Defaults to true. + */ export class ContentModelAutoFormatPlugin implements EditorPlugin { private editor: IContentModelEditor | null = null; private options: AutoFormatOptions = { From bbce1b32ca9861ad597f0f036650458a957300f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 15 Jan 2024 18:24:40 -0300 Subject: [PATCH 012/112] add comment --- .../lib/autoFormat/ContentModelAutoFormatPlugin.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts index f6f6a009559..fd753f78837 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -7,6 +7,9 @@ import type { } from 'roosterjs-content-model-types'; import type { IContentModelEditor } from 'roosterjs-content-model-editor'; +/** + * @internal + */ interface AutoFormatOptions { autoBullet: boolean; autoNumbering: boolean; From 9f9c50e9ad6646f7cb431d75af0453f9fd086490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 15 Jan 2024 18:34:42 -0300 Subject: [PATCH 013/112] add comment --- .../lib/autoFormat/keyboardListTrigger.ts | 3 +++ .../lib/autoFormat/utils/getIndex.ts | 3 +++ .../lib/autoFormat/utils/getListTypeStyle.ts | 3 +++ .../lib/autoFormat/utils/getNumberingListStyle.ts | 4 ---- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts index 6a6af219d43..f91c8e5dfb6 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -7,6 +7,9 @@ import { } from 'roosterjs-content-model-core'; import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +/** + * @internal + */ export default function keyboardListTrigger( editor: IStandaloneEditor, shouldSearchForBullet: boolean = true, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts index b218f041330..8ae6e6a26cf 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts @@ -1,5 +1,8 @@ import convertAlphaToDecimals from './convertAlphaToDecimals'; +/** + * @internal + */ export default function getIndex(listIndex: string) { const index = listIndex.replace(/[^a-zA-Z0-9 ]/g, ''); const indexNumber = parseInt(index); diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts index 6877c0ed2ea..522f0918c1c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts @@ -20,6 +20,9 @@ interface ListTypeStyle { index?: number; } +/** + * @internal + */ export function getListTypeStyle( model: ContentModelDocument, shouldSearchForBullet: boolean = true, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts index f1c1b1108e6..17e73d1d484 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts @@ -128,10 +128,6 @@ const identifyNumberingListType = ( /** * @internal - * @param textBeforeCursor The trigger character - * @param previousListChain @optional This parameters is used to keep the list chain, if the is not a new list - * @param previousListStyle @optional The list style of the previous list - * @returns The style of a numbering list triggered by a string */ export default function getNumberingListStyle( textBeforeCursor: string, From 50973e4737a2f1bc21f7e66dcd297ce8b8acaf60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 15 Jan 2024 18:57:45 -0300 Subject: [PATCH 014/112] add comment --- .../lib/autoFormat/utils/getListTypeStyle.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts index 522f0918c1c..9b20774d65b 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts @@ -14,6 +14,9 @@ import { getSelectedSegmentsAndParagraphs, } from 'roosterjs-content-model-core'; +/** + * @internal + */ interface ListTypeStyle { listType: 'UL' | 'OL'; styleType: number; From 79e711fc4dbbec06b9a1fa7d30c066fe44cf676f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 15 Jan 2024 19:08:26 -0300 Subject: [PATCH 015/112] add comment --- .../lib/autoFormat/ContentModelAutoFormatPlugin.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts index fd753f78837..1d5bf664cba 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -8,7 +8,8 @@ import type { import type { IContentModelEditor } from 'roosterjs-content-model-editor'; /** - * @internal + * - autoBullet: A boolean that enables or disables automatic bullet list formatting. Defaults to true. + * - autoNumbering: A boolean that enables or disables automatic numbering formatting. Defaults to true. */ interface AutoFormatOptions { autoBullet: boolean; From 51b4722c51f4c84a723b0cd544c1439eb0afcea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 15 Jan 2024 21:26:33 -0300 Subject: [PATCH 016/112] comments --- .../lib/autoFormat/ContentModelAutoFormatPlugin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts index 1d5bf664cba..092fc43bc17 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -8,6 +8,7 @@ import type { import type { IContentModelEditor } from 'roosterjs-content-model-editor'; /** + * @internal * - autoBullet: A boolean that enables or disables automatic bullet list formatting. Defaults to true. * - autoNumbering: A boolean that enables or disables automatic numbering formatting. Defaults to true. */ From 6ccabad2009f23f60c7e1cf9676fdd61ab7b7334 Mon Sep 17 00:00:00 2001 From: Julia Roldi Date: Tue, 16 Jan 2024 13:58:44 -0300 Subject: [PATCH 017/112] fix build --- .../lib/autoFormat/ContentModelAutoFormatPlugin.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts index 092fc43bc17..3710d747de3 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -7,15 +7,6 @@ import type { } from 'roosterjs-content-model-types'; import type { IContentModelEditor } from 'roosterjs-content-model-editor'; -/** - * @internal - * - autoBullet: A boolean that enables or disables automatic bullet list formatting. Defaults to true. - * - autoNumbering: A boolean that enables or disables automatic numbering formatting. Defaults to true. - */ -interface AutoFormatOptions { - autoBullet: boolean; - autoNumbering: boolean; -} /** * Auto Format plugin handles auto formatting, such as transforming * characters into a bullet list. * It can be customized with options to enable or disable auto list features. @@ -25,12 +16,12 @@ interface AutoFormatOptions { */ export class ContentModelAutoFormatPlugin implements EditorPlugin { private editor: IContentModelEditor | null = null; - private options: AutoFormatOptions = { + private options = { autoBullet: true, autoNumbering: true, }; - constructor(options?: AutoFormatOptions) { + constructor(options?: { autoBullet: boolean; autoNumbering: boolean }) { if (options) { this.options = options; } From b887a12afa9482ce11a8fb3f9c9fda46a4300824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 17 Jan 2024 12:33:24 -0300 Subject: [PATCH 018/112] fixes --- .../ContentModelAutoFormatPlugin.ts | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts index 3710d747de3..9be32ff5c8b 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -5,26 +5,41 @@ import type { KeyDownEvent, PluginEvent, } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from 'roosterjs-content-model-editor'; + +/** + * @internal + */ +type AutoFormatOptions = { + autoBullet: boolean; + autoNumbering: boolean; +}; + +/** + * @internal + */ +const DefaultOptions: Required = { + autoBullet: true, + autoNumbering: true, +}; /** * Auto Format plugin handles auto formatting, such as transforming * characters into a bullet list. * It can be customized with options to enable or disable auto list features. - * @param options An optional parameter that takes in an object of type AutoFormatOptions, which includes the following properties: - * - autoBullet: A boolean that enables or disables automatic bullet list formatting. Defaults to true. - * - autoNumbering: A boolean that enables or disables automatic numbering formatting. Defaults to true. */ export class ContentModelAutoFormatPlugin implements EditorPlugin { - private editor: IContentModelEditor | null = null; + private editor: IStandaloneEditor | null = null; private options = { autoBullet: true, autoNumbering: true, }; - constructor(options?: { autoBullet: boolean; autoNumbering: boolean }) { - if (options) { - this.options = options; - } + /** + * @param options An optional parameter that takes in an object of type AutoFormatOptions, which includes the following properties: + * - autoBullet: A boolean that enables or disables automatic bullet list formatting. Defaults to true. + * - autoNumbering: A boolean that enables or disables automatic numbering formatting. Defaults to true. + */ + constructor(options: AutoFormatOptions = DefaultOptions) { + this.options = options; } /** @@ -41,8 +56,7 @@ export class ContentModelAutoFormatPlugin implements EditorPlugin { * @param editor The editor object */ initialize(editor: IStandaloneEditor) { - // TODO: Later we may need a different interface for Content Model editor plugin - this.editor = editor as IContentModelEditor; + this.editor = editor; } /** @@ -70,7 +84,7 @@ export class ContentModelAutoFormatPlugin implements EditorPlugin { } } - private handleKeyDownEvent(editor: IContentModelEditor, event: KeyDownEvent) { + private handleKeyDownEvent(editor: IStandaloneEditor, event: KeyDownEvent) { const rawEvent = event.rawEvent; if (!rawEvent.defaultPrevented && !event.handledByEditFeature) { switch (rawEvent.key) { From b90a7956eca053acfc9ce2b9d9bc6ed1015ed696 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 17 Jan 2024 13:24:05 -0300 Subject: [PATCH 019/112] fixes --- demo/scripts/controls/StandaloneEditorMainPane.tsx | 14 ++++++++++++-- .../lib/autoFormat/ContentModelAutoFormatPlugin.ts | 4 ++-- .../roosterjs-content-model-plugins/lib/index.ts | 5 ++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/demo/scripts/controls/StandaloneEditorMainPane.tsx b/demo/scripts/controls/StandaloneEditorMainPane.tsx index 908e0cebc2d..661af47ff02 100644 --- a/demo/scripts/controls/StandaloneEditorMainPane.tsx +++ b/demo/scripts/controls/StandaloneEditorMainPane.tsx @@ -16,7 +16,6 @@ import SampleEntityPlugin from './sampleEntity/SampleEntityPlugin'; import SidePane from './sidePane/SidePane'; import TitleBar from './titleBar/TitleBar'; import { arrayPush } from 'roosterjs-editor-dom'; -import { ContentModelEditPlugin, EntityDelimiterPlugin } from 'roosterjs-content-model-plugins'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; import { createEmojiPlugin, createPasteOptionPlugin } from 'roosterjs-react'; import { EditorPlugin, Snapshots } from 'roosterjs-editor-types'; @@ -24,6 +23,11 @@ import { getDarkColor } from 'roosterjs-color-utils'; import { PartialTheme } from '@fluentui/react/lib/Theme'; import { StandaloneEditor } from 'roosterjs-content-model-core'; import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; +import { + ContentModelAutoFormatPlugin, + ContentModelEditPlugin, + EntityDelimiterPlugin, +} from 'roosterjs-content-model-plugins'; import { ContentModelSegmentFormat, IStandaloneEditor, @@ -99,6 +103,7 @@ class ContentModelEditorMainPane extends MainPaneBase private contentModelPanePlugin: ContentModelPanePlugin; private contentModelEditPlugin: ContentModelEditPlugin; private contentModelRibbonPlugin: RibbonPlugin; + private contentAutoFormatPlugin: ContentModelAutoFormatPlugin; private pasteOptionPlugin: EditorPlugin; private emojiPlugin: EditorPlugin; private snapshotPlugin: ContentModelSnapshotPlugin; @@ -126,6 +131,7 @@ class ContentModelEditorMainPane extends MainPaneBase this.snapshotPlugin = new ContentModelSnapshotPlugin(this.snapshots); this.contentModelPanePlugin = new ContentModelPanePlugin(); this.contentModelEditPlugin = new ContentModelEditPlugin(); + this.contentAutoFormatPlugin = new ContentModelAutoFormatPlugin(); this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.pasteOptionPlugin = createPasteOptionPlugin(); this.emojiPlugin = createEmojiPlugin(); @@ -246,7 +252,11 @@ class ContentModelEditorMainPane extends MainPaneBase id={MainPaneBase.editorDivId} className={styles.editor} legacyPlugins={allPlugins} - plugins={[this.contentModelRibbonPlugin, this.formatPainterPlugin]} + plugins={[ + this.contentModelRibbonPlugin, + this.formatPainterPlugin, + this.contentAutoFormatPlugin, + ]} defaultSegmentFormat={defaultFormat} inDarkMode={this.state.isDarkMode} getDarkColor={getDarkColor} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts index 9be32ff5c8b..d7233785ae4 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -7,9 +7,9 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * Options to customize the Content Model Auto Format Plugin */ -type AutoFormatOptions = { +export type AutoFormatOptions = { autoBullet: boolean; autoNumbering: boolean; }; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts index 59bf98f4478..74913772b7c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts @@ -1,4 +1,7 @@ export { ContentModelPastePlugin } from './paste/ContentModelPastePlugin'; export { ContentModelEditPlugin } from './edit/ContentModelEditPlugin'; export { EntityDelimiterPlugin } from './entityDelimiter/EntityDelimiterPlugin'; -export { ContentModelAutoFormatPlugin } from './autoFormat/ContentModelAutoFormatPlugin'; +export { + ContentModelAutoFormatPlugin, + AutoFormatOptions, +} from './autoFormat/ContentModelAutoFormatPlugin'; From edbdef74ad6fe26613c2af485524b181d452f0ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 18 Jan 2024 15:04:01 -0300 Subject: [PATCH 020/112] refactor --- .../ContentModelAutoFormatPlugin.ts | 17 ++-- .../lib/autoFormat/keyboardListTrigger.ts | 58 +++++--------- .../utils/convertAlphaToDecimals.ts | 2 +- .../lib/autoFormat/utils/getIndex.ts | 4 +- .../lib/autoFormat/utils/getListTypeStyle.ts | 4 +- .../autoFormat/utils/getNumberingListStyle.ts | 4 +- .../ContentModelAutoFormatPluginTest.ts | 5 +- .../autoFormat/keyboardListTriggerTest.ts | 80 +++++++++++++++---- .../utils/convertAlphaToDecimalsTest.ts | 2 +- .../test/autoFormat/utils/getIndexTest.ts | 2 +- .../utils/getNumberingListStyleTest.ts | 2 +- 11 files changed, 110 insertions(+), 70 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts index d7233785ae4..624e5d97cba 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -1,4 +1,4 @@ -import keyboardListTrigger from './keyboardListTrigger'; +import { keyboardListTrigger } from './keyboardListTrigger'; import type { EditorPlugin, IStandaloneEditor, @@ -10,7 +10,14 @@ import type { * Options to customize the Content Model Auto Format Plugin */ export type AutoFormatOptions = { + /** + * When true, after type *, ->, -, --, => , —, > and space key a type of bullet list will be triggered. @default true + */ autoBullet: boolean; + + /** + * When true, after type 1, A, a, i, I followed by ., ), - or between () and space key a type of numbering list will be triggered. @default true + */ autoNumbering: boolean; }; @@ -28,19 +35,13 @@ const DefaultOptions: Required = { */ export class ContentModelAutoFormatPlugin implements EditorPlugin { private editor: IStandaloneEditor | null = null; - private options = { - autoBullet: true, - autoNumbering: true, - }; /** * @param options An optional parameter that takes in an object of type AutoFormatOptions, which includes the following properties: * - autoBullet: A boolean that enables or disables automatic bullet list formatting. Defaults to true. * - autoNumbering: A boolean that enables or disables automatic numbering formatting. Defaults to true. */ - constructor(options: AutoFormatOptions = DefaultOptions) { - this.options = options; - } + constructor(private options: AutoFormatOptions = DefaultOptions) {} /** * Get name of this plugin diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts index f91c8e5dfb6..fbc86e2704c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -1,56 +1,42 @@ -import { createListItem, createListLevel } from 'roosterjs-content-model-dom'; import { getListTypeStyle } from './utils/getListTypeStyle'; -import { - getOperationalBlocks, - isBlockGroupOfType, - updateListMetadata, -} from 'roosterjs-content-model-core'; +import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core'; +import { setListStartNumber, setListStyle } from 'roosterjs-content-model-api'; +import { setListType } from 'roosterjs-content-model-api/lib/modelApi/list/setListType'; import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * @internal */ -export default function keyboardListTrigger( +export function keyboardListTrigger( editor: IStandaloneEditor, shouldSearchForBullet: boolean = true, shouldSearchForNumbering: boolean = true ) { - editor.formatContentModel((model, _) => { + editor.formatContentModel((model, context) => { const listStyleType = getListTypeStyle( model, shouldSearchForBullet, shouldSearchForNumbering ); if (listStyleType) { + const paragraph = getSelectedSegmentsAndParagraphs(model, false)[0][1]; + if (paragraph) { + paragraph.segments.splice(0, 1); + } const { listType, styleType, index } = listStyleType; - - const paragraphOrListItems = getOperationalBlocks(model, ['ListItem'], []); - paragraphOrListItems.forEach(({ block, parent }, itemIndex) => { - if (!isBlockGroupOfType(block, 'General')) { - const blockIndex = parent.blocks.indexOf(block); - const listLevel = createListLevel(listType, { - startNumberOverride: index, - direction: block.format.direction, - textAlign: block.format.textAlign, - marginTop: '0', - marginBlockEnd: '0px', - marginBlockStart: '0px', - }); - updateListMetadata(listLevel, metadata => { - return (metadata = - listType == 'UL' - ? { - unorderedStyleType: styleType, - } - : { - orderedStyleType: styleType, - }); - }); - - const listItem = createListItem([listLevel]); - parent.blocks.splice(blockIndex, 1, listItem); - } - }); + const isOrderedList = listType === 'OL'; + setListType(model, listType); + if (index && isOrderedList) { + setListStartNumber(editor, index); + } + setListStyle( + editor, + isOrderedList + ? { + orderedStyleType: styleType, + } + : { unorderedStyleType: styleType } + ); return true; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/convertAlphaToDecimals.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/convertAlphaToDecimals.ts index c29583b92c9..f439feeab7e 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/convertAlphaToDecimals.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/convertAlphaToDecimals.ts @@ -4,7 +4,7 @@ * @param letter The letter that needs to be converted * @returns */ -export default function convertAlphaToDecimals(letter: string): number | undefined { +export function convertAlphaToDecimals(letter: string): number | undefined { const alpha = letter.toUpperCase(); if (alpha) { let result = 0; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts index 8ae6e6a26cf..7997045550a 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getIndex.ts @@ -1,9 +1,9 @@ -import convertAlphaToDecimals from './convertAlphaToDecimals'; +import { convertAlphaToDecimals } from './convertAlphaToDecimals'; /** * @internal */ -export default function getIndex(listIndex: string) { +export function getIndex(listIndex: string) { const index = listIndex.replace(/[^a-zA-Z0-9 ]/g, ''); const indexNumber = parseInt(index); return !isNaN(indexNumber) ? indexNumber : convertAlphaToDecimals(index); diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts index 9b20774d65b..86b8571ae24 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts @@ -1,5 +1,5 @@ -import getIndex from './getIndex'; -import getNumberingListStyle from './getNumberingListStyle'; +import { getIndex } from './getIndex'; +import { getNumberingListStyle } from './getNumberingListStyle'; import type { ContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts index 17e73d1d484..bc0de54d63f 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts @@ -1,4 +1,4 @@ -import getIndex from './getIndex'; +import { getIndex } from './getIndex'; import { NumberingListType } from 'roosterjs-content-model-core'; const enum NumberingTypes { @@ -129,7 +129,7 @@ const identifyNumberingListType = ( /** * @internal */ -export default function getNumberingListStyle( +export function getNumberingListStyle( textBeforeCursor: string, previousListIndex?: number, previousListStyle?: number diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts index 22ee09b0f4f..dd86d02fbdb 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts @@ -1,4 +1,4 @@ -import * as keyboardListTrigger from '../../lib/autoFormat/keyboardListTrigger'; +import * as keyboardTrigger from '../../lib/autoFormat/keyboardListTrigger'; import { ContentModelAutoFormatPlugin } from '../../lib/autoFormat/ContentModelAutoFormatPlugin'; import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { KeyDownEvent } from 'roosterjs-content-model-types'; @@ -8,6 +8,7 @@ describe('Content Model Auto Format Plugin Test', () => { beforeEach(() => { editor = ({ + focus: () => {}, getDOMSelection: () => ({ type: -1, @@ -19,7 +20,7 @@ describe('Content Model Auto Format Plugin Test', () => { let keyboardListTriggerSpy: jasmine.Spy; beforeEach(() => { - keyboardListTriggerSpy = spyOn(keyboardListTrigger, 'default'); + keyboardListTriggerSpy = spyOn(keyboardTrigger, 'keyboardListTrigger'); }); function runTest( diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts index 80b8cc91ed1..0432e5ca0c6 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts @@ -1,5 +1,5 @@ -import keyboardListTrigger from '../../lib/autoFormat/keyboardListTrigger'; import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { keyboardListTrigger } from '../../lib/autoFormat/keyboardListTrigger'; describe('keyboardListTrigger', () => { function runTest( @@ -22,13 +22,14 @@ describe('keyboardListTrigger', () => { keyboardListTrigger( { + focus: () => {}, formatContentModel: formatWithContentModelSpy, } as any, shouldSearchForBullet, shouldSearchForNumbering ); - expect(formatWithContentModelSpy).toHaveBeenCalledTimes(1); + expect(formatWithContentModelSpy).toHaveBeenCalled(); expect(input).toEqual(expectedModel); } @@ -62,14 +63,27 @@ describe('keyboardListTrigger', () => { { blockType: 'BlockGroup', blockGroupType: 'ListItem', - blocks: [], + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], levels: [ { listType: 'OL', format: { - marginTop: '0', + marginTop: undefined, marginBlockEnd: '0px', - startNumberOverride: undefined, + startNumberOverride: 1, direction: undefined, textAlign: undefined, marginBlockStart: '0px', @@ -82,7 +96,11 @@ describe('keyboardListTrigger', () => { formatHolder: { segmentType: 'SelectionMarker', isSelected: true, - format: {}, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, }, format: {}, }, @@ -167,7 +185,7 @@ describe('keyboardListTrigger', () => { segments: [ { segmentType: 'Text', - text: ' test', + text: 'test', format: {}, }, ], @@ -199,7 +217,20 @@ describe('keyboardListTrigger', () => { { blockType: 'BlockGroup', blockGroupType: 'ListItem', - blocks: [], + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], levels: [ { listType: 'OL', @@ -208,7 +239,7 @@ describe('keyboardListTrigger', () => { direction: undefined, textAlign: undefined, marginBlockStart: '0px', - marginTop: '0', + marginTop: undefined, marginBlockEnd: '0px', }, dataset: { @@ -219,7 +250,11 @@ describe('keyboardListTrigger', () => { formatHolder: { segmentType: 'SelectionMarker', isSelected: true, - format: {}, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, }, format: {}, }, @@ -312,14 +347,27 @@ describe('keyboardListTrigger', () => { { blockType: 'BlockGroup', blockGroupType: 'ListItem', - blocks: [], + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], levels: [ { listType: 'UL', format: { - marginTop: '0', + marginTop: undefined, marginBlockEnd: '0px', - startNumberOverride: undefined, + startNumberOverride: 1, direction: undefined, textAlign: undefined, marginBlockStart: '0px', @@ -332,7 +380,11 @@ describe('keyboardListTrigger', () => { formatHolder: { segmentType: 'SelectionMarker', isSelected: true, - format: {}, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, }, format: {}, }, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/convertAlphaToDecimalsTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/convertAlphaToDecimalsTest.ts index 92fda526128..223e7349689 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/convertAlphaToDecimalsTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/convertAlphaToDecimalsTest.ts @@ -1,4 +1,4 @@ -import convertAlphaToDecimals from '../../../lib/autoFormat/utils/convertAlphaToDecimals'; +import { convertAlphaToDecimals } from '../../../lib/autoFormat/utils/convertAlphaToDecimals'; describe('convertAlphaToDecimals', () => { function runTest(alpha: string, expectedResult: number) { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts index 9ba5e811be0..9a5e006100d 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getIndexTest.ts @@ -1,4 +1,4 @@ -import getIndex from '../../../lib/autoFormat/utils/getIndex'; +import { getIndex } from '../../../lib/autoFormat/utils/getIndex'; describe('getIndex', () => { function runTest(listMarker: string, expectedResult: number) { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getNumberingListStyleTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getNumberingListStyleTest.ts index 2675a2f1e50..5877b37a805 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getNumberingListStyleTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/utils/getNumberingListStyleTest.ts @@ -1,4 +1,4 @@ -import getNumberingListStyle from '../../../lib/autoFormat/utils/getNumberingListStyle'; +import { getNumberingListStyle } from '../../../lib/autoFormat/utils/getNumberingListStyle'; import { NumberingListType } from 'roosterjs-content-model-core'; describe('getNumberingListStyle', () => { From d4b7e38bfa089fad5bc34e5aba33c248c570431c Mon Sep 17 00:00:00 2001 From: Julia Roldi Date: Fri, 19 Jan 2024 11:47:53 -0300 Subject: [PATCH 021/112] fixes --- .../roosterjs-content-model-api/lib/index.ts | 1 + .../lib/modelApi/list/setListType.ts | 4 +- .../ContentModelAutoFormatPlugin.ts | 1 + .../lib/autoFormat/keyboardListTrigger.ts | 46 +++++++++++-------- .../package.json | 3 +- .../ContentModelAutoFormatPluginTest.ts | 12 ++--- 6 files changed, 41 insertions(+), 26 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/index.ts b/packages-content-model/roosterjs-content-model-api/lib/index.ts index 8bef29554bd..b5bd38fc378 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/index.ts @@ -40,3 +40,4 @@ export { default as adjustImageSelection } from './publicApi/image/adjustImageSe export { default as setParagraphMargin } from './publicApi/block/setParagraphMargin'; export { default as toggleCode } from './publicApi/segment/toggleCode'; export { default as insertEntity } from './publicApi/entity/insertEntity'; +export { setListType } from './modelApi/list/setListType'; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts index bac375bd0cb..1558caa024a 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts @@ -12,7 +12,9 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * Set a list type to content model + * @param model the model document + * @param listType the list type OL | UL */ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') { const paragraphOrListItems = getOperationalBlocks( diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts index 624e5d97cba..759951f922f 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -92,6 +92,7 @@ export class ContentModelAutoFormatPlugin implements EditorPlugin { case ' ': const { autoBullet, autoNumbering } = this.options; if (autoBullet || autoNumbering) { + event.rawEvent.preventDefault(); keyboardListTrigger(editor, autoBullet, autoNumbering); } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts index fbc86e2704c..dc837123cb7 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -1,8 +1,7 @@ import { getListTypeStyle } from './utils/getListTypeStyle'; import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core'; -import { setListStartNumber, setListStyle } from 'roosterjs-content-model-api'; -import { setListType } from 'roosterjs-content-model-api/lib/modelApi/list/setListType'; -import type { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { setListStartNumber, setListStyle, setListType } from 'roosterjs-content-model-api'; +import type { ContentModelDocument, IStandaloneEditor } from 'roosterjs-content-model-types'; /** * @internal @@ -12,7 +11,7 @@ export function keyboardListTrigger( shouldSearchForBullet: boolean = true, shouldSearchForNumbering: boolean = true ) { - editor.formatContentModel((model, context) => { + editor.formatContentModel((model, _context) => { const listStyleType = getListTypeStyle( model, shouldSearchForBullet, @@ -24,22 +23,33 @@ export function keyboardListTrigger( paragraph.segments.splice(0, 1); } const { listType, styleType, index } = listStyleType; - const isOrderedList = listType === 'OL'; - setListType(model, listType); - if (index && isOrderedList) { - setListStartNumber(editor, index); - } - setListStyle( - editor, - isOrderedList - ? { - orderedStyleType: styleType, - } - : { unorderedStyleType: styleType } - ); - + triggerList(editor, model, listType, styleType, index); return true; } return false; }); } + +const triggerList = ( + editor: IStandaloneEditor, + model: ContentModelDocument, + listType: 'OL' | 'UL', + styleType: number, + index?: number +) => { + setListType(model, listType); + const isOrderedList = listType == 'OL'; + if (index && isOrderedList) { + setListStartNumber(editor, index); + } + setListStyle( + editor, + isOrderedList + ? { + orderedStyleType: styleType, + } + : { + unorderedStyleType: styleType, + } + ); +}; diff --git a/packages-content-model/roosterjs-content-model-plugins/package.json b/packages-content-model/roosterjs-content-model-plugins/package.json index e33c18cf24d..1685a157bce 100644 --- a/packages-content-model/roosterjs-content-model-plugins/package.json +++ b/packages-content-model/roosterjs-content-model-plugins/package.json @@ -8,7 +8,8 @@ "roosterjs-content-model-core": "", "roosterjs-content-model-editor": "", "roosterjs-content-model-dom": "", - "roosterjs-content-model-types": "" + "roosterjs-content-model-types": "", + "roosterjs-content-model-api": "" }, "version": "0.0.0", "main": "./lib/index.ts" diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts index dd86d02fbdb..0c000d327b1 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts @@ -47,7 +47,7 @@ describe('Content Model Auto Format Plugin Test', () => { it('should trigger keyboardListTrigger', () => { const event: KeyDownEvent = { eventType: 'keyDown', - rawEvent: { key: ' ', defaultPrevented: false } as any, + rawEvent: { key: ' ', defaultPrevented: false, preventDefault: () => {} } as any, handledByEditFeature: false, }; runTest(event, true); @@ -56,7 +56,7 @@ describe('Content Model Auto Format Plugin Test', () => { it('should not trigger keyboardListTrigger', () => { const event: KeyDownEvent = { eventType: 'keyDown', - rawEvent: { key: '*', defaultPrevented: false } as any, + rawEvent: { key: '*', defaultPrevented: false, preventDefault: () => {} } as any, handledByEditFeature: false, }; @@ -66,7 +66,7 @@ describe('Content Model Auto Format Plugin Test', () => { it('should not trigger keyboardListTrigger', () => { const event: KeyDownEvent = { eventType: 'keyDown', - rawEvent: { key: ' ', defaultPrevented: false } as any, + rawEvent: { key: ' ', defaultPrevented: false, preventDefault: () => {} } as any, handledByEditFeature: false, }; @@ -76,7 +76,7 @@ describe('Content Model Auto Format Plugin Test', () => { it('should trigger keyboardListTrigger with auto bullet only', () => { const event: KeyDownEvent = { eventType: 'keyDown', - rawEvent: { key: ' ', defaultPrevented: false } as any, + rawEvent: { key: ' ', defaultPrevented: false, preventDefault: () => {} } as any, handledByEditFeature: false, }; runTest(event, true, { autoBullet: true, autoNumbering: false }); @@ -85,7 +85,7 @@ describe('Content Model Auto Format Plugin Test', () => { it('should trigger keyboardListTrigger with auto numbering only', () => { const event: KeyDownEvent = { eventType: 'keyDown', - rawEvent: { key: ' ', defaultPrevented: false } as any, + rawEvent: { key: ' ', defaultPrevented: false, preventDefault: () => {} } as any, handledByEditFeature: false, }; runTest(event, true, { autoBullet: false, autoNumbering: true }); @@ -94,7 +94,7 @@ describe('Content Model Auto Format Plugin Test', () => { it('should not trigger keyboardListTrigger, because handledByEditFeature', () => { const event: KeyDownEvent = { eventType: 'keyDown', - rawEvent: { key: ' ', defaultPrevented: false } as any, + rawEvent: { key: ' ', defaultPrevented: false, preventDefault: () => {} } as any, handledByEditFeature: true, }; From 98f69d49e8e5b3c58abc9c8425bd098f81eea2c0 Mon Sep 17 00:00:00 2001 From: Gani <107857762+gm-al@users.noreply.github.com> Date: Sun, 21 Jan 2024 09:58:27 +0300 Subject: [PATCH 022/112] Add shortcut support Ctrl+K for insertLink (#2333) * Add shortcut support Ctrl+K for insertLink * Add metakey and alt key checks * Add insertButton check and disable param * add options to ribbon and decouple onButtonClick * update * update fix * add break statement * remove duplicate imports --------- Co-authored-by: Ghanem10 <107857762+Ghanem10@users.noreply.github.com> Co-authored-by: Jiuqing Song --- .../roosterjs-react/lib/common/index.ts | 1 + .../lib/common/type/RibbonPluginOptions.ts | 10 ++++ .../lib/ribbon/plugin/createRibbonPlugin.ts | 46 +++++++++++++++++-- 3 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 packages-ui/roosterjs-react/lib/common/type/RibbonPluginOptions.ts diff --git a/packages-ui/roosterjs-react/lib/common/index.ts b/packages-ui/roosterjs-react/lib/common/index.ts index cb2e3997321..b1b9e505a13 100644 --- a/packages-ui/roosterjs-react/lib/common/index.ts +++ b/packages-ui/roosterjs-react/lib/common/index.ts @@ -8,3 +8,4 @@ export { default as UIUtilities } from './type/UIUtilities'; export { default as ReactEditorPlugin } from './type/ReactEditorPlugin'; export { default as createUIUtilities } from './utils/createUIUtilities'; export { default as getLocalizedString } from './utils/getLocalizedString'; +export { default as RibbonPluginOptions } from './type/RibbonPluginOptions'; diff --git a/packages-ui/roosterjs-react/lib/common/type/RibbonPluginOptions.ts b/packages-ui/roosterjs-react/lib/common/type/RibbonPluginOptions.ts new file mode 100644 index 00000000000..5c8578a5b1e --- /dev/null +++ b/packages-ui/roosterjs-react/lib/common/type/RibbonPluginOptions.ts @@ -0,0 +1,10 @@ +/** + * Interface to allow insert link on hot key press in ribbon plugin. + */ +export default interface RibbonPluginOptions { + /** + * Set the allowInsertLinkHotKey property to false when the user doesn't want to use this feature + * @default true + */ + allowInsertLinkHotKey?: Boolean; +} diff --git a/packages-ui/roosterjs-react/lib/ribbon/plugin/createRibbonPlugin.ts b/packages-ui/roosterjs-react/lib/ribbon/plugin/createRibbonPlugin.ts index b1835f59305..a372864b216 100644 --- a/packages-ui/roosterjs-react/lib/ribbon/plugin/createRibbonPlugin.ts +++ b/packages-ui/roosterjs-react/lib/ribbon/plugin/createRibbonPlugin.ts @@ -1,10 +1,14 @@ import { getFormatState } from 'roosterjs-editor-api'; -import { getObjectKeys } from 'roosterjs-editor-dom'; import { PluginEventType } from 'roosterjs-editor-types'; +import { insertLink } from '../component/buttons/insertLink'; + +import { getObjectKeys, isCtrlOrMetaPressed } from 'roosterjs-editor-dom'; + import type RibbonButton from '../type/RibbonButton'; import type RibbonPlugin from '../type/RibbonPlugin'; + import type { FormatState, IEditor, PluginEvent } from 'roosterjs-editor-types'; -import type { LocalizedStrings, UIUtilities } from '../../common/index'; +import type { LocalizedStrings, UIUtilities, RibbonPluginOptions } from '../../common/index'; /** * A plugin to connect format ribbon component and the editor @@ -15,12 +19,16 @@ class RibbonPluginImpl implements RibbonPlugin { private timer = 0; private formatState: FormatState | null = null; private uiUtilities: UIUtilities | null = null; + private options: RibbonPluginOptions | undefined; /** * Construct a new instance of RibbonPlugin object * @param delayUpdateTime The time to wait before refresh the button when user do some editing operation in editor + * @param options The options for ribbon plugin to allow insert link on hot key press. */ - constructor(private delayUpdateTime: number = 200) {} + constructor(private delayUpdateTime: number = 200, options?: RibbonPluginOptions) { + this.options = options; + } /** * Get a friendly name of this plugin @@ -57,6 +65,16 @@ class RibbonPluginImpl implements RibbonPlugin { break; case PluginEventType.KeyDown: + if ( + event.rawEvent.key == 'k' && + isCtrlOrMetaPressed(event.rawEvent) && + !event.rawEvent.altKey && + this.options?.allowInsertLinkHotKey + ) { + this.handleButtonClick(insertLink, 'insertLinkTitle', undefined); + event.rawEvent.preventDefault(); + } + break; case PluginEventType.MouseUp: this.delayUpdate(); break; @@ -92,6 +110,20 @@ class RibbonPluginImpl implements RibbonPlugin { button: RibbonButton, key: T, strings?: LocalizedStrings + ) { + this.handleButtonClick(button, key, strings); + } + + /** + * Common method to handle button clicks + * @param button The button that is clicked + * @param key Key of child menu item that is clicked if any + * @param strings The localized string map for this button + */ + private handleButtonClick( + button: RibbonButton, + key: T, + strings?: LocalizedStrings ) { if (this.editor && this.uiUtilities) { this.editor.stopShadowEdit(); @@ -173,7 +205,11 @@ class RibbonPluginImpl implements RibbonPlugin { /** * Create a new instance of RibbonPlugin object * @param delayUpdateTime The time to wait before refresh the button when user do some editing operation in editor + * @param options The options for ribbon plugin to allow insert link on hot key press. */ -export default function createRibbonPlugin(delayUpdateTime?: number): RibbonPlugin { - return new RibbonPluginImpl(delayUpdateTime); +export default function createRibbonPlugin( + delayUpdateTime?: number, + options?: RibbonPluginOptions +): RibbonPlugin { + return new RibbonPluginImpl(delayUpdateTime, options); } From 7ada3d3c7e425856dc32780d258cb0dd724dd80f Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 22 Jan 2024 09:30:09 -0800 Subject: [PATCH 023/112] Content Model: Add back ContentModelBeforePasteEvent (#2347) --- .../lib/editor/utils/eventConverter.ts | 30 ++++++++++++------- .../lib/index.ts | 1 + .../ContentModelBeforePasteEvent.ts | 20 +++++++++++++ .../test/editor/utils/eventConverterTest.ts | 9 ++++-- 4 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelBeforePasteEvent.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts index bce247dd55f..c973fc8c0c2 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts @@ -1,5 +1,6 @@ import { convertDomSelectionToRangeEx, convertRangeExToDomSelection } from './selectionConverter'; import { createDefaultHtmlSanitizerOptions } from 'roosterjs-editor-dom'; +import type { ContentModelBeforePasteEvent } from '../../publicTypes/ContentModelBeforePasteEvent'; import { KnownAnnounceStrings as OldKnownAnnounceStrings, PasteType as OldPasteType, @@ -133,20 +134,23 @@ export function oldEventToNewEvent( case PluginEventType.BeforePaste: const refBeforePasteEvent = refEvent?.eventType == 'beforePaste' ? refEvent : undefined; + const cmBeforePasteEvent = input as ContentModelBeforePasteEvent; return { eventType: 'beforePaste', clipboardData: input.clipboardData, - customizedMerge: refBeforePasteEvent?.customizedMerge, - domToModelOption: refBeforePasteEvent?.domToModelOption ?? { - additionalAllowedTags: [], - additionalDisallowedTags: [], - additionalFormatParsers: {}, - formatParserOverride: {}, - processorOverride: {}, - styleSanitizers: {}, - attributeSanitizers: {}, - }, + customizedMerge: + cmBeforePasteEvent.customizedMerge ?? refBeforePasteEvent?.customizedMerge, + domToModelOption: cmBeforePasteEvent.domToModelOption ?? + refBeforePasteEvent?.domToModelOption ?? { + additionalAllowedTags: [], + additionalDisallowedTags: [], + additionalFormatParsers: {}, + formatParserOverride: {}, + processorOverride: {}, + styleSanitizers: {}, + attributeSanitizers: {}, + }, eventDataCache: input.eventDataCache, fragment: input.fragment, htmlAfter: input.htmlAfter, @@ -349,7 +353,7 @@ export function newEventToOldEvent(input: NewEvent, refEvent?: OldEvent): OldEve const refBeforePasteEvent = refEvent?.eventType == PluginEventType.BeforePaste ? refEvent : undefined; - return { + const oldBeforePasteEvent: ContentModelBeforePasteEvent = { eventType: PluginEventType.BeforePaste, clipboardData: input.clipboardData, eventDataCache: input.eventDataCache, @@ -360,8 +364,12 @@ export function newEventToOldEvent(input: NewEvent, refEvent?: OldEvent): OldEve pasteType: PasteTypeNewToOld[input.pasteType], sanitizingOption: refBeforePasteEvent?.sanitizingOption ?? createDefaultHtmlSanitizerOptions(), + domToModelOption: input.domToModelOption, + customizedMerge: input.customizedMerge, }; + return oldBeforePasteEvent; + case 'beforeSetContent': return { eventType: PluginEventType.BeforeSetContent, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 38364003350..1aecfd69e2c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -10,6 +10,7 @@ export { export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; export { ContextMenuPluginState } from './publicTypes/ContextMenuPluginState'; export { ContentModelCorePluginState } from './publicTypes/ContentModelCorePlugins'; +export { ContentModelBeforePasteEvent } from './publicTypes/ContentModelBeforePasteEvent'; export { ContentModelEditor } from './editor/ContentModelEditor'; export { isContentModelEditor } from './editor/isContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelBeforePasteEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelBeforePasteEvent.ts new file mode 100644 index 00000000000..d9cb72619f1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelBeforePasteEvent.ts @@ -0,0 +1,20 @@ +import type { BeforePasteEvent } from 'roosterjs-editor-types'; +import type { + DomToModelOptionForPaste, + MergePastedContentFunc, +} from 'roosterjs-content-model-types'; + +/** + * A temporary event type to be compatible with both legacy plugin and content model editor + */ +export interface ContentModelBeforePasteEvent extends BeforePasteEvent { + /** + * domToModel Options to use when creating the content model from the paste fragment + */ + readonly domToModelOption: DomToModelOptionForPaste; + + /** + * customizedMerge Customized merge function to use when merging the paste fragment into the editor + */ + customizedMerge?: MergePastedContentFunc; +} diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts index fe0752ef384..3adf797f5ec 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts @@ -8,6 +8,7 @@ import { } from 'roosterjs-editor-types'; import type { ContentChangedEvent, PluginEvent as OldEvent } from 'roosterjs-editor-types'; import type { PluginEvent as NewEvent } from 'roosterjs-content-model-types'; +import type { ContentModelBeforePasteEvent } from '../../../lib/publicTypes/ContentModelBeforePasteEvent'; describe('oldEventToNewEvent', () => { function runTest( @@ -744,7 +745,9 @@ describe('newEventToOldEvent', () => { preserveHtmlComments: false, unknownTagReplacement: null, }, - } + customizedMerge: mockedCustomizedMerge, + domToModelOption: mockedDomToModelOption, + } as ContentModelBeforePasteEvent ); }); @@ -792,7 +795,9 @@ describe('newEventToOldEvent', () => { htmlAttributes: mockedHTmlAttributes, pasteType: PasteType.AsImage, sanitizingOption: mockedSanitizeOption, - } + customizedMerge: mockedCustomizedMerge, + domToModelOption: mockedDomToModelOption, + } as ContentModelBeforePasteEvent ); }); From d6a4eac2746f03156d6de83485505262cb176221 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 22 Jan 2024 09:57:10 -0800 Subject: [PATCH 024/112] Use margin-top and bottom for list margin (#2346) --- .../lib/modelApi/list/setListType.ts | 10 +-- .../test/modelApi/list/setListTypeTest.ts | 64 ++++++++----------- .../block/marginFormatHandler.ts | 20 ------ .../block/marginFormatHandlerTest.ts | 54 +--------------- .../test/edit/deleteSteps/deleteListTest.ts | 4 +- .../lib/format/formatParts/MarginFormat.ts | 10 --- .../roosterjs-editor-dom/lib/list/VList.ts | 4 +- .../lib/list/VListItem.ts | 9 +-- .../test/list/VListTest.ts | 25 ++++---- 9 files changed, 52 insertions(+), 148 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts index bac375bd0cb..3e894367f3a 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts @@ -26,7 +26,6 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') : shouldIgnoreBlock(block) ); let existingListItems: ContentModelListItem[] = []; - let hasIgnoredParagraphBefore = false; paragraphOrListItems.forEach(({ block, parent }, itemIndex) => { if (isBlockGroupOfType(block, 'ListItem')) { @@ -75,9 +74,8 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') : 1, direction: block.format.direction, textAlign: block.format.textAlign, - marginTop: hasIgnoredParagraphBefore ? '0' : undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }), ], // For list bullet, we only want to carry over these formats from segments: @@ -112,9 +110,7 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') parent.blocks.splice(index, 1, newListItem); existingListItems.push(newListItem); } else { - hasIgnoredParagraphBefore = true; - - existingListItems.forEach(x => (x.levels[0].format.marginBottom = '0')); + existingListItems.forEach(x => (x.levels[0].format.marginBottom = '0px')); existingListItems = []; } } diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts index 609b2ac2ee1..12ab4e5aab5 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts @@ -75,9 +75,8 @@ describe('indent', () => { startNumberOverride: 1, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, dataset: {}, }, @@ -304,9 +303,8 @@ describe('indent', () => { startNumberOverride: undefined, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, dataset: {}, }, @@ -369,9 +367,8 @@ describe('indent', () => { startNumberOverride: 1, direction: 'rtl', textAlign: 'start', - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, }, ], @@ -451,10 +448,8 @@ describe('indent', () => { startNumberOverride: 1, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBottom: '0', - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginBottom: '0px', + marginTop: '0px', }, }, ], @@ -482,9 +477,8 @@ describe('indent', () => { direction: undefined, textAlign: undefined, startNumberOverride: undefined, - marginTop: '0', - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, }, ], @@ -538,9 +532,8 @@ describe('indent', () => { startNumberOverride: 1, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, }, ], @@ -596,9 +589,8 @@ describe('indent', () => { startNumberOverride: 1, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, }, ], @@ -625,9 +617,8 @@ describe('indent', () => { startNumberOverride: undefined, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, }, ], @@ -651,12 +642,11 @@ describe('indent', () => { listType: 'OL', dataset: {}, format: { - marginTop: undefined, direction: undefined, textAlign: undefined, startNumberOverride: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, }, ], @@ -729,9 +719,8 @@ describe('indent', () => { startNumberOverride: 1, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, dataset: {}, }, @@ -760,9 +749,8 @@ describe('indent', () => { startNumberOverride: undefined, direction: undefined, textAlign: undefined, - marginTop: undefined, - marginBlockEnd: '0px', - marginBlockStart: '0px', + marginTop: '0px', + marginBottom: '0px', }, dataset: {}, }, @@ -814,8 +802,8 @@ describe('indent', () => { { listType: 'UL', format: { - marginBlockStart: '0px', - marginBlockEnd: '0px', + marginTop: '0px', + marginBottom: '0px', }, dataset: {}, }, @@ -855,8 +843,8 @@ describe('indent', () => { { listType: 'UL', format: { - marginBlockStart: '0px', - marginBlockEnd: '0px', + marginTop: '0px', + marginBottom: '0px', }, dataset: {}, }, diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginFormatHandler.ts index 28d2b576ea7..823735279b9 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginFormatHandler.ts @@ -35,18 +35,6 @@ export const marginFormatHandler: FormatHandler = { } } }); - - const marginBlockStart = element.style.marginBlockStart || defaultStyle.marginBlockStart; - const marginTop = element.style.marginTop || defaultStyle.marginTop; - if (marginBlockStart && !marginTop) { - format.marginBlockStart = parseValueWithUnit(marginBlockStart) + 'px'; - } - - const marginBlockEnd = element.style.marginBlockEnd || defaultStyle.marginBlockEnd; - const marginBottom = element.style.marginBottom || defaultStyle.marginBottom; - if (marginBlockEnd && !marginBottom) { - format.marginBlockEnd = parseValueWithUnit(marginBlockEnd) + 'px'; - } }, apply: (format, element, context) => { MarginKeys.forEach(key => { @@ -56,13 +44,5 @@ export const marginFormatHandler: FormatHandler = { element.style[key] = value || '0'; } }); - - if (format.marginBlockStart && !format.marginTop) { - element.style.marginBlockStart = format.marginBlockStart; - } - - if (format.marginBlockEnd && !format.marginBottom) { - element.style.marginBlockEnd = format.marginBlockEnd; - } }, }; diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginFormatHandlerTest.ts index da4aa15fd5a..d1f6918e3c1 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginFormatHandlerTest.ts @@ -50,33 +50,12 @@ describe('marginFormatHandler.parse', () => { }); }); - it('Has margin block in CSS', () => { - div.style.marginBlockEnd = '1px'; - div.style.marginBlockStart = '1px'; - marginFormatHandler.parse(format, div, context, {}); - expect(format).toEqual({ - marginBlockEnd: '1px', - marginBlockStart: '1px', - }); - }); - - it('Has margin block in default style', () => { - marginFormatHandler.parse(format, div, context, { - marginBlockEnd: '1em', - marginBlockStart: '1em', - }); - expect(format).toEqual({ - marginBlockEnd: '0px', - marginBlockStart: '0px', - }); - }); - it('Merge margin values', () => { - div.style.marginBlockStart = '15pt'; - format.marginBlockStart = '30px'; + div.style.marginTop = '15pt'; + format.marginTop = '30px'; marginFormatHandler.parse(format, div, context, {}); expect(format).toEqual({ - marginBlockStart: '20px', + marginTop: '15pt', }); }); @@ -153,31 +132,4 @@ describe('marginFormatHandler.apply', () => { marginFormatHandler.apply(format, div, context); expect(div.outerHTML).toBe('
'); }); - - it('No margin block', () => { - marginFormatHandler.apply(format, div, context); - expect(div.outerHTML).toBe('
'); - }); - - it('Has margin block', () => { - format.marginBlockEnd = '1px'; - format.marginBlockStart = '2px'; - - marginFormatHandler.apply(format, div, context); - - expect(div.outerHTML).toBe('
'); - }); - - it('Do not overlay margin values with margin block values', () => { - format.marginTop = '1px'; - format.marginRight = '2px'; - format.marginBottom = '3px'; - format.marginLeft = '4px'; - format.marginBlockEnd = '5px'; - format.marginBlockStart = '6px'; - - marginFormatHandler.apply(format, div, context); - - expect(div.outerHTML).toBe('
'); - }); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteListTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteListTest.ts index 8d7cd3c356e..820c410160b 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteListTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteListTest.ts @@ -363,8 +363,8 @@ describe('deleteList', () => { { listType: 'UL', format: { - marginBlockStart: '0px', - marginBlockEnd: '0px', + marginTop: '0px', + marginBottom: '0px', listStyleType: 'disc', }, dataset: {}, diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginFormat.ts index 36deed9dcbc..7377b7f3588 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginFormat.ts @@ -21,14 +21,4 @@ export type MarginFormat = { * Margin left value */ marginLeft?: string; - - /** - * Margin-block start value - */ - marginBlockStart?: string; - - /** - * Margin-block end value - */ - marginBlockEnd?: string; }; diff --git a/packages/roosterjs-editor-dom/lib/list/VList.ts b/packages/roosterjs-editor-dom/lib/list/VList.ts index a8022ba1662..9ce4a0a16a7 100644 --- a/packages/roosterjs-editor-dom/lib/list/VList.ts +++ b/packages/roosterjs-editor-dom/lib/list/VList.ts @@ -332,8 +332,8 @@ export default class VList { */ removeMargins() { if (!this.rootList.style.marginTop && !this.rootList.style.marginBottom) { - this.rootList.style.marginBlockStart = '0px'; - this.rootList.style.marginBlockEnd = '0px'; + this.rootList.style.marginTop = '0px'; + this.rootList.style.marginBottom = '0px'; } } diff --git a/packages/roosterjs-editor-dom/lib/list/VListItem.ts b/packages/roosterjs-editor-dom/lib/list/VListItem.ts index cb165d0a060..dad80b6b83b 100644 --- a/packages/roosterjs-editor-dom/lib/list/VListItem.ts +++ b/packages/roosterjs-editor-dom/lib/list/VListItem.ts @@ -473,12 +473,9 @@ function createListElement( result = doc.createElement(listType == ListType.Ordered ? 'ol' : 'ul'); } - if ( - originalRoot?.style.marginBlockStart == '0px' && - originalRoot?.style.marginBlockEnd == '0px' - ) { - result.style.marginBlockStart = '0px'; - result.style.marginBlockEnd = '0px'; + if (originalRoot?.style.marginTop == '0px' && originalRoot?.style.marginBottom == '0px') { + result.style.marginTop = '0px'; + result.style.marginBottom = '0px'; } // Always maintain the metadata saved in the list diff --git a/packages/roosterjs-editor-dom/test/list/VListTest.ts b/packages/roosterjs-editor-dom/test/list/VListTest.ts index 0090990035e..7bc729d2ee0 100644 --- a/packages/roosterjs-editor-dom/test/list/VListTest.ts +++ b/packages/roosterjs-editor-dom/test/list/VListTest.ts @@ -1286,10 +1286,10 @@ describe('VList.split', () => { ); }); - it('split List 4 with margin-block', () => { + it('split List 4 with margin-top and bottom', () => { runTest( - `
  1. 1
    1. 1
    2. 2
    3. 3
  2. 3
  3. 4
`, - '
  1. 1
    1. 1
    2. 2
    3. 3
  2. 3
  3. 4
', + `
  1. 1
    1. 1
    2. 2
    3. 3
  2. 3
  3. 4
`, + '
  1. 1
    1. 1
    2. 2
    3. 3
  2. 3
  3. 4
', 9 ); }); @@ -1531,7 +1531,7 @@ describe('VList.removeMargins', () => { DomTestHelper.removeElement(testId); }); - function runTest(source: string, shouldNotRemoveMargin: boolean = false) { + function runTest(source: string, expectedMarginTop: string, expectedMarginBottom: string) { DomTestHelper.createElementFromContent(testId, source); const list = document.getElementById(ListRoot) as HTMLOListElement; @@ -1542,27 +1542,28 @@ describe('VList.removeMargins', () => { // Act vList.removeMargins(); - if (shouldNotRemoveMargin) { - expect(list.style.marginBlock).toEqual(''); - } else { - expect(list.style.marginBlock).toEqual('0px'); - } + + expect(list.style.marginTop).toBe(expectedMarginTop); + expect(list.style.marginBottom).toBe(expectedMarginBottom); DomTestHelper.removeElement(testId); } it('remove list margins OL list', () => { const list = `
    `; - runTest(list); + + runTest(list, '0px', '0px'); }); it('remove list margins UL list', () => { const list = `
      `; - runTest(list); + + runTest(list, '0px', '0px'); }); it('do not remove list margins UL list', () => { const list = `
      • test
      `; - runTest(list, true /** shouldNotRemoveMargin */); + + runTest(list, '1px', ''); }); }); From 704b19eb501488e03b5f1860d4c6e54d1422acf6 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 22 Jan 2024 10:04:33 -0800 Subject: [PATCH 025/112] Standalone Editor: Improve cache (3rd try) (#2344) Co-authored-by: Bryan Valverde U --- .../lib/coreApi/createContentModel.ts | 40 ++-- .../lib/coreApi/createEditorContext.ts | 4 +- .../lib/coreApi/setContentModel.ts | 4 +- .../lib/corePlugin/ContentModelCachePlugin.ts | 97 +++----- .../createStandaloneEditorCorePlugins.ts | 2 +- .../corePlugin/utils/textMutationObserver.ts | 48 ++++ .../test/coreApi/createContentModelTest.ts | 2 +- .../test/coreApi/createEditorContextTest.ts | 21 +- .../corePlugin/ContentModelCachePluginTest.ts | 225 ++++-------------- .../utils/textMutationObserverTest.ts | 115 +++++++++ .../lib/context/TextMutationObserver.ts | 19 ++ .../lib/editor/StandaloneEditorCore.ts | 4 +- .../lib/index.ts | 1 + .../ContentModelCachePluginState.ts | 6 + 14 files changed, 305 insertions(+), 283 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/textMutationObserver.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/textMutationObserverTest.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/context/TextMutationObserver.ts diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts index 3d01c9aeaa5..b495c656b2c 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts @@ -4,12 +4,7 @@ import { createDomToModelContextWithConfig, domToContentModel, } from 'roosterjs-content-model-dom'; -import type { - DOMSelection, - DomToModelOption, - CreateContentModel, - StandaloneEditorCore, -} from 'roosterjs-content-model-types'; +import type { CreateContentModel } from 'roosterjs-content-model-types'; /** * @internal @@ -30,9 +25,20 @@ export const createContentModel: CreateContentModel = (core, option, selectionOv return cachedModel; } else { const selection = selectionOverride || core.api.getDOMSelection(core) || undefined; - const model = internalCreateContentModel(core, selection, option); + const saveIndex = !option && !selectionOverride; + const editorContext = core.api.createEditorContext(core, saveIndex); + const domToModelContext = option + ? createDomToModelContext( + editorContext, + core.domToModelSettings.builtIn, + core.domToModelSettings.customized, + option + ) + : createDomToModelContextWithConfig(core.domToModelSettings.calculated, editorContext); - if (!option && !selectionOverride) { + const model = domToContentModel(core.contentDiv, domToModelContext, selection); + + if (saveIndex) { core.cache.cachedModel = model; core.cache.cachedSelection = selection; } @@ -40,21 +46,3 @@ export const createContentModel: CreateContentModel = (core, option, selectionOv return model; } }; - -function internalCreateContentModel( - core: StandaloneEditorCore, - selection?: DOMSelection, - option?: DomToModelOption -) { - const editorContext = core.api.createEditorContext(core); - const domToModelContext = option - ? createDomToModelContext( - editorContext, - core.domToModelSettings.builtIn, - core.domToModelSettings.customized, - option - ) - : createDomToModelContextWithConfig(core.domToModelSettings.calculated, editorContext); - - return domToContentModel(core.contentDiv, domToModelContext, selection); -} diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts index f9a1329f461..020eb6c71dd 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts @@ -4,7 +4,7 @@ import type { EditorContext, CreateEditorContext } from 'roosterjs-content-model * @internal * Create a EditorContext object used by ContentModel API */ -export const createEditorContext: CreateEditorContext = core => { +export const createEditorContext: CreateEditorContext = (core, saveIndex) => { const { lifecycle, format, darkColorHandler, contentDiv, cache } = core; const context: EditorContext = { @@ -14,7 +14,7 @@ export const createEditorContext: CreateEditorContext = core => { darkColorHandler: darkColorHandler, addDelimiterForEntity: true, allowCacheElement: true, - domIndexer: cache.domIndexer, + domIndexer: saveIndex ? cache.domIndexer : undefined, }; checkRootRtl(contentDiv, context); diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts index ef396794bfc..b247a141d75 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts @@ -13,7 +13,7 @@ import type { SetContentModel } from 'roosterjs-content-model-types'; * @param option Additional options to customize the behavior of Content Model to DOM conversion */ export const setContentModel: SetContentModel = (core, model, option, onNodeCreated) => { - const editorContext = core.api.createEditorContext(core); + const editorContext = core.api.createEditorContext(core, true /*saveIndex*/); const modelToDomContext = option ? createModelToDomContext( editorContext, @@ -40,6 +40,8 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea core.selection.selection = selection; } + // Clear pending mutations since we will use our latest model object to replace existing cache + core.cache.textMutationObserver?.flushMutations(); core.cache.cachedModel = model; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts index 892a85cb1e5..05608d2a8e3 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts @@ -1,10 +1,9 @@ import { areSameSelection } from './utils/areSameSelection'; import { contentModelDomIndexer } from './utils/contentModelDomIndexer'; -import { isCharacterValue } from '../publicApi/domUtils/eventUtils'; +import { createTextMutationObserver } from './utils/textMutationObserver'; import type { ContentModelCachePluginState, IStandaloneEditor, - KeyDownEvent, PluginEvent, PluginWithState, StandaloneEditorOptions, @@ -20,11 +19,15 @@ class ContentModelCachePlugin implements PluginWithState { + if (this.editor) { + if (isTextChangeOnly) { + this.updateCachedModel(this.editor, true /*forceUpdate*/); + } else { + this.invalidateCache(); + } + } + }; + private onNativeSelectionChange = () => { if (this.editor?.hasFocus()) { this.updateCachedModel(this.editor); @@ -150,48 +152,17 @@ class ContentModelCachePlugin implements PluginWithState { - return new ContentModelCachePlugin(option); + return new ContentModelCachePlugin(option, contentDiv); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts index 9ecbed73c79..1a3a95a71a1 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts @@ -21,7 +21,7 @@ export function createStandaloneEditorCorePlugins( contentDiv: HTMLDivElement ): StandaloneEditorCorePlugins { return { - cache: createContentModelCachePlugin(options), + cache: createContentModelCachePlugin(options, contentDiv), format: createContentModelFormatPlugin(options), copyPaste: createContentModelCopyPastePlugin(options), domEvent: createDOMEventPlugin(options, contentDiv), diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/textMutationObserver.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/textMutationObserver.ts new file mode 100644 index 00000000000..cfbddbbf881 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/textMutationObserver.ts @@ -0,0 +1,48 @@ +import type { TextMutationObserver } from 'roosterjs-content-model-types'; + +class TextMutationObserverImpl implements TextMutationObserver { + private observer: MutationObserver; + + constructor( + private contentDiv: HTMLDivElement, + private onMutation: (isTextChangeOnly: boolean) => void + ) { + this.observer = new MutationObserver(this.onMutationInternal); + } + + startObserving() { + this.observer.observe(this.contentDiv, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + }); + } + + stopObserving() { + this.observer.disconnect(); + } + + flushMutations() { + this.observer.takeRecords(); + } + + private onMutationInternal = (mutations: MutationRecord[]) => { + const firstTarget = mutations[0]?.target; + const isTextChangeOnly = mutations.every( + mutation => mutation.type == 'characterData' && mutation.target == firstTarget + ); + + this.onMutation(isTextChangeOnly); + }; +} + +/** + * @internal + */ +export function createTextMutationObserver( + contentDiv: HTMLDivElement, + onMutation: (isTextChangeOnly: boolean) => void +): TextMutationObserver { + return new TextMutationObserverImpl(contentDiv, onMutation); +} diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts index 49f99ac25cd..b1773a116cf 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts @@ -52,7 +52,7 @@ describe('createContentModel', () => { const model = createContentModel(core); - expect(createEditorContext).toHaveBeenCalledWith(core); + expect(createEditorContext).toHaveBeenCalledWith(core, true); expect(getDOMSelection).toHaveBeenCalledWith(core); expect(domToContentModelSpy).toHaveBeenCalledWith(mockedDiv, mockedContext, undefined); expect(model).toBe(mockedModel); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts index a7251eac6cd..fbcaad5743c 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts @@ -8,6 +8,7 @@ describe('createEditorContext', () => { const darkColorHandler = 'DARKHANDLER' as any; const getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); const getBoundingClientRectSpy = jasmine.createSpy('getBoundingClientRect'); + const domIndexer = 'DOMINDEXER' as any; const div = { ownerDocument: { @@ -27,10 +28,12 @@ describe('createEditorContext', () => { defaultFormat, }, darkColorHandler, - cache: {}, + cache: { + domIndexer: domIndexer, + }, } as any) as StandaloneEditorCore; - const context = createEditorContext(core); + const context = createEditorContext(core, false); expect(context).toEqual({ isDarkMode, @@ -74,7 +77,7 @@ describe('createEditorContext', () => { }, } as any) as StandaloneEditorCore; - const context = createEditorContext(core); + const context = createEditorContext(core, true); expect(context).toEqual({ isDarkMode, @@ -117,7 +120,7 @@ describe('createEditorContext', () => { cache: {}, } as any) as StandaloneEditorCore; - const context = createEditorContext(core); + const context = createEditorContext(core, false); expect(context).toEqual({ isDarkMode, @@ -171,7 +174,7 @@ describe('createEditorContext - checkZoomScale', () => { width: 100, }); - const context = createEditorContext(core); + const context = createEditorContext(core, false); expect(context).toEqual({ isDarkMode, @@ -191,7 +194,7 @@ describe('createEditorContext - checkZoomScale', () => { width: 100, }); - const context = createEditorContext(core); + const context = createEditorContext(core, false); expect(context).toEqual({ isDarkMode, @@ -211,7 +214,7 @@ describe('createEditorContext - checkZoomScale', () => { width: 100, }); - const context = createEditorContext(core); + const context = createEditorContext(core, false); expect(context).toEqual({ isDarkMode, @@ -265,7 +268,7 @@ describe('createEditorContext - checkRootDir', () => { direction: 'ltr', }); - const context = createEditorContext(core); + const context = createEditorContext(core, false); expect(context).toEqual({ isDarkMode, @@ -283,7 +286,7 @@ describe('createEditorContext - checkRootDir', () => { direction: 'rtl', }); - const context = createEditorContext(core); + const context = createEditorContext(core, false); expect(context).toEqual({ isDarkMode, diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts index ce777586b4e..c47f218bfa6 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts @@ -1,9 +1,12 @@ +import * as textMutationObserver from '../../lib/corePlugin/utils/textMutationObserver'; +import { contentModelDomIndexer } from '../../lib/corePlugin/utils/contentModelDomIndexer'; import { createContentModelCachePlugin } from '../../lib/corePlugin/ContentModelCachePlugin'; import { ContentModelCachePluginState, ContentModelDomIndexer, IStandaloneEditor, PluginWithState, + StandaloneEditorOptions, } from 'roosterjs-content-model-types'; describe('ContentModelCachePlugin', () => { @@ -16,8 +19,9 @@ describe('ContentModelCachePlugin', () => { let reconcileSelectionSpy: jasmine.Spy; let isInShadowEditSpy: jasmine.Spy; let domIndexer: ContentModelDomIndexer; + let contentDiv: HTMLDivElement; - function init() { + function init(option: StandaloneEditorOptions) { addEventListenerSpy = jasmine.createSpy('addEventListenerSpy'); removeEventListenerSpy = jasmine.createSpy('removeEventListener'); getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); @@ -28,6 +32,8 @@ describe('ContentModelCachePlugin', () => { reconcileSelection: reconcileSelectionSpy, } as any; + contentDiv = document.createElement('div'); + editor = ({ getDOMSelection: getDOMSelectionSpy, isInShadowEdit: isInShadowEditSpy, @@ -39,23 +45,47 @@ describe('ContentModelCachePlugin', () => { }, } as any) as IStandaloneEditor; - plugin = createContentModelCachePlugin({}); + plugin = createContentModelCachePlugin(option, contentDiv); plugin.initialize(editor); } describe('initialize', () => { - beforeEach(init); afterEach(() => { plugin.dispose(); }); it('initialize', () => { + init({}); expect(addEventListenerSpy).toHaveBeenCalledWith('selectionchange', jasmine.anything()); + expect(plugin.getState()).toEqual({}); + }); + + it('initialize with cache', () => { + const startObservingSpy = jasmine.createSpy('startObserving'); + const stopObservingSpy = jasmine.createSpy('stopObserving'); + const mockedObserver = { + startObserving: startObservingSpy, + stopObserving: stopObservingSpy, + } as any; + spyOn(textMutationObserver, 'createTextMutationObserver').and.returnValue( + mockedObserver + ); + init({ + cacheModel: true, + }); + expect(addEventListenerSpy).toHaveBeenCalledWith('selectionchange', jasmine.anything()); + expect(plugin.getState()).toEqual({ + domIndexer: contentModelDomIndexer, + textMutationObserver: mockedObserver, + }); + expect(startObservingSpy).toHaveBeenCalledTimes(1); }); }); describe('KeyDown event', () => { - beforeEach(init); + beforeEach(() => { + init({}); + }); afterEach(() => { plugin.dispose(); }); @@ -68,11 +98,7 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(plugin.getState()).toEqual({ - cachedModel: undefined, - cachedSelection: undefined, - domIndexer: undefined, - }); + expect(plugin.getState()).toEqual({}); }); it('Other key without selection', () => { @@ -83,11 +109,7 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(plugin.getState()).toEqual({ - cachedModel: undefined, - cachedSelection: undefined, - domIndexer: undefined, - }); + expect(plugin.getState()).toEqual({}); }); it('Other key with collapsed selection', () => { @@ -106,28 +128,6 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedSelection: { type: 'range', range: { collapsed: true } as any }, - domIndexer: undefined, - }); - }); - - it('Expanded selection with text input', () => { - const state = plugin.getState(); - state.cachedSelection = { - type: 'range', - range: { collapsed: false } as any, - }; - - plugin.onPluginEvent({ - eventType: 'keyDown', - rawEvent: { - key: 'B', - } as any, - }); - - expect(state).toEqual({ - cachedModel: undefined, - cachedSelection: undefined, - domIndexer: undefined, }); }); @@ -150,47 +150,6 @@ describe('ContentModelCachePlugin', () => { type: 'range', range: { collapsed: false } as any, }, - domIndexer: undefined, - }); - }); - - it('Table selection', () => { - const state = plugin.getState(); - state.cachedSelection = { - type: 'table', - } as any; - - plugin.onPluginEvent({ - eventType: 'keyDown', - rawEvent: { - key: 'B', - } as any, - }); - - expect(state).toEqual({ - cachedModel: undefined, - cachedSelection: undefined, - domIndexer: undefined, - }); - }); - - it('Image selection', () => { - const state = plugin.getState(); - state.cachedSelection = { - type: 'image', - } as any; - - plugin.onPluginEvent({ - eventType: 'keyDown', - rawEvent: { - key: 'B', - } as any, - }); - - expect(state).toEqual({ - cachedModel: undefined, - cachedSelection: undefined, - domIndexer: undefined, }); }); @@ -205,14 +164,14 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(state).toEqual({ - domIndexer: undefined, - }); + expect(state).toEqual({}); }); }); describe('Input event', () => { - beforeEach(init); + beforeEach(() => { + init({}); + }); afterEach(() => { plugin.dispose(); }); @@ -233,106 +192,14 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, - domIndexer: undefined, - }); - }); - - it('No cached range, has cached model', () => { - const selection = 'MockedRange' as any; - const model = 'MockedModel' as any; - const state = plugin.getState(); - - state.cachedModel = model; - state.cachedSelection = undefined; - - getDOMSelectionSpy.and.returnValue(selection); - - plugin.onPluginEvent({ - eventType: 'input', - rawEvent: null!, - }); - - expect(state).toEqual({ - cachedModel: undefined, - cachedSelection: undefined, - domIndexer: undefined, - }); - }); - - it('No cached range, has cached model, reconcile succeed', () => { - const selection = 'MockedRange' as any; - const model = 'MockedModel' as any; - const state = plugin.getState(); - - state.cachedModel = model; - state.cachedSelection = undefined; - state.domIndexer = domIndexer; - - getDOMSelectionSpy.and.returnValue(selection); - reconcileSelectionSpy.and.returnValue(true); - - plugin.onPluginEvent({ - eventType: 'input', - rawEvent: null!, - }); - - expect(state).toEqual({ - cachedModel: model, - cachedSelection: selection, - domIndexer: domIndexer, - }); - }); - - it('has cached range, has cached model', () => { - const oldRangeEx = 'MockedRangeOld' as any; - const newRangeEx = 'MockedRangeNew' as any; - const model = 'MockedModel' as any; - const state = plugin.getState(); - - state.cachedModel = model; - state.cachedSelection = oldRangeEx; - getDOMSelectionSpy.and.returnValue(newRangeEx); - - plugin.onPluginEvent({ - eventType: 'input', - rawEvent: null!, - }); - - expect(state).toEqual({ - cachedModel: undefined, - cachedSelection: undefined, - domIndexer: undefined, - }); - }); - - it('has cached range, has cached model, has domIndexer', () => { - const oldRangeEx = 'MockedRangeOld' as any; - const newRangeEx = 'MockedRangeNew' as any; - const model = 'MockedModel' as any; - const state = plugin.getState(); - - state.cachedModel = model; - state.cachedSelection = oldRangeEx; - state.domIndexer = domIndexer; - - getDOMSelectionSpy.and.returnValue(newRangeEx); - reconcileSelectionSpy.and.returnValue(true); - - plugin.onPluginEvent({ - eventType: 'input', - rawEvent: null!, - }); - - expect(state).toEqual({ - cachedModel: model, - cachedSelection: newRangeEx, - domIndexer, }); }); }); describe('SelectionChanged', () => { - beforeEach(init); + beforeEach(() => { + init({}); + }); afterEach(() => { plugin.dispose(); }); @@ -416,7 +283,9 @@ describe('ContentModelCachePlugin', () => { }); describe('ContentChanged', () => { - beforeEach(init); + beforeEach(() => { + init({}); + }); afterEach(() => { plugin.dispose(); }); @@ -436,7 +305,6 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, - domIndexer: undefined, }); expect(reconcileSelectionSpy).not.toHaveBeenCalled(); }); @@ -462,7 +330,6 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, - domIndexer: undefined, }); expect(reconcileSelectionSpy).not.toHaveBeenCalled(); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/textMutationObserverTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/textMutationObserverTest.ts new file mode 100644 index 00000000000..b0cd900713b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/textMutationObserverTest.ts @@ -0,0 +1,115 @@ +import * as TextMutationObserver from '../../../lib/corePlugin/utils/textMutationObserver'; + +describe('TextMutationObserverImpl', () => { + it('init', () => { + const div = document.createElement('div'); + const onMutation = jasmine.createSpy('onMutation'); + TextMutationObserver.createTextMutationObserver(div, onMutation); + + expect(onMutation).not.toHaveBeenCalled(); + }); + + it('not text change', async () => { + const div = document.createElement('div'); + const onMutation = jasmine.createSpy('onMutation'); + const observer = TextMutationObserver.createTextMutationObserver(div, onMutation); + + observer.startObserving(); + + div.appendChild(document.createElement('br')); + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith(false); + }); + + it('text change', async () => { + const div = document.createElement('div'); + const text = document.createTextNode('test'); + + div.appendChild(text); + + const onMutation = jasmine.createSpy('onMutation'); + const observer = TextMutationObserver.createTextMutationObserver(div, onMutation); + + observer.startObserving(); + + text.nodeValue = '1'; + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith(true); + }); + + it('text change in deeper node', async () => { + const div = document.createElement('div'); + const span = document.createElement('span'); + const text = document.createTextNode('test'); + + span.appendChild(text); + div.appendChild(span); + + const onMutation = jasmine.createSpy('onMutation'); + const observer = TextMutationObserver.createTextMutationObserver(div, onMutation); + + observer.startObserving(); + + text.nodeValue = '1'; + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith(true); + }); + + it('text and non-text change', async () => { + const div = document.createElement('div'); + const text = document.createTextNode('test'); + + div.appendChild(text); + + const onMutation = jasmine.createSpy('onMutation'); + const observer = TextMutationObserver.createTextMutationObserver(div, onMutation); + + observer.startObserving(); + + text.nodeValue = '1'; + div.appendChild(document.createElement('br')); + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(onMutation).toHaveBeenCalledTimes(1); + expect(onMutation).toHaveBeenCalledWith(false); + }); + + it('flush mutation', async () => { + const div = document.createElement('div'); + const text = document.createTextNode('test'); + + div.appendChild(text); + + const onMutation = jasmine.createSpy('onMutation'); + const observer = TextMutationObserver.createTextMutationObserver(div, onMutation); + + observer.startObserving(); + + text.nodeValue = '1'; + observer.flushMutations(); + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + + expect(onMutation).not.toHaveBeenCalled(); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/TextMutationObserver.ts b/packages-content-model/roosterjs-content-model-types/lib/context/TextMutationObserver.ts new file mode 100644 index 00000000000..96fc813ada0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/context/TextMutationObserver.ts @@ -0,0 +1,19 @@ +/** + * A wrapper of MutationObserver to observe text change from editor + */ +export interface TextMutationObserver { + /** + * Start observing mutations from editor + */ + startObserving(): void; + + /** + * Stop observing mutations from editor + */ + stopObserving(): void; + + /** + * Flush all pending mutations that have not be handled in order to ignore them + */ + flushMutations(): void; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts index 776a2fa11df..c256bbe7435 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts @@ -25,8 +25,9 @@ import type { /** * Create a EditorContext object used by ContentModel API * @param core The StandaloneEditorCore object + * @param saveIndex True to allow saving index info into node using domIndexer, otherwise false */ -export type CreateEditorContext = (core: StandaloneEditorCore) => EditorContext; +export type CreateEditorContext = (core: StandaloneEditorCore, saveIndex: boolean) => EditorContext; /** * Create Content Model from DOM tree in this editor @@ -176,6 +177,7 @@ export interface StandaloneCoreApiMap { /** * Create a EditorContext object used by ContentModel API * @param core The StandaloneEditorCore object + * @param saveIndex True to allow saving index info into node using domIndexer, otherwise false */ createEditorContext: CreateEditorContext; 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 e4b65bf7679..e43dcd77bc4 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -184,6 +184,7 @@ export { export { DomToModelOption } from './context/DomToModelOption'; export { ModelToDomOption } from './context/ModelToDomOption'; export { ContentModelDomIndexer } from './context/ContentModelDomIndexer'; +export { TextMutationObserver } from './context/TextMutationObserver'; export { DefinitionType } from './metadata/DefinitionType'; export { diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts index b9fbc4befcb..82353224c75 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts @@ -1,3 +1,4 @@ +import type { TextMutationObserver } from '../context/TextMutationObserver'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { ContentModelDomIndexer } from '../context/ContentModelDomIndexer'; import type { DOMSelection } from '../selection/DOMSelection'; @@ -20,4 +21,9 @@ export interface ContentModelCachePluginState { * @optional Indexer for content model, to help build backward relationship from DOM node to Content Model */ domIndexer?: ContentModelDomIndexer; + + /** + * @optional A wrapper of MutationObserver to help detect text changes in editor + */ + textMutationObserver?: TextMutationObserver; } From 462c4799d556d2ad8d437781e27d6a3979829482 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 22 Jan 2024 15:46:58 -0300 Subject: [PATCH 026/112] fixes --- .../lib/autoFormat/ContentModelAutoFormatPlugin.ts | 4 +--- .../lib/autoFormat/keyboardListTrigger.ts | 2 ++ .../test/autoFormat/ContentModelAutoFormatPluginTest.ts | 1 + .../test/autoFormat/keyboardListTriggerTest.ts | 3 +++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts index 759951f922f..63589ea9e99 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -92,10 +92,8 @@ export class ContentModelAutoFormatPlugin implements EditorPlugin { case ' ': const { autoBullet, autoNumbering } = this.options; if (autoBullet || autoNumbering) { - event.rawEvent.preventDefault(); - keyboardListTrigger(editor, autoBullet, autoNumbering); + keyboardListTrigger(editor, rawEvent, autoBullet, autoNumbering); } - break; } } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts index dc837123cb7..a8d74788ce7 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -8,6 +8,7 @@ import type { ContentModelDocument, IStandaloneEditor } from 'roosterjs-content- */ export function keyboardListTrigger( editor: IStandaloneEditor, + rawEvent: KeyboardEvent, shouldSearchForBullet: boolean = true, shouldSearchForNumbering: boolean = true ) { @@ -24,6 +25,7 @@ export function keyboardListTrigger( } const { listType, styleType, index } = listStyleType; triggerList(editor, model, listType, styleType, index); + rawEvent.preventDefault(); return true; } return false; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts index 0c000d327b1..3060811a4b3 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts @@ -36,6 +36,7 @@ describe('Content Model Auto Format Plugin Test', () => { if (shouldCallTrigger) { expect(keyboardListTriggerSpy).toHaveBeenCalledWith( editor, + event.rawEvent, options?.autoBullet ?? true, options?.autoNumbering ?? true ); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts index 0432e5ca0c6..4d067fb9cfd 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts @@ -25,6 +25,9 @@ describe('keyboardListTrigger', () => { focus: () => {}, formatContentModel: formatWithContentModelSpy, } as any, + { + preventDefault: () => {}, + } as KeyboardEvent, shouldSearchForBullet, shouldSearchForNumbering ); From 52d83f739efa52c6dc822f067a61574cbd074131 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 22 Jan 2024 16:53:17 -0300 Subject: [PATCH 027/112] fix test --- .../lib/autoFormat/keyboardListTrigger.ts | 9 +++++---- .../autoFormat/keyboardListTriggerTest.ts | 19 ++++++++----------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts index a8d74788ce7..7e1951bcc8d 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -19,9 +19,9 @@ export function keyboardListTrigger( shouldSearchForNumbering ); if (listStyleType) { - const paragraph = getSelectedSegmentsAndParagraphs(model, false)[0][1]; - if (paragraph) { - paragraph.segments.splice(0, 1); + const segmentsAndParagraphs = getSelectedSegmentsAndParagraphs(model, false); + if (segmentsAndParagraphs[0] && segmentsAndParagraphs[0][1]) { + segmentsAndParagraphs[0][1].segments.splice(0, 1); } const { listType, styleType, index } = listStyleType; triggerList(editor, model, listType, styleType, index); @@ -41,7 +41,8 @@ const triggerList = ( ) => { setListType(model, listType); const isOrderedList = listType == 'OL'; - if (index && isOrderedList) { + // If the index < 1, it is a new list, so it will be starting by 1, then no need to set startNumber + if (index && index > 1 && isOrderedList) { setListStartNumber(editor, index); } setListStyle( diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts index 4d067fb9cfd..0822ac97084 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts @@ -84,12 +84,11 @@ describe('keyboardListTrigger', () => { { listType: 'OL', format: { - marginTop: undefined, - marginBlockEnd: '0px', + marginTop: '0px', + marginBottom: '0px', startNumberOverride: 1, direction: undefined, textAlign: undefined, - marginBlockStart: '0px', }, dataset: { editingInfo: '{"orderedStyleType":3}', @@ -141,7 +140,7 @@ describe('keyboardListTrigger', () => { listType: 'OL', format: { marginTop: '0px', - marginBlockEnd: '0px', + marginBottom: '0px', }, dataset: { editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', @@ -201,7 +200,7 @@ describe('keyboardListTrigger', () => { listType: 'OL', format: { marginTop: '0px', - marginBlockEnd: '0px', + marginBottom: '0px', }, dataset: { editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', @@ -241,9 +240,8 @@ describe('keyboardListTrigger', () => { startNumberOverride: 2, direction: undefined, textAlign: undefined, - marginBlockStart: '0px', - marginTop: undefined, - marginBlockEnd: '0px', + marginBottom: '0px', + marginTop: '0px', }, dataset: { editingInfo: '{"orderedStyleType":3}', @@ -368,12 +366,11 @@ describe('keyboardListTrigger', () => { { listType: 'UL', format: { - marginTop: undefined, - marginBlockEnd: '0px', + marginTop: '0px', + marginBottom: '0px', startNumberOverride: 1, direction: undefined, textAlign: undefined, - marginBlockStart: '0px', }, dataset: { editingInfo: '{"unorderedStyleType":1}', From a42eb96e8c0ca932d4c9e08c59a60527b0c87fba Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 22 Jan 2024 13:51:16 -0800 Subject: [PATCH 028/112] Fix safari selection issue (#2332) * try fix safari selection issue * fix content model editor, add test * fix focus bug --- .../lib/coreApi/getDOMSelection.ts | 10 +- .../lib/corePlugin/SelectionPlugin.ts | 45 ++-- .../test/coreApi/getDOMSelectionTest.ts | 81 +++++- .../test/corePlugin/SelectionPluginTest.ts | 234 ++++++++++++++++++ .../lib/coreApi/focus.ts | 6 +- .../lib/corePlugins/DOMEventPlugin.ts | 32 +-- 6 files changed, 355 insertions(+), 53 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts index a693df4c426..3ba745b7f72 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts @@ -8,9 +8,15 @@ import type { * @internal */ export const getDOMSelection: GetDOMSelection = core => { + const selection = core.selection.selection; + return core.lifecycle.shadowEditFragment - ? null - : core.selection.selection ?? getNewSelection(core); + ? null // 1. In shadow editor, always return null + : selection && selection.type != 'range' + ? selection // 2. Editor has Table Selection or Image Selection, use it + : core.api.hasFocus(core) + ? getNewSelection(core) // 3. Not Table/Image selection, and editor has focus, pull a latest selection from DOM + : selection; // 4. Fallback to cached selection for all other cases }; function getNewSelection(core: StandaloneEditorCore): DOMSelection | null { diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts index f06475293d9..ea1b327febc 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts @@ -16,6 +16,7 @@ class SelectionPlugin implements PluginWithState { private editor: IStandaloneEditor | null = null; private state: SelectionPluginState; private disposer: (() => void) | null = null; + private isSafari = false; constructor(options: StandaloneEditorOptions) { this.state = { @@ -41,10 +42,10 @@ class SelectionPlugin implements PluginWithState { const env = this.editor.getEnvironment(); const document = this.editor.getDocument(); - if (env.isSafari) { - document.addEventListener('mousedown', this.onMouseDownDocument, true /*useCapture*/); - document.addEventListener('keydown', this.onKeyDownDocument); - document.defaultView?.addEventListener('blur', this.onBlur); + this.isSafari = !!env.isSafari; + + if (this.isSafari) { + document.addEventListener('selectionchange', this.onSelectionChangeSafari); this.disposer = this.editor.attachDomEvent({ focus: { beforeDispatch: this.onFocus } }); } else { this.disposer = this.editor.attachDomEvent({ @@ -55,6 +56,10 @@ class SelectionPlugin implements PluginWithState { } dispose() { + this.editor + ?.getDocument() + .removeEventListener('selectionchange', this.onSelectionChangeSafari); + if (this.state.selectionStyleNode) { this.state.selectionStyleNode.parentNode?.removeChild(this.state.selectionStyleNode); this.state.selectionStyleNode = null; @@ -65,19 +70,7 @@ class SelectionPlugin implements PluginWithState { this.disposer = null; } - if (this.editor) { - const document = this.editor.getDocument(); - - document.removeEventListener( - 'mousedown', - this.onMouseDownDocument, - true /*useCapture*/ - ); - document.removeEventListener('keydown', this.onKeyDownDocument); - document.defaultView?.removeEventListener('blur', this.onBlur); - - this.editor = null; - } + this.editor = null; } getState(): SelectionPluginState { @@ -179,7 +172,7 @@ class SelectionPlugin implements PluginWithState { this.editor?.setDOMSelection(this.state.selection); } - if (this.state.selection?.type == 'range') { + if (this.state.selection?.type == 'range' && !this.isSafari) { // Editor is focused, now we can get live selection. So no need to keep a selection if the selection type is range. this.state.selection = null; } @@ -191,15 +184,15 @@ class SelectionPlugin implements PluginWithState { } }; - private onKeyDownDocument = (event: KeyboardEvent) => { - if (event.key == 'Tab' && !event.defaultPrevented) { - this.onBlur(); - } - }; + private onSelectionChangeSafari = () => { + if (this.editor?.hasFocus() && !this.editor.isInShadowEdit()) { + // Safari has problem to handle onBlur event. When blur, we cannot get the original selection from editor. + // So we always save a selection whenever editor has focus. Then after blur, we can still use this cached selection. + const newSelection = this.editor.getDOMSelection(); - private onMouseDownDocument = (event: MouseEvent) => { - if (this.editor && !this.editor.isNodeInEditor(event.target as Node)) { - this.onBlur(); + if (newSelection?.type == 'range') { + this.state.selection = newSelection; + } } }; } diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts index d689ff28b48..cfbc93a5623 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts @@ -4,11 +4,13 @@ import { StandaloneEditorCore } from 'roosterjs-content-model-types'; describe('getDOMSelection', () => { let core: StandaloneEditorCore; let getSelectionSpy: jasmine.Spy; + let hasFocusSpy: jasmine.Spy; let containsSpy: jasmine.Spy; beforeEach(() => { getSelectionSpy = jasmine.createSpy('getSelection'); containsSpy = jasmine.createSpy('contains'); + hasFocusSpy = jasmine.createSpy('hasFocus'); core = { lifecycle: {}, @@ -21,6 +23,9 @@ describe('getDOMSelection', () => { }, contains: containsSpy, }, + api: { + hasFocus: hasFocusSpy, + }, } as any; }); @@ -30,6 +35,7 @@ describe('getDOMSelection', () => { }; getSelectionSpy.and.returnValue(mockedSelection); + hasFocusSpy.and.returnValue(true); const result = getDOMSelection(core); @@ -47,6 +53,7 @@ describe('getDOMSelection', () => { getSelectionSpy.and.returnValue(mockedSelection); containsSpy.and.returnValue(false); + hasFocusSpy.and.returnValue(true); const result = getDOMSelection(core); @@ -65,6 +72,7 @@ describe('getDOMSelection', () => { getSelectionSpy.and.returnValue(mockedSelection); containsSpy.and.returnValue(true); + hasFocusSpy.and.returnValue(true); const result = getDOMSelection(core); @@ -74,10 +82,81 @@ describe('getDOMSelection', () => { }); }); - it('has cached selection', () => { + it('has cached selection, editor is in shadowEdit', () => { const mockedSelection = 'SELECTION' as any; core.selection.selection = mockedSelection; + core.lifecycle.shadowEditFragment = true as any; + containsSpy.and.returnValue(true); + hasFocusSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toBe(null); + }); + + it('has cached table selection, editor has focus', () => { + const mockedSelection = { + type: 'table', + } as any; + core.selection.selection = mockedSelection; + + hasFocusSpy.and.returnValue(true); + containsSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toBe(mockedSelection); + }); + + it('has cached image selection, editor has focus', () => { + const mockedSelection = { + type: 'image', + } as any; + core.selection.selection = mockedSelection; + + hasFocusSpy.and.returnValue(true); + containsSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toBe(mockedSelection); + }); + + it('has cached range selection, editor has focus', () => { + const mockedSelection = { + type: 'range', + } as any; + const mockedElement = 'ELEMENT' as any; + const mockedRange = { + commonAncestorContainer: mockedElement, + } as any; + const mockedSelectionNew = { + rangeCount: 1, + getRangeAt: () => mockedRange, + }; + + core.selection.selection = mockedSelection; + getSelectionSpy.and.returnValue(mockedSelectionNew); + + hasFocusSpy.and.returnValue(true); + containsSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toEqual({ + type: 'range', + range: mockedRange, + }); + }); + + it('has cached range selection, editor does not have focus', () => { + const mockedSelection = { + type: 'image', + } as any; + core.selection.selection = mockedSelection; + + hasFocusSpy.and.returnValue(false); containsSpy.and.returnValue(true); const result = getDOMSelection(core); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts index a8920c267ae..3e44b01f148 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts @@ -569,3 +569,237 @@ describe('SelectionPlugin handle image selection', () => { expect(setDOMSelectionSpy).not.toHaveBeenCalled(); }); }); + +describe('SelectionPlugin on Safari', () => { + let disposer: jasmine.Spy; + let createElementSpy: jasmine.Spy; + let appendChildSpy: jasmine.Spy; + let attachDomEvent: jasmine.Spy; + let removeEventListenerSpy: jasmine.Spy; + let addEventListenerSpy: jasmine.Spy; + let getDocumentSpy: jasmine.Spy; + let hasFocusSpy: jasmine.Spy; + let isInShadowEditSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let editor: IStandaloneEditor; + + beforeEach(() => { + disposer = jasmine.createSpy('disposer'); + createElementSpy = jasmine.createSpy('createElement').and.returnValue(MockedStyleNode); + appendChildSpy = jasmine.createSpy('appendChild'); + attachDomEvent = jasmine.createSpy('attachDomEvent').and.returnValue(disposer); + removeEventListenerSpy = jasmine.createSpy('removeEventListener'); + addEventListenerSpy = jasmine.createSpy('addEventListener'); + getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ + createElement: createElementSpy, + head: { + appendChild: appendChildSpy, + }, + addEventListener: addEventListenerSpy, + removeEventListener: removeEventListenerSpy, + }); + hasFocusSpy = jasmine.createSpy('hasFocus'); + isInShadowEditSpy = jasmine.createSpy('isInShadowEdit'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + + editor = ({ + getDocument: getDocumentSpy, + attachDomEvent, + getEnvironment: () => ({ + isSafari: true, + }), + hasFocus: hasFocusSpy, + isInShadowEdit: isInShadowEditSpy, + getDOMSelection: getDOMSelectionSpy, + } as any) as IStandaloneEditor; + }); + + it('init and dispose', () => { + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + + plugin.initialize(editor); + + expect(state).toEqual({ + selection: null, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + }); + expect(attachDomEvent).toHaveBeenCalled(); + expect(addEventListenerSpy).toHaveBeenCalledWith('selectionchange', jasmine.anything()); + expect(removeEventListenerSpy).not.toHaveBeenCalled(); + expect(disposer).not.toHaveBeenCalled(); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + + plugin.dispose(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('selectionchange', onSelectionChange); + expect(disposer).toHaveBeenCalled(); + }); + + it('onSelectionChange when editor has focus, no selection, not in shadow edit', () => { + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = 'OLDSELECTION' as any; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + + hasFocusSpy.and.returnValue(true); + isInShadowEditSpy.and.returnValue(false); + getDOMSelectionSpy.and.returnValue(null); + + onSelectionChange(); + + expect(state).toEqual({ + selection: mockedOldSelection, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + }); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); + }); + + it('onSelectionChange when editor has focus, range selection, not in shadow edit', () => { + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = 'OLDSELECTION' as any; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + const mockedNewSelection = { + type: 'range', + } as any; + + hasFocusSpy.and.returnValue(true); + isInShadowEditSpy.and.returnValue(false); + getDOMSelectionSpy.and.returnValue(mockedNewSelection); + + onSelectionChange(); + + expect(state).toEqual({ + selection: mockedNewSelection, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + }); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); + }); + + it('onSelectionChange when editor has focus, table selection, not in shadow edit', () => { + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = 'OLDSELECTION' as any; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + const mockedNewSelection = { + type: 'table', + } as any; + + hasFocusSpy.and.returnValue(true); + isInShadowEditSpy.and.returnValue(false); + getDOMSelectionSpy.and.returnValue(mockedNewSelection); + + onSelectionChange(); + + expect(state).toEqual({ + selection: mockedOldSelection, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + }); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); + }); + + it('onSelectionChange when editor has focus, image selection, not in shadow edit', () => { + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = 'OLDSELECTION' as any; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + const mockedNewSelection = { + type: 'image', + } as any; + + hasFocusSpy.and.returnValue(true); + isInShadowEditSpy.and.returnValue(false); + getDOMSelectionSpy.and.returnValue(mockedNewSelection); + + onSelectionChange(); + + expect(state).toEqual({ + selection: mockedOldSelection, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + }); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); + }); + + it('onSelectionChange when editor has focus, is in shadow edit', () => { + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = 'OLDSELECTION' as any; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + const mockedNewSelection = { + type: 'range', + } as any; + + hasFocusSpy.and.returnValue(true); + isInShadowEditSpy.and.returnValue(true); + getDOMSelectionSpy.and.returnValue(mockedNewSelection); + + onSelectionChange(); + + expect(state).toEqual({ + selection: mockedOldSelection, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + }); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); + + it('onSelectionChange when editor does not have focus', () => { + const plugin = createSelectionPlugin({}); + const state = plugin.getState(); + const mockedOldSelection = 'OLDSELECTION' as any; + + state.selection = mockedOldSelection; + + plugin.initialize(editor); + + const onSelectionChange = addEventListenerSpy.calls.argsFor(0)[1] as Function; + const mockedNewSelection = { + type: 'range', + } as any; + + hasFocusSpy.and.returnValue(false); + isInShadowEditSpy.and.returnValue(false); + getDOMSelectionSpy.and.returnValue(mockedNewSelection); + + onSelectionChange(); + + expect(state).toEqual({ + selection: mockedOldSelection, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + }); + expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/roosterjs-editor-core/lib/coreApi/focus.ts b/packages/roosterjs-editor-core/lib/coreApi/focus.ts index 4490f5a24a0..5b86767a890 100644 --- a/packages/roosterjs-editor-core/lib/coreApi/focus.ts +++ b/packages/roosterjs-editor-core/lib/coreApi/focus.ts @@ -1,4 +1,4 @@ -import { createRange, getFirstLeafNode } from 'roosterjs-editor-dom'; +import { Browser, createRange, getFirstLeafNode } from 'roosterjs-editor-dom'; import { PositionType } from 'roosterjs-editor-types'; import type { EditorCore, Focus } from 'roosterjs-editor-types'; @@ -34,7 +34,9 @@ export const focus: Focus = (core: EditorCore) => { } // remember to clear cached selection range - core.domEvent.selectionRange = null; + if (!Browser.isSafari) { + core.domEvent.selectionRange = null; + } // This is more a fallback to ensure editor gets focus if it didn't manage to move focus to editor if (!core.api.hasFocus(core)) { diff --git a/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts b/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts index 206ab44193e..3a924751ec5 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts @@ -95,9 +95,7 @@ export default class DOMEventPlugin implements PluginWithState; @@ -121,13 +119,7 @@ export default class DOMEventPlugin implements PluginWithState { - if (event.which == Keys.TAB && !event.defaultPrevented) { - this.cacheSelection(); + if (!Browser.isSafari) { + this.state.selectionRange = null; } }; - private onMouseDownDocument = (event: MouseEvent) => { - if ( - this.editor && - !this.state.selectionRange && - !this.editor.contains(event.target as Node) - ) { - this.cacheSelection(); + private onSelectionChangeSafari = () => { + // Safari has problem to handle onBlur event. When blur, we cannot get the original selection from editor. + // So we always save a selection whenever editor has focus. Then after blur, we can still use this cached selection. + if (this.editor?.hasFocus() && !this.editor.isInShadowEdit()) { + this.state.selectionRange = this.editor.getSelectionRange(false /*tryGetFromCache*/); } }; @@ -196,6 +183,7 @@ export default class DOMEventPlugin implements PluginWithState { this.editor?.triggerPluginEvent(PluginEventType.Scroll, { rawEvent: e, From 019f86112b7eb46b2a350ad257de92eea6b5cc9b Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 22 Jan 2024 14:53:33 -0800 Subject: [PATCH 029/112] Standalone Editor: Remove dependency to old code from api and plugins package (#2349) --- .../controls/ContentModelEditorMainPane.tsx | 9 +- .../lib/modelApi/link/matchLink.ts | 114 ++++++++ .../lib/publicApi/entity/insertEntity.ts | 18 +- .../lib/publicApi/link/insertLink.ts | 15 +- .../roosterjs-content-model-api/package.json | 2 - .../test/modelApi/link/matchLinkTest.ts | 260 ++++++++++++++++++ .../test/publicApi/link/insertLinkTest.ts | 43 +++ .../publicApi/segment/segmentTestCommon.ts | 2 - .../lib/corePlugins/BridgePlugin.ts | 3 + .../lib/corePlugins}/EntityDelimiterPlugin.ts | 13 +- .../test/corePlugins/BridgePluginTest.ts | 5 +- .../lib/index.ts | 1 - .../package.json | 2 - ...processPastedContentFromWordDesktopTest.ts | 38 ++- .../lib/createContentModelEditor.ts | 8 +- 15 files changed, 463 insertions(+), 70 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-api/lib/modelApi/link/matchLink.ts create mode 100644 packages-content-model/roosterjs-content-model-api/test/modelApi/link/matchLinkTest.ts rename packages-content-model/{roosterjs-content-model-plugins/lib/entityDelimiter => roosterjs-content-model-editor/lib/corePlugins}/EntityDelimiterPlugin.ts (97%) diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 7ddadb797fc..86717594ce3 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -16,6 +16,7 @@ import SampleEntityPlugin from './sampleEntity/SampleEntityPlugin'; import SidePane from './sidePane/SidePane'; import TitleBar from './titleBar/TitleBar'; import { arrayPush } from 'roosterjs-editor-dom'; +import { ContentModelEditPlugin, ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; import { ContentModelSegmentFormat, Snapshots } from 'roosterjs-content-model-types'; import { createEmojiPlugin, createPasteOptionPlugin } from 'roosterjs-react'; @@ -23,11 +24,6 @@ import { EditorPlugin } from 'roosterjs-editor-types'; import { getDarkColor } from 'roosterjs-color-utils'; import { PartialTheme } from '@fluentui/react/lib/Theme'; import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; -import { - ContentModelEditPlugin, - ContentModelPastePlugin, - EntityDelimiterPlugin, -} from 'roosterjs-content-model-plugins'; import { ContentModelEditor, ContentModelEditorOptions, @@ -105,7 +101,6 @@ class ContentModelEditorMainPane extends MainPaneBase private pasteOptionPlugin: EditorPlugin; private emojiPlugin: EditorPlugin; private snapshotPlugin: ContentModelSnapshotPlugin; - private entityDelimiterPlugin: EntityDelimiterPlugin; private toggleablePlugins: EditorPlugin[] | null = null; private formatPainterPlugin: ContentModelFormatPainterPlugin; private pastePlugin: ContentModelPastePlugin; @@ -133,7 +128,6 @@ class ContentModelEditorMainPane extends MainPaneBase this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.pasteOptionPlugin = createPasteOptionPlugin(); this.emojiPlugin = createEmojiPlugin(); - this.entityDelimiterPlugin = new EntityDelimiterPlugin(); this.formatPainterPlugin = new ContentModelFormatPainterPlugin(); this.pastePlugin = new ContentModelPastePlugin(); this.sampleEntityPlugin = new SampleEntityPlugin(); @@ -195,7 +189,6 @@ class ContentModelEditorMainPane extends MainPaneBase this.contentModelPanePlugin.getInnerRibbonPlugin(), this.pasteOptionPlugin, this.emojiPlugin, - this.entityDelimiterPlugin, this.sampleEntityPlugin, ]; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/link/matchLink.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/link/matchLink.ts new file mode 100644 index 00000000000..b2238fe08e9 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/link/matchLink.ts @@ -0,0 +1,114 @@ +import { getObjectKeys } from 'roosterjs-content-model-dom'; + +/** + * @internal + */ +export interface LinkData { + /** + * Schema of a hyperlink + */ + scheme: string; + + /** + * Original url of a hyperlink + */ + originalUrl: string; + + /** + * Normalized url of a hyperlink + */ + normalizedUrl: string; +} + +interface LinkMatchRule { + match: RegExp; + except?: RegExp; + normalizeUrl?: (url: string) => string; +} + +// http exclude matching regex +// invalid URL example (in particular on IE and Edge): +// - http://www.bing.com%00, %00 before ? (question mark) is considered invalid. IE/Edge throws invalid argument exception +// - http://www.bing.com%1, %1 is invalid +// - http://www.bing.com%g, %g is invalid (IE and Edge expects a two hex value after a %) +// - http://www.bing.com%, % as ending is invalid (IE and Edge expects a two hex value after a %) +// All above % cases if they're after ? (question mark) is then considered valid again +// Similar for @, it needs to be after / (forward slash), or ? (question mark). Otherwise IE/Edge will throw security exception +// - http://www.bing.com@name, @name before ? (question mark) is considered invalid +// - http://www.bing.com/@name, is valid sine it is after / (forward slash) +// - http://www.bing.com?@name, is also valid since it is after ? (question mark) +// The regex below is essentially a break down of: +// ^[^?]+%[^0-9a-f]+ => to exclude URL like www.bing.com%% +// ^[^?]+%[0-9a-f][^0-9a-f]+ => to exclude URL like www.bing.com%1 +// ^[^?]+%00 => to exclude URL like www.bing.com%00 +// ^[^?]+%$ => to exclude URL like www.bing.com% +// ^https?:\/\/[^?\/]+@ => to exclude URL like http://www.bing.com@name +// ^www\.[^?\/]+@ => to exclude URL like www.bing.com@name +// , => to exclude url like www.bing,,com +const httpExcludeRegEx = /^[^?]+%[^0-9a-f]+|^[^?]+%[0-9a-f][^0-9a-f]+|^[^?]+%00|^[^?]+%$|^https?:\/\/[^?\/]+@|^www\.[^?\/]+@/i; + +// via https://tools.ietf.org/html/rfc1035 Page 7 +const labelRegEx = '[a-z0-9](?:[a-z0-9-]*[a-z0-9])?'; // We're using case insensitive regexps below so don't bother including A-Z +const domainNameRegEx = `(?:${labelRegEx}\\.)*${labelRegEx}`; +const domainPortRegEx = `${domainNameRegEx}(?:\\:[0-9]+)?`; +const domainPortWithUrlRegEx = `${domainPortRegEx}(?:[\\/\\?]\\S*)?`; + +const linkMatchRules: Record = { + http: { + match: new RegExp( + `^(?:microsoft-edge:)?http:\\/\\/${domainPortWithUrlRegEx}|www\\.${domainPortWithUrlRegEx}`, + 'i' + ), + except: httpExcludeRegEx, + normalizeUrl: url => + new RegExp('^(?:microsoft-edge:)?http:\\/\\/', 'i').test(url) ? url : 'http://' + url, + }, + https: { + match: new RegExp(`^(?:microsoft-edge:)?https:\\/\\/${domainPortWithUrlRegEx}`, 'i'), + except: httpExcludeRegEx, + }, + mailto: { match: new RegExp('^mailto:\\S+@\\S+\\.\\S+', 'i') }, + notes: { match: new RegExp('^notes:\\/\\/\\S+', 'i') }, + file: { match: new RegExp('^file:\\/\\/\\/?\\S+', 'i') }, + unc: { match: new RegExp('^\\\\\\\\\\S+', 'i') }, + ftp: { + match: new RegExp( + `^ftp:\\/\\/${domainPortWithUrlRegEx}|ftp\\.${domainPortWithUrlRegEx}`, + 'i' + ), + normalizeUrl: url => (new RegExp('^ftp:\\/\\/', 'i').test(url) ? url : 'ftp://' + url), + }, + news: { match: new RegExp(`^news:(\\/\\/)?${domainPortWithUrlRegEx}`, 'i') }, + telnet: { match: new RegExp(`^telnet:(\\/\\/)?${domainPortWithUrlRegEx}`, 'i') }, + gopher: { match: new RegExp(`^gopher:\\/\\/${domainPortWithUrlRegEx}`, 'i') }, + wais: { match: new RegExp(`^wais:(\\/\\/)?${domainPortWithUrlRegEx}`, 'i') }, +}; + +/** + * @internal + * Try to match a given string with link match rules, return matched link + * @param url Input url to match + * @param option Link match option, exact or partial. If it is exact match, we need + * to check the length of matched link and url + * @param rules Optional link match rules, if not passed, only the default link match + * rules will be applied + * @returns The matched link data, or null if no match found. + * The link data includes an original url and a normalized url + */ +export function matchLink(url: string): LinkData | null { + if (url) { + for (const schema of getObjectKeys(linkMatchRules)) { + const rule = linkMatchRules[schema]; + const matches = url.match(rule.match); + if (matches && matches[0] == url && (!rule.except || !rule.except.test(url))) { + return { + scheme: schema, + originalUrl: url, + normalizedUrl: rule.normalizeUrl ? rule.normalizeUrl(url) : url, + }; + } + } + } + + return null; +} diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts index 7b7b4701d5d..2cefb62eb9b 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts @@ -8,7 +8,6 @@ import type { InsertEntityOptions, IStandaloneEditor, } from 'roosterjs-content-model-types'; -import type { Entity } from 'roosterjs-editor-types'; const BlockEntityTag = 'div'; const InlineEntityTag = 'span'; @@ -91,17 +90,12 @@ export default function insertEntity( { selectionOverride: typeof position === 'object' ? position : undefined, changeSource: ChangeSource.InsertEntity, - getChangeData: () => { - // TODO: Remove this entity when we have standalone editor - const entity: Entity = { - wrapper, - type, - id: '', - isReadonly: true, - }; - - return entity; - }, + getChangeData: () => ({ + wrapper, + type, + id: '', + isReadonly: true, + }), apiName: 'insertEntity', } ); diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts index 64b68247a7b..90b16943afe 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts @@ -1,6 +1,6 @@ import { adjustTrailingSpaceSelection } from '../../modelApi/selection/adjustTrailingSpaceSelection'; import { ChangeSource, getSelectedSegments, mergeModel } from 'roosterjs-content-model-core'; -import { HtmlSanitizer, matchLink } from 'roosterjs-editor-dom'; +import { matchLink } from '../../modelApi/link/matchLink'; import type { ContentModelLink, IStandaloneEditor } from 'roosterjs-content-model-types'; import { addLink, @@ -126,7 +126,6 @@ const createLink = ( }; }; -// TODO: This is copied from original code. We may need to integrate this logic into matchLink() later. function applyLinkPrefix(url: string): string { if (!url) { return url; @@ -152,16 +151,6 @@ function applyLinkPrefix(url: string): string { return prefix + url; } -// TODO: This is copied from original code. However, ContentModel should be able to filter out malicious -// attributes later, so no need to use HtmlSanitizer here function checkXss(link: string): string { - const sanitizer = new HtmlSanitizer(); - const a = document.createElement('a'); - - a.href = link || ''; - - sanitizer.sanitize(a); - // We use getAttribute because some browsers will try to make the href property a valid link. - // This has unintended side effects when the link lacks a protocol. - return a.getAttribute('href') || ''; + return link.match(/s\n*c\n*r\n*i\n*p\n*t\n*:/i) ? '' : link; } diff --git a/packages-content-model/roosterjs-content-model-api/package.json b/packages-content-model/roosterjs-content-model-api/package.json index d4f7558e6ae..9981d13b011 100644 --- a/packages-content-model/roosterjs-content-model-api/package.json +++ b/packages-content-model/roosterjs-content-model-api/package.json @@ -3,8 +3,6 @@ "description": "Content Model for roosterjs (Under development)", "dependencies": { "tslib": "^2.3.1", - "roosterjs-editor-types": "", - "roosterjs-editor-dom": "", "roosterjs-content-model-core": "", "roosterjs-content-model-dom": "", "roosterjs-content-model-types": "" diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/link/matchLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/link/matchLinkTest.ts new file mode 100644 index 00000000000..ad25b487f07 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/link/matchLinkTest.ts @@ -0,0 +1,260 @@ +import { LinkData, matchLink } from '../../../lib/modelApi/link/matchLink'; + +function runMatchTestWithValidLink(link: string, expected: LinkData): void { + let resultData = matchLink(link); + expect(resultData).not.toBe(null); + expect(resultData.scheme).toBe(expected.scheme); + expect(resultData.originalUrl).toBe(expected.originalUrl); + expect(resultData.normalizedUrl).toBe(expected.normalizedUrl); +} + +function runMatchTestWithBadLink(link: string): void { + let linkData = matchLink(link); + expect(linkData).toBeNull(); +} + +describe('defaultLinkMatchRules regular http links with extact match', () => { + it('http://www.bing.com', () => { + let link = 'http://www.bing.com'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); + + it('http://www.bing.com/', () => { + let link = 'http://www.bing.com/'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); + + it('http://1drv.com/test', () => { + let link = 'http://1drv.com/test'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); + + it('www.1234.com/test', () => { + let link = 'www.1234.com/test'; + runMatchTestWithValidLink(link, { + scheme: 'http', + originalUrl: link, + normalizedUrl: 'http://' + link, + }); + }); + + it('http://www.lifewire.com/how-torrent-downloading-works-2483513', () => { + let link = 'http://www.lifewire.com/how-torrent-downloading-works-2483513'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); +}); + +describe('defaultLinkMatchRules regular www links with extact match', () => { + it('www.eartheasy.com/grow_compost.html', () => { + let link = 'www.eartheasy.com/grow_compost.html'; + runMatchTestWithValidLink(link, { + scheme: 'http', + originalUrl: link, + normalizedUrl: 'http://' + link, + }); + }); +}); + +describe('defaultLinkMatchRules regular https links with extact match', () => { + it('https://en.wikipedia.org/wiki/Compost', () => { + let link = 'https://en.wikipedia.org/wiki/Compost'; + runMatchTestWithValidLink(link, { + scheme: 'https', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('https://www.youtube.com/watch?v=e3Nl_TCQXuw', () => { + let link = 'https://www.youtube.com/watch?v=e3Nl_TCQXuw'; + runMatchTestWithValidLink(link, { + scheme: 'https', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('https://www.bing.com/news/search?q=MSFT&qpvt=msft&FORM=EWRE', () => { + let link = 'https://www.bing.com/news/search?q=MSFT&qpvt=msft&FORM=EWRE'; + runMatchTestWithValidLink(link, { + scheme: 'https', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('https://microsoft.sharepoint.com/teams/peopleposse/Shared%20Documents/Feedback%20Plan.pptx?web=1', () => { + let link = + 'https://microsoft.sharepoint.com/teams/peopleposse/Shared%20Documents/Feedback%20Plan.pptx?web=1'; + runMatchTestWithValidLink(link, { + scheme: 'https', + originalUrl: link, + normalizedUrl: link, + }); + }); +}); + +describe('defaultLinkMatchRules special http links that has % and @, but is valid', () => { + it('www.test.com/?test=test%00it', () => { + // URL: www.test.com/?test=test%00it %00 => %00 is invalid percent encoding but URL is valid since it is after ? + let link = 'www.test.com/?test=test%00it'; + runMatchTestWithValidLink(link, { + scheme: 'http', + originalUrl: link, + normalizedUrl: 'http://' + link, + }); + }); + + it('http://www.test.com/?test=test%hhit', () => { + // URL: http://www.test.com/?test=test%hhit => %h is invalid encoding, but URL is valid since it is after ? + let link = 'http://www.test.com/?test=test%hhit'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); + + it('www.test.com/kitty@supercute.com', () => { + // URL: www.test.com/kitty@supercute.com => @ is valid when it is after / + let link = 'www.test.com/kitty@supercute.com'; + runMatchTestWithValidLink(link, { + scheme: 'http', + originalUrl: link, + normalizedUrl: 'http://' + link, + }); + }); + + it('www.test.com?kitty@supercute.com', () => { + // URL: www.test.com?kitty@supercute.com => @ is valid when it is after ? + let link = 'www.test.com?kitty@supercute.com'; + runMatchTestWithValidLink(link, { + scheme: 'http', + originalUrl: link, + normalizedUrl: 'http://' + link, + }); + }); + + it('http://www.test.com/kitty@supercute.com', () => { + // URL: http://www.test.com/kitty@supercute.com @ is valid when it is after / for URL that has http:// prefix + let link = 'http://www.test.com/kitty@supercute.com'; + runMatchTestWithValidLink(link, { scheme: 'http', originalUrl: link, normalizedUrl: link }); + }); +}); + +describe('defaultLinkMatchRules invalid http links with % and @ in URL', () => { + it('http://www.test%00it.com/', () => { + // %00 is invalid percent encoding + runMatchTestWithBadLink('http://www.test%00it.com/'); + }); + + it('http://www.test%hhit.com/', () => { + // %h is invalid percent encoding + runMatchTestWithBadLink('http://www.test%hhit.com/'); + }); + + it('www.test%0hit.com/', () => { + // %0 is invalid percent encoding + runMatchTestWithBadLink('www.test%0hit.com/'); + }); + + it('www.kitty@supercute.com.com/test', () => { + // @ is invalid when it apperas before / + runMatchTestWithBadLink('www.kitty@supercute.com.com/test'); + }); + + it('www.kitty@supercute.com.com?test', () => { + // @ is invalid when it apperas before ? + runMatchTestWithBadLink('www.kitty@supercute.com.com?test'); + }); + + it('https' + '://www.kitty@supercute.com.com/test', () => { + // @ is invalid when it apperas before /. Note we're testing @ after http:// and before first / + runMatchTestWithBadLink('https' + '://www.kitty@supercute.com.com/test'); + }); +}); + +describe('defaultLinkMatchRules exact match with extra space and text', () => { + it('www.bing.com more', () => { + // exact match should not match since there is some space and extra text after the url + runMatchTestWithBadLink('www.bing.com more'); + }); +}); + +describe('defaultLinkmatchRules does not match invalid urls', () => { + it('www.bing,com', () => { + runMatchTestWithBadLink('www.bing,com'); + }); + + it('www.b,,au', () => { + runMatchTestWithBadLink('www.b,,au'); + }); +}); + +describe('defaultLinkMatchRules other protocols, mailto, notes, file etc.', () => { + it('mailto:someone@example.com', () => { + let link = 'mailto:someone@example.com'; + runMatchTestWithValidLink(link, { + scheme: 'mailto', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('notes://Garth/86256EDF005310E2/A4D87D90E1B19842852564FF006DED4E/', () => { + let link = 'notes://Garth/86256EDF005310E2/A4D87D90E1B19842852564FF006DED4E/'; + runMatchTestWithValidLink(link, { + scheme: 'notes', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('file://hostname/path/to/the%20file.txt', () => { + let link = 'file://hostname/path/to/the%20file.txt'; + runMatchTestWithValidLink(link, { scheme: 'file', originalUrl: link, normalizedUrl: link }); + }); + + it('\\\\fileserver\\SharedFolder\\Resource', () => { + let link = '\\\\fileserver\\SharedFolder\\Resource'; + runMatchTestWithValidLink(link, { scheme: 'unc', originalUrl: link, normalizedUrl: link }); + }); + + it('ftp://test.com/share', () => { + let link = 'ftp://test.com/share'; + runMatchTestWithValidLink(link, { scheme: 'ftp', originalUrl: link, normalizedUrl: link }); + }); + + it('ftp.test.com/share', () => { + let link = 'ftp.test.com/share'; + runMatchTestWithValidLink(link, { + scheme: 'ftp', + originalUrl: link, + normalizedUrl: 'ftp://' + link, + }); + }); + + it('news://news.server.example/example', () => { + let link = 'news://news.server.example/example'; + runMatchTestWithValidLink(link, { scheme: 'news', originalUrl: link, normalizedUrl: link }); + }); + + it('telnet://test.com:25', () => { + let link = 'telnet://test.com:25'; + runMatchTestWithValidLink(link, { + scheme: 'telnet', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('gopher://test.com/share', () => { + let link = 'gopher://test.com/share'; + runMatchTestWithValidLink(link, { + scheme: 'gopher', + originalUrl: link, + normalizedUrl: link, + }); + }); + + it('wais://testserver:2000/DB1', () => { + let link = 'wais://testserver:2000/DB1'; + runMatchTestWithValidLink(link, { scheme: 'wais', originalUrl: link, normalizedUrl: link }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts index 176da2eee1d..96e9ed0ac03 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts @@ -403,4 +403,47 @@ describe('insertLink', () => { ], }); }); + + it('Invalid url', () => { + const doc = createContentModelDocument(); + addSegment(doc, createSelectionMarker()); + + const url = 'javasc\nript:onC\nlick()'; + let formatResult: boolean | undefined; + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(doc, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + } + ); + + editor.formatContentModel = formatContentModel; + + insertLink(editor, url); + + expect(formatContentModel).toHaveBeenCalledTimes(0); + expect(formatResult).toBeFalsy(); + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts index 4ed22a5ea0c..99205b8e38b 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts @@ -1,5 +1,4 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; -import { NodePosition } from 'roosterjs-editor-types'; import { ContentModelDocument, ContentModelFormatter, @@ -26,7 +25,6 @@ export function segmentTestCommon( }); const editor = ({ focus: jasmine.createSpy(), - getFocusedPosition: () => null as NodePosition, getPendingFormat: () => null as any, formatContentModel, } as any) as IStandaloneEditor; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts index 9f1d76ae781..cefe2cbe012 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts @@ -1,5 +1,6 @@ import { createContextMenuPlugin } from './ContextMenuPlugin'; import { createEditPlugin } from './EditPlugin'; +import { createEntityDelimiterPlugin } from './EntityDelimiterPlugin'; import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; import { newEventToOldEvent, oldEventToNewEvent } from '../editor/utils/eventConverter'; import { PluginEventType } from 'roosterjs-editor-types'; @@ -30,10 +31,12 @@ export class BridgePlugin implements EditorPlugin { const editPlugin = createEditPlugin(); const contextMenuPlugin = createContextMenuPlugin(options); const normalizeTablePlugin = createNormalizeTablePlugin(); + const entityDelimiterPlugin = createEntityDelimiterPlugin(); this.legacyPlugins = [ editPlugin, ...(options.legacyPlugins ?? []).filter(x => !!x), + entityDelimiterPlugin, contextMenuPlugin, normalizeTablePlugin, ]; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts similarity index 97% rename from packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts rename to packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts index 6d0bb852b17..95e8b9c6ffe 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts @@ -1,4 +1,5 @@ import { isCharacterValue } from 'roosterjs-content-model-core'; +import type { IContentModelEditor } from '../publicTypes/IContentModelEditor'; import { addDelimiters, isBlockElement, @@ -28,7 +29,6 @@ import type { PluginEvent, PluginKeyDownEvent, } from 'roosterjs-editor-types'; -import type { IContentModelEditor } from 'roosterjs-content-model-editor'; const DELIMITER_SELECTOR = '.' + DelimiterClasses.DELIMITER_AFTER + ',.' + DelimiterClasses.DELIMITER_BEFORE; @@ -36,9 +36,10 @@ const ZERO_WIDTH_SPACE = '\u200B'; const INLINE_ENTITY_SELECTOR = 'span' + getEntitySelector(); /** + * @internal * Entity delimiter plugin helps maintain delimiter elements around an entity so that user can put focus before/after an entity */ -export class EntityDelimiterPlugin implements EditorPlugin { +class EntityDelimiterPlugin implements EditorPlugin { private editor: IContentModelEditor | null = null; /** @@ -314,3 +315,11 @@ function handleKeyDownEvent(editor: IEditor, event: PluginKeyDownEvent) { handleSelectionNotCollapsed(editor, currentRange, rawEvent); } } + +/** + * @internal + * Create a new instance of EntityDelimiterPlugin. + */ +export function createEntityDelimiterPlugin(): EditorPlugin { + return new EntityDelimiterPlugin(); +} diff --git a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts index 6c42aeef97a..981c9afbde2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts @@ -28,6 +28,7 @@ describe('BridgePlugin', () => { const onPluginEventSpy1 = jasmine.createSpy('onPluginEvent1'); const onPluginEventSpy2 = jasmine.createSpy('onPluginEvent2'); const disposeSpy = jasmine.createSpy('dispose'); + const queryElementsSpy = jasmine.createSpy('queryElement').and.returnValue([]); const mockedPlugin1 = { initialize: initializeSpy, @@ -39,7 +40,9 @@ describe('BridgePlugin', () => { onPluginEvent: onPluginEventSpy2, dispose: disposeSpy, } as any; - const mockedEditor = 'EDITOR' as any; + const mockedEditor = { + queryElements: queryElementsSpy, + } as any; const plugin = new BridgePlugin({ legacyPlugins: [mockedPlugin1, mockedPlugin2], diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts index d479dec6ddf..373fd4da9eb 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts @@ -1,3 +1,2 @@ export { ContentModelPastePlugin } from './paste/ContentModelPastePlugin'; export { ContentModelEditPlugin } from './edit/ContentModelEditPlugin'; -export { EntityDelimiterPlugin } from './entityDelimiter/EntityDelimiterPlugin'; diff --git a/packages-content-model/roosterjs-content-model-plugins/package.json b/packages-content-model/roosterjs-content-model-plugins/package.json index e33c18cf24d..405b4241a0f 100644 --- a/packages-content-model/roosterjs-content-model-plugins/package.json +++ b/packages-content-model/roosterjs-content-model-plugins/package.json @@ -3,8 +3,6 @@ "description": "Content Model for roosterjs (Under development)", "dependencies": { "tslib": "^2.3.1", - "roosterjs-editor-types": "", - "roosterjs-editor-dom": "", "roosterjs-content-model-core": "", "roosterjs-content-model-editor": "", "roosterjs-content-model-dom": "", diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts index 47fd3f62e40..e818e12b1ae 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts @@ -1,9 +1,8 @@ import * as getStyleMetadata from '../../lib/paste/WordDesktop/getStyleMetadata'; +import { BeforePasteEvent, ClipboardData } from 'roosterjs-content-model-types'; import { expectEqual } from './e2e/testUtils'; -import { PluginEventType } from 'roosterjs-editor-types'; import { processPastedContentFromWordDesktop } from '../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; import { WordMetadata } from '../../lib/paste/WordDesktop/WordMetadata'; -import { BeforePasteEvent, ClipboardData } from 'roosterjs-content-model-types'; import { createDomToModelContext, domToContentModel, @@ -5127,29 +5126,28 @@ describe('processPastedContentFromWordDesktopTest', () => { }); }); -export function createBeforePasteEventMock(fragment: DocumentFragment, htmlBefore: string = '') { - return ({ - eventType: PluginEventType.BeforePaste, +export function createBeforePasteEventMock( + fragment: DocumentFragment, + htmlBefore: string = '' +): BeforePasteEvent { + return { + eventType: 'beforePaste', clipboardData: {}, fragment: fragment, - sanitizingOption: { - elementCallbacks: {}, - attributeCallbacks: {}, - cssStyleCallbacks: {}, - additionalTagReplacements: {}, - additionalAllowedAttributes: [], - additionalAllowedCssClasses: [], - additionalDefaultStyleValues: {}, - additionalGlobalStyleNodes: [], - additionalPredefinedCssForElement: {}, - preserveHtmlComments: false, - unknownTagReplacement: null, - }, htmlBefore, htmlAfter: '', htmlAttributes: {}, - domToModelOption: { additionalAllowedTags: [], additionalDisallowedTags: [] }, - } as any) as BeforePasteEvent; + pasteType: 'normal', + domToModelOption: { + additionalAllowedTags: [], + additionalDisallowedTags: [], + additionalFormatParsers: {}, + attributeSanitizers: {}, + formatParserOverride: {}, + processorOverride: {}, + styleSanitizers: {}, + }, + }; } function createListElementFromWord( diff --git a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts index ffad56e762c..089b79b1809 100644 --- a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts @@ -1,9 +1,5 @@ import { ContentModelEditor } from 'roosterjs-content-model-editor'; -import { - ContentModelEditPlugin, - ContentModelPastePlugin, - EntityDelimiterPlugin, -} from 'roosterjs-content-model-plugins'; +import { ContentModelEditPlugin, ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; import type { ContentModelEditorOptions, IContentModelEditor, @@ -23,7 +19,6 @@ export function createContentModelEditor( additionalPlugins?: EditorPlugin[], initialContent?: string ): IContentModelEditor { - const legacyPlugins = [new EntityDelimiterPlugin()]; const plugins = [ new ContentModelPastePlugin(), new ContentModelEditPlugin(), @@ -31,7 +26,6 @@ export function createContentModelEditor( ]; const options: ContentModelEditorOptions = { - legacyPlugins: legacyPlugins, plugins: plugins, initialContent: initialContent, defaultSegmentFormat: { From d84634d7bd401f8137f45e54839c14c0983e9cfe Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 23 Jan 2024 09:32:07 -0800 Subject: [PATCH 030/112] Content Model: Decouple test code from old code (#2351) --- .../publicApi/segment/changeFontSizeTest.ts | 2 +- .../test/coreApi/pasteTest.ts | 37 ++-- .../test/coreApi/setDOMSelectionTest.ts | 175 +++++++----------- .../ContentModelCopyPastePluginTest.ts | 2 +- .../utils/contentModelDomIndexerTest.ts | 2 +- .../handleListItemWithMetadataTest.ts | 2 +- .../metadata/handleListWithMetadataTest.ts | 15 +- .../utils/paste/createPasteFragmentTest.ts | 2 +- .../test/utils/paste/retrieveHtmlInfoTest.ts | 2 +- .../processors/delimiterProcessorTest.ts | 2 +- .../processors/textProcessorTest.ts | 2 +- .../test/endToEndTest.ts | 2 +- .../backgroundColorFormatHandlerTest.ts | 2 +- .../common/borderFormatHandlerTest.ts | 15 +- .../segment/textColorFormatHandlerTest.ts | 2 +- .../modelToDom/handlers/handleImageTest.ts | 25 +-- .../modelToDom/handlers/handleListTest.ts | 2 +- .../handlers/handleSegmentDecoratorTest.ts | 2 +- .../test/testUtils.ts | 31 ++++ .../package.json | 1 - .../paste/e2e/cmPasteFromExcelOnlineTest.ts | 3 +- .../test/paste/e2e/cmPasteFromExcelTest.ts | 6 +- .../test/paste/e2e/cmPasteFromWordTest.ts | 2 +- .../test/paste/e2e/cmPasteTest.ts | 2 +- .../getPasteSourceTest.ts | 16 +- .../processPastedContentFromExcelTest.ts | 7 +- .../paste/processPastedContentFromWacTest.ts | 11 +- ...eContentModelEditor.ts => createEditor.ts} | 23 +-- .../roosterjs-content-model/lib/index.ts | 3 +- .../roosterjs-content-model/package.json | 1 - tools/tsconfig.doc.json | 3 + 31 files changed, 199 insertions(+), 203 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-dom/test/testUtils.ts rename packages-content-model/roosterjs-content-model/lib/{createContentModelEditor.ts => createEditor.ts} (68%) diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts index 69de2270a9b..d786c24e905 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts @@ -1,6 +1,6 @@ import changeFontSize from '../../../lib/publicApi/segment/changeFontSize'; import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; -import { createRange } from 'roosterjs-editor-dom'; +import { createRange } from 'roosterjs-content-model-dom/test/testUtils'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { segmentTestCommon } from './segmentTestCommon'; import { diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts index 6ab45b0ca22..a806609e965 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts @@ -9,9 +9,9 @@ import * as PPT from 'roosterjs-content-model-plugins/lib/paste/PowerPoint/proce import * as setProcessorF from 'roosterjs-content-model-plugins/lib/paste/utils/setProcessor'; import * as WacComponents from 'roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents'; import * as WordDesktopFile from 'roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop'; -import { ContentModelEditor } from 'roosterjs-content-model-editor'; import { ContentModelPastePlugin } from 'roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin'; import { expectEqual, initEditor } from 'roosterjs-content-model-plugins/test/paste/e2e/testUtils'; +import { StandaloneEditor } from '../../lib/editor/StandaloneEditor'; import { tableProcessor } from 'roosterjs-content-model-dom'; import { ClipboardData, @@ -36,7 +36,6 @@ describe('Paste ', () => { let mockedModel: ContentModelDocument; let mockedMergeModel: ContentModelDocument; let getFocusedPosition: jasmine.Spy; - let getContent: jasmine.Spy; let getVisibleViewport: jasmine.Spy; let mergeModelSpy: jasmine.Spy; let formatResult: boolean | undefined; @@ -70,7 +69,6 @@ describe('Paste ', () => { createContentModel = jasmine.createSpy('createContentModel').and.returnValue(mockedModel); focus = jasmine.createSpy('focus'); getFocusedPosition = jasmine.createSpy('getFocusedPosition').and.returnValue(mockedPos); - getContent = jasmine.createSpy('getContent'); getVisibleViewport = jasmine.createSpy('getVisibleViewport'); mergeModelSpy = spyOn(mergeModelFile, 'mergeModel').and.callFake(() => { mockedModel = mockedMergeModel; @@ -104,7 +102,7 @@ describe('Paste ', () => { formatResult = undefined; context = undefined; - editor = new ContentModelEditor(div, { + editor = new StandaloneEditor(div, { plugins: [new ContentModelPastePlugin()], coreApiOverride: { focus, @@ -112,9 +110,6 @@ describe('Paste ', () => { getVisibleViewport, formatContentModel, }, - legacyCoreApiOverride: { - getContent, - }, }); spyOn(editor, 'getDocument').and.callThrough(); @@ -188,13 +183,13 @@ describe('Paste ', () => { }); describe('paste with content model & paste plugin', () => { - let editor: ContentModelEditor | undefined; + let editor: StandaloneEditor | undefined; let div: HTMLDivElement | undefined; beforeEach(() => { div = document.createElement('div'); document.body.appendChild(div); - editor = new ContentModelEditor(div, { + editor = new StandaloneEditor(div, { plugins: [new ContentModelPastePlugin()], }); spyOn(addParserF, 'default').and.callThrough(); @@ -220,7 +215,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wordDesktop'); spyOn(WordDesktopFile, 'processPastedContentFromWordDesktop').and.callThrough(); - editor?.paste(clipboardData); + editor?.pasteFromClipboard(clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 5); @@ -231,7 +226,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wacComponents'); spyOn(WacComponents, 'processPastedContentWacComponents').and.callThrough(); - editor?.paste(clipboardData); + editor?.pasteFromClipboard(clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(2); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 6); @@ -242,7 +237,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelOnline'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - editor?.paste(clipboardData); + editor?.pasteFromClipboard(clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); @@ -253,7 +248,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelDesktop'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - editor?.paste(clipboardData); + editor?.pasteFromClipboard(clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); @@ -264,7 +259,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('powerPointDesktop'); spyOn(PPT, 'processPastedContentFromPowerPoint').and.callThrough(); - editor?.paste(clipboardData); + editor?.pasteFromClipboard(clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); @@ -276,7 +271,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wordDesktop'); spyOn(WordDesktopFile, 'processPastedContentFromWordDesktop').and.callThrough(); - editor?.paste(clipboardData, true /* pasteAsText */); + editor?.pasteFromClipboard(clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -287,7 +282,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wacComponents'); spyOn(WacComponents, 'processPastedContentWacComponents').and.callThrough(); - editor?.paste(clipboardData, true /* pasteAsText */); + editor?.pasteFromClipboard(clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -298,7 +293,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelOnline'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - editor?.paste(clipboardData, true /* pasteAsText */); + editor?.pasteFromClipboard(clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -309,7 +304,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelDesktop'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - editor?.paste(clipboardData, true /* pasteAsText */); + editor?.pasteFromClipboard(clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -320,7 +315,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('powerPointDesktop'); spyOn(PPT, 'processPastedContentFromPowerPoint').and.callThrough(); - editor?.paste(clipboardData, true /* pasteAsText */); + editor?.pasteFromClipboard(clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -341,7 +336,7 @@ describe('paste with content model & paste plugin', () => { }; let eventChecker: BeforePasteEvent = {}; - editor = new ContentModelEditor(div!, { + editor = new StandaloneEditor(div!, { plugins: [ { initialize: () => {}, @@ -356,7 +351,7 @@ describe('paste with content model & paste plugin', () => { ], }); - editor?.paste(clipboardData); + editor?.pasteFromClipboard(clipboardData); expect(eventChecker?.clipboardData).toEqual(clipboardData); expect(eventChecker?.htmlBefore).toBeTruthy(); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts index 239c1f38dc3..99327281c9c 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts @@ -1,6 +1,4 @@ import * as addRangeToSelection from '../../lib/corePlugin/utils/addRangeToSelection'; -import { createElement } from 'roosterjs-editor-dom'; -import { CreateElementData } from 'roosterjs-editor-types'; import { DOMSelection, StandaloneEditorCore } from 'roosterjs-content-model-types'; import { setDOMSelection } from '../../lib/coreApi/setDOMSelection'; @@ -666,41 +664,29 @@ describe('setDOMSelection', () => { }); it('Select TH and TR in the same row', () => { + const table = document.createElement('table'); + const tr1 = document.createElement('tr'); + const th1 = document.createElement('th'); + const td1 = document.createElement('td'); + const tr2 = document.createElement('tr'); + const th2 = document.createElement('th'); + const td2 = document.createElement('td'); + + th1.appendChild(document.createTextNode('test')); + td1.appendChild(document.createTextNode('test')); + tr1.appendChild(th1); + tr1.appendChild(td1); + + th2.appendChild(document.createTextNode('test')); + td2.appendChild(document.createTextNode('test')); + tr2.appendChild(th2); + tr2.appendChild(td2); + + table.appendChild(tr1); + table.appendChild(tr2); + runTest( - createElement( - { - tag: 'table', - children: [ - { - tag: 'TR', - children: [ - { - tag: 'TH', - children: ['test'], - }, - { - tag: 'TD', - children: ['test'], - }, - ], - }, - { - tag: 'TR', - children: [ - { - tag: 'TH', - children: ['test'], - }, - { - tag: 'TD', - children: ['test'], - }, - ], - }, - ], - }, - document - ) as HTMLTableElement, + table, 0, 0, 0, @@ -773,41 +759,32 @@ describe('setDOMSelection', () => { }); function buildTable(tbody: boolean, thead: boolean = false, tfoot: boolean = false) { - const getElement = (tag: string): CreateElementData => { - return { - tag, - children: [ - { - tag: 'TR', - children: [ - { - tag: 'TD', - children: ['test'], - }, - { - tag: 'TD', - children: ['test'], - }, - ], - }, - { - tag: 'TR', - children: [ - { - tag: 'TD', - children: ['test'], - }, - { - tag: 'TD', - children: ['test'], - }, - ], - }, - ], - }; + const getElement = (tag: string) => { + const container = document.createElement(tag); + const tr1 = document.createElement('tr'); + const td1 = document.createElement('td'); + const td2 = document.createElement('td'); + const tr2 = document.createElement('tr'); + const td3 = document.createElement('td'); + const td4 = document.createElement('td'); + + td1.appendChild(document.createTextNode('test')); + td2.appendChild(document.createTextNode('test')); + tr1.appendChild(td1); + tr1.appendChild(td2); + + td3.appendChild(document.createTextNode('test')); + td4.appendChild(document.createTextNode('test')); + tr2.appendChild(td3); + tr2.appendChild(td4); + + container.appendChild(tr1); + container.appendChild(tr2); + + return container; }; - const children: (string | CreateElementData)[] = []; + const children: HTMLElement[] = []; if (thead) { children.push(getElement('thead')); } @@ -818,41 +795,31 @@ function buildTable(tbody: boolean, thead: boolean = false, tfoot: boolean = fal children.push(getElement('tfoot')); } if (children.length === 0) { - children.push( - { - tag: 'TR', - children: [ - { - tag: 'TD', - children: ['test'], - }, - { - tag: 'TD', - children: ['test'], - }, - ], - }, - { - tag: 'TR', - children: [ - { - tag: 'TD', - children: ['test'], - }, - { - tag: 'TD', - children: ['test'], - }, - ], - } - ); + const tr1 = document.createElement('tr'); + const td1 = document.createElement('td'); + const td2 = document.createElement('td'); + const tr2 = document.createElement('tr'); + const td3 = document.createElement('td'); + const td4 = document.createElement('td'); + + td1.appendChild(document.createTextNode('test')); + td2.appendChild(document.createTextNode('test')); + tr1.appendChild(td1); + tr1.appendChild(td2); + + td3.appendChild(document.createTextNode('test')); + td4.appendChild(document.createTextNode('test')); + tr2.appendChild(td3); + tr2.appendChild(td4); + + children.push(tr1, tr2); } - return createElement( - { - tag: 'table', - children, - }, - document - ) as HTMLTableElement; + const table = document.createElement('table'); + + children.forEach(node => { + table.appendChild(node); + }); + + return table; } diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts index 9795fa2a2bc..4bab5bd3bfc 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts @@ -7,7 +7,7 @@ import * as iterateSelectionsFile from '../../lib/publicApi/selection/iterateSel import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import * as transformColor from '../../lib/publicApi/color/transformColor'; import { createModelToDomContext, createTable, createTableCell } from 'roosterjs-content-model-dom'; -import { createRange } from 'roosterjs-editor-dom'; +import { createRange } from 'roosterjs-content-model-dom/test/testUtils'; import { setEntityElementClasses } from 'roosterjs-content-model-dom/test/domUtils/entityUtilTest'; import { ContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts index a6d1c33c4a2..3ffde59360a 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts @@ -1,6 +1,6 @@ import * as setSelection from '../../../lib/publicApi/selection/setSelection'; import { contentModelDomIndexer } from '../../../lib/corePlugin/utils/contentModelDomIndexer'; -import { createRange } from 'roosterjs-editor-dom'; +import { createRange } from 'roosterjs-content-model-dom/test/testUtils'; import { ContentModelDocument, ContentModelSegment, diff --git a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts index 3069b2e9100..3ec21ecffb4 100644 --- a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts @@ -1,5 +1,5 @@ import * as applyFormat from 'roosterjs-content-model-dom/lib/modelToDom/utils/applyFormat'; -import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { expectHtml } from 'roosterjs-content-model-dom/test/testUtils'; import { handleList as originalHandleList } from 'roosterjs-content-model-dom/lib/modelToDom/handlers/handleList'; import { handleListItem } from 'roosterjs-content-model-dom/lib/modelToDom/handlers/handleListItem'; import { diff --git a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts index cce69e71cfb..2f713dab8d1 100644 --- a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts @@ -1,4 +1,4 @@ -import { expectHtml, itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { expectHtml } from 'roosterjs-content-model-dom/test/testUtils'; import { handleList } from 'roosterjs-content-model-dom/lib/modelToDom/handlers/handleList'; import { ModelToDomContext } from 'roosterjs-content-model-types'; import { @@ -118,7 +118,7 @@ describe('handleList with metadata', () => { }); }); - itChromeOnly('Context has OL, single OL list item, do not reuse existing OL element', () => { + it('Context has OL, single OL list item, do not reuse existing OL element', () => { const existingOL = document.createElement('ol'); const listItem = createListItem([ createListLevel('OL', {}, { editingInfo: JSON.stringify({ orderedStyleType: 2 }) }), @@ -150,7 +150,7 @@ describe('handleList with metadata', () => { }); }); - itChromeOnly('Context has OL, 2 level OL list item, reuse existing OL element', () => { + it('Context has OL, 2 level OL list item, reuse existing OL element', () => { const existingOL = document.createElement('ol'); const listItem = createListItem([ createListLevel('OL'), @@ -187,7 +187,7 @@ describe('handleList with metadata', () => { }); }); - itChromeOnly('Context has OL, 2 level OL list item, do not reuse existing OL element', () => { + it('Context has OL, 2 level OL list item, do not reuse existing OL element', () => { const existingOL = document.createElement('ol'); const listItem = createListItem([ createListLevel( @@ -209,9 +209,10 @@ describe('handleList with metadata', () => { handleList(document, parent, listItem, context, null); - expect(parent.outerHTML).toBe( - '
          ' - ); + expectHtml(parent.outerHTML, [ + '
              ', + '
                  ', + ]); expect(context.listFormat).toEqual({ threadItemCounts: [1, 0], nodeStack: [ diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/paste/createPasteFragmentTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/paste/createPasteFragmentTest.ts index 0d66cd3ffa7..93cd2da2f48 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/paste/createPasteFragmentTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/paste/createPasteFragmentTest.ts @@ -1,7 +1,7 @@ import * as moveChildNodes from 'roosterjs-content-model-dom/lib/domUtils/moveChildNodes'; import { ClipboardData, PasteType } from 'roosterjs-content-model-types'; import { createPasteFragment } from '../../../lib/utils/paste/createPasteFragment'; -import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { expectHtml } from 'roosterjs-content-model-dom/test/testUtils'; describe('createPasteFragment', () => { let moveChildNodesSpy: jasmine.Spy; diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/paste/retrieveHtmlInfoTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/paste/retrieveHtmlInfoTest.ts index dafa01821e1..9e705f56b2b 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/paste/retrieveHtmlInfoTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/paste/retrieveHtmlInfoTest.ts @@ -1,6 +1,6 @@ import { ClipboardData } from 'roosterjs-content-model-types'; import { HtmlFromClipboard, retrieveHtmlInfo } from '../../../lib/utils/paste/retrieveHtmlInfo'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; describe('retrieveHtmlInfo', () => { function runTest( diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts index e2312476e6f..b1297ca024b 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts @@ -1,7 +1,7 @@ import * as delimiterProcessorFile from '../../../lib/domToModel/processors/childProcessor'; import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; -import { createRange } from 'roosterjs-editor-dom'; +import { createRange } from '../../testUtils'; import { delimiterProcessor } from '../../../lib/domToModel/processors/delimiterProcessor'; import { DomToModelContext } from 'roosterjs-content-model-types'; diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts index 0d05deee228..4d154e510b9 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts @@ -4,7 +4,7 @@ import { addSegment } from '../../../lib/modelApi/common/addSegment'; import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; -import { createRange } from 'roosterjs-editor-dom'; +import { createRange } from '../../testUtils'; import { createSelectionMarker } from '../../../lib/modelApi/creators/createSelectionMarker'; import { createText } from '../../../lib/modelApi/creators/createText'; import { textProcessor } from '../../../lib/domToModel/processors/textProcessor'; diff --git a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts index b58a881d99e..b86beaf0ddc 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts @@ -2,7 +2,7 @@ import * as createGeneralBlock from '../lib/modelApi/creators/createGeneralBlock import { contentModelToDom } from '../lib/modelToDom/contentModelToDom'; import { createDomToModelContext, createModelToDomContext } from '../lib'; import { domToContentModel } from '../lib/domToModel/domToContentModel'; -import { expectHtml } from 'roosterjs-editor-api/test/TestHelper'; +import { expectHtml } from './testUtils'; import { ContentModelBlockFormat, ContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts index c54a70e1945..8b2fff7bd78 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts @@ -2,7 +2,7 @@ import { backgroundColorFormatHandler } from '../../../lib/formatHandlers/common import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { DeprecatedColors } from '../../../lib/formatHandlers/utils/color'; -import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { expectHtml } from '../../testUtils'; import { BackgroundColorFormat, DomToModelContext, diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/borderFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/borderFormatHandlerTest.ts index fbf9e8aae58..d989ab9ff5f 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/borderFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/borderFormatHandlerTest.ts @@ -2,7 +2,6 @@ import { BorderFormat, DomToModelContext, ModelToDomContext } from 'roosterjs-co import { borderFormatHandler } from '../../../lib/formatHandlers/common/borderFormatHandler'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; describe('borderFormatHandler.parse', () => { let div: HTMLElement; @@ -51,7 +50,7 @@ describe('borderFormatHandler.parse', () => { }); }); - itChromeOnly('Has border width none value', () => { + it('Has border width none value', () => { div.style.borderWidth = '1px 2px 3px 4px'; div.style.borderStyle = 'none'; div.style.borderColor = 'red'; @@ -59,10 +58,10 @@ describe('borderFormatHandler.parse', () => { borderFormatHandler.parse(format, div, context, {}); expect(format).toEqual({ - borderTop: '1px none red', - borderRight: '2px none red', - borderBottom: '3px none red', - borderLeft: '4px none red', + borderTop: jasmine.stringMatching(/1px (none )?red/), + borderRight: jasmine.stringMatching(/2px (none )?red/), + borderBottom: jasmine.stringMatching(/3px (none )?red/), + borderLeft: jasmine.stringMatching(/4px (none )?red/), }); }); @@ -171,7 +170,7 @@ describe('borderFormatHandler.apply', () => { expect(div.outerHTML).toEqual('
                  '); }); - itChromeOnly('Has border color - empty values', () => { + it('Has border color - empty values', () => { format.borderTop = 'red'; borderFormatHandler.apply(format, div, context); @@ -179,7 +178,7 @@ describe('borderFormatHandler.apply', () => { expect(div.outerHTML).toEqual('
                  '); }); - itChromeOnly('Use independant border radius 1', () => { + it('Use independant border radius 1', () => { format.borderBottomLeftRadius = '2px'; format.borderBottomRightRadius = '3px'; format.borderTopRightRadius = '3px'; diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts index 4f57a69d524..379dab76eab 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts @@ -2,7 +2,7 @@ import { createDomToModelContext } from '../../../lib/domToModel/context/createD import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { defaultHTMLStyleMap } from '../../../lib/config/defaultHTMLStyleMap'; import { DeprecatedColors } from '../../../lib'; -import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { expectHtml } from '../../testUtils'; import { textColorFormatHandler } from '../../../lib/formatHandlers/segment/textColorFormatHandler'; import { DomToModelContext, diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleImageTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleImageTest.ts index f9c0a109424..4cb684d0b80 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleImageTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleImageTest.ts @@ -1,7 +1,7 @@ import * as stackFormat from '../../../lib/modelToDom/utils/stackFormat'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; +import { expectHtml } from '../../testUtils'; import { handleImage } from '../../../lib/modelToDom/handlers/handleImage'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; import { ContentModelBlock, ContentModelBlockHandler, @@ -25,14 +25,14 @@ describe('handleSegment', () => { function runTest( segment: ContentModelImage, - expectedInnerHTML: string, + expectedInnerHTML: string[], expectedCreateBlockFromContentModelCalledTimes: number ) { parent = document.createElement('div'); handleImage(document, parent, segment, context, []); - expect(parent.innerHTML).toBe(expectedInnerHTML); + expectHtml(parent.innerHTML, expectedInnerHTML); expect(handleBlock).toHaveBeenCalledTimes(expectedCreateBlockFromContentModelCalledTimes); } @@ -44,7 +44,7 @@ describe('handleSegment', () => { dataset: {}, }; - runTest(segment, '', 0); + runTest(segment, [''], 0); expect(context.imageSelection).toBeUndefined(); }); @@ -58,7 +58,7 @@ describe('handleSegment', () => { dataset: {}, }; - runTest(segment, '', 0); + runTest(segment, [''], 0); expect(context.imageSelection!.image.src).toBe('http://test.com/test'); }); @@ -73,7 +73,7 @@ describe('handleSegment', () => { dataset: {}, }; - runTest(segment, 'a', 0); + runTest(segment, ['a'], 0); }); it('image segment with link', () => { @@ -85,10 +85,10 @@ describe('handleSegment', () => { dataset: {}, }; - runTest(segment, '', 0); + runTest(segment, [''], 0); }); - itChromeOnly('image segment with size', () => { + it('image segment with size', () => { const segment: ContentModelImage = { segmentType: 'Image', src: 'http://test.com/test', @@ -99,7 +99,10 @@ describe('handleSegment', () => { runTest( segment, - '', + [ + '', + '', + ], 0 ); }); @@ -117,7 +120,7 @@ describe('handleSegment', () => { runTest( segment, - '', + [''], 0 ); }); @@ -133,7 +136,7 @@ describe('handleSegment', () => { spyOn(stackFormat, 'stackFormat').and.callThrough(); - runTest(segment, '', 0); + runTest(segment, [''], 0); expect(stackFormat.stackFormat).toHaveBeenCalledTimes(1); expect((stackFormat.stackFormat).calls.argsFor(0)[1]).toBe('a'); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts index 19de98dc6f7..99ef6fecd6a 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts @@ -3,7 +3,7 @@ import { ContentModelListItem, ModelToDomContext } from 'roosterjs-content-model import { createListItem } from '../../../lib/modelApi/creators/createListItem'; import { createListLevel } from '../../../lib/modelApi/creators/createListLevel'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; -import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { expectHtml } from '../../testUtils'; import { handleList } from '../../../lib/modelToDom/handlers/handleList'; import { NumberingListType } from 'roosterjs-content-model-core/lib/constants/NumberingListType'; diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts index 3cd180aae63..022733f2116 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts @@ -1,5 +1,5 @@ import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; -import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { expectHtml } from '../../testUtils'; import { handleSegmentDecorator } from '../../../lib/modelToDom/handlers/handleSegmentDecorator'; import { ContentModelCode, diff --git a/packages-content-model/roosterjs-content-model-dom/test/testUtils.ts b/packages-content-model/roosterjs-content-model-dom/test/testUtils.ts new file mode 100644 index 00000000000..ce9da85fe1d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/test/testUtils.ts @@ -0,0 +1,31 @@ +export function expectHtml(actualHtml: string, expectedHtml: string | string[]) { + expectedHtml = Array.isArray(expectedHtml) ? expectedHtml : [expectedHtml]; + expect(expectedHtml.indexOf(actualHtml)).toBeGreaterThanOrEqual(0, actualHtml); +} + +export function createRange(node1: Node, offset1?: number, node2?: Node, offset2?: number): Range { + const range = document.createRange(); + + if (typeof offset1 == 'number') { + range.setStart(node1, offset1); + } else { + range.selectNode(node1); + } + + if (node2 && typeof offset2 == 'number') { + range.setEnd(node2, offset2); + } + + return range; +} + +declare var __karma__: any; + +export function itChromeOnly( + expectation: string, + assertion?: jasmine.ImplementationCallback, + timeout?: number +) { + const func = __karma__.config.browser == 'Chrome' ? it : xit; + return func(expectation, assertion, timeout); +} diff --git a/packages-content-model/roosterjs-content-model-plugins/package.json b/packages-content-model/roosterjs-content-model-plugins/package.json index 085ea8475ec..9472ec52eb4 100644 --- a/packages-content-model/roosterjs-content-model-plugins/package.json +++ b/packages-content-model/roosterjs-content-model-plugins/package.json @@ -4,7 +4,6 @@ "dependencies": { "tslib": "^2.3.1", "roosterjs-content-model-core": "", - "roosterjs-content-model-editor": "", "roosterjs-content-model-dom": "", "roosterjs-content-model-types": "", "roosterjs-content-model-api": "" diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts index 3550d9d49c3..716e3f26acb 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts @@ -1,6 +1,5 @@ import * as processPastedContentFromExcel from '../../../lib/paste/Excel/processPastedContentFromExcel'; import { expectEqual, initEditor } from './testUtils'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; import { tableProcessor } from 'roosterjs-content-model-dom'; import type { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; @@ -43,7 +42,7 @@ describe(ID, () => { expect(processPastedContentFromExcel.processPastedContentFromExcel).toHaveBeenCalled(); }); - itChromeOnly('E2E Table with table cells with text color', () => { + it('E2E Table with table cells with text color', () => { const CD = ({ types: ['text/plain', 'text/html'], text: diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts index be1a41baf9d..12232a4b4a8 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts @@ -1,6 +1,6 @@ import * as processPastedContentFromExcel from '../../../lib/paste/Excel/processPastedContentFromExcel'; import { expectEqual, initEditor } from './testUtils'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; import { tableProcessor } from 'roosterjs-content-model-dom'; import type { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; @@ -30,7 +30,7 @@ describe(ID, () => { document.getElementById(ID)?.remove(); }); - itChromeOnly('E2E', () => { + it('E2E', () => { spyOn(processPastedContentFromExcel, 'processPastedContentFromExcel').and.callThrough(); editor.pasteFromClipboard(clipboardData); @@ -39,7 +39,7 @@ describe(ID, () => { expect(processPastedContentFromExcel.processPastedContentFromExcel).toHaveBeenCalled(); }); - itChromeOnly('E2E paste as image', () => { + it('E2E paste as image', () => { spyOn(processPastedContentFromExcel, 'processPastedContentFromExcel').and.callThrough(); editor.pasteFromClipboard(clipboardData, 'asImage'); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts index a4aa5f48082..4f2e16faee7 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts @@ -2,7 +2,7 @@ import * as wordFile from '../../../lib/paste/WordDesktop/processPastedContentFr import { ClipboardData, DomToModelOption, IStandaloneEditor } from 'roosterjs-content-model-types'; import { cloneModel } from 'roosterjs-content-model-core'; import { expectEqual, initEditor } from './testUtils'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_From_WORD_E2E'; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts index 66e3f228515..545127f0a84 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts @@ -1,7 +1,7 @@ import * as wordFile from '../../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; import { ClipboardData, DomToModelOption, IStandaloneEditor } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_E2E'; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/getPasteSourceTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/getPasteSourceTest.ts index e00e653cdaf..136f4f36eb3 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/getPasteSourceTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/getPasteSourceTest.ts @@ -1,12 +1,16 @@ import { BeforePasteEvent, ClipboardData } from 'roosterjs-content-model-types'; import { getPasteSource } from '../../../lib/paste/pasteSourceValidations/getPasteSource'; import { PastePropertyNames } from '../../../lib/paste/pasteSourceValidations/constants'; -import { - EXCEL_ATTRIBUTE_VALUE, - getWacElement, - POWERPOINT_ATTRIBUTE_VALUE, - WORD_ATTRIBUTE_VALUE, -} from 'roosterjs-editor-plugins/test/paste/pasteTestUtils'; + +const EXCEL_ATTRIBUTE_VALUE = 'urn:schemas-microsoft-com:office:excel'; +const POWERPOINT_ATTRIBUTE_VALUE = 'PowerPoint.Slide'; +const WORD_ATTRIBUTE_VALUE = 'urn:schemas-microsoft-com:office:word'; + +const getWacElement = (): HTMLElement => { + const element = document.createElement('span'); + element.classList.add('WACImageContainer'); + return element; +}; describe('getPasteSourceTest | ', () => { it('Is Word', () => { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts index ecd95a2316d..fbe214f5316 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts @@ -1,5 +1,4 @@ import * as PastePluginFile from '../../lib/paste/Excel/processPastedContentFromExcel'; -import { Browser } from 'roosterjs-editor-dom'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createBeforePasteEventMock } from './processPastedContentFromWordDesktopTest'; import { processPastedContentFromExcel } from '../../lib/paste/Excel/processPastedContentFromExcel'; @@ -46,7 +45,7 @@ describe('processPastedContentFromExcelTest', () => { ); //Assert - if (expected && Browser.isChrome) { + if (expected) { expect(div.innerHTML.replace(' ', '')).toBe(expected.replace(' ', '')); } @@ -357,9 +356,7 @@ describe('Do not run scenarios', () => { } moveChildNodes(div, fragment1); - if (Browser.isChrome) { - expect(div.innerHTML).toEqual(result); - } + expect(div.innerHTML).toEqual(result); } it('excel is modified', () => { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts index 3fe1f3189cf..e6832379ba0 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts @@ -1,8 +1,7 @@ -import { Browser } from 'roosterjs-editor-dom'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createBeforePasteEventMock } from './processPastedContentFromWordDesktopTest'; import { expectEqual } from './e2e/testUtils'; -import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; import { pasteDisplayFormatParser } from 'roosterjs-content-model-core/lib/override/pasteDisplayFormatParser'; import { processPastedContentWacComponents } from '../../lib/paste/WacComponents/processPastedContentWacComponents'; import { @@ -1400,10 +1399,10 @@ describe('wordOnlineHandler', () => { marginRight: '0px', marginBottom: '0px', marginLeft: '0px', - borderTop: Browser.isFirefox ? 'medium none' : '', - borderRight: Browser.isFirefox ? 'medium none' : '', - borderBottom: Browser.isFirefox ? 'medium none' : '', - borderLeft: Browser.isFirefox ? 'medium none' : '', + borderTop: '', + borderRight: '', + borderBottom: '', + borderLeft: '', verticalAlign: 'top', }, dataset: {}, diff --git a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts b/packages-content-model/roosterjs-content-model/lib/createEditor.ts similarity index 68% rename from packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts rename to packages-content-model/roosterjs-content-model/lib/createEditor.ts index 089b79b1809..dc7c2e1b807 100644 --- a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createEditor.ts @@ -1,10 +1,11 @@ -import { ContentModelEditor } from 'roosterjs-content-model-editor'; import { ContentModelEditPlugin, ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; +import { StandaloneEditor } from 'roosterjs-content-model-core'; import type { - ContentModelEditorOptions, - IContentModelEditor, -} from 'roosterjs-content-model-editor'; -import type { EditorPlugin } from 'roosterjs-content-model-types'; + ContentModelDocument, + EditorPlugin, + IStandaloneEditor, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; /** * Create a Content Model Editor using the given options @@ -14,25 +15,25 @@ import type { EditorPlugin } from 'roosterjs-content-model-types'; * @param initialContent The initial content to show in editor. It can't be removed by undo, user need to manually remove it if needed. * @returns The ContentModelEditor instance */ -export function createContentModelEditor( +export function createEditor( contentDiv: HTMLDivElement, additionalPlugins?: EditorPlugin[], - initialContent?: string -): IContentModelEditor { + initialModel?: ContentModelDocument +): IStandaloneEditor { const plugins = [ new ContentModelPastePlugin(), new ContentModelEditPlugin(), ...(additionalPlugins ?? []), ]; - const options: ContentModelEditorOptions = { + const options: StandaloneEditorOptions = { plugins: plugins, - initialContent: initialContent, + initialModel, defaultSegmentFormat: { fontFamily: 'Calibri,Arial,Helvetica,sans-serif', fontSize: '11pt', textColor: '#000000', }, }; - return new ContentModelEditor(contentDiv, options); + return new StandaloneEditor(contentDiv, options); } diff --git a/packages-content-model/roosterjs-content-model/lib/index.ts b/packages-content-model/roosterjs-content-model/lib/index.ts index ef7a866c977..ddfbf48efca 100644 --- a/packages-content-model/roosterjs-content-model/lib/index.ts +++ b/packages-content-model/roosterjs-content-model/lib/index.ts @@ -1,7 +1,6 @@ -export { createContentModelEditor } from './createContentModelEditor'; +export { createEditor } from './createEditor'; export * from 'roosterjs-content-model-types'; export * from 'roosterjs-content-model-dom'; export * from 'roosterjs-content-model-core'; export * from 'roosterjs-content-model-api'; -export * from 'roosterjs-content-model-editor'; export * from 'roosterjs-content-model-plugins'; diff --git a/packages-content-model/roosterjs-content-model/package.json b/packages-content-model/roosterjs-content-model/package.json index f821aa513a7..49ae50dea08 100644 --- a/packages-content-model/roosterjs-content-model/package.json +++ b/packages-content-model/roosterjs-content-model/package.json @@ -7,7 +7,6 @@ "roosterjs-content-model-dom": "", "roosterjs-content-model-core": "", "roosterjs-content-model-api": "", - "roosterjs-content-model-editor": "", "roosterjs-content-model-plugins": "" }, "version": "0.0.0", diff --git a/tools/tsconfig.doc.json b/tools/tsconfig.doc.json index 63b74d2dfb3..6c968646613 100644 --- a/tools/tsconfig.doc.json +++ b/tools/tsconfig.doc.json @@ -33,6 +33,9 @@ "../packages/roosterjs/lib/index.ts", "../packages-content-model/roosterjs-content-model-types/lib/index.ts", "../packages-content-model/roosterjs-content-model-dom/lib/index.ts", + "../packages-content-model/roosterjs-content-model-core/lib/index.ts", + "../packages-content-model/roosterjs-content-model-api/lib/index.ts", + "../packages-content-model/roosterjs-content-model-plugins/lib/index.ts", "../packages-content-model/roosterjs-content-model-editor/lib/index.ts", "../packages-content-model/roosterjs-content-model/lib/index.ts" ], From fb3f108fe6db9af098a4a2c37e81a914dab73bea Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 23 Jan 2024 16:59:12 -0800 Subject: [PATCH 031/112] Fix demo site (#2353) --- tools/buildTools/buildDemo.js | 2 +- tools/buildTools/pack.js | 23 ++++++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/tools/buildTools/buildDemo.js b/tools/buildTools/buildDemo.js index 3d8ea2de4de..445fb90b28f 100644 --- a/tools/buildTools/buildDemo.js +++ b/tools/buildTools/buildDemo.js @@ -77,7 +77,7 @@ async function buildDemoSite() { [/^roosterjs-editor-plugins\/.*$/, 'roosterjs'], [/^roosterjs-react\/.*$/, 'roosterjsReact'], [/^roosterjs-react$/, 'roosterjsReact'], - [/^roosterjs-content-model.*$/, 'roosterjsContentModel'], + [/^roosterjs-content-model((?!-editor).)*\/.*$/, 'roosterjsContentModel'], ], [] ), diff --git a/tools/buildTools/pack.js b/tools/buildTools/pack.js index f42f4dc01ba..8715cd34654 100644 --- a/tools/buildTools/pack.js +++ b/tools/buildTools/pack.js @@ -53,24 +53,36 @@ async function pack(isProduction, isAmd, target, filename) { await runWebPack(webpackConfig); } -function createStep(isProduction, isAmd, target) { +function createStep(isProduction, isAmd, target, enableForDemoSite) { const fileName = `${buildConfig[target].jsFileBaseName}${isAmd ? '-amd' : ''}${ isProduction ? '-min' : '' }.js`; return { message: `Packing ${fileName}...`, callback: async () => pack(isProduction, isAmd, target, fileName), - enabled: options => (isProduction ? options.packprod : options.pack), + enabled: options => + (enableForDemoSite && options.builddemo) || + (isProduction ? options.packprod : options.pack), }; } module.exports = { commonJsDebug: createStep(false /*isProduction*/, false /*isAmd*/, 'packages'), - commonJsProduction: createStep(true /*isProduction*/, false /*isAmd*/, 'packages'), + commonJsProduction: createStep( + true /*isProduction*/, + false /*isAmd*/, + 'packages', + true /*enableForDemoSite*/ + ), amdDebug: createStep(false /*isProduction*/, true /*isAmd*/, 'packages'), amdProduction: createStep(true /*isProduction*/, true /*isAmd*/, 'packages'), commonJsDebugUi: createStep(false /*isProduction*/, false /*isAmd*/, 'packages-ui'), - commonJsProductionUi: createStep(true /*isProduction*/, false /*isAmd*/, 'packages-ui'), + commonJsProductionUi: createStep( + true /*isProduction*/, + false /*isAmd*/, + 'packages-ui', + true /*enableForDemoSite*/ + ), amdDebugUi: createStep(false /*isProduction*/, true /*isAmd*/, 'packages-ui'), amdProductionUi: createStep(true /*isProduction*/, true /*isAmd*/, 'packages-ui'), commonJsDebugContentModel: createStep( @@ -81,7 +93,8 @@ module.exports = { commonJsProductionContentModel: createStep( true /*isProduction*/, false /*isAmd*/, - 'packages-content-model' + 'packages-content-model', + true /*enableForDemoSite*/ ), amdDebugContentModel: createStep( false /*isProduction*/, From 55066fae186e3c409831a7f5706cd1ec8045c136 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 24 Jan 2024 12:14:47 -0800 Subject: [PATCH 032/112] Content Model: Fix table text color (#2359) --- .../lib/editor/ContentModelEditor.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 2e861825e34..4fd612d08bc 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -955,12 +955,14 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode ) { const core = this.getCore(); - transformColor( - node, - true /*includeSelf*/, - direction == ColorTransformDirection.DarkToLight ? 'darkToLight' : 'lightToDark', - core.darkColorHandler - ); + if (core.lifecycle.isDarkMode) { + transformColor( + node, + true /*includeSelf*/, + direction == ColorTransformDirection.DarkToLight ? 'darkToLight' : 'lightToDark', + core.darkColorHandler + ); + } } /** From c16ba1ac0c1fa03d33c48f1f2cc8ee90eddccb84 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 25 Jan 2024 10:01:59 -0800 Subject: [PATCH 033/112] Fix pending format (#2354) * Fix pending format * improve --- .../lib/coreApi/createContentModel.ts | 3 +++ .../lib/coreApi/formatContentModel.ts | 1 - .../lib/corePlugin/ContentModelCachePlugin.ts | 8 +++++++ .../corePlugin/utils/textMutationObserver.ts | 15 ++++++++----- .../test/coreApi/createContentModelTest.ts | 20 ++++++++++++++++++ .../corePlugin/ContentModelCachePluginTest.ts | 21 +++++++++++-------- .../utils/textMutationObserverTest.ts | 19 +++++++++++++++++ 7 files changed, 72 insertions(+), 15 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts index b495c656b2c..42f09475ec6 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts @@ -14,6 +14,9 @@ import type { CreateContentModel } from 'roosterjs-content-model-types'; * @param selectionOverride When passed, use this selection range instead of current selection in editor */ export const createContentModel: CreateContentModel = (core, option, selectionOverride) => { + // Flush all mutations if any, so that we can get an up-to-date Content Model + core.cache.textMutationObserver?.flushMutations(); + let cachedModel = selectionOverride ? null : core.cache.cachedModel; if (cachedModel && core.lifecycle.shadowEditFragment) { diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts index 4f3c8bed1ec..dd11ef571ac 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts @@ -21,7 +21,6 @@ import type { export const formatContentModel: FormatContentModel = (core, formatter, options) => { const { apiName, onNodeCreated, getChangeData, changeSource, rawEvent, selectionOverride } = options || {}; - const model = core.api.createContentModel(core, undefined /*option*/, selectionOverride); const context: FormatWithContentModelContext = { newEntities: [], diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts index 05608d2a8e3..5d42d070a08 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts @@ -85,6 +85,14 @@ class ContentModelCachePlugin implements PluginWithState { const firstTarget = mutations[0]?.target; - const isTextChangeOnly = mutations.every( - mutation => mutation.type == 'characterData' && mutation.target == firstTarget - ); - this.onMutation(isTextChangeOnly); + if (firstTarget) { + const isTextChangeOnly = mutations.every( + mutation => mutation.type == 'characterData' && mutation.target == firstTarget + ); + + this.onMutation(isTextChangeOnly); + } }; } diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts index b1773a116cf..526e3991776 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts @@ -201,4 +201,24 @@ describe('createContentModel with selection', () => { type: 'table', } as any); }); + + it('Flush mutation before create model', () => { + const cachedModel = 'MODEL1' as any; + const updatedModel = 'MODEL2' as any; + const flushMutationsSpy = jasmine.createSpy('flushMutations').and.callFake(() => { + core.cache.cachedModel = updatedModel; + }); + + core.cache.cachedModel = cachedModel; + core.lifecycle = {}; + + core.cache.textMutationObserver = { + flushMutations: flushMutationsSpy, + } as any; + + const model = createContentModel(core); + + expect(model).toBe(updatedModel); + expect(flushMutationsSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts index c47f218bfa6..ca994716344 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts @@ -98,7 +98,10 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(plugin.getState()).toEqual({}); + expect(plugin.getState()).toEqual({ + cachedModel: undefined, + cachedSelection: undefined, + }); }); it('Other key without selection', () => { @@ -109,7 +112,10 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(plugin.getState()).toEqual({}); + expect(plugin.getState()).toEqual({ + cachedModel: undefined, + cachedSelection: undefined, + }); }); it('Other key with collapsed selection', () => { @@ -127,7 +133,8 @@ describe('ContentModelCachePlugin', () => { }); expect(state).toEqual({ - cachedSelection: { type: 'range', range: { collapsed: true } as any }, + cachedModel: undefined, + cachedSelection: undefined, }); }); @@ -146,10 +153,8 @@ describe('ContentModelCachePlugin', () => { }); expect(state).toEqual({ - cachedSelection: { - type: 'range', - range: { collapsed: false } as any, - }, + cachedModel: undefined, + cachedSelection: undefined, }); }); @@ -178,8 +183,6 @@ describe('ContentModelCachePlugin', () => { it('No cached range, no cached model', () => { const state = plugin.getState(); - state.cachedModel = undefined; - state.cachedSelection = undefined; const selection = 'MockedRange' as any; getDOMSelectionSpy.and.returnValue(selection); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/textMutationObserverTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/textMutationObserverTest.ts index b0cd900713b..7ba5a6df1b3 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/textMutationObserverTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/textMutationObserverTest.ts @@ -110,6 +110,25 @@ describe('TextMutationObserverImpl', () => { window.setTimeout(resolve, 10); }); + expect(onMutation).toHaveBeenCalledWith(true); + }); + + it('flush mutation without change', async () => { + const div = document.createElement('div'); + const text = document.createTextNode('test'); + + div.appendChild(text); + + const onMutation = jasmine.createSpy('onMutation'); + const observer = TextMutationObserver.createTextMutationObserver(div, onMutation); + + observer.startObserving(); + observer.flushMutations(); + + await new Promise(resolve => { + window.setTimeout(resolve, 10); + }); + expect(onMutation).not.toHaveBeenCalled(); }); }); From 9c69ea0d40a68bbf3ab500db29334473883fd95d Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 25 Jan 2024 11:45:56 -0800 Subject: [PATCH 034/112] Standalone Editor: Provide a DOMHelper to allow access DOM tree (#2363) --- .../lib/editor/DOMHelperImpl.ts | 17 +++++++++++++ .../lib/editor/StandaloneEditor.ts | 8 ++++++ .../lib/editor/createStandaloneEditorCore.ts | 2 ++ .../test/editor/DOMHelperImplTest.ts | 20 +++++++++++++++ .../test/editor/StandaloneEditorTest.ts | 25 +++++++++++++++++++ .../editor/createStandaloneEditorCoreTest.ts | 4 +++ .../lib/editor/IStandaloneEditor.ts | 6 +++++ .../lib/editor/StandaloneEditorCore.ts | 6 +++++ .../lib/index.ts | 1 + .../lib/parameter/DOMHelper.ts | 22 ++++++++++++++++ 10 files changed, 111 insertions(+) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts new file mode 100644 index 00000000000..4f68d601711 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts @@ -0,0 +1,17 @@ +import { toArray } from 'roosterjs-content-model-dom'; +import type { DOMHelper } from 'roosterjs-content-model-types'; + +class DOMHelperImpl implements DOMHelper { + constructor(private contentDiv: HTMLElement) {} + + queryElements(selector: string): HTMLElement[] { + return toArray(this.contentDiv.querySelectorAll(selector)) as HTMLElement[]; + } +} + +/** + * @internal Create new instance of DOMHelper + */ +export function createDOMHelper(contentDiv: HTMLElement): DOMHelper { + return new DOMHelperImpl(contentDiv); +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts index 00e76f285a0..494898daa15 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts @@ -8,6 +8,7 @@ import type { ContentModelSegmentFormat, DarkColorHandler, DOMEventRecord, + DOMHelper, DOMSelection, DomToModelOption, EditorEnvironment, @@ -158,6 +159,13 @@ export class StandaloneEditor implements IStandaloneEditor { return this.getCore().format.pendingFormat?.format ?? null; } + /** + * Get a DOM Helper object to help access DOM tree in editor + */ + getDOMHelper(): DOMHelper { + return this.getCore().domHelper; + } + /** * Add a single undo snapshot to undo stack */ diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts index d208bc568b4..f6cfdbc86fc 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts @@ -1,4 +1,5 @@ import { createDarkColorHandler } from './DarkColorHandlerImpl'; +import { createDOMHelper } from './DOMHelperImpl'; import { createStandaloneEditorCorePlugins } from '../corePlugin/createStandaloneEditorCorePlugins'; import { standaloneCoreApiMap } from './standaloneCoreApiMap'; import { @@ -49,6 +50,7 @@ export function createStandaloneEditorCore( trustedHTMLHandler: options.trustedHTMLHandler || defaultTrustHtmlHandler, domToModelSettings: createDomToModelSettings(options), modelToDomSettings: createModelToDomSettings(options), + domHelper: createDOMHelper(contentDiv), ...getPluginState(corePlugins), disposeErrorHandler: options.disposeErrorHandler, zoomScale: (options.zoomScale ?? -1) > 0 ? options.zoomScale! : 1, diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts new file mode 100644 index 00000000000..e5d3b243f96 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts @@ -0,0 +1,20 @@ +import { createDOMHelper } from '../../lib/editor/DOMHelperImpl'; + +describe('DOMHelperImpl', () => { + it('queryElements', () => { + const mockedResult = ['RESULT'] as any; + const querySelectorAllSpy = jasmine + .createSpy('querySelectorAll') + .and.returnValue(mockedResult); + const mockedDiv: HTMLElement = { + querySelectorAll: querySelectorAllSpy, + } as any; + const mockedSelector = 'SELECTOR'; + const domHelper = createDOMHelper(mockedDiv); + + const result = domHelper.queryElements(mockedSelector); + + expect(result).toEqual(mockedResult); + expect(querySelectorAllSpy).toHaveBeenCalledWith(mockedSelector); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts index 7cc680eee62..2d10bc22ed0 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts @@ -368,6 +368,31 @@ describe('StandaloneEditor', () => { expect(() => editor.takeSnapshot()).toThrow(); }); + it('getDOMHelper', () => { + const div = document.createElement('div'); + const mockedDOMHelper = 'DOMHELPER' as any; + const resetSpy = jasmine.createSpy('reset'); + const mockedCore = { + plugins: [], + domHelper: mockedDOMHelper, + darkColorHandler: { + updateKnownColor: updateKnownColorSpy, + reset: resetSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + const domHelper = editor.getDOMHelper(); + + expect(domHelper).toBe(mockedDOMHelper); + + editor.dispose(); + expect(resetSpy).toHaveBeenCalledWith(); + expect(() => editor.takeSnapshot()).toThrow(); + }); + it('restoreSnapshot', () => { const div = document.createElement('div'); const mockedSnapshot = 'SNAPSHOT' as any; diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts index cd6e4dfa861..6f50bb315a8 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts @@ -1,6 +1,7 @@ import * as createDefaultSettings from '../../lib/editor/createStandaloneEditorDefaultSettings'; import * as createStandaloneEditorCorePlugins from '../../lib/corePlugin/createStandaloneEditorCorePlugins'; import * as DarkColorHandlerImpl from '../../lib/editor/DarkColorHandlerImpl'; +import * as DOMHelperImpl from '../../lib/editor/DOMHelperImpl'; import { standaloneCoreApiMap } from '../../lib/editor/standaloneCoreApiMap'; import { StandaloneEditorCore, StandaloneEditorOptions } from 'roosterjs-content-model-types'; import { @@ -37,6 +38,7 @@ describe('createEditorCore', () => { const mockedDarkColorHandler = 'DARKCOLOR' as any; const mockedDomToModelSettings = 'DOMTOMODEL' as any; const mockedModelToDomSettings = 'MODELTODOM' as any; + const mockedDOMHelper = 'DOMHELPER' as any; beforeEach(() => { spyOn( @@ -52,6 +54,7 @@ describe('createEditorCore', () => { spyOn(createDefaultSettings, 'createModelToDomSettings').and.returnValue( mockedModelToDomSettings ); + spyOn(DOMHelperImpl, 'createDOMHelper').and.returnValue(mockedDOMHelper); }); function runTest( @@ -92,6 +95,7 @@ describe('createEditorCore', () => { entity: 'entity' as any, selection: 'selection' as any, undo: 'undo' as any, + domHelper: mockedDOMHelper, disposeErrorHandler: undefined, zoomScale: 1, ...additionalResult, diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts index bee62884242..3d22af37544 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts @@ -1,3 +1,4 @@ +import type { DOMHelper } from '../parameter/DOMHelper'; import type { PluginEventData, PluginEventFromType } from '../event/PluginEventData'; import type { PluginEventType } from '../event/PluginEventType'; import type { PasteType } from '../enum/PasteType'; @@ -90,6 +91,11 @@ export interface IStandaloneEditor { */ isDisposed(): boolean; + /** + * Get a DOM Helper object to help access DOM tree in editor + */ + getDOMHelper(): DOMHelper; + /** * Get document which contains this editor * @returns The HTML document which contains this editor diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts index c256bbe7435..60035289328 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts @@ -1,3 +1,4 @@ +import type { DOMHelper } from '../parameter/DOMHelper'; import type { PluginEvent } from '../event/PluginEvent'; import type { PluginState } from '../pluginState/PluginState'; import type { EditorPlugin } from './EditorPlugin'; @@ -340,6 +341,11 @@ export interface StandaloneEditorCore extends PluginState { */ readonly trustedHTMLHandler: TrustedHTMLHandler; + /** + * A helper class to provide DOM access APIs + */ + readonly domHelper: DOMHelper; + /** * A callback to be invoked when any exception is thrown during disposing editor * @param plugin The plugin that causes exception 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 e43dcd77bc4..47efd401494 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -284,6 +284,7 @@ export { AnnounceData, KnownAnnounceStrings } from './parameter/AnnounceData'; export { TrustedHTMLHandler } from './parameter/TrustedHTMLHandler'; export { Rect } from './parameter/Rect'; export { ValueSanitizer } from './parameter/ValueSanitizer'; +export { DOMHelper } from './parameter/DOMHelper'; export { BasePluginEvent, BasePluginDomEvent } from './event/BasePluginEvent'; export { BeforeCutCopyEvent } from './event/BeforeCutCopyEvent'; diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts new file mode 100644 index 00000000000..ceb73564a90 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts @@ -0,0 +1,22 @@ +/** + * A helper class to provide DOM access APIs + */ +export interface DOMHelper { + /** + * Query HTML elements in editor by tag name. + * Be careful of this function since it will also return element under entity. + * @param tag Tag name of the element to query + * @returns HTML Element array of the query result + */ + queryElements( + tag: TTag + ): HTMLElementTagNameMap[TTag][]; + + /** + * Query HTML elements in editor by a selector string + * Be careful of this function since it will also return element under entity. + * @param selector Selector string to query + * @returns HTML Element array of the query result + */ + queryElements(selector: string): HTMLElement[]; +} From 2234d2f1565b697e1628925fe108c92048df6288 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 25 Jan 2024 12:50:02 -0800 Subject: [PATCH 035/112] Fix shadow edit (#2355) --- .../lib/coreApi/getDOMSelection.ts | 16 ++++++++-------- .../test/coreApi/getDOMSelectionTest.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts index 3ba745b7f72..11e033fc242 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts @@ -8,15 +8,15 @@ import type { * @internal */ export const getDOMSelection: GetDOMSelection = core => { - const selection = core.selection.selection; + if (core.lifecycle.shadowEditFragment) { + return null; + } else { + const selection = core.selection.selection; - return core.lifecycle.shadowEditFragment - ? null // 1. In shadow editor, always return null - : selection && selection.type != 'range' - ? selection // 2. Editor has Table Selection or Image Selection, use it - : core.api.hasFocus(core) - ? getNewSelection(core) // 3. Not Table/Image selection, and editor has focus, pull a latest selection from DOM - : selection; // 4. Fallback to cached selection for all other cases + return selection && (selection.type != 'range' || !core.api.hasFocus(core)) + ? selection + : getNewSelection(core); + } }; function getNewSelection(core: StandaloneEditorCore): DOMSelection | null { diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts index cfbc93a5623..d0046033a56 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts @@ -163,4 +163,23 @@ describe('getDOMSelection', () => { expect(result).toBe(mockedSelection); }); + + it('no cached selection, editor does not have focus', () => { + const mockedNewSelection = 'NEWSELECTION' as any; + + hasFocusSpy.and.returnValue(false); + containsSpy.and.returnValue(true); + + getSelectionSpy.and.returnValue({ + rangeCount: 1, + getRangeAt: () => mockedNewSelection, + }); + + const result = getDOMSelection(core); + + expect(result).toEqual({ + type: 'range', + range: mockedNewSelection, + }); + }); }); From 7018043bf5bc313b5a7f05fc84edde62c0ce89a0 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 25 Jan 2024 12:59:34 -0800 Subject: [PATCH 036/112] Fix 2 shadow edit issue (#2356) --- .../lib/coreApi/setContentModel.ts | 2 +- .../test/coreApi/setContentModelTest.ts | 99 +++++++++++++++++-- .../TableCellSelection/TableCellSelection.ts | 32 +----- 3 files changed, 96 insertions(+), 37 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts index b247a141d75..989e08eb360 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts @@ -36,7 +36,7 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea if (!option?.ignoreSelection && selection) { core.api.setDOMSelection(core, selection); - } else if (!selection || selection.type !== 'range') { + } else { core.selection.selection = selection; } diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts index bfacc023283..9a7eb05bbe6 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts @@ -3,9 +3,6 @@ import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelT import { setContentModel } from '../../lib/coreApi/setContentModel'; import { StandaloneEditorCore } from 'roosterjs-content-model-types'; -const mockedRange = { - type: 'image', -} as any; const mockedDoc = 'DOCUMENT' as any; const mockedModel = 'MODEL' as any; const mockedEditorContext = 'EDITORCONTEXT' as any; @@ -23,9 +20,7 @@ describe('setContentModel', () => { let getDOMSelectionSpy: jasmine.Spy; beforeEach(() => { - contentModelToDomSpy = spyOn(contentModelToDom, 'contentModelToDom').and.returnValue( - mockedRange - ); + contentModelToDomSpy = spyOn(contentModelToDom, 'contentModelToDom'); createEditorContext = jasmine .createSpy('createEditorContext') .and.returnValue(mockedEditorContext); @@ -56,6 +51,12 @@ describe('setContentModel', () => { }); it('no default option, no shadow edit', () => { + const mockedRange = { + type: 'image', + } as any; + + contentModelToDomSpy.and.returnValue(mockedRange); + setContentModel(core, mockedModel); expect(createModelToDomContextSpy).not.toHaveBeenCalled(); @@ -76,6 +77,12 @@ describe('setContentModel', () => { }); it('with default option, no shadow edit', () => { + const mockedRange = { + type: 'image', + } as any; + + contentModelToDomSpy.and.returnValue(mockedRange); + setContentModel(core, mockedModel); expect(createModelToDomContextWithConfigSpy).toHaveBeenCalledWith( @@ -95,6 +102,11 @@ describe('setContentModel', () => { it('with default option, no shadow edit, with additional option', () => { const defaultOption = { o: 'OPTION' } as any; const additionalOption = { o: 'OPTION1', o2: 'OPTION2' } as any; + const mockedRange = { + type: 'image', + } as any; + + contentModelToDomSpy.and.returnValue(mockedRange); core.modelToDomSettings.builtIn = defaultOption; setContentModel(core, mockedModel, additionalOption); @@ -117,6 +129,11 @@ describe('setContentModel', () => { it('no default option, with shadow edit', () => { core.lifecycle.shadowEditFragment = {} as any; + const mockedRange = { + type: 'image', + } as any; + + contentModelToDomSpy.and.returnValue(mockedRange); setContentModel(core, mockedModel); @@ -135,6 +152,12 @@ describe('setContentModel', () => { }); it('restore selection ', () => { + const mockedRange = { + type: 'image', + } as any; + + contentModelToDomSpy.and.returnValue(mockedRange); + core.selection = { selection: null, selectionStyleNode: null, @@ -161,4 +184,68 @@ describe('setContentModel', () => { expect(setDOMSelectionSpy).not.toHaveBeenCalled(); expect(core.selection.selection).toBe(mockedRange); }); + + it('restore range selection ', () => { + const mockedRange = { + type: 'range', + } as any; + + contentModelToDomSpy.and.returnValue(mockedRange); + + core.selection = { + selection: null, + selectionStyleNode: null, + }; + setContentModel(core, mockedModel, { + ignoreSelection: true, + }); + + expect(createModelToDomContextSpy).toHaveBeenCalledWith( + mockedEditorContext, + undefined, + undefined, + { + ignoreSelection: true, + } + ); + expect(contentModelToDomSpy).toHaveBeenCalledWith( + mockedDoc, + mockedDiv, + mockedModel, + mockedContext, + undefined + ); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + expect(core.selection.selection).toBe(mockedRange); + }); + + it('restore null selection ', () => { + contentModelToDomSpy.and.returnValue(null); + + core.selection = { + selection: null, + selectionStyleNode: null, + }; + setContentModel(core, mockedModel, { + ignoreSelection: true, + }); + + expect(createModelToDomContextSpy).toHaveBeenCalledWith( + mockedEditorContext, + undefined, + undefined, + { + ignoreSelection: true, + } + ); + expect(contentModelToDomSpy).toHaveBeenCalledWith( + mockedDoc, + mockedDiv, + mockedModel, + mockedContext, + undefined + ); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + expect(core.selection.selection).toBe(null); + }); }); diff --git a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/TableCellSelection.ts b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/TableCellSelection.ts index c634efc8aa3..55def37f29b 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/TableCellSelection.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/TableCellSelection.ts @@ -4,9 +4,9 @@ import { handleKeyDownEvent } from './keyUtils/handleKeyDownEvent'; import { handleKeyUpEvent } from './keyUtils/handleKeyUpEvent'; import { handleMouseDownEvent } from './mouseUtils/handleMouseDownEvent'; import { handleScrollEvent } from './mouseUtils/handleScrollEvent'; -import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { PluginEventType } from 'roosterjs-editor-types'; import type { TableCellSelectionState } from './TableCellSelectionState'; -import type { EditorPlugin, IEditor, PluginEvent, TableSelection } from 'roosterjs-editor-types'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; /** * TableCellSelectionPlugin help highlight table cells @@ -14,7 +14,6 @@ import type { EditorPlugin, IEditor, PluginEvent, TableSelection } from 'rooster export default class TableCellSelection implements EditorPlugin { private editor: IEditor | null = null; private state: TableCellSelectionState | null; - private shadowEditCoordinatesBackup: TableSelection | null = null; constructor() { this.state = { @@ -62,12 +61,6 @@ export default class TableCellSelection implements EditorPlugin { onPluginEvent(event: PluginEvent) { if (this.editor && this.state) { switch (event.eventType) { - case PluginEventType.EnteredShadowEdit: - this.handleEnteredShadowEdit(this.state, this.editor); - break; - case PluginEventType.LeavingShadowEdit: - this.handleLeavingShadowEdit(this.state, this.editor); - break; case PluginEventType.MouseDown: if (!this.state.startedSelection) { handleMouseDownEvent(event, this.state, this.editor); @@ -100,25 +93,4 @@ export default class TableCellSelection implements EditorPlugin { } } } - - private handleLeavingShadowEdit(state: TableCellSelectionState, editor: IEditor) { - if (state.firstTable && state.tableSelection && state.firstTable) { - const table = editor.queryElements('#' + state.firstTable.id); - if (table.length == 1) { - state.firstTable = table[0] as HTMLTableElement; - editor.select(state.firstTable, this.shadowEditCoordinatesBackup); - this.shadowEditCoordinatesBackup = null; - } - } - } - - private handleEnteredShadowEdit(state: TableCellSelectionState, editor: IEditor) { - const selection = editor.getSelectionRangeEx(); - if (selection.type == SelectionRangeTypes.TableSelection) { - this.shadowEditCoordinatesBackup = selection.coordinates ?? null; - state.firstTable = selection.table; - state.tableSelection = true; - editor.select(selection.table, null); - } - } } From 647b0c200f7b4698f97c3b6ac543660cb26b68e2 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 25 Jan 2024 13:57:40 -0800 Subject: [PATCH 037/112] Support unit "inch" when parse value (#2357) * Content Model: Support inch when parse unit value * add comment --- .../utils/parseValueWithUnit.ts | 6 ++++++ .../utils/parseValueWithUnitTest.ts | 20 +++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts index f5a5a7adea4..bb3eaeefb01 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts @@ -1,5 +1,8 @@ const MarginValueRegex = /(-?\d+(\.\d+)?)([a-z]+|%)/; +// According to https://developer.mozilla.org/en-US/docs/Glossary/CSS_pixel, 1in = 96px +const PixelPerInch = 96; + /** * Parse unit value with its unit * @param value The source value to parse @@ -35,6 +38,9 @@ export function parseValueWithUnit( case '%': result = (getFontSize(currentSizePxOrElement) * num) / 100; break; + case 'in': + result = num * PixelPerInch; + break; default: // TODO: Support more unit if need break; diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts index 989766c9f4c..c2e25db3bad 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts @@ -17,7 +17,11 @@ describe('parseValueWithUnit with element', () => { const input = value + unit; const result = parseValueWithUnit(input, mockedElement); - expect(result).toBe(results[i], input); + if (Number.isNaN(results[i])) { + expect(result).toBeNaN(); + } else { + expect(Math.abs(result - results[i])).toBeLessThan(1e-3, input); + } }); } @@ -70,6 +74,10 @@ describe('parseValueWithUnit with element', () => { expect(result).toBe(16); }); + + it('in to px', () => { + runTest('in', [0, 96, 105.6, -105.6]); + }); }); describe('parseValueWithUnit with number', () => { @@ -78,7 +86,11 @@ describe('parseValueWithUnit with number', () => { const input = value + unit; const result = parseValueWithUnit(input, 20); - expect(result).toBe(results[i], input); + if (Number.isNaN(results[i])) { + expect(result).toBeNaN(); + } else { + expect(Math.abs(result - results[i])).toBeLessThan(1e-3, input); + } }); } @@ -127,4 +139,8 @@ describe('parseValueWithUnit with number', () => { expect(result).toBe(16); }); + + it('in to px', () => { + runTest('in', [0, 96, 105.6, -105.6]); + }); }); From 7524f5dbdfcdcc061c51027d1a96bae5c93e3ced Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 26 Jan 2024 10:04:57 -0800 Subject: [PATCH 038/112] Code clean up: small refactor to paste code (#2365) * Refactor paste * add test --- .../lib/coreApi/paste.ts | 65 ++++---- .../corePlugin/ContentModelCopyPastePlugin.ts | 2 +- .../lib/utils/paste/mergePasteContent.ts | 95 +++++++----- .../test/coreApi/pasteTest.ts | 80 +--------- .../test/utils/paste/mergePasteContentTest.ts | 143 ++++++++++++++---- 5 files changed, 195 insertions(+), 190 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts index 1e2849b50bd..aee4fbf2dc3 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts @@ -1,4 +1,3 @@ -import { ChangeSource } from '../constants/ChangeSource'; import { cloneModel } from '../publicApi/model/cloneModel'; import { convertInlineCss } from '../utils/paste/convertInlineCss'; import { createPasteFragment } from '../utils/paste/createPasteFragment'; @@ -38,49 +37,37 @@ export const paste: Paste = ( clipboardData.modelBeforePaste = cloneModel(core.api.createContentModel(core), CloneOption); } - core.api.formatContentModel( - core, - (model, context) => { - // 1. Prepare variables - const doc = createDOMFromHtml(clipboardData.rawHtml, core.trustedHTMLHandler); - - // 2. Handle HTML from clipboard - const htmlFromClipboard = retrieveHtmlInfo(doc, clipboardData); + // 1. Prepare variables + const doc = createDOMFromHtml(clipboardData.rawHtml, core.trustedHTMLHandler); - // 3. Create target fragment - const sourceFragment = createPasteFragment( - core.contentDiv.ownerDocument, - clipboardData, - pasteType, - (clipboardData.rawHtml == clipboardData.html - ? doc - : createDOMFromHtml(clipboardData.html, core.trustedHTMLHandler) - )?.body - ); + // 2. Handle HTML from clipboard + const htmlFromClipboard = retrieveHtmlInfo(doc, clipboardData); - // 4. Trigger BeforePaste event to allow plugins modify the fragment - const eventResult = generatePasteOptionFromPlugins( - core, - clipboardData, - sourceFragment, - htmlFromClipboard, - pasteType - ); + // 3. Create target fragment + const sourceFragment = createPasteFragment( + core.contentDiv.ownerDocument, + clipboardData, + pasteType, + (clipboardData.rawHtml == clipboardData.html + ? doc + : createDOMFromHtml(clipboardData.html, core.trustedHTMLHandler) + )?.body + ); - // 5. Convert global CSS to inline CSS - convertInlineCss(eventResult.fragment, htmlFromClipboard.globalCssRules); + // 4. Trigger BeforePaste event to allow plugins modify the fragment + const eventResult = generatePasteOptionFromPlugins( + core, + clipboardData, + sourceFragment, + htmlFromClipboard, + pasteType + ); - // 6. Merge pasted content into main Content Model - mergePasteContent(model, context, eventResult, core.domToModelSettings.customized); + // 5. Convert global CSS to inline CSS + convertInlineCss(eventResult.fragment, htmlFromClipboard.globalCssRules); - return true; - }, - { - changeSource: ChangeSource.Paste, - getChangeData: () => clipboardData, - apiName: 'paste', - } - ); + // 6. Merge pasted content into main Content Model + mergePasteContent(core, eventResult, clipboardData); }; function createDOMFromHtml( diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts index b727f9b8d71..21c9a4beccf 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts @@ -109,7 +109,7 @@ class ContentModelCopyPastePlugin implements PluginWithState = { @@ -34,51 +35,65 @@ const EmptySegmentFormat: Required = { * @internal */ export function mergePasteContent( - model: ContentModelDocument, - context: FormatWithContentModelContext, + core: StandaloneEditorCore, eventResult: BeforePasteEvent, - defaultDomToModelOptions: DomToModelOption + clipboardData: ClipboardData ) { const { fragment, domToModelOption, customizedMerge, pasteType } = eventResult; - const selectedSegment = getSelectedSegments(model, true /*includeFormatHolder*/)[0]; - const domToModelContext = createDomToModelContext( - undefined /*editorContext*/, - defaultDomToModelOptions, - { - processorOverride: { - '#text': pasteTextProcessor, - entity: createPasteEntityProcessor(domToModelOption), - '*': createPasteGeneralProcessor(domToModelOption), - }, - formatParserOverride: { - display: pasteDisplayFormatParser, - }, - additionalFormatParsers: { - container: [containerSizeFormatParser], - }, - }, - domToModelOption - ); - domToModelContext.segmentFormat = selectedSegment ? getSegmentTextFormat(selectedSegment) : {}; + core.api.formatContentModel( + core, + (model, context) => { + const selectedSegment = getSelectedSegments(model, true /*includeFormatHolder*/)[0]; + const domToModelContext = createDomToModelContext( + undefined /*editorContext*/, + core.domToModelSettings.customized, + { + processorOverride: { + '#text': pasteTextProcessor, + entity: createPasteEntityProcessor(domToModelOption), + '*': createPasteGeneralProcessor(domToModelOption), + }, + formatParserOverride: { + display: pasteDisplayFormatParser, + }, + additionalFormatParsers: { + container: [containerSizeFormatParser], + }, + }, + domToModelOption + ); - const pasteModel = domToContentModel(fragment, domToModelContext); - const mergeOption: MergeModelOption = { - mergeFormat: pasteType == 'mergeFormat' ? 'keepSourceEmphasisFormat' : 'none', - mergeTable: shouldMergeTable(pasteModel), - }; + domToModelContext.segmentFormat = selectedSegment + ? getSegmentTextFormat(selectedSegment) + : {}; - const insertPoint = customizedMerge - ? customizedMerge(model, pasteModel) - : mergeModel(model, pasteModel, context, mergeOption); + const pasteModel = domToContentModel(fragment, domToModelContext); + const mergeOption: MergeModelOption = { + mergeFormat: pasteType == 'mergeFormat' ? 'keepSourceEmphasisFormat' : 'none', + mergeTable: shouldMergeTable(pasteModel), + }; - if (insertPoint) { - context.newPendingFormat = { - ...EmptySegmentFormat, - ...model.format, - ...insertPoint.marker.format, - }; - } + const insertPoint = customizedMerge + ? customizedMerge(model, pasteModel) + : mergeModel(model, pasteModel, context, mergeOption); + + if (insertPoint) { + context.newPendingFormat = { + ...EmptySegmentFormat, + ...model.format, + ...insertPoint.marker.format, + }; + } + + return true; + }, + { + changeSource: ChangeSource.Paste, + getChangeData: () => clipboardData, + apiName: 'paste', + } + ); } function shouldMergeTable(pasteModel: ContentModelDocument): boolean | undefined { diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts index a806609e965..be6038636af 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts @@ -17,9 +17,6 @@ import { ClipboardData, ContentModelDocument, DomToModelOption, - ContentModelFormatter, - FormatWithContentModelContext, - FormatWithContentModelOptions, IStandaloneEditor, BeforePasteEvent, PluginEvent, @@ -35,13 +32,8 @@ describe('Paste ', () => { let focus: jasmine.Spy; let mockedModel: ContentModelDocument; let mockedMergeModel: ContentModelDocument; - let getFocusedPosition: jasmine.Spy; let getVisibleViewport: jasmine.Spy; - let mergeModelSpy: jasmine.Spy; - let formatResult: boolean | undefined; - let context: FormatWithContentModelContext | undefined; - const mockedPos = 'POS' as any; const mockedCloneModel = 'CloneModel' as any; let div: HTMLDivElement; @@ -68,9 +60,8 @@ describe('Paste ', () => { createContentModel = jasmine.createSpy('createContentModel').and.returnValue(mockedModel); focus = jasmine.createSpy('focus'); - getFocusedPosition = jasmine.createSpy('getFocusedPosition').and.returnValue(mockedPos); getVisibleViewport = jasmine.createSpy('getVisibleViewport'); - mergeModelSpy = spyOn(mergeModelFile, 'mergeModel').and.callFake(() => { + spyOn(mergeModelFile, 'mergeModel').and.callFake(() => { mockedModel = mockedMergeModel; return null; }); @@ -82,25 +73,6 @@ describe('Paste ', () => { }, } as any, ]); - const formatContentModel = jasmine - .createSpy('formatContentModel') - .and.callFake( - ( - core: any, - callback: ContentModelFormatter, - options: FormatWithContentModelOptions - ) => { - context = { - newEntities: [], - deletedEntities: [], - newImages: [], - }; - formatResult = callback(mockedModel, context); - } - ); - - formatResult = undefined; - context = undefined; editor = new StandaloneEditor(div, { plugins: [new ContentModelPastePlugin()], @@ -108,7 +80,7 @@ describe('Paste ', () => { focus, createContentModel, getVisibleViewport, - formatContentModel, + // formatContentModel, }, }); @@ -124,62 +96,14 @@ describe('Paste ', () => { it('Execute', () => { editor.pasteFromClipboard(clipboardData); - expect(formatResult).toBeTrue(); expect(mockedModel).toEqual(mockedMergeModel); }); it('Execute | As plain text', () => { editor.pasteFromClipboard(clipboardData, 'asPlainText'); - expect(formatResult).toBeTrue(); expect(mockedModel).toEqual(mockedMergeModel); }); - - it('Preserve segment format after paste', () => { - const mockedNode = 'Node' as any; - const mockedOffset = 'Offset' as any; - const mockedFormat = { - fontFamily: 'Arial', - }; - clipboardData.rawHtml = - 'test'; - getFocusedPosition.and.returnValue({ - node: mockedNode, - offset: mockedOffset, - }); - mergeModelSpy.and.returnValue({ - marker: { - format: mockedFormat, - }, - }); - - editor.pasteFromClipboard(clipboardData); - - editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); - - expect(context).toEqual({ - newEntities: [], - newImages: [], - deletedEntities: [], - newPendingFormat: { - backgroundColor: '', - fontFamily: 'Arial', - fontSize: '', - fontWeight: '', - italic: false, - letterSpacing: '', - lineHeight: '', - strikethrough: false, - superOrSubScriptSequence: '', - textColor: '', - underline: false, - }, - }); - }); }); describe('paste with content model & paste plugin', () => { 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 373cc5bc8f8..c0e8c7df151 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 @@ -10,12 +10,55 @@ import { pasteDisplayFormatParser } from '../../../lib/override/pasteDisplayForm import { pasteTextProcessor } from '../../../lib/override/pasteTextProcessor'; import { ContentModelDocument, + ContentModelFormatter, ContentModelSegmentFormat, FormatWithContentModelContext, + FormatWithContentModelOptions, InsertPoint, + StandaloneEditorCore, } from 'roosterjs-content-model-types'; describe('mergePasteContent', () => { + let formatResult: boolean | undefined; + let context: FormatWithContentModelContext | undefined; + let formatContentModel: jasmine.Spy; + let sourceModel: ContentModelDocument; + let core: StandaloneEditorCore; + const mockedClipboard = 'CLIPBOARD' as any; + + beforeEach(() => { + formatResult = undefined; + context = undefined; + + formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + ( + core: any, + callback: ContentModelFormatter, + options: FormatWithContentModelOptions + ) => { + context = { + newEntities: [], + deletedEntities: [], + newImages: [], + }; + formatResult = callback(sourceModel, context); + + const changedData = options.getChangeData!(); + + expect(changedData).toBe(mockedClipboard); + } + ); + + core = { + api: { + formatContentModel, + }, + domToModelSettings: {}, + } as any; + }); + it('merge table', () => { // A doc with only one table in content const pasteModel: ContentModelDocument = { @@ -63,7 +106,7 @@ describe('mergePasteContent', () => { }; // A doc with a table, and selection marker inside of it. - const sourceModel: ContentModelDocument = { + sourceModel = { blockGroupType: 'Document', blocks: [ { @@ -122,14 +165,10 @@ describe('mergePasteContent', () => { domToModelOption: { additionalAllowedTags: [] }, } as any; - const context: FormatWithContentModelContext = { - newEntities: [], - deletedEntities: [], - newImages: [], - }; - - mergePasteContent(sourceModel, context, eventResult, {}); + mergePasteContent(core, eventResult, mockedClipboard); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); expect(mergeModelFile.mergeModel).toHaveBeenCalledWith(sourceModel, pasteModel, context, { mergeFormat: 'none', mergeTable: true, @@ -217,7 +256,8 @@ describe('mergePasteContent', () => { it('customized merge', () => { const pasteModel: ContentModelDocument = createContentModelDocument(); - const sourceModel: ContentModelDocument = createContentModelDocument(); + + sourceModel = createContentModelDocument(); const customizedMerge = jasmine.createSpy('customizedMerge'); @@ -230,20 +270,18 @@ describe('mergePasteContent', () => { customizedMerge, } as any; - mergePasteContent( - sourceModel, - { newEntities: [], deletedEntities: [], newImages: [] }, - eventResult, - {} - ); + mergePasteContent(core, eventResult, mockedClipboard); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); expect(mergeModelFile.mergeModel).not.toHaveBeenCalled(); expect(customizedMerge).toHaveBeenCalledWith(sourceModel, pasteModel); }); it('Apply current format', () => { const pasteModel: ContentModelDocument = createContentModelDocument(); - const sourceModel: ContentModelDocument = createContentModelDocument(); + + sourceModel = createContentModelDocument(); spyOn(mergeModelFile, 'mergeModel').and.callThrough(); spyOn(domToContentModel, 'domToContentModel').and.returnValue(pasteModel); @@ -253,13 +291,10 @@ describe('mergePasteContent', () => { domToModelOption: { additionalAllowedTags: [] }, } as any; - mergePasteContent( - sourceModel, - { newEntities: [], deletedEntities: [], newImages: [] }, - eventResult, - {} - ); + mergePasteContent(core, eventResult, mockedClipboard); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( sourceModel, pasteModel, @@ -276,7 +311,8 @@ describe('mergePasteContent', () => { blockGroupType: 'Document', blocks: [], }; - const targetModel: ContentModelDocument = { + + sourceModel = { blockGroupType: 'Document', blocks: [ { @@ -335,25 +371,25 @@ describe('mergePasteContent', () => { 'createDomToModelContext' ).and.returnValue(mockedDomToModelContext); - const context: FormatWithContentModelContext = { - deletedEntities: [], - newEntities: [], - newImages: [], - }; const mockedDomToModelOptions = 'OPTION1' as any; const mockedDefaultDomToModelOptions = 'OPTIONS3' as any; const mockedFragment = 'FRAGMENT' as any; + (core as any).domToModelSettings = { + customized: mockedDomToModelOptions, + }; + mergePasteContent( - targetModel, - context, + core, { fragment: mockedFragment, domToModelOption: mockedDefaultDomToModelOptions, } as any, - mockedDomToModelOptions + mockedClipboard ); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); expect(context).toEqual({ deletedEntities: [], newEntities: [], @@ -373,7 +409,7 @@ describe('mergePasteContent', () => { }, }); expect(domToContentModelSpy).toHaveBeenCalledWith(mockedFragment, mockedDomToModelContext); - expect(mergeModelSpy).toHaveBeenCalledWith(targetModel, pasteModel, context, { + expect(mergeModelSpy).toHaveBeenCalledWith(sourceModel, pasteModel, context, { mergeFormat: 'none', mergeTable: false, }); @@ -399,4 +435,47 @@ describe('mergePasteContent', () => { ); expect(mockedDomToModelContext.segmentFormat).toEqual({ lineHeight: '1pt' }); }); + + it('Preserve segment format after paste', () => { + const pasteModel: ContentModelDocument = createContentModelDocument(); + const mockedFormat = { + fontFamily: 'Arial', + }; + sourceModel = createContentModelDocument(); + + spyOn(mergeModelFile, 'mergeModel').and.returnValue({ + marker: { + format: mockedFormat, + }, + } as any); + spyOn(domToContentModel, 'domToContentModel').and.returnValue(pasteModel); + + const eventResult = { + pasteType: 'normal', + domToModelOption: { additionalAllowedTags: [] }, + } as any; + + mergePasteContent(core, eventResult, mockedClipboard); + + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); + expect(context).toEqual({ + newEntities: [], + newImages: [], + deletedEntities: [], + newPendingFormat: { + backgroundColor: '', + fontFamily: 'Arial', + fontSize: '', + fontWeight: '', + italic: false, + letterSpacing: '', + lineHeight: '', + strikethrough: false, + superOrSubScriptSequence: '', + textColor: '', + underline: false, + }, + }); + }); }); From 01e79bd356339b9262d269b9b384a231d3b370fa Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 26 Jan 2024 10:30:18 -0800 Subject: [PATCH 039/112] Code clean up: Remove IStandaloneEditor.setContentModel (#2364) --- .../contentModel/buttons/exportButton.ts | 10 +- .../test/publicApi/block/setAlignmentTest.ts | 3 - .../lib/corePlugin/LifecyclePlugin.ts | 30 +----- .../lib/editor/StandaloneEditor.ts | 24 +---- .../test/corePlugin/LifecyclePluginTest.ts | 69 -------------- .../test/editor/StandaloneEditorTest.ts | 92 +++++++++---------- .../roosterjs-content-model-dom/lib/index.ts | 1 + .../lib/modelApi/creators/createEmptyModel.ts | 22 +++++ .../modelApi/creators/createEmptyModelTest.ts | 59 ++++++++++++ .../test/editor/ContentModelEditorTest.ts | 76 --------------- .../lib/editor/IStandaloneEditor.ts | 14 --- 11 files changed, 138 insertions(+), 262 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel.ts create mode 100644 packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/createEmptyModelTest.ts diff --git a/demo/scripts/controls/sidePane/contentModel/buttons/exportButton.ts b/demo/scripts/controls/sidePane/contentModel/buttons/exportButton.ts index 8642941f4af..30bbc3d3c4b 100644 --- a/demo/scripts/controls/sidePane/contentModel/buttons/exportButton.ts +++ b/demo/scripts/controls/sidePane/contentModel/buttons/exportButton.ts @@ -1,4 +1,3 @@ -import { ChangeSource } from 'roosterjs-editor-types'; import { getCurrentContentModel } from '../currentModel'; import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; @@ -11,10 +10,11 @@ export const exportButton: RibbonButton<'buttonNameExport'> = { const model = getCurrentContentModel(editor); if (model && isContentModelEditor(editor)) { - editor.addUndoSnapshot(() => { - editor.focus(); - editor.setContentModel(model); - }, ChangeSource.SetContent); + editor.formatContentModel(currentModel => { + currentModel.blocks = model.blocks; + + return true; + }); } }, }; 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 27e65109885..e941f0baf3b 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 @@ -821,13 +821,11 @@ describe('setAlignment in table', () => { describe('setAlignment in list', () => { let editor: IStandaloneEditor; - let setContentModel: jasmine.Spy; let createContentModel: jasmine.Spy; let triggerEvent: jasmine.Spy; let getVisibleViewport: jasmine.Spy; beforeEach(() => { - setContentModel = jasmine.createSpy('setContentModel'); createContentModel = jasmine.createSpy('createContentModel'); triggerEvent = jasmine.createSpy('triggerEvent'); getVisibleViewport = jasmine.createSpy('getVisibleViewport'); @@ -835,7 +833,6 @@ describe('setAlignment in list', () => { editor = ({ focus: () => {}, addUndoSnapshot: (callback: Function) => callback(), - setContentModel, createContentModel, isDarkMode: () => false, triggerEvent, diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts index 9f393bf3767..77875155d38 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts @@ -1,14 +1,6 @@ import { ChangeSource } from '../constants/ChangeSource'; -import { - createBr, - createContentModelDocument, - createParagraph, - createSelectionMarker, - setColor, -} from 'roosterjs-content-model-dom'; +import { setColor } from 'roosterjs-content-model-dom'; import type { - ContentModelDocument, - ContentModelSegmentFormat, IStandaloneEditor, LifecyclePluginState, PluginEvent, @@ -26,7 +18,6 @@ const DefaultBackColor = '#ffffff'; class LifecyclePlugin implements PluginWithState { private editor: IStandaloneEditor | null = null; private state: LifecyclePluginState; - private initialModel: ContentModelDocument; private initializer: (() => void) | null = null; private disposer: (() => void) | null = null; private adjustColor: () => void; @@ -37,9 +28,6 @@ class LifecyclePlugin implements PluginWithState { * @param contentDiv The editor content DIV */ constructor(options: StandaloneEditorOptions, contentDiv: HTMLDivElement) { - this.initialModel = - options.initialModel ?? this.createInitModel(options.defaultSegmentFormat); - // Make the container editable and set its selection styles if (contentDiv.getAttribute(ContentEditableAttributeName) === null) { this.initializer = () => { @@ -77,12 +65,6 @@ class LifecyclePlugin implements PluginWithState { initialize(editor: IStandaloneEditor) { this.editor = editor; - this.editor.setContentModel(this.initialModel, { ignoreSelection: true }); - - // Initial model is only used once. After that we can just clean it up to make sure we don't cache anything useless - // including the cached DOM element inside the model. - this.initialModel = createContentModelDocument(); - // Set content DIV to be editable this.initializer?.(); @@ -150,16 +132,6 @@ class LifecyclePlugin implements PluginWithState { ); } } - - private createInitModel(format?: ContentModelSegmentFormat) { - const model = createContentModelDocument(format); - const paragraph = createParagraph(false /*isImplicit*/, undefined /*blockFormat*/, format); - - paragraph.segments.push(createSelectionMarker(format), createBr(format)); - model.blocks.push(paragraph); - - return model; - } } /** diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts index 494898daa15..74e4e2a9da0 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts @@ -1,4 +1,5 @@ import { ChangeSource } from '../constants/ChangeSource'; +import { createEmptyModel } from 'roosterjs-content-model-dom'; import { createStandaloneEditorCore } from './createStandaloneEditorCore'; import { transformColor } from '../publicApi/color/transformColor'; import type { @@ -14,8 +15,6 @@ import type { EditorEnvironment, FormatWithContentModelOptions, IStandaloneEditor, - ModelToDomOption, - OnNodeCreated, PasteType, PluginEventData, PluginEventFromType, @@ -47,7 +46,10 @@ export class StandaloneEditor implements IStandaloneEditor { onBeforeInitializePlugins?.(); - this.getCore().plugins.forEach(plugin => plugin.initialize(this)); + const initialModel = options.initialModel ?? createEmptyModel(options.defaultSegmentFormat); + + this.core.api.setContentModel(this.core, initialModel, { ignoreSelection: true }); + this.core.plugins.forEach(plugin => plugin.initialize(this)); } /** @@ -93,22 +95,6 @@ export class StandaloneEditor implements IStandaloneEditor { return core.api.createContentModel(core, option, selectionOverride); } - /** - * Set content with content model - * @param model The content model to set - * @param option Additional options to customize the behavior of Content Model to DOM conversion - * @param onNodeCreated An optional callback that will be called when a DOM node is created - */ - setContentModel( - model: ContentModelDocument, - option?: ModelToDomOption, - onNodeCreated?: OnNodeCreated - ): DOMSelection | null { - const core = this.getCore(); - - return core.api.setContentModel(core, model, option, onNodeCreated); - } - /** * Get current running environment, such as if editor is running on Mac */ diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts index 9c434af5ccc..d4a3e3bb258 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts @@ -9,14 +9,12 @@ describe('LifecyclePlugin', () => { const plugin = createLifecyclePlugin({}, div); const triggerEvent = jasmine.createSpy('triggerEvent'); const state = plugin.getState(); - const setContentModelSpy = jasmine.createSpy('setContentModel'); plugin.initialize(({ triggerEvent, getFocusedPosition: () => null, getColorManager: () => null, isDarkMode: () => false, - setContentModel: setContentModelSpy, })); expect(state).toEqual({ @@ -29,23 +27,6 @@ describe('LifecyclePlugin', () => { expect(div.innerHTML).toBe(''); expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent.calls.argsFor(0)[0]).toBe('editorReady'); - expect(setContentModelSpy).toHaveBeenCalledTimes(1); - expect(setContentModelSpy).toHaveBeenCalledWith( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { segmentType: 'SelectionMarker', isSelected: true, format: {} }, - { segmentType: 'Br', format: {} }, - ], - format: {}, - }, - ], - }, - { ignoreSelection: true } - ); plugin.dispose(); expect(div.isContentEditable).toBeFalse(); @@ -65,14 +46,12 @@ describe('LifecyclePlugin', () => { ); const triggerEvent = jasmine.createSpy('triggerEvent'); const state = plugin.getState(); - const setContentModelSpy = jasmine.createSpy('setContentModel'); plugin.initialize(({ triggerEvent, getFocusedPosition: () => null, getColorManager: () => null, isDarkMode: () => false, - setContentModel: setContentModelSpy, })); expect(state).toEqual({ @@ -80,9 +59,6 @@ describe('LifecyclePlugin', () => { shadowEditFragment: null, }); - expect(setContentModelSpy).toHaveBeenCalledTimes(1); - expect(setContentModelSpy).toHaveBeenCalledWith(mockedModel, { ignoreSelection: true }); - expect(div.isContentEditable).toBeTrue(); expect(div.style.userSelect).toBe('text'); expect(triggerEvent).toHaveBeenCalledTimes(1); @@ -97,14 +73,12 @@ describe('LifecyclePlugin', () => { div.contentEditable = 'true'; const plugin = createLifecyclePlugin({}, div); const triggerEvent = jasmine.createSpy('triggerEvent'); - const setContentModelSpy = jasmine.createSpy('setContentModel'); plugin.initialize(({ triggerEvent, getFocusedPosition: () => null, getColorManager: () => null, isDarkMode: () => false, - setContentModel: setContentModelSpy, })); expect(div.isContentEditable).toBeTrue(); @@ -112,24 +86,6 @@ describe('LifecyclePlugin', () => { expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent.calls.argsFor(0)[0]).toBe('editorReady'); - expect(setContentModelSpy).toHaveBeenCalledTimes(1); - expect(setContentModelSpy).toHaveBeenCalledWith( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { segmentType: 'SelectionMarker', isSelected: true, format: {} }, - { segmentType: 'Br', format: {} }, - ], - format: {}, - }, - ], - }, - { ignoreSelection: true } - ); - plugin.dispose(); expect(div.isContentEditable).toBeTrue(); }); @@ -139,33 +95,14 @@ describe('LifecyclePlugin', () => { div.contentEditable = 'false'; const plugin = createLifecyclePlugin({}, div); const triggerEvent = jasmine.createSpy('triggerEvent'); - const setContentModelSpy = jasmine.createSpy('setContentModel'); plugin.initialize(({ triggerEvent, getFocusedPosition: () => null, getColorManager: () => null, isDarkMode: () => false, - setContentModel: setContentModelSpy, })); - expect(setContentModelSpy).toHaveBeenCalledTimes(1); - expect(setContentModelSpy).toHaveBeenCalledWith( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { segmentType: 'SelectionMarker', isSelected: true, format: {} }, - { segmentType: 'Br', format: {} }, - ], - format: {}, - }, - ], - }, - { ignoreSelection: true } - ); expect(div.isContentEditable).toBeFalse(); expect(div.style.userSelect).toBe(''); expect(triggerEvent).toHaveBeenCalledTimes(1); @@ -180,7 +117,6 @@ describe('LifecyclePlugin', () => { const plugin = createLifecyclePlugin({}, div); const triggerEvent = jasmine.createSpy('triggerEvent'); const state = plugin.getState(); - const setContentModelSpy = jasmine.createSpy('setContentModel'); const mockedDarkColorHandler = 'HANDLER' as any; const setColorSpy = spyOn(color, 'setColor'); @@ -188,7 +124,6 @@ describe('LifecyclePlugin', () => { plugin.initialize(({ triggerEvent, getColorManager: () => mockedDarkColorHandler, - setContentModel: setContentModelSpy, })); expect(setColorSpy).toHaveBeenCalledTimes(2); @@ -212,7 +147,6 @@ describe('LifecyclePlugin', () => { const plugin = createLifecyclePlugin({}, div); const triggerEvent = jasmine.createSpy('triggerEvent'); const state = plugin.getState(); - const setContentModelSpy = jasmine.createSpy('setContentModel'); const mockedDarkColorHandler = 'HANDLER' as any; const setColorSpy = spyOn(color, 'setColor'); @@ -220,7 +154,6 @@ describe('LifecyclePlugin', () => { plugin.initialize(({ triggerEvent, getColorManager: () => mockedDarkColorHandler, - setContentModel: setContentModelSpy, })); expect(setColorSpy).toHaveBeenCalledTimes(2); @@ -266,7 +199,6 @@ describe('LifecyclePlugin', () => { ); const triggerEvent = jasmine.createSpy('triggerEvent'); const state = plugin.getState(); - const setContentModelSpy = jasmine.createSpy('setContentModel'); const mockedDarkColorHandler = 'HANDLER' as any; const setColorSpy = spyOn(color, 'setColor'); @@ -274,7 +206,6 @@ describe('LifecyclePlugin', () => { plugin.initialize(({ triggerEvent, getDarkColorHandler: () => mockedDarkColorHandler, - setContentModel: setContentModelSpy, })); expect(setColorSpy).toHaveBeenCalledTimes(0); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts index 2d10bc22ed0..4fd746f92bc 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts @@ -1,3 +1,4 @@ +import * as createEmptyModel from 'roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel'; import * as createStandaloneEditorCore from '../../lib/editor/createStandaloneEditorCore'; import * as transformColor from '../../lib/publicApi/color/transformColor'; import { ChangeSource } from '../../lib/constants/ChangeSource'; @@ -6,6 +7,8 @@ import { StandaloneEditor } from '../../lib/editor/StandaloneEditor'; describe('StandaloneEditor', () => { let createEditorCoreSpy: jasmine.Spy; let updateKnownColorSpy: jasmine.Spy; + let setContentModelSpy: jasmine.Spy; + let createEmptyModelSpy: jasmine.Spy; beforeEach(() => { updateKnownColorSpy = jasmine.createSpy('updateKnownColor'); @@ -13,10 +16,15 @@ describe('StandaloneEditor', () => { createStandaloneEditorCore, 'createStandaloneEditorCore' ).and.callThrough(); + setContentModelSpy = jasmine.createSpy('setContentModel'); + createEmptyModelSpy = spyOn(createEmptyModel, 'createEmptyModel'); }); it('ctor and dispose, no options', () => { const div = document.createElement('div'); + + createEmptyModelSpy.and.callThrough(); + const editor = new StandaloneEditor(div); expect(createEditorCoreSpy).toHaveBeenCalledWith(div, {}); @@ -25,6 +33,7 @@ describe('StandaloneEditor', () => { expect(editor.isDarkMode()).toBeFalse(); expect(editor.isInIME()).toBeFalse(); expect(editor.isInShadowEdit()).toBeFalse(); + expect(createEmptyModelSpy).toHaveBeenCalledWith(undefined); editor.dispose(); @@ -48,14 +57,21 @@ describe('StandaloneEditor', () => { initialize: initSpy2, dispose: disposeSpy2, } as any; - + const setContentModelSpy = jasmine.createSpy('setContentModel'); const disposeErrorHandlerSpy = jasmine.createSpy('disposeErrorHandler'); + const mockedInitialModel = 'INITMODEL' as any; const options = { plugins: [mockedPlugin1, mockedPlugin2], disposeErrorHandler: disposeErrorHandlerSpy, inDarkMode: true, + initialModel: mockedInitialModel, + coreApiOverride: { + setContentModel: setContentModelSpy, + }, }; + createEmptyModelSpy.and.callThrough(); + const editor = new StandaloneEditor(div, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(div, options); @@ -64,6 +80,12 @@ describe('StandaloneEditor', () => { expect(editor.isDarkMode()).toBeTrue(); expect(editor.isInIME()).toBeFalse(); expect(editor.isInShadowEdit()).toBeFalse(); + expect(createEmptyModelSpy).not.toHaveBeenCalled(); + expect(setContentModelSpy).toHaveBeenCalledWith( + jasmine.anything() /*core*/, + mockedInitialModel, + { ignoreSelection: true } + ); expect(initSpy1).toHaveBeenCalledWith(editor); expect(initSpy2).toHaveBeenCalledWith(editor); @@ -99,6 +121,7 @@ describe('StandaloneEditor', () => { }, api: { createContentModel: createContentModelSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -128,52 +151,6 @@ describe('StandaloneEditor', () => { expect(resetSpy).toHaveBeenCalledWith(); }); - it('setContentModel', () => { - const div = document.createElement('div'); - const setContentModelSpy = jasmine.createSpy('setContentModel'); - const resetSpy = jasmine.createSpy('reset'); - const mockedCore = { - plugins: [], - darkColorHandler: { - updateKnownColor: updateKnownColorSpy, - reset: resetSpy, - }, - api: { - setContentModel: setContentModelSpy, - }, - } as any; - - createEditorCoreSpy.and.returnValue(mockedCore); - - const mockedModel = 'MODEL' as any; - const editor = new StandaloneEditor(div); - - editor.setContentModel(mockedModel); - - expect(setContentModelSpy).toHaveBeenCalledWith( - mockedCore, - mockedModel, - undefined, - undefined - ); - - const mockedOptions = 'OPTIONS' as any; - const mockedOnNodeCreated = 'ONNODECREATED' as any; - - editor.setContentModel(mockedModel, mockedOptions, mockedOnNodeCreated); - - expect(setContentModelSpy).toHaveBeenCalledWith( - mockedCore, - mockedModel, - mockedOptions, - mockedOnNodeCreated - ); - - editor.dispose(); - expect(resetSpy).toHaveBeenCalledWith(); - expect(() => editor.setContentModel(mockedModel)).toThrow(); - }); - it('getEnvironment', () => { const div = document.createElement('div'); const mockedEnvironment = 'ENVIRONMENT' as any; @@ -185,6 +162,7 @@ describe('StandaloneEditor', () => { reset: resetSpy, }, environment: mockedEnvironment, + api: { setContentModel: setContentModelSpy }, } as any; createEditorCoreSpy.and.returnValue(mockedCore); @@ -215,6 +193,7 @@ describe('StandaloneEditor', () => { }, api: { getDOMSelection: getDOMSelectionSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -245,6 +224,7 @@ describe('StandaloneEditor', () => { }, api: { setDOMSelection: setDOMSelectionSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -279,6 +259,7 @@ describe('StandaloneEditor', () => { }, api: { formatContentModel: formatContentModelSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -314,6 +295,9 @@ describe('StandaloneEditor', () => { reset: resetSpy, }, format: {}, + api: { + setContentModel: setContentModelSpy, + }, } as any; createEditorCoreSpy.and.returnValue(mockedCore); @@ -351,6 +335,7 @@ describe('StandaloneEditor', () => { }, api: { addUndoSnapshot: addUndoSnapshotSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -379,6 +364,7 @@ describe('StandaloneEditor', () => { updateKnownColor: updateKnownColorSpy, reset: resetSpy, }, + api: { setContentModel: setContentModelSpy }, } as any; createEditorCoreSpy.and.returnValue(mockedCore); @@ -406,6 +392,7 @@ describe('StandaloneEditor', () => { }, api: { restoreUndoSnapshot: restoreUndoSnapshotSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -434,6 +421,7 @@ describe('StandaloneEditor', () => { }, api: { focus: focusSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -462,6 +450,7 @@ describe('StandaloneEditor', () => { }, api: { hasFocus: hasFocusSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -496,6 +485,7 @@ describe('StandaloneEditor', () => { }, api: { triggerEvent: triggerEventSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -541,6 +531,7 @@ describe('StandaloneEditor', () => { }, api: { attachDomEvent: attachDomEventSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -571,7 +562,9 @@ describe('StandaloneEditor', () => { }, undo: { snapshotsManager: mockedSnapshotManager, + setContentModel: setContentModelSpy, }, + api: { setContentModel: setContentModelSpy }, } as any; createEditorCoreSpy.and.returnValue(mockedCore); @@ -604,6 +597,7 @@ describe('StandaloneEditor', () => { lifecycle: {}, api: { switchShadowEdit: switchShadowEditSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -644,6 +638,7 @@ describe('StandaloneEditor', () => { }, api: { paste: pasteSpy, + setContentModel: setContentModelSpy, }, } as any; @@ -677,6 +672,7 @@ describe('StandaloneEditor', () => { const mockedCore = { plugins: [], darkColorHandler: mockedColorHandler, + api: { setContentModel: setContentModelSpy }, } as any; createEditorCoreSpy.and.returnValue(mockedCore); @@ -708,6 +704,7 @@ describe('StandaloneEditor', () => { reset: resetSpy, }, contentDiv: div, + api: { setContentModel: setContentModelSpy }, } as any; createEditorCoreSpy.and.returnValue(mockedCore); @@ -745,6 +742,7 @@ describe('StandaloneEditor', () => { }, api: { triggerEvent: triggerEventSpy, + setContentModel: setContentModelSpy, }, } as any; 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 54159c679ac..551ce399585 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -46,6 +46,7 @@ export { createGeneralBlock } from './modelApi/creators/createGeneralBlock'; export { createEntity } from './modelApi/creators/createEntity'; export { createDivider } from './modelApi/creators/createDivider'; export { createListLevel } from './modelApi/creators/createListLevel'; +export { createEmptyModel } from './modelApi/creators/createEmptyModel'; export { addBlock } from './modelApi/common/addBlock'; export { addCode } from './modelApi/common/addDecorators'; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel.ts new file mode 100644 index 00000000000..23c3e2c439a --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel.ts @@ -0,0 +1,22 @@ +import { createBr } from './createBr'; +import { createContentModelDocument } from './createContentModelDocument'; +import { createParagraph } from './createParagraph'; +import { createSelectionMarker } from './createSelectionMarker'; +import type { + ContentModelDocument, + ContentModelSegmentFormat, +} from 'roosterjs-content-model-types'; + +/** + * Create an empty Content Model Document with initial empty line and insert point with default format + * @param format @optional The default format to be applied to this Content Model + */ +export function createEmptyModel(format?: ContentModelSegmentFormat): ContentModelDocument { + const model = createContentModelDocument(format); + const paragraph = createParagraph(false /*isImplicit*/, undefined /*blockFormat*/, format); + + paragraph.segments.push(createSelectionMarker(format), createBr(format)); + model.blocks.push(paragraph); + + return model; +} diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/createEmptyModelTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/createEmptyModelTest.ts new file mode 100644 index 00000000000..c4fc77a8773 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/createEmptyModelTest.ts @@ -0,0 +1,59 @@ +import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import { createEmptyModel } from '../../../lib/modelApi/creators/createEmptyModel'; + +describe('createEmptyModel', () => { + it('no param', () => { + const result = createEmptyModel(); + + expect(result).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + ], + }); + }); + + it('with param', () => { + const mockedFormat: ContentModelSegmentFormat = { + fontFamily: 'Arial', + }; + const result = createEmptyModel(mockedFormat); + + expect(result).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: mockedFormat, + }, + { + segmentType: 'Br', + format: mockedFormat, + }, + ], + segmentFormat: mockedFormat, + }, + ], + format: mockedFormat, + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts index 15c28837049..8ef04ef12cc 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -1,6 +1,4 @@ -import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; -import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as findAllEntities from 'roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities'; import { ContentModelEditor } from '../../lib/editor/ContentModelEditor'; @@ -90,80 +88,6 @@ describe('ContentModelEditor', () => { ); }); - it('setContentModel with normal selection', () => { - const mockedRange = { - type: 'range', - range: document.createRange(), - } as any; - const mockedModel = 'MockedModel' as any; - const mockedContext = 'MockedContext' as any; - const mockedConfig = 'MockedConfig' as any; - - spyOn(contentModelToDom, 'contentModelToDom').and.returnValue(mockedRange); - spyOn(createModelToDomContext, 'createModelToDomContextWithConfig').and.returnValue( - mockedContext - ); - spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue(mockedConfig); - - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - - spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); - - const selection = editor.setContentModel(mockedModel); - - expect(contentModelToDom.contentModelToDom).toHaveBeenCalledTimes(2); - expect(contentModelToDom.contentModelToDom).toHaveBeenCalledWith( - document, - div, - mockedModel, - mockedContext, - undefined - ); - expect(createModelToDomContext.createModelToDomContextWithConfig).toHaveBeenCalledWith( - mockedConfig, - editorContext - ); - expect(selection).toBe(mockedRange); - }); - - it('setContentModel', () => { - const mockedRange = { - type: 'range', - range: document.createRange(), - } as any; - const mockedModel = 'MockedModel' as any; - const mockedContext = 'MockedContext' as any; - const mockedConfig = 'MockedConfig' as any; - - spyOn(contentModelToDom, 'contentModelToDom').and.returnValue(mockedRange); - spyOn(createModelToDomContext, 'createModelToDomContextWithConfig').and.returnValue( - mockedContext - ); - spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue(mockedConfig); - - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - - spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); - - const selection = editor.setContentModel(mockedModel); - - expect(contentModelToDom.contentModelToDom).toHaveBeenCalledTimes(2); - expect(contentModelToDom.contentModelToDom).toHaveBeenCalledWith( - document, - div, - mockedModel, - mockedContext, - undefined - ); - expect(createModelToDomContext.createModelToDomContextWithConfig).toHaveBeenCalledWith( - mockedConfig, - editorContext - ); - expect(selection).toBe(mockedRange); - }); - it('createContentModel in EditorReady event', () => { let model: ContentModelDocument | undefined; let pluginEditor: any; diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts index 3d22af37544..c7f3b44315b 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts @@ -11,8 +11,6 @@ import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFor import type { DOMSelection } from '../selection/DOMSelection'; import type { DomToModelOption } from '../context/DomToModelOption'; import type { EditorEnvironment } from '../parameter/EditorEnvironment'; -import type { ModelToDomOption } from '../context/ModelToDomOption'; -import type { OnNodeCreated } from '../context/ModelToDomSettings'; import type { ContentModelFormatter, FormatWithContentModelOptions, @@ -37,18 +35,6 @@ export interface IStandaloneEditor { selectionOverride?: DOMSelection ): ContentModelDocument; - /** - * Set content with content model - * @param model The content model to set - * @param option Additional options to customize the behavior of Content Model to DOM conversion - * @param onNodeCreated An optional callback that will be called when a DOM node is created - */ - setContentModel( - model: ContentModelDocument, - option?: ModelToDomOption, - onNodeCreated?: OnNodeCreated - ): DOMSelection | null; - /** * Get current running environment, such as if editor is running on Mac */ From 7998d03a7f225e270d5e061926587bf978a27e98 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Mon, 29 Jan 2024 08:10:42 -0600 Subject: [PATCH 040/112] Add support to cursor around Block entities. (#2350) --- .../lib/publicApi/entity/insertEntity.ts | 10 +-- .../test/publicApi/entity/insertEntityTest.ts | 7 +- .../corePlugin/ContentModelCopyPastePlugin.ts | 4 +- .../override/pasteCopyBlockEntityParser.ts | 40 +++++++++ .../lib/utils/paste/mergePasteContent.ts | 2 + .../pasteCopyBlockEntityParserTest.ts | 82 +++++++++++++++++++ .../test/utils/paste/mergePasteContentTest.ts | 2 + .../lib/modelToDom/handlers/handleEntity.ts | 17 +++- 8 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/override/pasteCopyBlockEntityParser.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/overrides/pasteCopyBlockEntityParserTest.ts diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts index 2cefb62eb9b..107bfb31332 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts @@ -9,7 +9,6 @@ import type { IStandaloneEditor, } from 'roosterjs-content-model-types'; -const BlockEntityTag = 'div'; const InlineEntityTag = 'span'; /** @@ -58,10 +57,11 @@ export default function insertEntity( options?: InsertEntityOptions ): ContentModelEntity | null { const { contentNode, focusAfterEntity, wrapperDisplay, skipUndoSnapshot } = options || {}; - const wrapper = editor.getDocument().createElement(isBlock ? BlockEntityTag : InlineEntityTag); - const display = wrapperDisplay ?? (isBlock ? undefined : 'inline-block'); - - wrapper.style.setProperty('display', display || null); + const wrapper = editor.getDocument().createElement(InlineEntityTag); + if (isBlock) { + wrapper.style.width = '100%'; + } + wrapper.style.setProperty('display', wrapperDisplay ?? ('inline-block' || null)); if (contentNode) { wrapper.appendChild(contentNode); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts index d820c74f355..0ef9c5704fe 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts @@ -110,12 +110,11 @@ describe('insertEntity', () => { wrapper: wrapper, }); }); - it('block inline entity to root', () => { const entity = insertEntity(editor, type, true, 'root'); - expect(createElementSpy).toHaveBeenCalledWith('div'); - expect(setPropertySpy).toHaveBeenCalledWith('display', null); + expect(createElementSpy).toHaveBeenCalledWith('span'); + expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block'); expect(appendChildSpy).not.toHaveBeenCalled(); expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName); expect(formatWithContentModelSpy.calls.argsFor(0)[1].changeSource).toEqual( @@ -165,7 +164,7 @@ describe('insertEntity', () => { wrapperDisplay: 'none', }); - expect(createElementSpy).toHaveBeenCalledWith('div'); + expect(createElementSpy).toHaveBeenCalledWith('span'); expect(setPropertySpy).toHaveBeenCalledWith('display', 'none'); expect(appendChildSpy).toHaveBeenCalledWith(contentNode); expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName); diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts index 21c9a4beccf..c85aa213829 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts @@ -6,6 +6,7 @@ import { deleteSelection } from '../publicApi/selection/deleteSelection'; import { extractClipboardItems } from '../utils/extractClipboardItems'; import { getSelectedCells } from '../publicApi/table/getSelectedCells'; import { iterateSelections } from '../publicApi/selection/iterateSelections'; +import { onCreateCopyEntityNode } from '../override/pasteCopyBlockEntityParser'; import { transformColor } from '../publicApi/color/transformColor'; import { contentModelToDom, @@ -321,13 +322,14 @@ function domSelectionToRange(doc: Document, selection: DOMSelection): Range | nu * @internal * Exported only for unit testing */ -export const onNodeCreated: OnNodeCreated = (_, node): void => { +export const onNodeCreated: OnNodeCreated = (model, node): void => { if (isNodeOfType(node, 'ELEMENT_NODE') && isElementOfType(node, 'table')) { wrap(node.ownerDocument, node, 'div'); } if (isNodeOfType(node, 'ELEMENT_NODE') && !node.isContentEditable) { node.removeAttribute('contenteditable'); } + onCreateCopyEntityNode(model, node); }; /** diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/pasteCopyBlockEntityParser.ts b/packages-content-model/roosterjs-content-model-core/lib/override/pasteCopyBlockEntityParser.ts new file mode 100644 index 00000000000..5918f4ffc1a --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/override/pasteCopyBlockEntityParser.ts @@ -0,0 +1,40 @@ +import { isElementOfType, isNodeOfType } from 'roosterjs-content-model-dom'; +import type { + ContentModelEntity, + EntityInfoFormat, + FormatParser, + OnNodeCreated, +} from 'roosterjs-content-model-types'; + +const BLOCK_ENTITY_CLASS = '_EBlock'; +const ONE_HUNDRED_PERCENT = '100%'; + +/** + * @internal + */ +export const onCreateCopyEntityNode: OnNodeCreated = (model, node) => { + const entityModel = model as ContentModelEntity; + if ( + entityModel && + entityModel.wrapper && + entityModel.segmentType == 'Entity' && + isNodeOfType(node, 'ELEMENT_NODE') && + isElementOfType(node, 'span') && + node.style.width == ONE_HUNDRED_PERCENT && + node.style.display == 'inline-block' + ) { + node.classList.add(BLOCK_ENTITY_CLASS); + node.style.display = 'block'; + } +}; + +/** + * @internal + */ +export const pasteBlockEntityParser: FormatParser = (_, element) => { + if (element.classList.contains(BLOCK_ENTITY_CLASS)) { + element.style.display = 'inline-block'; + element.style.width = ONE_HUNDRED_PERCENT; + element.classList.remove(BLOCK_ENTITY_CLASS); + } +}; 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 57796bbb053..9e570619bad 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 @@ -6,6 +6,7 @@ import { createPasteGeneralProcessor } from '../../override/pasteGeneralProcesso import { getSegmentTextFormat } from '../../publicApi/domUtils/getSegmentTextFormat'; import { getSelectedSegments } from '../../publicApi/selection/collectSelections'; import { mergeModel } from '../../publicApi/model/mergeModel'; +import { pasteBlockEntityParser } from '../../override/pasteCopyBlockEntityParser'; import { pasteDisplayFormatParser } from '../../override/pasteDisplayFormatParser'; import { pasteTextProcessor } from '../../override/pasteTextProcessor'; import type { MergeModelOption } from '../../publicApi/model/mergeModel'; @@ -59,6 +60,7 @@ export function mergePasteContent( }, additionalFormatParsers: { container: [containerSizeFormatParser], + entity: [pasteBlockEntityParser], }, }, domToModelOption diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/pasteCopyBlockEntityParserTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteCopyBlockEntityParserTest.ts new file mode 100644 index 00000000000..3ee0548f219 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteCopyBlockEntityParserTest.ts @@ -0,0 +1,82 @@ +import { ContentModelEntity } from 'roosterjs-content-model-types'; +import { + onCreateCopyEntityNode, + pasteBlockEntityParser, +} from '../../lib/override/pasteCopyBlockEntityParser'; + +describe('onCreateCopyEntityNode', () => { + it('handle', () => { + const span = document.createElement('span'); + span.style.width = '100%'; + span.style.display = 'inline-block'; + const modelEntity: ContentModelEntity = { + entityFormat: {}, + format: {}, + wrapper: span, + segmentType: 'Entity', + blockType: 'Entity', + }; + + onCreateCopyEntityNode(modelEntity, span); + + expect(span.style.display).toEqual('block'); + expect(span.classList.contains('_EBlock')).toBeTrue(); + }); + + it('Dont handle, no 100% width', () => { + const span = document.createElement('span'); + span.style.display = 'inline-block'; + const modelEntity: ContentModelEntity = { + entityFormat: {}, + format: {}, + wrapper: span, + segmentType: 'Entity', + blockType: 'Entity', + }; + + onCreateCopyEntityNode(modelEntity, span); + + expect(span.style.display).not.toEqual('block'); + expect(span.classList.contains('_EBlock')).not.toBeTrue(); + }); + + it('Dont handle, not inline block', () => { + const span = document.createElement('span'); + span.style.width = '100%'; + const modelEntity: ContentModelEntity = { + entityFormat: {}, + format: {}, + wrapper: span, + segmentType: 'Entity', + blockType: 'Entity', + }; + + onCreateCopyEntityNode(modelEntity, span); + + expect(span.style.display).not.toEqual('block'); + expect(span.classList.contains('_EBlock')).not.toBeTrue(); + }); +}); + +describe('pasteBlockEntityParser', () => { + it('handle', () => { + const span = document.createElement('span'); + span.classList.add('_EBlock'); + + pasteBlockEntityParser({}, span, {}, {}); + + expect(span.style.width).toEqual('100%'); + expect(span.style.display).toEqual('inline-block'); + expect(span.classList.contains('_EBlock')).toBeFalse(); + }); + + it('Dont handle', () => { + const span = document.createElement('span'); + + pasteBlockEntityParser({}, span, {}, {}); + + expect(span.style.width).not.toEqual('100%'); + expect(span.style.display).not.toEqual('inline-block'); + expect(span.classList.contains('_EBlock')).toBeFalse(); + }); +}); 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 c0e8c7df151..2c6abdb2ed0 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 @@ -6,6 +6,7 @@ import * as mergeModelFile from '../../../lib/publicApi/model/mergeModel'; import { containerSizeFormatParser } from '../../../lib/override/containerSizeFormatParser'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { mergePasteContent } from '../../../lib/utils/paste/mergePasteContent'; +import { pasteBlockEntityParser } from '../../../lib/override/pasteCopyBlockEntityParser'; import { pasteDisplayFormatParser } from '../../../lib/override/pasteDisplayFormatParser'; import { pasteTextProcessor } from '../../../lib/override/pasteTextProcessor'; import { @@ -429,6 +430,7 @@ describe('mergePasteContent', () => { }, additionalFormatParsers: { container: [containerSizeFormatParser], + entity: [pasteBlockEntityParser], }, }, mockedDefaultDomToModelOptions diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts index 740118c4e64..c55a838bf62 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts @@ -7,6 +7,7 @@ import type { ContentModelBlockHandler, ContentModelEntity, ContentModelSegmentHandler, + ModelToDomContext, } from 'roosterjs-content-model-types'; /** @@ -53,7 +54,7 @@ export const handleEntitySegment: ContentModelSegmentHandler applyFormat(wrapper, context.formatAppliers.entity, entityFormat, context); if (context.addDelimiterForEntity && entityFormat.isReadonly) { - const [after, before] = addDelimiters(doc, wrapper); + const [after, before] = addDelimiterForEntity(doc, wrapper, context); newSegments?.push(after, before); context.regularSelection.current.segment = after; @@ -63,3 +64,17 @@ export const handleEntitySegment: ContentModelSegmentHandler context.onNodeCreated?.(entityModel, wrapper); }; + +function addDelimiterForEntity(doc: Document, wrapper: HTMLElement, context: ModelToDomContext) { + const [after, before] = addDelimiters(doc, wrapper); + + const format = { + ...context.pendingFormat?.format, + ...context.defaultFormat, + }; + + applyFormat(after, context.formatAppliers.segment, format, context); + applyFormat(before, context.formatAppliers.segment, format, context); + + return [after, before]; +} From 269f192f3060456eebd8fddb9f831844c1969f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 29 Jan 2024 14:41:54 -0300 Subject: [PATCH 041/112] rotator space --- .../lib/plugins/ImageEdit/imageEditors/Rotator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts index c3970238edb..4fdbf7451c2 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts @@ -124,7 +124,7 @@ export function getRotateHTML({ className: ImageEditElementClass.RotateHandle, style: `position:absolute;background-color:${rotateHandleBackColor};border:solid 1px ${borderColor};border-radius:50%;width:${ROTATE_SIZE}px;height:${ROTATE_SIZE}px;left:-${ handleLeft + ROTATE_WIDTH - }px;cursor:move;top:${-ROTATE_SIZE}px;`, + }px;cursor:move;top:${-ROTATE_SIZE}px;line-height: 0px;`, children: [getRotateIconHTML(borderColor)], }, ], From fea345f6699e5a197a385d612894497d2f58566c Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 29 Jan 2024 10:38:52 -0800 Subject: [PATCH 042/112] Port ContextMenu plugin (#2366) * Port ContextMenu plugin * add test --- .../lib/corePlugin/ContextMenuPlugin.ts | 127 +++++++++++++ .../createStandaloneEditorCorePlugins.ts | 2 + .../lib/editor/createStandaloneEditorCore.ts | 2 + .../test/corePlugin/ContextMenuPluginTest.ts | 169 ++++++++++++++++++ .../editor/createStandaloneEditorCoreTest.ts | 5 + .../lib/corePlugins/BridgePlugin.ts | 38 +++- .../lib/corePlugins/ContextMenuPlugin.ts | 111 ------------ .../lib/index.ts | 1 - .../publicTypes/ContentModelCorePlugins.ts | 7 +- .../test/corePlugins/BridgePluginTest.ts | 83 ++++++++- .../test/corePlugins/ContextMenuPluginTest.ts | 93 ---------- .../test/editor/createEditorCoreTest.ts | 10 +- .../lib/editor/StandaloneEditorCorePlugins.ts | 6 + .../lib/index.ts | 1 + .../pluginState}/ContextMenuPluginState.ts | 2 +- 15 files changed, 431 insertions(+), 226 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContextMenuPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/corePlugin/ContextMenuPluginTest.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ContextMenuPlugin.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/test/corePlugins/ContextMenuPluginTest.ts rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes => roosterjs-content-model-types/lib/pluginState}/ContextMenuPluginState.ts (75%) diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContextMenuPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContextMenuPlugin.ts new file mode 100644 index 00000000000..ed433c9d890 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContextMenuPlugin.ts @@ -0,0 +1,127 @@ +import { getSelectionRootNode } from '../publicApi/selection/getSelectionRootNode'; +import type { + ContextMenuPluginState, + ContextMenuProvider, + IStandaloneEditor, + PluginWithState, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; + +const ContextMenuButton = 2; + +/** + * Edit Component helps handle Content edit features + */ +class ContextMenuPlugin implements PluginWithState { + private editor: IStandaloneEditor | null = null; + private state: ContextMenuPluginState; + private disposer: (() => void) | null = null; + + /** + * Construct a new instance of EditPlugin + * @param options The editor options + */ + constructor(options: StandaloneEditorOptions) { + this.state = { + contextMenuProviders: + options.plugins?.filter>(isContextMenuProvider) || [], + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'ContextMenu'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IStandaloneEditor) { + this.editor = editor; + this.disposer = this.editor.attachDomEvent({ + contextmenu: { + beforeDispatch: this.onContextMenuEvent, + }, + }); + } + + /** + * Dispose this plugin + */ + dispose() { + this.disposer?.(); + this.disposer = null; + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + private onContextMenuEvent = (e: Event) => { + if (this.editor) { + const allItems: any[] = []; + const mouseEvent = e as MouseEvent; + + // ContextMenu event can be triggered from mouse right click or keyboard (e.g. Shift+F10 on Windows) + // Need to check if this is from keyboard, we need to get target node from selection because in that case + // event.target is always the element that attached context menu event, here it will be editor content div. + const targetNode = + mouseEvent.button == ContextMenuButton + ? (mouseEvent.target as Node) + : this.getFocusedNode(this.editor); + + if (targetNode) { + this.state.contextMenuProviders.forEach(provider => { + const items = provider.getContextMenuItems(targetNode) ?? []; + if (items?.length > 0) { + if (allItems.length > 0) { + allItems.push(null); + } + + allItems.push(...items); + } + }); + } + + this.editor?.triggerEvent('contextMenu', { + rawEvent: mouseEvent, + items: allItems, + }); + } + }; + + private getFocusedNode(editor: IStandaloneEditor) { + const selection = editor.getDOMSelection(); + + if (selection) { + if (selection.type == 'range') { + selection.range.collapse(true /*toStart*/); + } + + return getSelectionRootNode(selection) || null; + } else { + return null; + } + } +} + +function isContextMenuProvider(source: unknown): source is ContextMenuProvider { + return !!(>source)?.getContextMenuItems; +} + +/** + * @internal + * Create a new instance of EditPlugin. + */ +export function createContextMenuPlugin( + options: StandaloneEditorOptions +): PluginWithState { + return new ContextMenuPlugin(options); +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts index 1a3a95a71a1..c329d7a33d1 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts @@ -1,6 +1,7 @@ import { createContentModelCachePlugin } from './ContentModelCachePlugin'; import { createContentModelCopyPastePlugin } from './ContentModelCopyPastePlugin'; import { createContentModelFormatPlugin } from './ContentModelFormatPlugin'; +import { createContextMenuPlugin } from './ContextMenuPlugin'; import { createDOMEventPlugin } from './DOMEventPlugin'; import { createEntityPlugin } from './EntityPlugin'; import { createLifecyclePlugin } from './LifecyclePlugin'; @@ -28,6 +29,7 @@ export function createStandaloneEditorCorePlugins( lifecycle: createLifecyclePlugin(options, contentDiv), entity: createEntityPlugin(), selection: createSelectionPlugin(options), + contextMenu: createContextMenuPlugin(options), undo: createUndoPlugin(options), }; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts index f6cfdbc86fc..1ce982f5be6 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts @@ -39,6 +39,7 @@ export function createStandaloneEditorCore( corePlugins.entity, ...(options.plugins ?? []).filter(x => !!x), corePlugins.undo, + corePlugins.contextMenu, corePlugins.lifecycle, ], environment: createEditorEnvironment(contentDiv), @@ -88,6 +89,7 @@ function getPluginState(corePlugins: StandaloneEditorCorePlugins): PluginState { lifecycle: corePlugins.lifecycle.getState(), entity: corePlugins.entity.getState(), selection: corePlugins.selection.getState(), + contextMenu: corePlugins.contextMenu.getState(), undo: corePlugins.undo.getState(), }; } diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContextMenuPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContextMenuPluginTest.ts new file mode 100644 index 00000000000..92fac47a729 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContextMenuPluginTest.ts @@ -0,0 +1,169 @@ +import * as getSelectionRootNode from '../../lib/publicApi/selection/getSelectionRootNode'; +import { createContextMenuPlugin } from '../../lib/corePlugin/ContextMenuPlugin'; +import { + ContextMenuPluginState, + DOMEventRecord, + IStandaloneEditor, + PluginWithState, +} from 'roosterjs-content-model-types'; + +describe('ContextMenu handle other event', () => { + let plugin: PluginWithState; + let eventMap: Record; + let triggerEventSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let attachDOMEventSpy: jasmine.Spy; + let getSelectionRootNodeSpy: jasmine.Spy; + let editor: IStandaloneEditor; + + beforeEach(() => { + triggerEventSpy = jasmine.createSpy('triggerEvent'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + getSelectionRootNodeSpy = spyOn(getSelectionRootNode, 'getSelectionRootNode'); + attachDOMEventSpy = jasmine + .createSpy('attachDOMEvent') + .and.callFake((handlers: Record) => { + eventMap = handlers; + }); + + editor = ({ + getDOMSelection: getDOMSelectionSpy, + attachDomEvent: attachDOMEventSpy, + triggerEvent: triggerEventSpy, + }); + }); + + afterEach(() => { + plugin.dispose(); + }); + + it('Ctor with parameter', () => { + const mockedPlugin1 = {} as any; + const mockedPlugin2 = { + getContextMenuItems: () => {}, + } as any; + + plugin = createContextMenuPlugin({ + plugins: [mockedPlugin1, mockedPlugin2], + }); + + plugin.initialize(editor); + + expect(attachDOMEventSpy).toHaveBeenCalledTimes(1); + + const state = plugin.getState(); + + expect(state).toEqual({ + contextMenuProviders: [mockedPlugin2], + }); + }); + + it('Trigger contextmenu event, skip reselect', () => { + plugin = createContextMenuPlugin({}); + plugin.initialize(editor); + + const state = plugin.getState(); + const mockedItems1 = ['Item1', 'Item2']; + const mockedItems2 = ['Item3', 'Item4']; + + const getContextMenuItemSpy1 = jasmine + .createSpy('getContextMenu 1') + .and.returnValue(mockedItems1); + const getContextMenuItemSpy2 = jasmine + .createSpy('getContextMenu 2') + .and.returnValue(mockedItems2); + + state.contextMenuProviders = [ + { + getContextMenuItems: getContextMenuItemSpy1, + } as any, + { + getContextMenuItems: getContextMenuItemSpy2, + } as any, + ]; + + const mockedTarget = 'TARGET' as any; + const mockedEvent = { + button: 2, + target: mockedTarget, + }; + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + collapse: collapseSpy, + }; + const mockedSelection = { + type: 'range', + range: mockedRange, + }; + const mockedNode = 'NODE'; + + getDOMSelectionSpy.and.returnValue(mockedSelection); + getSelectionRootNodeSpy.and.returnValue(mockedNode); + + eventMap.contextmenu.beforeDispatch(mockedEvent); + + expect(collapseSpy).not.toHaveBeenCalled(); + expect(getDOMSelectionSpy).not.toHaveBeenCalled(); + expect(getSelectionRootNodeSpy).not.toHaveBeenCalled(); + expect(getContextMenuItemSpy1).toHaveBeenCalledWith(mockedTarget); + expect(getContextMenuItemSpy2).toHaveBeenCalledWith(mockedTarget); + expect(triggerEventSpy).toHaveBeenCalledWith('contextMenu', { + rawEvent: mockedEvent, + items: ['Item1', 'Item2', null, 'Item3', 'Item4'], + }); + }); + + it('Trigger contextmenu event using keyboard', () => { + plugin = createContextMenuPlugin({}); + plugin.initialize(editor); + + const state = plugin.getState(); + const mockedItems1 = ['Item1', 'Item2']; + const mockedItems2 = ['Item3', 'Item4']; + const getContextMenuItemSpy1 = jasmine + .createSpy('getContextMenu 1') + .and.returnValue(mockedItems1); + const getContextMenuItemSpy2 = jasmine + .createSpy('getContextMenu 2') + .and.returnValue(mockedItems2); + + state.contextMenuProviders = [ + { + getContextMenuItems: getContextMenuItemSpy1, + } as any, + { + getContextMenuItems: getContextMenuItemSpy2, + } as any, + ]; + + const mockedTarget = 'TARGET' as any; + const mockedEvent = { + button: -1, + target: mockedTarget, + }; + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + collapse: collapseSpy, + }; + const mockedSelection = { + type: 'range', + range: mockedRange, + }; + const mockedNode = 'NODE'; + + getDOMSelectionSpy.and.returnValue(mockedSelection); + getSelectionRootNodeSpy.and.returnValue(mockedNode); + + eventMap.contextmenu.beforeDispatch(mockedEvent); + + expect(collapseSpy).toHaveBeenCalledWith(true); + expect(getDOMSelectionSpy).toHaveBeenCalledWith(); + expect(getSelectionRootNodeSpy).toHaveBeenCalledWith(mockedSelection); + expect(getContextMenuItemSpy1).toHaveBeenCalledWith(mockedNode); + expect(getContextMenuItemSpy2).toHaveBeenCalledWith(mockedNode); + expect(triggerEventSpy).toHaveBeenCalledWith('contextMenu', { + rawEvent: mockedEvent, + items: ['Item1', 'Item2', null, 'Item3', 'Item4'], + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts index 6f50bb315a8..e55741fbc42 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts @@ -25,6 +25,7 @@ describe('createEditorCore', () => { const mockedEntityPlugin = createMockedPlugin('entity'); const mockedSelectionPlugin = createMockedPlugin('selection'); const mockedUndoPlugin = createMockedPlugin('undo'); + const mockedContextMenuPlugin = createMockedPlugin('contextMenu'); const mockedPlugins = { cache: mockedCachePlugin, format: mockedFormatPlugin, @@ -34,6 +35,7 @@ describe('createEditorCore', () => { entity: mockedEntityPlugin, selection: mockedSelectionPlugin, undo: mockedUndoPlugin, + contextMenu: mockedContextMenuPlugin, }; const mockedDarkColorHandler = 'DARKCOLOR' as any; const mockedDomToModelSettings = 'DOMTOMODEL' as any; @@ -76,6 +78,7 @@ describe('createEditorCore', () => { mockedSelectionPlugin, mockedEntityPlugin, mockedUndoPlugin, + mockedContextMenuPlugin, mockedLifeCyclePlugin, ], environment: { @@ -95,6 +98,7 @@ describe('createEditorCore', () => { entity: 'entity' as any, selection: 'selection' as any, undo: 'undo' as any, + contextMenu: 'contextMenu' as any, domHelper: mockedDOMHelper, disposeErrorHandler: undefined, zoomScale: 1, @@ -166,6 +170,7 @@ describe('createEditorCore', () => { mockedPlugin1, mockedPlugin2, mockedUndoPlugin, + mockedContextMenuPlugin, mockedLifeCyclePlugin, ], darkColorHandler: mockedDarkColorHandler, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts index cefe2cbe012..defb4abd069 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts @@ -1,4 +1,3 @@ -import { createContextMenuPlugin } from './ContextMenuPlugin'; import { createEditPlugin } from './EditPlugin'; import { createEntityDelimiterPlugin } from './EntityDelimiterPlugin'; import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; @@ -12,8 +11,9 @@ import type { import type { EditorPlugin as LegacyEditorPlugin, PluginEvent as LegacyPluginEvent, + ContextMenuProvider as LegacyContextMenuProvider, } from 'roosterjs-editor-types'; -import type { EditorPlugin, PluginEvent } from 'roosterjs-content-model-types'; +import type { ContextMenuProvider, PluginEvent } from 'roosterjs-content-model-types'; const ExclusivelyHandleEventPluginKey = '__ExclusivelyHandleEventPlugin'; @@ -21,7 +21,7 @@ const ExclusivelyHandleEventPluginKey = '__ExclusivelyHandleEventPlugin'; * @internal * Act as a bridge between Standalone editor and Content Model editor, translate Standalone editor event type to legacy event type */ -export class BridgePlugin implements EditorPlugin { +export class BridgePlugin implements ContextMenuProvider { private legacyPlugins: LegacyEditorPlugin[]; private corePluginState: ContentModelCorePluginState; private outerEditor: IContentModelEditor | null = null; @@ -29,7 +29,6 @@ export class BridgePlugin implements EditorPlugin { constructor(options: ContentModelEditorOptions) { const editPlugin = createEditPlugin(); - const contextMenuPlugin = createContextMenuPlugin(options); const normalizeTablePlugin = createNormalizeTablePlugin(); const entityDelimiterPlugin = createEntityDelimiterPlugin(); @@ -37,12 +36,11 @@ export class BridgePlugin implements EditorPlugin { editPlugin, ...(options.legacyPlugins ?? []).filter(x => !!x), entityDelimiterPlugin, - contextMenuPlugin, normalizeTablePlugin, ]; this.corePluginState = { edit: editPlugin.getState(), - contextMenu: contextMenuPlugin.getState(), + contextMenuProviders: this.legacyPlugins.filter(isContextMenuProvider), }; this.checkExclusivelyHandling = this.legacyPlugins.some( plugin => plugin.willHandleEventExclusively @@ -137,4 +135,32 @@ export class BridgePlugin implements EditorPlugin { Object.assign(event, oldEventToNewEvent(oldEvent, event)); } } + + /** + * A callback to return context menu items + * @param target Target node that triggered a ContextMenu event + * @returns An array of context menu items, or null means no items needed + */ + getContextMenuItems(target: Node): any[] { + const allItems: any[] = []; + + this.corePluginState.contextMenuProviders.forEach(provider => { + const items = provider.getContextMenuItems(target) ?? []; + if (items?.length > 0) { + if (allItems.length > 0) { + allItems.push(null); + } + + allItems.push(...items); + } + }); + + return allItems; + } +} + +function isContextMenuProvider( + source: LegacyEditorPlugin +): source is LegacyContextMenuProvider { + return !!(>source)?.getContextMenuItems; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ContextMenuPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ContextMenuPlugin.ts deleted file mode 100644 index 850101d5447..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ContextMenuPlugin.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { PluginEventType } from 'roosterjs-editor-types'; -import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; -import type { ContextMenuPluginState } from '../publicTypes/ContextMenuPluginState'; -import type { - ContextMenuProvider, - EditorPlugin, - IEditor, - PluginEvent, - PluginWithState, -} from 'roosterjs-editor-types'; - -/** - * Edit Component helps handle Content edit features - */ -class ContextMenuPlugin implements PluginWithState { - private editor: IEditor | null = null; - private state: ContextMenuPluginState; - private disposer: (() => void) | null = null; - - /** - * Construct a new instance of EditPlugin - * @param options The editor options - */ - constructor(options: ContentModelEditorOptions) { - this.state = { - contextMenuProviders: - options.legacyPlugins?.filter>(isContextMenuProvider) || - [], - }; - } - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'Edit'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - this.disposer = this.editor.addDomEventHandler('contextmenu', this.onContextMenuEvent); - } - - /** - * Dispose this plugin - */ - dispose() { - this.disposer?.(); - this.disposer = null; - this.editor = null; - } - - /** - * Get plugin state object - */ - getState() { - return this.state; - } - - /** - * Handle events triggered from editor - * @param event PluginEvent object - */ - onPluginEvent(event: PluginEvent) {} - - private onContextMenuEvent = (e: Event) => { - const event = e as MouseEvent; - const allItems: any[] = []; - - // TODO: Remove dependency to ContentSearcher - const searcher = this.editor?.getContentSearcherOfCursor(); - const elementBeforeCursor = searcher?.getInlineElementBefore(); - - let eventTargetNode = event.target as Node; - if (event.button != 2 && elementBeforeCursor) { - eventTargetNode = elementBeforeCursor.getContainerNode(); - } - this.state.contextMenuProviders.forEach(provider => { - const items = provider.getContextMenuItems(eventTargetNode) ?? []; - if (items?.length > 0) { - if (allItems.length > 0) { - allItems.push(null); - } - - allItems.push(...items); - } - }); - this.editor?.triggerPluginEvent(PluginEventType.ContextMenu, { - rawEvent: event, - items: allItems, - }); - }; -} - -function isContextMenuProvider(source: EditorPlugin): source is ContextMenuProvider { - return !!(>source)?.getContextMenuItems; -} - -/** - * @internal - * Create a new instance of EditPlugin. - */ -export function createContextMenuPlugin( - options: ContentModelEditorOptions -): PluginWithState { - return new ContextMenuPlugin(options); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 1aecfd69e2c..a2036c046ea 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -8,7 +8,6 @@ export { EnsureTypeInContainer, } from './publicTypes/ContentModelEditorCore'; export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; -export { ContextMenuPluginState } from './publicTypes/ContextMenuPluginState'; export { ContentModelCorePluginState } from './publicTypes/ContentModelCorePlugins'; export { ContentModelBeforePasteEvent } from './publicTypes/ContentModelBeforePasteEvent'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts index 851b170b720..450b843c1e1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts @@ -1,5 +1,4 @@ -import type { ContextMenuPluginState } from './ContextMenuPluginState'; -import type { EditPluginState } from 'roosterjs-editor-types'; +import type { ContextMenuProvider, EditPluginState } from 'roosterjs-editor-types'; /** * Core plugin state for Content Model Editor @@ -11,7 +10,7 @@ export interface ContentModelCorePluginState { readonly edit: EditPluginState; /** - * Plugin state of ContextMenuPlugin + * Context Menu providers */ - readonly contextMenu: ContextMenuPluginState; + readonly contextMenuProviders: ContextMenuProvider[]; } diff --git a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts index 981c9afbde2..15b61c74814 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts @@ -1,4 +1,3 @@ -import * as ContextMenuPlugin from '../../lib/corePlugins/ContextMenuPlugin'; import * as EditPlugin from '../../lib/corePlugins/EditPlugin'; import * as eventConverter from '../../lib/editor/utils/eventConverter'; import * as NormalizeTablePlugin from '../../lib/corePlugins/NormalizeTablePlugin'; @@ -14,9 +13,6 @@ describe('BridgePlugin', () => { } as any; } beforeEach(() => { - spyOn(ContextMenuPlugin, 'createContextMenuPlugin').and.returnValue( - createMockedPlugin('contextMenu') - ); spyOn(EditPlugin, 'createEditPlugin').and.returnValue(createMockedPlugin('edit')); spyOn(NormalizeTablePlugin, 'createNormalizeTablePlugin').and.returnValue( createMockedPlugin('normalizeTable') @@ -54,7 +50,7 @@ describe('BridgePlugin', () => { expect(plugin.getCorePluginState()).toEqual({ edit: 'edit', - contextMenu: 'contextMenu', + contextMenuProviders: [], } as any); plugin.setOuterEditor(mockedEditor); @@ -261,4 +257,81 @@ describe('BridgePlugin', () => { plugin.dispose(); }); + + it('Context Menu provider', () => { + const initializeSpy = jasmine.createSpy('initialize'); + const onPluginEventSpy1 = jasmine.createSpy('onPluginEvent1'); + const onPluginEventSpy2 = jasmine.createSpy('onPluginEvent2'); + const disposeSpy = jasmine.createSpy('dispose'); + const queryElementsSpy = jasmine.createSpy('queryElement').and.returnValue([]); + const getContextMenuItemsSpy1 = jasmine + .createSpy('getContextMenuItems 1') + .and.returnValue(['item1', 'item2']); + const getContextMenuItemsSpy2 = jasmine + .createSpy('getContextMenuItems 2') + .and.returnValue(['item3', 'item4']); + + const mockedPlugin1 = { + initialize: initializeSpy, + onPluginEvent: onPluginEventSpy1, + dispose: disposeSpy, + getContextMenuItems: getContextMenuItemsSpy1, + } as any; + const mockedPlugin2 = { + initialize: initializeSpy, + onPluginEvent: onPluginEventSpy2, + dispose: disposeSpy, + getContextMenuItems: getContextMenuItemsSpy2, + } as any; + const mockedEditor = { + queryElements: queryElementsSpy, + } as any; + + const plugin = new BridgePlugin({ + legacyPlugins: [mockedPlugin1, mockedPlugin2], + }); + expect(initializeSpy).not.toHaveBeenCalled(); + expect(onPluginEventSpy1).not.toHaveBeenCalled(); + expect(onPluginEventSpy2).not.toHaveBeenCalled(); + expect(disposeSpy).not.toHaveBeenCalled(); + + expect(plugin.getCorePluginState()).toEqual({ + edit: 'edit', + contextMenuProviders: [mockedPlugin1, mockedPlugin2], + } as any); + + plugin.setOuterEditor(mockedEditor); + + expect(initializeSpy).toHaveBeenCalledTimes(0); + expect(onPluginEventSpy1).toHaveBeenCalledTimes(0); + expect(onPluginEventSpy2).toHaveBeenCalledTimes(0); + expect(disposeSpy).not.toHaveBeenCalled(); + + plugin.initialize(); + + expect(initializeSpy).toHaveBeenCalledTimes(2); + expect(onPluginEventSpy1).toHaveBeenCalledTimes(1); + expect(onPluginEventSpy2).toHaveBeenCalledTimes(1); + expect(disposeSpy).not.toHaveBeenCalled(); + + expect(initializeSpy).toHaveBeenCalledWith(mockedEditor); + expect(onPluginEventSpy1).toHaveBeenCalledWith({ + eventType: PluginEventType.EditorReady, + }); + expect(onPluginEventSpy2).toHaveBeenCalledWith({ + eventType: PluginEventType.EditorReady, + }); + + const mockedNode = 'NODE' as any; + + const items = plugin.getContextMenuItems(mockedNode); + + expect(items).toEqual(['item1', 'item2', null, 'item3', 'item4']); + expect(getContextMenuItemsSpy1).toHaveBeenCalledWith(mockedNode); + expect(getContextMenuItemsSpy2).toHaveBeenCalledWith(mockedNode); + + plugin.dispose(); + + expect(disposeSpy).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/ContextMenuPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/ContextMenuPluginTest.ts deleted file mode 100644 index 763cda3b4c0..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/ContextMenuPluginTest.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { ContextMenuPluginState } from '../../lib/publicTypes/ContextMenuPluginState'; -import { createContextMenuPlugin } from '../../lib/corePlugins/ContextMenuPlugin'; -import { IEditor, PluginEventType, PluginWithState } from 'roosterjs-editor-types'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; - -describe('ContextMenu handle other event', () => { - let plugin: PluginWithState; - let addEventListener: jasmine.Spy; - let removeEventListener: jasmine.Spy; - let triggerPluginEvent: jasmine.Spy; - let eventMap: Record; - let getElementAtCursorSpy: jasmine.Spy; - let triggerContentChangedEventSpy: jasmine.Spy; - let editor: IEditor & IStandaloneEditor; - - beforeEach(() => { - addEventListener = jasmine.createSpy('addEventListener'); - removeEventListener = jasmine.createSpy('.removeEventListener'); - triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - getElementAtCursorSpy = jasmine.createSpy('getElementAtCursor'); - triggerContentChangedEventSpy = jasmine.createSpy('triggerContentChangedEvent'); - - editor = ({ - getDocument: () => ({ - addEventListener, - removeEventListener, - }), - triggerPluginEvent, - getEnvironment: () => ({}), - addDomEventHandler: (name: string, handler: Function) => { - eventMap = { - [name]: { - beforeDispatch: handler, - }, - }; - }, - getElementAtCursor: getElementAtCursorSpy, - triggerContentChangedEvent: triggerContentChangedEventSpy, - }); - }); - - afterEach(() => { - plugin.dispose(); - }); - - it('Ctor with parameter', () => { - const mockedPlugin1 = {} as any; - const mockedPlugin2 = { - getContextMenuItems: () => {}, - } as any; - - plugin = createContextMenuPlugin({ - legacyPlugins: [mockedPlugin1, mockedPlugin2], - }); - plugin.initialize(editor); - - const state = plugin.getState(); - - expect(state).toEqual({ - contextMenuProviders: [mockedPlugin2], - }); - }); - - it('Trigger contextmenu event, skip reselect', () => { - plugin = createContextMenuPlugin({}); - plugin.initialize(editor); - - editor.getContentSearcherOfCursor = () => null!; - const state = plugin.getState(); - const mockedItems1 = ['Item1', 'Item2']; - const mockedItems2 = ['Item3', 'Item4']; - - state.contextMenuProviders = [ - { - getContextMenuItems: () => mockedItems1, - } as any, - { - getContextMenuItems: () => mockedItems2, - } as any, - ]; - - const mockedEvent = { - target: {}, - }; - - eventMap.contextmenu.beforeDispatch(mockedEvent); - - expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.ContextMenu, { - rawEvent: mockedEvent, - items: ['Item1', 'Item2', null, 'Item3', 'Item4'], - }); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts index 4a382a51853..7b92620902b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts @@ -5,7 +5,6 @@ import { createEditorCore } from '../../lib/editor/createEditorCore'; describe('createEditorCore', () => { const mockedSizeTransformer = 'TRANSFORMER' as any; const mockedEditPluginState = 'EDITSTATE' as any; - const mockedContextMenuPluginState = 'CONTEXTMENUSTATE' as any; const mockedInnerHandler = 'INNER' as any; const mockedDarkHandler = 'DARK' as any; @@ -18,7 +17,7 @@ describe('createEditorCore', () => { {}, { edit: mockedEditPluginState, - contextMenu: mockedContextMenuPluginState, + contextMenuProviders: [], }, mockedInnerHandler, mockedSizeTransformer @@ -30,7 +29,7 @@ describe('createEditorCore', () => { customData: {}, experimentalFeatures: [], edit: mockedEditPluginState, - contextMenu: mockedContextMenuPluginState, + contextMenuProviders: [], sizeTransformer: mockedSizeTransformer, darkColorHandler: mockedDarkHandler, }); @@ -40,6 +39,7 @@ describe('createEditorCore', () => { it('With additional plugins', () => { const mockedPlugin1 = 'P1' as any; const mockedPlugin2 = 'P2' as any; + const mockedPlugin3 = 'P3' as any; const mockedFeatures = 'FEATURES' as any; const mockedCoreApi = { a: 'b', @@ -53,7 +53,7 @@ describe('createEditorCore', () => { }, { edit: mockedEditPluginState, - contextMenu: mockedContextMenuPluginState, + contextMenuProviders: [mockedPlugin3], }, mockedInnerHandler, mockedSizeTransformer @@ -63,9 +63,9 @@ describe('createEditorCore', () => { api: { ...coreApiMap, a: 'b' } as any, originalApi: { ...coreApiMap }, customData: {}, + contextMenuProviders: [mockedPlugin3], experimentalFeatures: mockedFeatures, edit: mockedEditPluginState, - contextMenu: mockedContextMenuPluginState, sizeTransformer: mockedSizeTransformer, darkColorHandler: mockedDarkHandler, }); diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts index 007a3cb6da7..3ef59057fd2 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts @@ -1,3 +1,4 @@ +import type { ContextMenuPluginState } from '../pluginState/ContextMenuPluginState'; import type { PluginWithState } from './PluginWithState'; import type { CopyPastePluginState } from '../pluginState/CopyPastePluginState'; import type { UndoPluginState } from '../pluginState/UndoPluginState'; @@ -47,6 +48,11 @@ export interface StandaloneEditorCorePlugins { */ readonly undo: PluginWithState; + /** + * Undo plugin provides the ability get context menu items and trigger ContextMenu event + */ + readonly contextMenu: PluginWithState; + /** * Lifecycle plugin handles editor initialization and disposing */ 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 47efd401494..48047b40c35 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -246,6 +246,7 @@ export { GenericPluginState, PluginState, } from './pluginState/PluginState'; +export { ContextMenuPluginState } from './pluginState/ContextMenuPluginState'; export { EditorEnvironment } from './parameter/EditorEnvironment'; export { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContextMenuPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContextMenuPluginState.ts similarity index 75% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContextMenuPluginState.ts rename to packages-content-model/roosterjs-content-model-types/lib/pluginState/ContextMenuPluginState.ts index 7b2f58a8d66..33aab4d8753 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContextMenuPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContextMenuPluginState.ts @@ -1,4 +1,4 @@ -import type { ContextMenuProvider } from 'roosterjs-editor-types'; +import type { ContextMenuProvider } from '../editor/ContextMenuProvider'; /** * The state object for DOMEventPlugin From 21616f29b8055de4d567a03c1b784334451371ab Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 29 Jan 2024 22:31:24 -0800 Subject: [PATCH 043/112] Content Model: Fix 252436 (#2367) * Content Model: Fix 252436 * add test --------- Co-authored-by: Bryan Valverde U --- .../lib/coreApi/addUndoSnapshot.ts | 10 +- .../lib/utils/createSnapshotSelection.ts | 101 +++- .../test/coreApi/addUndoSnapshotTest.ts | 59 ++- .../test/utils/createSnapshotSelectionTest.ts | 487 +++++++++++++++++- .../lib/corePlugins/NormalizeTablePlugin.ts | 124 +---- 5 files changed, 624 insertions(+), 157 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts index dccba9db403..c2bcdcf9eb2 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts @@ -11,17 +11,19 @@ import type { AddUndoSnapshot, Snapshot } from 'roosterjs-content-model-types'; * when undo/redo to this snapshot */ export const addUndoSnapshot: AddUndoSnapshot = (core, canUndoByBackspace, entityStates) => { - const { lifecycle, api, contentDiv, undo } = core; + const { lifecycle, contentDiv, undo } = core; let snapshot: Snapshot | null = null; if (!lifecycle.shadowEditFragment) { - const selection = api.getDOMSelection(core); + // Need to create snapshot selection before retrieve innerHTML since HTML can be changed during creating selection when normalize table + const selection = createSnapshotSelection(core); + const html = contentDiv.innerHTML; snapshot = { - html: contentDiv.innerHTML, + html, entityStates, isDarkMode: !!lifecycle.isDarkMode, - selection: createSnapshotSelection(contentDiv, selection), + selection, }; undo.snapshotsManager.addSnapshot(snapshot, !!canUndoByBackspace); diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts index f01e75929d0..10a3e684d1a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts @@ -1,13 +1,38 @@ -import { isNodeOfType } from 'roosterjs-content-model-dom'; -import type { DOMSelection, SnapshotSelection } from 'roosterjs-content-model-types'; +import { isElementOfType, isNodeOfType, moveChildNodes } from 'roosterjs-content-model-dom'; +import type { SnapshotSelection, StandaloneEditorCore } from 'roosterjs-content-model-types'; /** * @internal */ -export function createSnapshotSelection( - contentDiv: HTMLElement, - selection: DOMSelection | null -): SnapshotSelection { +export function createSnapshotSelection(core: StandaloneEditorCore): SnapshotSelection { + const { contentDiv, api } = core; + const selection = api.getDOMSelection(core); + + // Normalize tables to ensure they have TBODY element between TABLE and TR so that the selection path will include correct values + if (selection?.type == 'range') { + const { startContainer, startOffset, endContainer, endOffset } = selection.range; + let isDOMChanged = normalizeTableTree(startContainer, contentDiv); + + if (endContainer != startContainer) { + isDOMChanged = normalizeTableTree(endContainer, contentDiv) || isDOMChanged; + } + + if (isDOMChanged) { + const newRange = contentDiv.ownerDocument.createRange(); + + newRange.setStart(startContainer, startOffset); + newRange.setEnd(endContainer, endOffset); + api.setDOMSelection( + core, + { + type: 'range', + range: newRange, + }, + true /*skipSelectionChangedEvent*/ + ); + } + } + switch (selection?.type) { case 'image': return { @@ -102,3 +127,67 @@ function getPath(node: Node | null, offset: number, rootNode: Node): number[] { return result; } + +function normalizeTableTree(startNode: Node, root: Node) { + let node: Node | null = startNode; + let isDOMChanged = false; + + while (node && root.contains(node)) { + if (isNodeOfType(node, 'ELEMENT_NODE') && isElementOfType(node, 'table')) { + isDOMChanged = normalizeTable(node) || isDOMChanged; + } + + node = node.parentNode; + } + + return isDOMChanged; +} + +function normalizeTable(table: HTMLTableElement): boolean { + let isDOMChanged = false; + let tbody: HTMLTableSectionElement | null = null; + + for (let child = table.firstChild; child; child = child.nextSibling) { + const tag = isNodeOfType(child, 'ELEMENT_NODE') ? child.tagName : null; + + switch (tag) { + case 'TR': + if (!tbody) { + tbody = table.ownerDocument.createElement('tbody'); + table.insertBefore(tbody, child); + } + + tbody.appendChild(child); + child = tbody; + isDOMChanged = true; + + break; + case 'TBODY': + if (tbody) { + moveChildNodes(tbody, child, true /*keepExistingChildren*/); + child.parentNode?.removeChild(child); + child = tbody; + isDOMChanged = true; + } else { + tbody = child as HTMLTableSectionElement; + } + break; + default: + tbody = null; + break; + } + } + + const colgroups = table.querySelectorAll('colgroup'); + const thead = table.querySelector('thead'); + + if (thead) { + colgroups.forEach(colgroup => { + if (!thead.contains(colgroup)) { + thead.appendChild(colgroup); + } + }); + } + + return isDOMChanged; +} diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts index c72c9a54e84..f1a579518e5 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts @@ -4,7 +4,6 @@ import { SnapshotsManager, StandaloneEditorCore } from 'roosterjs-content-model- describe('addUndoSnapshot', () => { let core: StandaloneEditorCore; - let getDOMSelectionSpy: jasmine.Spy; let contentDiv: HTMLDivElement; let addSnapshotSpy: jasmine.Spy; let getKnownColorsCopySpy: jasmine.Spy; @@ -12,7 +11,6 @@ describe('addUndoSnapshot', () => { let snapshotsManager: SnapshotsManager; beforeEach(() => { - getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); addSnapshotSpy = jasmine.createSpy('addSnapshot'); getKnownColorsCopySpy = jasmine.createSpy('getKnownColorsCopy'); createSnapshotSelectionSpy = spyOn(createSnapshotSelection, 'createSnapshotSelection'); @@ -28,9 +26,6 @@ describe('addUndoSnapshot', () => { getKnownColorsCopy: getKnownColorsCopySpy, }, lifecycle: {}, - api: { - getDOMSelection: getDOMSelectionSpy, - }, undo: { snapshotsManager, }, @@ -42,21 +37,18 @@ describe('addUndoSnapshot', () => { const result = addUndoSnapshot(core, false); - expect(getDOMSelectionSpy).not.toHaveBeenCalled(); expect(createSnapshotSelectionSpy).not.toHaveBeenCalled(); expect(addSnapshotSpy).not.toHaveBeenCalled(); expect(result).toEqual(null); }); it('Has a selection', () => { - const mockedSelection = 'SELECTION' as any; const mockedColors = 'COLORS' as any; const mockedHTML = 'HTML' as any; const mockedSnapshotSelection = 'SNAPSHOTSELECTION' as any; contentDiv.innerHTML = mockedHTML; - getDOMSelectionSpy.and.returnValue(mockedSelection); getKnownColorsCopySpy.and.returnValue(mockedColors); createSnapshotSelectionSpy.and.returnValue(mockedSnapshotSelection); @@ -66,8 +58,7 @@ describe('addUndoSnapshot', () => { snapshotsManager: snapshotsManager, } as any); expect(snapshotsManager.hasNewContent).toBeFalse(); - expect(getDOMSelectionSpy).toHaveBeenCalledWith(core); - expect(createSnapshotSelectionSpy).toHaveBeenCalledWith(contentDiv, mockedSelection); + expect(createSnapshotSelectionSpy).toHaveBeenCalledWith(core); expect(addSnapshotSpy).toHaveBeenCalledWith( { html: mockedHTML, @@ -86,14 +77,12 @@ describe('addUndoSnapshot', () => { }); it('Has a selection, canUndoByBackspace', () => { - const mockedSelection = 'SELECTION' as any; const mockedColors = 'COLORS' as any; const mockedHTML = 'HTML' as any; const mockedSnapshotSelection = 'SNAPSHOTSELECTION' as any; contentDiv.innerHTML = mockedHTML; - getDOMSelectionSpy.and.returnValue(mockedSelection); getKnownColorsCopySpy.and.returnValue(mockedColors); createSnapshotSelectionSpy.and.returnValue(mockedSnapshotSelection); @@ -103,8 +92,7 @@ describe('addUndoSnapshot', () => { snapshotsManager: snapshotsManager, } as any); expect(snapshotsManager.hasNewContent).toBeFalse(); - expect(getDOMSelectionSpy).toHaveBeenCalledWith(core); - expect(createSnapshotSelectionSpy).toHaveBeenCalledWith(contentDiv, mockedSelection); + expect(createSnapshotSelectionSpy).toHaveBeenCalledWith(core); expect(addSnapshotSpy).toHaveBeenCalledWith( { html: mockedHTML, @@ -123,7 +111,6 @@ describe('addUndoSnapshot', () => { }); it('Has entityStates', () => { - const mockedSelection = 'SELECTION' as any; const mockedColors = 'COLORS' as any; const mockedHTML = 'HTML' as any; const mockedSnapshotSelection = 'SNAPSHOTSELECTION' as any; @@ -131,7 +118,6 @@ describe('addUndoSnapshot', () => { contentDiv.innerHTML = mockedHTML; - getDOMSelectionSpy.and.returnValue(mockedSelection); getKnownColorsCopySpy.and.returnValue(mockedColors); createSnapshotSelectionSpy.and.returnValue(mockedSnapshotSelection); @@ -141,8 +127,7 @@ describe('addUndoSnapshot', () => { snapshotsManager: snapshotsManager, } as any); expect(snapshotsManager.hasNewContent).toBeFalse(); - expect(getDOMSelectionSpy).toHaveBeenCalledWith(core); - expect(createSnapshotSelectionSpy).toHaveBeenCalledWith(contentDiv, mockedSelection); + expect(createSnapshotSelectionSpy).toHaveBeenCalledWith(core); expect(addSnapshotSpy).toHaveBeenCalledWith( { html: mockedHTML, @@ -159,4 +144,42 @@ describe('addUndoSnapshot', () => { selection: mockedSnapshotSelection, }); }); + + it('Verify get html after create selection', () => { + const mockedColors = 'COLORS' as any; + const mockedHTML1 = 'HTML1' as any; + const mockedHTML2 = 'HTML2' as any; + const mockedSnapshotSelection = 'SNAPSHOTSELECTION' as any; + + contentDiv.innerHTML = mockedHTML1; + + getKnownColorsCopySpy.and.returnValue(mockedColors); + createSnapshotSelectionSpy.and.callFake(() => { + contentDiv.innerHTML = mockedHTML2; + return mockedSnapshotSelection; + }); + + const result = addUndoSnapshot(core, false); + + expect(core.undo).toEqual({ + snapshotsManager: snapshotsManager, + } as any); + expect(snapshotsManager.hasNewContent).toBeFalse(); + expect(createSnapshotSelectionSpy).toHaveBeenCalledWith(core); + expect(addSnapshotSpy).toHaveBeenCalledWith( + { + html: mockedHTML2, + entityStates: undefined, + isDarkMode: false, + selection: mockedSnapshotSelection, + }, + false + ); + expect(result).toEqual({ + html: mockedHTML2, + entityStates: undefined, + isDarkMode: false, + selection: mockedSnapshotSelection, + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts index 6448918105e..e251c3a88b9 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts @@ -1,11 +1,20 @@ import { createSnapshotSelection } from '../../lib/utils/createSnapshotSelection'; -import { DOMSelection } from 'roosterjs-content-model-types'; +import { DOMSelection, StandaloneEditorCore } from 'roosterjs-content-model-types'; describe('createSnapshotSelection', () => { let div: HTMLDivElement; + let core: StandaloneEditorCore; + let getDOMSelectionSpy: jasmine.Spy; beforeEach(() => { div = document.createElement('div'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + core = { + contentDiv: div, + api: { + getDOMSelection: getDOMSelectionSpy, + }, + } as any; }); it('Image selection', () => { @@ -17,7 +26,9 @@ describe('createSnapshotSelection', () => { image.id = 'id1'; - const result = createSnapshotSelection(div, selection); + getDOMSelectionSpy.and.returnValue(selection); + + const result = createSnapshotSelection(core); expect(result).toEqual({ type: 'image', @@ -38,7 +49,9 @@ describe('createSnapshotSelection', () => { table.id = 'id1'; - const result = createSnapshotSelection(div, selection); + getDOMSelectionSpy.and.returnValue(selection); + + const result = createSnapshotSelection(core); expect(result).toEqual({ type: 'table', @@ -53,13 +66,24 @@ describe('createSnapshotSelection', () => { describe('createSnapshotSelection - Range selection', () => { let div: HTMLDivElement; + let core: StandaloneEditorCore; + let getDOMSelectionSpy: jasmine.Spy; beforeEach(() => { div = document.createElement('div'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + core = { + contentDiv: div, + api: { + getDOMSelection: getDOMSelectionSpy, + }, + } as any; }); it('Null selection', () => { - const result = createSnapshotSelection(div, null); + getDOMSelectionSpy.and.returnValue(null); + + const result = createSnapshotSelection(core); expect(result).toEqual({ type: 'range', @@ -75,11 +99,13 @@ describe('createSnapshotSelection - Range selection', () => { range.setStart(div.childNodes[0], 2); range.setEnd(div.childNodes[0], 4); - const result = createSnapshotSelection(div, { + getDOMSelectionSpy.and.returnValue({ type: 'range', range: range, }); + const result = createSnapshotSelection(core); + expect(result).toEqual({ type: 'range', start: [0, 2], @@ -94,11 +120,13 @@ describe('createSnapshotSelection - Range selection', () => { range.setStart(div.childNodes[0].firstChild!, 2); range.setEnd(div.childNodes[0].firstChild!, 4); - const result = createSnapshotSelection(div, { + getDOMSelectionSpy.and.returnValue({ type: 'range', range: range, }); + const result = createSnapshotSelection(core); + expect(result).toEqual({ type: 'range', start: [0, 0, 2], @@ -113,11 +141,13 @@ describe('createSnapshotSelection - Range selection', () => { range.setStart(div.childNodes[0].childNodes[0], 1); range.setEnd(div.childNodes[1].childNodes[0], 0); - const result = createSnapshotSelection(div, { + getDOMSelectionSpy.and.returnValue({ type: 'range', range: range, }); + const result = createSnapshotSelection(core); + expect(result).toEqual({ type: 'range', start: [0, 0, 1], @@ -135,11 +165,13 @@ describe('createSnapshotSelection - Range selection', () => { range.setStart(div.childNodes[1], 2); range.setEnd(div.childNodes[2], 2); - const result = createSnapshotSelection(div, { + getDOMSelectionSpy.and.returnValue({ type: 'range', range: range, }); + const result = createSnapshotSelection(core); + expect(result).toEqual({ type: 'range', start: [0, 7], @@ -165,11 +197,13 @@ describe('createSnapshotSelection - Range selection', () => { range.setStart(div.childNodes[2].childNodes[1], 2); range.setEnd(div.childNodes[4], 2); - const result = createSnapshotSelection(div, { + getDOMSelectionSpy.and.returnValue({ type: 'range', range: range, }); + const result = createSnapshotSelection(core); + expect(result).toEqual({ type: 'range', start: [1, 0, 7], @@ -177,3 +211,438 @@ describe('createSnapshotSelection - Range selection', () => { }); }); }); + +describe('createSnapshotSelection - Normalize Table', () => { + const TABLE_ID1 = 't1'; + const TABLE_ID2 = 't2'; + let div: HTMLDivElement; + let core: StandaloneEditorCore; + let getDOMSelectionSpy: jasmine.Spy; + let setDOMSelectionSpy: jasmine.Spy; + + beforeEach(() => { + div = document.createElement('div'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + core = { + contentDiv: div, + api: { + getDOMSelection: getDOMSelectionSpy, + setDOMSelection: setDOMSelectionSpy, + }, + } as any; + }); + + function runTest( + input: CreateElementData, + output: string, + startPath: number[], + endPath: number[], + setDOMSelection: boolean + ) { + div.appendChild(createElement(input)); + + const node1 = div.querySelector('#' + TABLE_ID1); + const node2 = div.querySelector('#' + TABLE_ID2) || node1; + const mockedRange = { + startContainer: node1, + startOffset: 0, + endContainer: node2, + endOffset: 1, + } as any; + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: mockedRange, + }); + + const result = createSnapshotSelection(core); + + expect(result).toEqual({ + type: 'range', + start: startPath, + end: endPath, + }); + expect(div.innerHTML).toBe(output); + + if (setDOMSelection) { + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + + const selection = setDOMSelectionSpy.calls.argsFor(0)[1]; + + expect(selection.type).toBe('range'); + expect(selection.range.startContainer).toBe(node1); + expect(selection.range.endContainer).toBe(node2); + expect(selection.range.startOffset).toBe(0); + expect(selection.range.endOffset).toBe(1); + } else { + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + } + } + + function createTd(text: string, id?: string, tag: string = 'td'): CreateElementData { + return { + tag: tag, + id: id, + children: [text], + }; + } + + function createTr(...tds: CreateElementData[]): CreateElementData { + return { + tag: 'tr', + children: [...tds], + }; + } + + function createTableSection(tag: string, ...trs: CreateElementData[]): CreateElementData { + return { + tag: tag, + children: [...trs], + }; + } + + function createTable(...children: CreateElementData[]): CreateElementData { + return { + tag: 'table', + children: [...children], + }; + } + + function createColGroup(): CreateElementData { + return { + tag: 'colgroup', + children: [ + { + tag: 'col', + }, + { + tag: 'col', + }, + ], + }; + } + + it('Table already has THEAD/TBODY/TFOOT', () => { + const input = createTable( + createTableSection('thead', createTr(createTd('test1'))), + createTableSection( + 'tbody', + createTr(createTd('test2', TABLE_ID1)), + createTr(createTd('test3')) + ), + createTableSection('tfoot', createTr(createTd('test4'))) + ); + runTest( + input, + '
                  test1
                  test2
                  test3
                  test4
                  ', + [0, 1, 0, 0, 0], + [0, 1, 0, 0, 1], + false + ); + }); + + it('Table only has TR', () => { + const input = createTable( + createTr(createTd('test1')), + createTr(createTd('test2', TABLE_ID1)) + ); + runTest( + input, + '
                  test1
                  test2
                  ', + [0, 0, 1, 0, 0], + [0, 0, 1, 0, 1], + true + ); + }); + + it('Table has TR and TBODY 1', () => { + runTest( + createTable( + createTr(createTd('test1')), + createTableSection('tbody', createTr(createTd('test2', TABLE_ID1))) + ), + '
                  test1
                  test2
                  ', + [0, 0, 1, 0, 0], + [0, 0, 1, 0, 1], + true + ); + }); + + it('Table has TR and TBODY 2', () => { + runTest( + createTable( + createTableSection('tbody', createTr(createTd('test1'))), + createTr(createTd('test2', TABLE_ID1)) + ), + '
                  test1
                  test2
                  ', + [0, 0, 1, 0, 0], + [0, 0, 1, 0, 1], + true + ); + }); + + it('Table has TR and TBODY and TR', () => { + runTest( + createTable( + createTr(createTd('test1')), + createTableSection('tbody', createTr(createTd('test2', TABLE_ID1))), + createTr(createTd('test3', TABLE_ID2)) + ), + '
                  test1
                  test2
                  test3
                  ', + [0, 0, 1, 0, 0], + [0, 0, 2, 0, 1], + true + ); + }); + + it('Table has THEAD and TR and TFOOT', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1'))), + createTr(createTd('test2', TABLE_ID1)), + createTr(createTd('test3', TABLE_ID2)), + createTableSection('tfoot', createTr(createTd('test4'))) + ), + '
                  test1
                  test2
                  test3
                  test4
                  ', + [0, 1, 0, 0, 0], + [0, 1, 1, 0, 1], + true + ); + }); + + it('Table has THEAD and TR and TBODY and TR and TFOOT', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1'))), + createTr(createTd('test2', TABLE_ID1)), + createTableSection('tbody', createTr(createTd('test3'))), + createTr(createTd('test4', TABLE_ID2)), + createTableSection('tfoot', createTr(createTd('test5'))) + ), + '' + + '' + + '' + + '' + + '
                  test1
                  test2
                  test3
                  test4
                  test5
                  ', + [0, 1, 0, 0, 0], + [0, 1, 2, 0, 1], + true + ); + }); + + it('Table has THEAD and TBODY and TR and TBODY and TFOOT', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1'))), + createTableSection('tbody', createTr(createTd('test2'))), + createTr(createTd('test3', TABLE_ID1)), + createTableSection('tbody', createTr(createTd('test4'))), + createTableSection('tfoot', createTr(createTd('test5'))) + ), + '' + + '' + + '' + + '' + + '
                  test1
                  test2
                  test3
                  test4
                  test5
                  ', + [0, 1, 1, 0, 0], + [0, 1, 1, 0, 1], + true + ); + }); + + it('Normalize table with THEAD With colgroup, Tbody, Tfoot', () => { + runTest( + createTable( + createTableSection('thead', createColGroup(), createTr(createTd('test1'))), + createTableSection('tbody', createTr(createTd('test2'))), + createTr(createTd('test3', TABLE_ID1)), + createTableSection('tbody', createTr(createTd('test4'))), + createTableSection('tfoot', createTr(createTd('test5'))) + ), + '' + + '' + + '' + + '' + + '
                  test1
                  test2
                  test3
                  test4
                  test5
                  ', + [0, 1, 1, 0, 0], + [0, 1, 1, 0, 1], + true + ); + }); + + it('Table already has THEAD With colgroup/TBODY/TFOOT', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1')), createColGroup()), + createTableSection( + 'tbody', + createTr(createTd('test2')), + createTr(createTd('test3')) + ), + createTableSection('tfoot', createTr(createTd('test4', TABLE_ID1))) + ), + '' + + '' + + '' + + '
                  test1
                  test2
                  test3
                  test4
                  ', + [0, 2, 0, 0, 0], + [0, 2, 0, 0, 1], + false + ); + }); + + it('Table has THEAD With colgroup and TR and TFOOT', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1')), createColGroup()), + createTr(createTd('test2')), + createTr(createTd('test3', TABLE_ID1)), + createTableSection('tfoot', createTr(createTd('test4'))) + ), + '' + + '' + + '' + + '
                  test1
                  test2
                  test3
                  test4
                  ', + [0, 1, 1, 0, 0], + [0, 1, 1, 0, 1], + true + ); + }); + + it('Table has THEAD With colgroup and TR and TBODY and TR and TFOOT', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1')), createColGroup()), + createTr(createTd('test2', TABLE_ID1)), + createTableSection('tbody', createTr(createTd('test3'))), + createTr(createTd('test4')), + createTableSection('tfoot', createTr(createTd('test5'))) + ), + '' + + '' + + '' + + '' + + '
                  test1
                  test2
                  test3
                  test4
                  test5
                  ', + [0, 1, 0, 0, 0], + [0, 1, 0, 0, 1], + true + ); + }); + + it('Table has THEAD With colgroup and TR and TBODY and TR and TFOOT 2', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1'))), + createColGroup(), + createTr(createTd('test2', TABLE_ID1)), + createTableSection('tbody', createTr(createTd('test3'))), + createTr(createTd('test4')), + createTableSection('tfoot', createTr(createTd('test5'))) + ), + '' + + '' + + '' + + '' + + '
                  test1
                  test2
                  test3
                  test4
                  test5
                  ', + [0, 1, 0, 0, 0], + [0, 1, 0, 0, 1], + true + ); + }); + + it('Table has THEAD With colgroup and TBODY and TR and TBODY and TFOOT', () => { + runTest( + createTable( + createTableSection('thead', createTr(createTd('test1')), createColGroup()), + createTableSection('tbody', createTr(createTd('test2'))), + createTr(createTd('test3')), + createTableSection('tbody', createTr(createTd('test4'))), + createTableSection('tfoot', createTr(createTd('test5', TABLE_ID1))) + ), + '' + + '' + + '' + + '' + + '
                  test1
                  test2
                  test3
                  test4
                  test5
                  ', + [0, 2, 0, 0, 0], + [0, 2, 0, 0, 1], + true + ); + }); + + it('Table has TR and TBODY and a orphaned colgroup 1', () => { + runTest( + createTable( + createColGroup(), + createTr(createTd('test1', TABLE_ID1)), + createColGroup(), + createTableSection('tbody', createTr(createTd('test2'))), + createColGroup() + ), + '' + + '' + + '' + + '
                  test1
                  test2
                  ', + [0, 1, 0, 0, 0], + [0, 1, 0, 0, 1], + true + ); + }); + + it('Table has TR and TBODY and a orphaned colgroup 2', () => { + runTest( + createTable( + createTableSection('tbody', createTr(createTd('test1', TABLE_ID1))), + createTr(createTd('test2')), + createColGroup() + ), + '' + + '' + + '
                  test1
                  test2
                  ', + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 1], + true + ); + }); + + it('Nested table', () => { + runTest( + createTable( + createTr({ + tag: 'td', + children: [createTable(createTr(createTd('test1', TABLE_ID1)))], + }) + ), + '
                  test1
                  ', + [0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 1], + true + ); + }); +}); + +interface CreateElementData { + tag: string; + id?: string; + children?: (CreateElementData | string)[]; +} + +function createElement(elementData: CreateElementData): Element { + const { tag, id, children } = elementData; + const result = document.createElement(tag); + + if (id) { + result.id = id; + } + + if (children) { + children.forEach(child => { + result.appendChild( + typeof child == 'string' ? document.createTextNode(child) : createElement(child) + ); + }); + } + + return result; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts index f0f3cecda33..70ff8cddb05 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts @@ -1,11 +1,5 @@ -import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { - changeElementTag, - getTagOfNode, - moveChildNodes, - safeInstanceOf, - toArray, -} from 'roosterjs-editor-dom'; +import { changeElementTag, safeInstanceOf, toArray } from 'roosterjs-editor-dom'; +import { PluginEventType } from 'roosterjs-editor-types'; import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; /** @@ -19,8 +13,6 @@ import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types' * new table is inserted, to make sure the selection path we created is correct. */ class NormalizeTablePlugin implements EditorPlugin { - private editor: IEditor | null = null; - /** * Get a friendly name of this plugin */ @@ -34,18 +26,14 @@ class NormalizeTablePlugin implements EditorPlugin { * editor reference so that it can call to any editor method or format API later. * @param editor The editor object */ - initialize(editor: IEditor) { - this.editor = editor; - } + initialize(editor: IEditor) {} /** * The last method that editor will call to a plugin before it is disposed. * Plugin can take this chance to clear the reference to editor. After this method is * called, plugin should not call to any editor method since it will result in error. */ - dispose() { - this.editor = null; - } + dispose() {} /** * Core method for a plugin. Once an event happens in editor, editor will call this @@ -55,115 +43,11 @@ class NormalizeTablePlugin implements EditorPlugin { */ onPluginEvent(event: PluginEvent) { switch (event.eventType) { - case PluginEventType.EditorReady: - case PluginEventType.ContentChanged: - if (this.editor) { - this.normalizeTables(this.editor.queryElements('table')); - } - break; - - case PluginEventType.BeforePaste: - this.normalizeTables(toArray(event.fragment.querySelectorAll('table'))); - break; - - case PluginEventType.MouseDown: - this.normalizeTableFromEvent(event.rawEvent); - break; - - case PluginEventType.KeyDown: - if (event.rawEvent.shiftKey) { - this.normalizeTableFromEvent(event.rawEvent); - } - break; - case PluginEventType.ExtractContentWithDom: normalizeListsForExport(event.clonedRoot); break; } } - - private normalizeTableFromEvent(event: KeyboardEvent | MouseEvent) { - const table = this.editor?.getElementAtCursor('table', event.target as Node); - - if (table) { - this.normalizeTables([table]); - } - } - - private normalizeTables(tables: HTMLTableElement[]) { - if (this.editor && tables.length > 0) { - const rangeEx = this.editor.getSelectionRangeEx(); - const { startContainer, endContainer, startOffset, endOffset } = - (rangeEx?.type == SelectionRangeTypes.Normal && rangeEx.ranges[0]) || {}; - - const isChanged = normalizeTables(tables); - - if (isChanged) { - if ( - startContainer && - endContainer && - typeof startOffset === 'number' && - typeof endOffset === 'number' - ) { - this.editor.select(startContainer, startOffset, endContainer, endOffset); - } else if ( - rangeEx?.type == SelectionRangeTypes.TableSelection && - rangeEx.coordinates - ) { - this.editor.select(rangeEx.table, rangeEx.coordinates); - } - } - } - } -} - -function normalizeTables(tables: HTMLTableElement[]) { - let isDOMChanged = false; - tables.forEach(table => { - let tbody: HTMLTableSectionElement | null = null; - - for (let child = table.firstChild; child; child = child.nextSibling) { - const tag = getTagOfNode(child); - switch (tag) { - case 'TR': - if (!tbody) { - tbody = table.ownerDocument.createElement('tbody'); - table.insertBefore(tbody, child); - } - - tbody.appendChild(child); - child = tbody; - isDOMChanged = true; - - break; - case 'TBODY': - if (tbody) { - moveChildNodes(tbody, child, true /*keepExistingChildren*/); - child.parentNode?.removeChild(child); - child = tbody; - isDOMChanged = true; - } else { - tbody = child as HTMLTableSectionElement; - } - break; - default: - tbody = null; - break; - } - } - - const colgroups = table.querySelectorAll('colgroup'); - const thead = table.querySelector('thead'); - if (thead) { - colgroups.forEach(colgroup => { - if (!thead.contains(colgroup)) { - thead.appendChild(colgroup); - } - }); - } - }); - - return isDOMChanged; } function normalizeListsForExport(root: ParentNode) { From 835057ae6091386bc2d61e1e1df8a0338834d97b Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 30 Jan 2024 09:45:12 -0800 Subject: [PATCH 044/112] Code cleanup: Remove parameter onNodeCreated (#2374) --- .../lib/coreApi/setContentModel.ts | 5 ++-- .../corePlugin/ContentModelCopyPastePlugin.ts | 7 ++++-- .../test/coreApi/setContentModelTest.ts | 23 +++++++------------ .../ContentModelCopyPastePluginTest.ts | 21 ++++++----------- .../lib/modelToDom/contentModelToDom.ts | 7 +----- 5 files changed, 24 insertions(+), 39 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts index 989e08eb360..bf776ab6b70 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts @@ -23,12 +23,13 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea ) : createModelToDomContextWithConfig(core.modelToDomSettings.calculated, editorContext); + modelToDomContext.onNodeCreated = onNodeCreated; + const selection = contentModelToDom( core.contentDiv.ownerDocument, core.contentDiv, model, - modelToDomContext, - onNodeCreated + modelToDomContext ); if (!core.lifecycle.shadowEditFragment) { diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts index c85aa213829..3564b81f6e4 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts @@ -131,12 +131,15 @@ class ContentModelCopyPastePlugin implements PluginWithState { mockedDoc, mockedDiv, mockedModel, - mockedContext, - undefined + mockedContext ); expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); expect(core.cache.cachedSelection).toBe(mockedRange); @@ -93,8 +92,7 @@ describe('setContentModel', () => { mockedDoc, mockedDiv, mockedModel, - mockedContext, - undefined + mockedContext ); expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); }); @@ -121,8 +119,7 @@ describe('setContentModel', () => { mockedDoc, mockedDiv, mockedModel, - mockedContext, - undefined + mockedContext ); expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); }); @@ -145,8 +142,7 @@ describe('setContentModel', () => { mockedDoc, mockedDiv, mockedModel, - mockedContext, - undefined + mockedContext ); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); }); @@ -178,8 +174,7 @@ describe('setContentModel', () => { mockedDoc, mockedDiv, mockedModel, - mockedContext, - undefined + mockedContext ); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); expect(core.selection.selection).toBe(mockedRange); @@ -212,8 +207,7 @@ describe('setContentModel', () => { mockedDoc, mockedDiv, mockedModel, - mockedContext, - undefined + mockedContext ); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); expect(core.selection.selection).toBe(mockedRange); @@ -242,8 +236,7 @@ describe('setContentModel', () => { mockedDoc, mockedDiv, mockedModel, - mockedContext, - undefined + mockedContext ); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); expect(core.selection.selection).toBe(null); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts index 4bab5bd3bfc..6b99ac15cf3 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts @@ -213,8 +213,7 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - createModelToDomContext(), - onNodeCreated + { ...createModelToDomContext(), onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); @@ -261,8 +260,7 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - createModelToDomContext(), - onNodeCreated + { ...createModelToDomContext(), onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); @@ -305,8 +303,7 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - createModelToDomContext(), - onNodeCreated + { ...createModelToDomContext(), onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); @@ -377,8 +374,7 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - createModelToDomContext(), - onNodeCreated + { ...createModelToDomContext(), onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); @@ -452,8 +448,7 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - createModelToDomContext(), - onNodeCreated + { ...createModelToDomContext(), onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); @@ -502,8 +497,7 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - createModelToDomContext(), - onNodeCreated + { ...createModelToDomContext(), onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalled(); @@ -551,8 +545,7 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - createModelToDomContext(), - onNodeCreated + { ...createModelToDomContext(), onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts index 876518c0fe1..eba0f04ebeb 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts @@ -5,7 +5,6 @@ import type { DOMSelection, ModelToDomBlockAndSegmentNode, ModelToDomContext, - OnNodeCreated, } from 'roosterjs-content-model-types'; /** @@ -16,18 +15,14 @@ import type { * won't be touched. * @param model The content model document to generate DOM tree from * @param context The context object for Content Model to DOM conversion - * @param onNodeCreated Callback invoked when a DOM node is created * @returns The selection range created in DOM tree from this model, or null when there is no selection */ export function contentModelToDom( doc: Document, root: Node, model: ContentModelDocument, - context: ModelToDomContext, - onNodeCreated?: OnNodeCreated + context: ModelToDomContext ): DOMSelection | null { - context.onNodeCreated = onNodeCreated; - context.modelHandlers.blockGroupChildren(doc, root, model, context); const range = extractSelectionRange(doc, context); From a9c60af3a6110374b4d4095b04425a70520621af Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 30 Jan 2024 10:15:49 -0800 Subject: [PATCH 045/112] Code cleanup: Remove isContentModelEditor (#2371) * Code cleanup: Remove isContentModelEditor * add buttons --- .../controls/ContentModelEditorMainPane.tsx | 144 ++++++++++++++++- .../controls/StandaloneEditorMainPane.tsx | 149 +++++++++++++++++- .../contentModel/ContentModelRibbon.tsx | 147 +---------------- .../contentModel/ContentModelPane.tsx | 13 +- .../contentModel/ContentModelPanePlugin.ts | 26 ++- .../contentModel/buttons/exportButton.ts | 9 +- .../contentModel/buttons/refreshButton.ts | 8 +- .../sidePane/contentModel/currentModel.ts | 22 +-- .../lib/editor/isContentModelEditor.ts | 13 -- .../lib/index.ts | 1 - .../test/editor/isContentModelEditorTest.ts | 21 --- 11 files changed, 316 insertions(+), 237 deletions(-) delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index baf0611d518..7a8fa60a0ce 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -6,7 +6,7 @@ import ContentModelEventViewPlugin from './sidePane/eventViewer/ContentModelEven import ContentModelFormatPainterPlugin from './contentModel/plugins/ContentModelFormatPainterPlugin'; import ContentModelFormatStatePlugin from './sidePane/formatState/ContentModelFormatStatePlugin'; import ContentModelPanePlugin from './sidePane/contentModel/ContentModelPanePlugin'; -import ContentModelRibbon from './ribbonButtons/contentModel/ContentModelRibbon'; +import ContentModelRibbonButton from './ribbonButtons/contentModel/ContentModelRibbonButton'; import ContentModelRooster from './contentModel/editor/ContentModelRooster'; import ContentModelSnapshotPlugin from './sidePane/snapshot/ContentModelSnapshotPlugin'; import getToggleablePlugins from './getToggleablePlugins'; @@ -15,14 +15,82 @@ import RibbonPlugin from './ribbonButtons/contentModel/RibbonPlugin'; import SampleEntityPlugin from './sampleEntity/SampleEntityPlugin'; import SidePane from './sidePane/SidePane'; import TitleBar from './titleBar/TitleBar'; +import { alignCenterButton } from './ribbonButtons/contentModel/alignCenterButton'; +import { alignJustifyButton } from './ribbonButtons/contentModel/alignJustifyButton'; +import { alignLeftButton } from './ribbonButtons/contentModel/alignLeftButton'; +import { alignRightButton } from './ribbonButtons/contentModel/alignRightButton'; import { arrayPush } from 'roosterjs-editor-dom'; +import { backgroundColorButton } from './ribbonButtons/contentModel/backgroundColorButton'; +import { blockQuoteButton } from './ribbonButtons/contentModel/blockQuoteButton'; +import { boldButton } from './ribbonButtons/contentModel/boldButton'; +import { bulletedListButton } from './ribbonButtons/contentModel/bulletedListButton'; +import { changeImageButton } from './ribbonButtons/contentModel/changeImageButton'; +import { clearFormatButton } from './ribbonButtons/contentModel/clearFormatButton'; +import { codeButton } from './ribbonButtons/contentModel/codeButton'; +import { ContentModelRibbon } from './ribbonButtons/contentModel/ContentModelRibbon'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; import { ContentModelSegmentFormat, Snapshots } from 'roosterjs-content-model-types'; import { createEmojiPlugin, createPasteOptionPlugin } from 'roosterjs-react'; +import { darkMode } from './ribbonButtons/contentModel/darkMode'; +import { decreaseFontSizeButton } from './ribbonButtons/contentModel/decreaseFontSizeButton'; +import { decreaseIndentButton } from './ribbonButtons/contentModel/decreaseIndentButton'; import { EditorPlugin } from 'roosterjs-editor-types'; +import { exportContent } from './ribbonButtons/contentModel/export'; +import { fontButton } from './ribbonButtons/contentModel/fontButton'; +import { fontSizeButton } from './ribbonButtons/contentModel/fontSizeButton'; +import { formatPainterButton } from './ribbonButtons/contentModel/formatPainterButton'; +import { formatTableButton } from './ribbonButtons/contentModel/formatTableButton'; import { getDarkColor } from 'roosterjs-color-utils'; +import { imageBorderColorButton } from './ribbonButtons/contentModel/imageBorderColorButton'; +import { imageBorderRemoveButton } from './ribbonButtons/contentModel/imageBorderRemoveButton'; +import { imageBorderStyleButton } from './ribbonButtons/contentModel/imageBorderStyleButton'; +import { imageBorderWidthButton } from './ribbonButtons/contentModel/imageBorderWidthButton'; +import { imageBoxShadowButton } from './ribbonButtons/contentModel/imageBoxShadowButton'; +import { increaseFontSizeButton } from './ribbonButtons/contentModel/increaseFontSizeButton'; +import { increaseIndentButton } from './ribbonButtons/contentModel/increaseIndentButton'; +import { insertImageButton } from './ribbonButtons/contentModel/insertImageButton'; +import { insertLinkButton } from './ribbonButtons/contentModel/insertLinkButton'; +import { insertTableButton } from './ribbonButtons/contentModel/insertTableButton'; +import { italicButton } from './ribbonButtons/contentModel/italicButton'; +import { listStartNumberButton } from './ribbonButtons/contentModel/listStartNumberButton'; +import { ltrButton } from './ribbonButtons/contentModel/ltrButton'; +import { numberedListButton } from './ribbonButtons/contentModel/numberedListButton'; import { PartialTheme } from '@fluentui/react/lib/Theme'; +import { pasteButton } from './ribbonButtons/contentModel/pasteButton'; +import { popout } from './ribbonButtons/contentModel/popout'; +import { redoButton } from './ribbonButtons/contentModel/redoButton'; +import { removeLinkButton } from './ribbonButtons/contentModel/removeLinkButton'; +import { rtlButton } from './ribbonButtons/contentModel/rtlButton'; +import { setBulletedListStyleButton } from './ribbonButtons/contentModel/setBulletedListStyleButton'; +import { setHeadingLevelButton } from './ribbonButtons/contentModel/setHeadingLevelButton'; +import { setNumberedListStyleButton } from './ribbonButtons/contentModel/setNumberedListStyleButton'; +import { setTableCellShadeButton } from './ribbonButtons/contentModel/setTableCellShadeButton'; +import { setTableHeaderButton } from './ribbonButtons/contentModel/setTableHeaderButton'; +import { spacingButton } from './ribbonButtons/contentModel/spacingButton'; +import { strikethroughButton } from './ribbonButtons/contentModel/strikethroughButton'; +import { subscriptButton } from './ribbonButtons/contentModel/subscriptButton'; +import { superscriptButton } from './ribbonButtons/contentModel/superscriptButton'; +import { tableBorderApplyButton } from './ribbonButtons/contentModel/tableBorderApplyButton'; +import { tableBorderColorButton } from './ribbonButtons/contentModel/tableBorderColorButton'; +import { tableBorderStyleButton } from './ribbonButtons/contentModel/tableBorderStyleButton'; +import { tableBorderWidthButton } from './ribbonButtons/contentModel/tableBorderWidthButton'; +import { textColorButton } from './ribbonButtons/contentModel/textColorButton'; import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; +import { underlineButton } from './ribbonButtons/contentModel/underlineButton'; +import { undoButton } from './ribbonButtons/contentModel/undoButton'; +import { zoom } from './ribbonButtons/contentModel/zoom'; +import { + spaceAfterButton, + spaceBeforeButton, +} from './ribbonButtons/contentModel/spaceBeforeAfterButtons'; +import { + tableAlignCellButton, + tableAlignTableButton, + tableDeleteButton, + tableInsertButton, + tableMergeButton, + tableSplitButton, +} from './ribbonButtons/contentModel/tableEditButtons'; import { ContentModelAutoFormatPlugin, ContentModelEditPlugin, @@ -111,6 +179,70 @@ class ContentModelEditorMainPane extends MainPaneBase private pastePlugin: ContentModelPastePlugin; private sampleEntityPlugin: SampleEntityPlugin; private snapshots: Snapshots; + private buttons: ContentModelRibbonButton[] = [ + formatPainterButton, + boldButton, + italicButton, + underlineButton, + fontButton, + fontSizeButton, + increaseFontSizeButton, + decreaseFontSizeButton, + textColorButton, + backgroundColorButton, + bulletedListButton, + numberedListButton, + decreaseIndentButton, + increaseIndentButton, + blockQuoteButton, + alignLeftButton, + alignCenterButton, + alignRightButton, + alignJustifyButton, + insertLinkButton, + removeLinkButton, + insertTableButton, + insertImageButton, + superscriptButton, + subscriptButton, + strikethroughButton, + setHeadingLevelButton, + codeButton, + ltrButton, + rtlButton, + undoButton, + redoButton, + clearFormatButton, + setBulletedListStyleButton, + setNumberedListStyleButton, + listStartNumberButton, + formatTableButton, + setTableCellShadeButton, + setTableHeaderButton, + tableInsertButton, + tableDeleteButton, + tableMergeButton, + tableSplitButton, + tableAlignCellButton, + tableAlignTableButton, + tableBorderApplyButton, + tableBorderColorButton, + tableBorderWidthButton, + tableBorderStyleButton, + imageBorderColorButton, + imageBorderWidthButton, + imageBorderStyleButton, + imageBorderRemoveButton, + changeImageButton, + imageBoxShadowButton, + spacingButton, + spaceBeforeButton, + spaceAfterButton, + pasteButton, + darkMode, + zoom, + exportContent, + ]; constructor(props: {}) { super(props); @@ -162,11 +294,13 @@ class ContentModelEditorMainPane extends MainPaneBase } renderRibbon(isPopout: boolean) { + const buttons = isPopout ? this.buttons : this.buttons.concat([popout]); + return ( ); } @@ -192,7 +326,6 @@ class ContentModelEditorMainPane extends MainPaneBase const plugins = [ ...this.toggleablePlugins, - this.contentModelPanePlugin.getInnerRibbonPlugin(), this.pasteOptionPlugin, this.emojiPlugin, this.sampleEntityPlugin, @@ -255,6 +388,7 @@ class ContentModelEditorMainPane extends MainPaneBase this.pastePlugin, this.contentModelAutoFormatPlugin, this.contentModelEditPlugin, + this.contentModelPanePlugin.getInnerRibbonPlugin(), ]} defaultSegmentFormat={defaultFormat} inDarkMode={this.state.isDarkMode} diff --git a/demo/scripts/controls/StandaloneEditorMainPane.tsx b/demo/scripts/controls/StandaloneEditorMainPane.tsx index 881de5936b9..5f5f435c141 100644 --- a/demo/scripts/controls/StandaloneEditorMainPane.tsx +++ b/demo/scripts/controls/StandaloneEditorMainPane.tsx @@ -6,29 +6,97 @@ import ContentModelEventViewPlugin from './sidePane/eventViewer/ContentModelEven import ContentModelFormatPainterPlugin from './contentModel/plugins/ContentModelFormatPainterPlugin'; import ContentModelFormatStatePlugin from './sidePane/formatState/ContentModelFormatStatePlugin'; import ContentModelPanePlugin from './sidePane/contentModel/ContentModelPanePlugin'; -import ContentModelRibbon from './ribbonButtons/contentModel/ContentModelRibbon'; +import ContentModelRibbonButton from './ribbonButtons/contentModel/ContentModelRibbonButton'; import ContentModelRooster from './contentModel/editor/ContentModelRooster'; import ContentModelSnapshotPlugin from './sidePane/snapshot/ContentModelSnapshotPlugin'; import MainPaneBase, { MainPaneBaseState } from './MainPaneBase'; import RibbonPlugin from './ribbonButtons/contentModel/RibbonPlugin'; import SidePane from './sidePane/SidePane'; import TitleBar from './titleBar/TitleBar'; +import { alignCenterButton } from './ribbonButtons/contentModel/alignCenterButton'; +import { alignJustifyButton } from './ribbonButtons/contentModel/alignJustifyButton'; +import { alignLeftButton } from './ribbonButtons/contentModel/alignLeftButton'; +import { alignRightButton } from './ribbonButtons/contentModel/alignRightButton'; +import { backgroundColorButton } from './ribbonButtons/contentModel/backgroundColorButton'; +import { blockQuoteButton } from './ribbonButtons/contentModel/blockQuoteButton'; +import { boldButton } from './ribbonButtons/contentModel/boldButton'; +import { bulletedListButton } from './ribbonButtons/contentModel/bulletedListButton'; +import { changeImageButton } from './ribbonButtons/contentModel/changeImageButton'; +import { clearFormatButton } from './ribbonButtons/contentModel/clearFormatButton'; +import { codeButton } from './ribbonButtons/contentModel/codeButton'; +import { ContentModelRibbon } from './ribbonButtons/contentModel/ContentModelRibbon'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; +import { darkMode } from './ribbonButtons/contentModel/darkMode'; +import { decreaseFontSizeButton } from './ribbonButtons/contentModel/decreaseFontSizeButton'; +import { decreaseIndentButton } from './ribbonButtons/contentModel/decreaseIndentButton'; +import { exportContent } from './ribbonButtons/contentModel/export'; +import { fontButton } from './ribbonButtons/contentModel/fontButton'; +import { fontSizeButton } from './ribbonButtons/contentModel/fontSizeButton'; +import { formatPainterButton } from './ribbonButtons/contentModel/formatPainterButton'; +import { formatTableButton } from './ribbonButtons/contentModel/formatTableButton'; import { getDarkColor } from 'roosterjs-color-utils'; +import { imageBorderColorButton } from './ribbonButtons/contentModel/imageBorderColorButton'; +import { imageBorderRemoveButton } from './ribbonButtons/contentModel/imageBorderRemoveButton'; +import { imageBorderStyleButton } from './ribbonButtons/contentModel/imageBorderStyleButton'; +import { imageBorderWidthButton } from './ribbonButtons/contentModel/imageBorderWidthButton'; +import { imageBoxShadowButton } from './ribbonButtons/contentModel/imageBoxShadowButton'; +import { increaseFontSizeButton } from './ribbonButtons/contentModel/increaseFontSizeButton'; +import { increaseIndentButton } from './ribbonButtons/contentModel/increaseIndentButton'; +import { insertImageButton } from './ribbonButtons/contentModel/insertImageButton'; +import { insertLinkButton } from './ribbonButtons/contentModel/insertLinkButton'; +import { insertTableButton } from './ribbonButtons/contentModel/insertTableButton'; +import { italicButton } from './ribbonButtons/contentModel/italicButton'; +import { listStartNumberButton } from './ribbonButtons/contentModel/listStartNumberButton'; +import { ltrButton } from './ribbonButtons/contentModel/ltrButton'; +import { numberedListButton } from './ribbonButtons/contentModel/numberedListButton'; import { PartialTheme } from '@fluentui/react/lib/Theme'; +import { pasteButton } from './ribbonButtons/contentModel/pasteButton'; +import { popout } from './ribbonButtons/contentModel/popout'; +import { redoButton } from './ribbonButtons/contentModel/redoButton'; +import { removeLinkButton } from './ribbonButtons/contentModel/removeLinkButton'; +import { rtlButton } from './ribbonButtons/contentModel/rtlButton'; +import { setBulletedListStyleButton } from './ribbonButtons/contentModel/setBulletedListStyleButton'; +import { setHeadingLevelButton } from './ribbonButtons/contentModel/setHeadingLevelButton'; +import { setNumberedListStyleButton } from './ribbonButtons/contentModel/setNumberedListStyleButton'; +import { setTableCellShadeButton } from './ribbonButtons/contentModel/setTableCellShadeButton'; +import { setTableHeaderButton } from './ribbonButtons/contentModel/setTableHeaderButton'; import { Snapshots } from 'roosterjs-editor-types'; +import { spacingButton } from './ribbonButtons/contentModel/spacingButton'; import { StandaloneEditor } from 'roosterjs-content-model-core'; +import { strikethroughButton } from './ribbonButtons/contentModel/strikethroughButton'; +import { subscriptButton } from './ribbonButtons/contentModel/subscriptButton'; +import { superscriptButton } from './ribbonButtons/contentModel/superscriptButton'; +import { tableBorderApplyButton } from './ribbonButtons/contentModel/tableBorderApplyButton'; +import { tableBorderColorButton } from './ribbonButtons/contentModel/tableBorderColorButton'; +import { tableBorderStyleButton } from './ribbonButtons/contentModel/tableBorderStyleButton'; +import { tableBorderWidthButton } from './ribbonButtons/contentModel/tableBorderWidthButton'; +import { textColorButton } from './ribbonButtons/contentModel/textColorButton'; import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; -import { - ContentModelAutoFormatPlugin, - ContentModelEditPlugin, -} from 'roosterjs-content-model-plugins'; +import { underlineButton } from './ribbonButtons/contentModel/underlineButton'; +import { undoButton } from './ribbonButtons/contentModel/undoButton'; +import { zoom } from './ribbonButtons/contentModel/zoom'; import { ContentModelSegmentFormat, IStandaloneEditor, Snapshot, StandaloneEditorOptions, } from 'roosterjs-content-model-types'; +import { + spaceAfterButton, + spaceBeforeButton, +} from './ribbonButtons/contentModel/spaceBeforeAfterButtons'; +import { + tableAlignCellButton, + tableAlignTableButton, + tableDeleteButton, + tableInsertButton, + tableMergeButton, + tableSplitButton, +} from './ribbonButtons/contentModel/tableEditButtons'; +import { + ContentModelAutoFormatPlugin, + ContentModelEditPlugin, +} from 'roosterjs-content-model-plugins'; const styles = require('./StandaloneEditorMainPane.scss'); @@ -102,6 +170,70 @@ class ContentModelEditorMainPane extends MainPaneBase private snapshotPlugin: ContentModelSnapshotPlugin; private formatPainterPlugin: ContentModelFormatPainterPlugin; private snapshots: Snapshots; + private buttons: ContentModelRibbonButton[] = [ + formatPainterButton, + boldButton, + italicButton, + underlineButton, + fontButton, + fontSizeButton, + increaseFontSizeButton, + decreaseFontSizeButton, + textColorButton, + backgroundColorButton, + bulletedListButton, + numberedListButton, + decreaseIndentButton, + increaseIndentButton, + blockQuoteButton, + alignLeftButton, + alignCenterButton, + alignRightButton, + alignJustifyButton, + insertLinkButton, + removeLinkButton, + insertTableButton, + insertImageButton, + superscriptButton, + subscriptButton, + strikethroughButton, + setHeadingLevelButton, + codeButton, + ltrButton, + rtlButton, + undoButton, + redoButton, + clearFormatButton, + setBulletedListStyleButton, + setNumberedListStyleButton, + listStartNumberButton, + formatTableButton, + setTableCellShadeButton, + setTableHeaderButton, + tableInsertButton, + tableDeleteButton, + tableMergeButton, + tableSplitButton, + tableAlignCellButton, + tableAlignTableButton, + tableBorderApplyButton, + tableBorderColorButton, + tableBorderWidthButton, + tableBorderStyleButton, + imageBorderColorButton, + imageBorderWidthButton, + imageBorderStyleButton, + imageBorderRemoveButton, + changeImageButton, + imageBoxShadowButton, + spacingButton, + spaceBeforeButton, + spaceAfterButton, + pasteButton, + darkMode, + zoom, + exportContent, + ]; constructor(props: {}) { super(props); @@ -149,11 +281,12 @@ class ContentModelEditorMainPane extends MainPaneBase } renderRibbon(isPopout: boolean) { + const buttons = isPopout ? this.buttons : this.buttons.concat([popout]); return ( ); } diff --git a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx index f083a761cc5..c061beb4930 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx +++ b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx @@ -1,141 +1,15 @@ import * as React from 'react'; import ContentModelRibbonButton from './ContentModelRibbonButton'; import RibbonPlugin from './RibbonPlugin'; -import { alignCenterButton } from './alignCenterButton'; -import { alignJustifyButton } from './alignJustifyButton'; -import { alignLeftButton } from './alignLeftButton'; -import { alignRightButton } from './alignRightButton'; -import { backgroundColorButton } from './backgroundColorButton'; -import { blockQuoteButton } from './blockQuoteButton'; -import { boldButton } from './boldButton'; -import { bulletedListButton } from './bulletedListButton'; -import { changeImageButton } from './changeImageButton'; -import { clearFormatButton } from './clearFormatButton'; -import { codeButton } from './codeButton'; import { CommandBar, ICommandBarItemProps, ICommandBarProps } from '@fluentui/react/lib/CommandBar'; -import { darkMode } from './darkMode'; -import { decreaseFontSizeButton } from './decreaseFontSizeButton'; -import { decreaseIndentButton } from './decreaseIndentButton'; -import { exportContent } from './export'; import { FocusZoneDirection } from '@fluentui/react/lib/FocusZone'; -import { fontButton } from './fontButton'; -import { fontSizeButton } from './fontSizeButton'; -import { formatPainterButton } from './formatPainterButton'; import { FormatState } from 'roosterjs-editor-types'; -import { formatTableButton } from './formatTableButton'; import { getLocalizedString, LocalizedStrings } from 'roosterjs-react'; import { getObjectKeys } from 'roosterjs-content-model-dom'; import { IContextualMenuItem, IContextualMenuItemProps } from '@fluentui/react/lib/ContextualMenu'; -import { imageBorderColorButton } from './imageBorderColorButton'; -import { imageBorderRemoveButton } from './imageBorderRemoveButton'; -import { imageBorderStyleButton } from './imageBorderStyleButton'; -import { imageBorderWidthButton } from './imageBorderWidthButton'; -import { imageBoxShadowButton } from './imageBoxShadowButton'; -import { increaseFontSizeButton } from './increaseFontSizeButton'; -import { increaseIndentButton } from './increaseIndentButton'; -import { insertImageButton } from './insertImageButton'; -import { insertLinkButton } from './insertLinkButton'; -import { insertTableButton } from './insertTableButton'; import { IRenderFunction } from '@fluentui/react/lib/Utilities'; -import { italicButton } from './italicButton'; -import { listStartNumberButton } from './listStartNumberButton'; -import { ltrButton } from './ltrButton'; import { mergeStyles } from '@fluentui/react/lib/Styling'; import { moreCommands } from './moreCommands'; -import { numberedListButton } from './numberedListButton'; -import { pasteButton } from './pasteButton'; -import { popout } from './popout'; -import { redoButton } from './redoButton'; -import { removeLinkButton } from './removeLinkButton'; -import { rtlButton } from './rtlButton'; -import { setBulletedListStyleButton } from './setBulletedListStyleButton'; -import { setHeadingLevelButton } from './setHeadingLevelButton'; -import { setNumberedListStyleButton } from './setNumberedListStyleButton'; -import { setTableCellShadeButton } from './setTableCellShadeButton'; -import { setTableHeaderButton } from './setTableHeaderButton'; -import { spaceAfterButton, spaceBeforeButton } from './spaceBeforeAfterButtons'; -import { spacingButton } from './spacingButton'; -import { strikethroughButton } from './strikethroughButton'; -import { subscriptButton } from './subscriptButton'; -import { superscriptButton } from './superscriptButton'; -import { tableBorderApplyButton } from './tableBorderApplyButton'; -import { tableBorderColorButton } from './tableBorderColorButton'; -import { tableBorderStyleButton } from './tableBorderStyleButton'; -import { tableBorderWidthButton } from './tableBorderWidthButton'; -import { textColorButton } from './textColorButton'; -import { underlineButton } from './underlineButton'; -import { undoButton } from './undoButton'; -import { zoom } from './zoom'; -import { - tableAlignCellButton, - tableAlignTableButton, - tableDeleteButton, - tableInsertButton, - tableMergeButton, - tableSplitButton, -} from './tableEditButtons'; - -const buttons: ContentModelRibbonButton[] = [ - formatPainterButton, - boldButton, - italicButton, - underlineButton, - fontButton, - fontSizeButton, - increaseFontSizeButton, - decreaseFontSizeButton, - textColorButton, - backgroundColorButton, - bulletedListButton, - numberedListButton, - decreaseIndentButton, - increaseIndentButton, - blockQuoteButton, - alignLeftButton, - alignCenterButton, - alignRightButton, - alignJustifyButton, - insertLinkButton, - removeLinkButton, - insertTableButton, - insertImageButton, - superscriptButton, - subscriptButton, - strikethroughButton, - setHeadingLevelButton, - codeButton, - ltrButton, - rtlButton, - undoButton, - redoButton, - clearFormatButton, - setBulletedListStyleButton, - setNumberedListStyleButton, - listStartNumberButton, - formatTableButton, - setTableCellShadeButton, - setTableHeaderButton, - tableInsertButton, - tableDeleteButton, - tableMergeButton, - tableSplitButton, - tableAlignCellButton, - tableAlignTableButton, - tableBorderApplyButton, - tableBorderColorButton, - tableBorderWidthButton, - tableBorderStyleButton, - imageBorderColorButton, - imageBorderWidthButton, - imageBorderStyleButton, - imageBorderRemoveButton, - changeImageButton, - imageBoxShadowButton, - spacingButton, - spaceBeforeButton, - spaceAfterButton, - pasteButton, -]; const ribbonClassName = mergeStyles({ '& .ms-CommandBar': { @@ -170,7 +44,7 @@ interface RibbonProps extends Partial { * @param props Properties of format ribbon component * @returns The format ribbon component */ -function Ribbon(props: RibbonProps) { +export function ContentModelRibbon(props: RibbonProps) { const { plugin, buttons, strings, dir } = props; const [formatState, setFormatState] = React.useState(null); const isRtl = dir == 'rtl'; @@ -302,22 +176,3 @@ function Ribbon(props: RibbonProps) { /> ); } - -export default function ContentModelRibbon(props: { - ribbonPlugin: RibbonPlugin; - isRtl: boolean; - isInPopout: boolean; -}) { - const { ribbonPlugin, isRtl, isInPopout } = props; - const ribbonButtons = React.useMemo(() => { - const result: ContentModelRibbonButton[] = [...buttons, darkMode, zoom, exportContent]; - - if (!isInPopout) { - result.push(popout); - } - - return result; - }, [isInPopout]); - - return ; -} diff --git a/demo/scripts/controls/sidePane/contentModel/ContentModelPane.tsx b/demo/scripts/controls/sidePane/contentModel/ContentModelPane.tsx index 129a44b6492..8f2b2e8db99 100644 --- a/demo/scripts/controls/sidePane/contentModel/ContentModelPane.tsx +++ b/demo/scripts/controls/sidePane/contentModel/ContentModelPane.tsx @@ -1,9 +1,11 @@ import * as React from 'react'; +import ContentModelRibbonButton from '../../ribbonButtons/contentModel/ContentModelRibbonButton'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { ContentModelDocumentView } from '../../contentModel/components/model/ContentModelDocumentView'; +import { ContentModelRibbon } from '../../ribbonButtons/contentModel/ContentModelRibbon'; +import { ContentModelRibbonPlugin } from '../../ribbonButtons/contentModel/ContentModelRibbonPlugin'; import { exportButton } from './buttons/exportButton'; import { refreshButton } from './buttons/refreshButton'; -import { Ribbon, RibbonButton, RibbonPlugin } from 'roosterjs-react'; import { SidePaneElementProps } from '../SidePaneElement'; const styles = require('./ContentModelPane.scss'); @@ -13,14 +15,14 @@ export interface ContentModelPaneState { } export interface ContentModelPaneProps extends ContentModelPaneState, SidePaneElementProps { - ribbonPlugin: RibbonPlugin; + ribbonPlugin: ContentModelRibbonPlugin; } export default class ContentModelPane extends React.Component< ContentModelPaneProps, ContentModelPaneState > { - private contentModelButtons: RibbonButton[]; + private contentModelButtons: ContentModelRibbonButton[]; constructor(props: ContentModelPaneProps) { super(props); @@ -41,7 +43,10 @@ export default class ContentModelPane extends React.Component< render() { return ( <> - +
                  {this.state.model ? : null}
                  diff --git a/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts b/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts index 405d0372e54..9333e290c15 100644 --- a/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts +++ b/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts @@ -1,8 +1,8 @@ import ContentModelPane, { ContentModelPaneProps } from './ContentModelPane'; import SidePanePluginImpl from '../SidePanePluginImpl'; -import { createRibbonPlugin, RibbonPlugin } from 'roosterjs-react'; +import { ContentModelRibbonPlugin } from '../../ribbonButtons/contentModel/ContentModelRibbonPlugin'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { IEditor, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; -import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { setCurrentContentModel } from './currentModel'; import { SidePaneElementProps } from '../SidePaneElement'; @@ -10,17 +10,17 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< ContentModelPane, ContentModelPaneProps > { - private contentModelRibbon: RibbonPlugin; + private contentModelRibbon: ContentModelRibbonPlugin; constructor() { super(ContentModelPane, 'contentModel', 'Content Model (Under development)'); - this.contentModelRibbon = createRibbonPlugin(); + this.contentModelRibbon = new ContentModelRibbonPlugin(); } initialize(editor: IEditor): void { super.initialize(editor); - this.contentModelRibbon.initialize(editor); + this.contentModelRibbon.initialize(editor as IContentModelEditor); // Temporarily use IContentModelEditor here. TODO: Port side pane to use IStandaloneEditor editor.getDocument().addEventListener('selectionchange', this.onModelChangeFromSelection); } @@ -36,11 +36,10 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< onPluginEvent(e: PluginEvent) { if (e.eventType == PluginEventType.ContentChanged && e.source == 'RefreshModel') { this.getComponent(component => { - const model = isContentModelEditor(this.editor) - ? this.editor.createContentModel() - : null; + // TODO: Port to use IStandaloneEditor and remove type cast here + const model = (this.editor as IContentModelEditor).createContentModel(); component.setContentModel(model); - setCurrentContentModel(this.editor, model); + setCurrentContentModel(model); }); } else if ( e.eventType == PluginEventType.Input || @@ -49,7 +48,7 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< this.onModelChange(); } - this.contentModelRibbon.onPluginEvent(e); + // this.contentModelRibbon.onPluginEvent(e); } getInnerRibbonPlugin() { @@ -72,11 +71,10 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< private onModelChange = () => { this.getComponent(component => { - const model = isContentModelEditor(this.editor) - ? this.editor.createContentModel() - : null; + // TODO: Port to use IStandaloneEditor and remove type cast here + const model = (this.editor as IContentModelEditor).createContentModel(); component.setContentModel(model); - setCurrentContentModel(this.editor, model); + setCurrentContentModel(model); }); }; } diff --git a/demo/scripts/controls/sidePane/contentModel/buttons/exportButton.ts b/demo/scripts/controls/sidePane/contentModel/buttons/exportButton.ts index 30bbc3d3c4b..8d1b43fd834 100644 --- a/demo/scripts/controls/sidePane/contentModel/buttons/exportButton.ts +++ b/demo/scripts/controls/sidePane/contentModel/buttons/exportButton.ts @@ -1,15 +1,14 @@ +import ContentModelRibbonButton from '../../../ribbonButtons/contentModel/ContentModelRibbonButton'; import { getCurrentContentModel } from '../currentModel'; -import { isContentModelEditor } from 'roosterjs-content-model-editor'; -import { RibbonButton } from 'roosterjs-react'; -export const exportButton: RibbonButton<'buttonNameExport'> = { +export const exportButton: ContentModelRibbonButton<'buttonNameExport'> = { key: 'buttonNameExport', unlocalizedText: 'Create DOM tree', iconName: 'DOM', onClick: editor => { - const model = getCurrentContentModel(editor); + const model = getCurrentContentModel(); - if (model && isContentModelEditor(editor)) { + if (model) { editor.formatContentModel(currentModel => { currentModel.blocks = model.blocks; diff --git a/demo/scripts/controls/sidePane/contentModel/buttons/refreshButton.ts b/demo/scripts/controls/sidePane/contentModel/buttons/refreshButton.ts index 9e9cc0ecc7e..d427036034f 100644 --- a/demo/scripts/controls/sidePane/contentModel/buttons/refreshButton.ts +++ b/demo/scripts/controls/sidePane/contentModel/buttons/refreshButton.ts @@ -1,10 +1,12 @@ -import { RibbonButton } from 'roosterjs-react'; +import ContentModelRibbonButton from '../../../ribbonButtons/contentModel/ContentModelRibbonButton'; -export const refreshButton: RibbonButton<'buttonNameRefresh'> = { +export const refreshButton: ContentModelRibbonButton<'buttonNameRefresh'> = { key: 'buttonNameRefresh', unlocalizedText: 'Refresh', iconName: 'Refresh', onClick: editor => { - editor.triggerContentChangedEvent('RefreshModel'); + editor.triggerEvent('contentChanged', { + source: 'RefreshModel', + }); }, }; diff --git a/demo/scripts/controls/sidePane/contentModel/currentModel.ts b/demo/scripts/controls/sidePane/contentModel/currentModel.ts index 3e942718feb..1431f568510 100644 --- a/demo/scripts/controls/sidePane/contentModel/currentModel.ts +++ b/demo/scripts/controls/sidePane/contentModel/currentModel.ts @@ -1,23 +1,11 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; -import { IEditor } from 'roosterjs-editor-types'; -const CurrentContentModelHolderKey = '_CurrentContentModelHolder'; +let currentModel: ContentModelDocument | null = null; -interface CurrentContentModelHolder { - model: ContentModelDocument | null; +export function getCurrentContentModel(): ContentModelDocument | null { + return currentModel; } -function getCurrentModelHolder(editor: IEditor) { - return editor.getCustomData( - CurrentContentModelHolderKey, - () => { model: null } - ); -} - -export function getCurrentContentModel(editor: IEditor): ContentModelDocument | null { - return getCurrentModelHolder(editor).model; -} - -export function setCurrentContentModel(editor: IEditor, model: ContentModelDocument | null) { - getCurrentModelHolder(editor).model = model; +export function setCurrentContentModel(model: ContentModelDocument | null) { + currentModel = model; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts deleted file mode 100644 index c2436986be0..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { IContentModelEditor } from '../publicTypes/IContentModelEditor'; -import type { IEditor } from 'roosterjs-editor-types'; - -/** - * Check if the given editor object is Content Model editor - * @param editor The editor to check - * @returns True if the given editor is Content Model editor, otherwise false - */ -export function isContentModelEditor(editor: IEditor): editor is IContentModelEditor { - const contentModelEditor = editor as IContentModelEditor; - - return !!contentModelEditor.createContentModel; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index a2036c046ea..d0a490ed6e3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -12,4 +12,3 @@ export { ContentModelCorePluginState } from './publicTypes/ContentModelCorePlugi export { ContentModelBeforePasteEvent } from './publicTypes/ContentModelBeforePasteEvent'; export { ContentModelEditor } from './editor/ContentModelEditor'; -export { isContentModelEditor } from './editor/isContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts deleted file mode 100644 index 19020bb14da..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ContentModelEditor } from '../../lib/editor/ContentModelEditor'; -import { Editor } from 'roosterjs-editor-core'; -import { isContentModelEditor } from '../../lib/editor/isContentModelEditor'; - -describe('isContentModelEditor', () => { - it('Legacy editor', () => { - const div = document.createElement('div'); - const editor = new Editor(div); - const result = isContentModelEditor(editor); - - expect(result).toBeFalse(); - }); - - it('Content Model editor', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const result = isContentModelEditor(editor); - - expect(result).toBeTrue(); - }); -}); From 1ad815d5f82d119c40f2e32318e12695100c2ac3 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Tue, 30 Jan 2024 12:23:16 -0600 Subject: [PATCH 046/112] Add `isReverted` to DOM Selection (#2368) * add Changes * add Tests * fix dependencies * address comments * address comment * fix after merge * fix test --------- Co-authored-by: Jiuqing Song --- .../publicApi/format/getFormatStateTest.ts | 6 + .../publicApi/segment/changeFontSizeTest.ts | 1 + .../lib/coreApi/getDOMSelection.ts | 16 ++- .../lib/coreApi/setDOMSelection.ts | 2 +- .../lib/corePlugin/SelectionPlugin.ts | 1 + .../corePlugin/utils/addRangeToSelection.ts | 14 ++- .../lib/utils/createSnapshotSelection.ts | 3 + .../lib/utils/restoreSnapshotSelection.ts | 1 + .../test/coreApi/getDOMSelectionTest.ts | 104 +++++++++++++++++- .../test/coreApi/setDOMSelectionTest.ts | 25 +++-- .../corePlugin/ContentModelCachePluginTest.ts | 2 + .../test/corePlugin/SelectionPluginTest.ts | 3 + .../corePlugin/utils/areSameRangeExTest.ts | 12 ++ .../utils/contentModelDomIndexerTest.ts | 10 ++ .../test/overrides/tablePreProcessorTest.ts | 1 + .../selection/getSelectionRootNodeTest.ts | 1 + .../test/utils/createSnapshotSelectionTest.ts | 12 ++ .../utils/restoreSnapshotSelectionTest.ts | 10 ++ .../lib/modelToDom/contentModelToDom.ts | 1 + .../processors/childProcessorTest.ts | 8 ++ .../processors/delimiterProcessorTest.ts | 2 + .../processors/generalProcessorTest.ts | 2 + .../processors/textProcessorTest.ts | 6 + .../lib/coreApi/ensureTypeInContainer.ts | 1 + .../lib/coreApi/insertNode.ts | 1 + .../lib/coreApi/setContent.ts | 1 + .../lib/editor/utils/selectionConverter.ts | 1 + .../editor/utils/selectionConverterTest.ts | 2 + .../test/edit/keyboardDeleteTest.ts | 4 + .../lib/parameter/Snapshot.ts | 6 + .../lib/selection/DOMSelection.ts | 6 + 31 files changed, 252 insertions(+), 13 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts index 0a58e470da6..65e891a88db 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts @@ -54,6 +54,7 @@ describe('getFormatState', () => { range: { commonAncestorContainer: selectedNode, } as any, + isReverted: false, }; } @@ -221,6 +222,7 @@ describe('reducedModelChildProcessor', () => { range: { commonAncestorContainer: span, } as any, + isReverted: false, }; reducedModelChildProcessor(doc, div, context); @@ -263,6 +265,7 @@ describe('reducedModelChildProcessor', () => { range: { commonAncestorContainer: span2, } as any, + isReverted: false, }; reducedModelChildProcessor(doc, div, context); @@ -305,6 +308,7 @@ describe('reducedModelChildProcessor', () => { range: { commonAncestorContainer: span2, } as any, + isReverted: false, }; reducedModelChildProcessor(doc, div, context); @@ -374,6 +378,7 @@ describe('reducedModelChildProcessor', () => { range: { commonAncestorContainer: span2, } as any, + isReverted: false, }; reducedModelChildProcessor(doc, div1, context); @@ -421,6 +426,7 @@ describe('reducedModelChildProcessor', () => { range: { commonAncestorContainer: div.querySelector('#selection') as HTMLElement, } as any, + isReverted: false, }; reducedModelChildProcessor(doc, div, context); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts index d786c24e905..b0ce78a6754 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts @@ -342,6 +342,7 @@ describe('changeFontSize', () => { const model = domToContentModel(div, createDomToModelContext(undefined), { type: 'range', range: createRange(sub), + isReverted: false, }); let formatResult: boolean | undefined; diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts index 11e033fc242..ee4c7f8e26a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts @@ -26,7 +26,21 @@ function getNewSelection(core: StandaloneEditorCore): DOMSelection | null { return range && core.contentDiv.contains(range.commonAncestorContainer) ? { type: 'range', - range: range, + range, + isReverted: isSelectionReverted(selection), } : null; } + +function isSelectionReverted(selection: Selection | null | undefined): boolean { + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + return ( + !range.collapsed && + selection.focusNode != range.endContainer && + selection.focusOffset != range.endOffset + ); + } + + return false; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts index 55432d099d0..9f40ab68127 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts @@ -58,7 +58,7 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC setRangeSelection(doc, table.rows[firstRow]?.cells[firstColumn]); break; case 'range': - addRangeToSelection(doc, selection.range); + addRangeToSelection(doc, selection.range, selection.isReverted); core.selection.selection = core.api.hasFocus(core) ? null : selection; break; diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts index ea1b327febc..8bd1ee361e0 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts @@ -155,6 +155,7 @@ class SelectionPlugin implements PluginWithState { editor.setDOMSelection({ type: 'range', range: range, + isReverted: false, }); } } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/addRangeToSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/addRangeToSelection.ts index e7a53dbe53e..bb128dd0a46 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/addRangeToSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/addRangeToSelection.ts @@ -1,11 +1,21 @@ /** * @internal */ -export function addRangeToSelection(doc: Document, range: Range) { +export function addRangeToSelection(doc: Document, range: Range, isReverted: boolean = false) { const selection = doc.defaultView?.getSelection(); if (selection) { selection.removeAllRanges(); - selection.addRange(range); + + if (!isReverted) { + selection.addRange(range); + } else { + selection.setBaseAndExtent( + range.endContainer, + range.endOffset, + range.startContainer, + range.startOffset + ); + } } } diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts index 10a3e684d1a..b15da47dd43 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts @@ -27,6 +27,7 @@ export function createSnapshotSelection(core: StandaloneEditorCore): SnapshotSel { type: 'range', range: newRange, + isReverted: !!selection.isReverted, }, true /*skipSelectionChangedEvent*/ ); @@ -57,6 +58,7 @@ export function createSnapshotSelection(core: StandaloneEditorCore): SnapshotSel type: 'range', start: getPath(range.startContainer, range.startOffset, contentDiv), end: getPath(range.endContainer, range.endOffset, contentDiv), + isReverted: !!selection.isReverted, }; default: @@ -64,6 +66,7 @@ export function createSnapshotSelection(core: StandaloneEditorCore): SnapshotSel type: 'range', start: [], end: [], + isReverted: false, }; } } diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotSelection.ts index f415aff8b99..79e2e4e2b70 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotSelection.ts @@ -22,6 +22,7 @@ export function restoreSnapshotSelection(core: StandaloneEditorCore, snapshot: S domSelection = { type: 'range', range, + isReverted: snapshotSelection.isReverted, }; break; case 'table': diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts index d0046033a56..952a2af9fa2 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts @@ -62,12 +62,23 @@ describe('getDOMSelection', () => { it('no cached selection, range selection is in editor', () => { const mockedElement = 'ELEMENT' as any; + const mockedElementOffset = 'MOCKED_ELEMENT_OFFSET' as any; + const secondaryMockedElement = 'ELEMENT_2' as any; + const secondaryMockedElementOffset = 'MOCKED_ELEMENT_OFFSET_2' as any; const mockedRange = { commonAncestorContainer: mockedElement, + startContainer: mockedElement, + startOffset: mockedElementOffset, + endContainer: secondaryMockedElement, + endOffset: secondaryMockedElementOffset, } as any; + const setBaseAndExtendSpy = jasmine.createSpy('setBaseAndExtendSpy'); const mockedSelection = { rangeCount: 1, getRangeAt: () => mockedRange, + setBaseAndExtend: setBaseAndExtendSpy, + focusNode: secondaryMockedElement, + focusOffset: secondaryMockedElementOffset, }; getSelectionSpy.and.returnValue(mockedSelection); @@ -79,6 +90,42 @@ describe('getDOMSelection', () => { expect(result).toEqual({ type: 'range', range: mockedRange, + isReverted: false, + }); + }); + + it('no cached selection, range selection is in editor, isReverted', () => { + const mockedElement = 'ELEMENT' as any; + const mockedElementOffset = 'MOCKED_ELEMENT_OFFSET' as any; + const secondaryMockedElement = 'ELEMENT_2' as any; + const secondaryMockedElementOffset = 'MOCKED_ELEMENT_OFFSET_2' as any; + const mockedRange = { + commonAncestorContainer: mockedElement, + startContainer: mockedElement, + startOffset: mockedElementOffset, + endContainer: secondaryMockedElement, + endOffset: secondaryMockedElementOffset, + collapsed: false, + } as any; + const setBaseAndExtendSpy = jasmine.createSpy('setBaseAndExtendSpy'); + const mockedSelection = { + rangeCount: 1, + getRangeAt: () => mockedRange, + setBaseAndExtend: setBaseAndExtendSpy, + focusNode: mockedElement, + focusOffset: mockedElementOffset, + }; + + getSelectionSpy.and.returnValue(mockedSelection); + containsSpy.and.returnValue(true); + hasFocusSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toEqual({ + type: 'range', + range: mockedRange, + isReverted: true, }); }); @@ -124,20 +171,71 @@ describe('getDOMSelection', () => { }); it('has cached range selection, editor has focus', () => { + const mockedElement = 'ELEMENT' as any; + const mockedElementOffset = 'MOCKED_ELEMENT_OFFSET' as any; + const secondaryMockedElement = 'ELEMENT_2' as any; + const secondaryMockedElementOffset = 'MOCKED_ELEMENT_OFFSET_2' as any; + const mockedRange = { + commonAncestorContainer: mockedElement, + startContainer: mockedElement, + startOffset: mockedElementOffset, + endContainer: secondaryMockedElement, + endOffset: secondaryMockedElementOffset, + } as any; + const setBaseAndExtendSpy = jasmine.createSpy('setBaseAndExtendSpy'); + const mockedSelectionObj = { + rangeCount: 1, + getRangeAt: () => mockedRange, + setBaseAndExtend: setBaseAndExtendSpy, + focusNode: secondaryMockedElement, + focusOffset: secondaryMockedElementOffset, + }; const mockedSelection = { type: 'range', } as any; + + core.selection.selection = mockedSelection; + getSelectionSpy.and.returnValue(mockedSelectionObj); + + hasFocusSpy.and.returnValue(true); + containsSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toEqual({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + }); + + it('has cached range selection, editor has focus, reverted', () => { const mockedElement = 'ELEMENT' as any; + const mockedElementOffset = 'MOCKED_ELEMENT_OFFSET' as any; + const secondaryMockedElement = 'ELEMENT_2' as any; + const secondaryMockedElementOffset = 'MOCKED_ELEMENT_OFFSET_2' as any; const mockedRange = { commonAncestorContainer: mockedElement, + startContainer: mockedElement, + startOffset: mockedElementOffset, + endContainer: secondaryMockedElement, + endOffset: secondaryMockedElementOffset, + collapsed: false, } as any; - const mockedSelectionNew = { + const setBaseAndExtendSpy = jasmine.createSpy('setBaseAndExtendSpy'); + const mockedSelectionObj = { rangeCount: 1, getRangeAt: () => mockedRange, + setBaseAndExtend: setBaseAndExtendSpy, + focusNode: mockedElement, + focusOffset: mockedElementOffset, }; + const mockedSelection = { + type: 'range', + } as any; core.selection.selection = mockedSelection; - getSelectionSpy.and.returnValue(mockedSelectionNew); + getSelectionSpy.and.returnValue(mockedSelectionObj); hasFocusSpy.and.returnValue(true); containsSpy.and.returnValue(true); @@ -147,6 +245,7 @@ describe('getDOMSelection', () => { expect(result).toEqual({ type: 'range', range: mockedRange, + isReverted: true, }); }); @@ -180,6 +279,7 @@ describe('getDOMSelection', () => { expect(result).toEqual({ type: 'range', range: mockedNewSelection, + isReverted: false, }); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts index 99327281c9c..d97267cd9a6 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts @@ -97,6 +97,7 @@ describe('setDOMSelection', () => { runTest({ type: 'range', range: {} as any, + isReverted: false, }); }); @@ -124,6 +125,7 @@ describe('setDOMSelection', () => { const mockedSelection = { type: 'range', range: mockedRange, + isReverted: false, } as any; (core.selection.selectionStyleNode!.sheet!.cssRules as any) = ['Rule1', 'Rule2']; @@ -146,7 +148,11 @@ describe('setDOMSelection', () => { }, true ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith( + doc, + mockedRange, + false /* isReverted */ + ); expect(contentDiv.id).toBe('contentDiv_0'); expect(deleteRuleSpy).toHaveBeenCalledTimes(2); expect(deleteRuleSpy).toHaveBeenCalledWith(1); @@ -178,7 +184,7 @@ describe('setDOMSelection', () => { }, true ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, undefined); expect(contentDiv.id).toBe('contentDiv_0'); expect(deleteRuleSpy).not.toHaveBeenCalled(); expect(insertRuleSpy).not.toHaveBeenCalled(); @@ -188,6 +194,7 @@ describe('setDOMSelection', () => { const mockedSelection = { type: 'range', range: mockedRange, + isReverted: false, } as any; querySelectorAllSpy.and.returnValue([]); @@ -201,7 +208,7 @@ describe('setDOMSelection', () => { selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).not.toHaveBeenCalled(); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); expect(contentDiv.id).toBe('contentDiv_0'); expect(deleteRuleSpy).not.toHaveBeenCalled(); expect(insertRuleSpy).not.toHaveBeenCalled(); @@ -211,6 +218,7 @@ describe('setDOMSelection', () => { const mockedSelection = { type: 'range', range: mockedRange, + isReverted: false, } as any; querySelectorAllSpy.and.returnValue([]); @@ -231,7 +239,7 @@ describe('setDOMSelection', () => { }, true ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); expect(contentDiv.id).toBe('contentDiv_0'); expect(deleteRuleSpy).not.toHaveBeenCalled(); expect(insertRuleSpy).not.toHaveBeenCalled(); @@ -241,6 +249,7 @@ describe('setDOMSelection', () => { const mockedSelection = { type: 'range', range: mockedRange, + isReverted: false, } as any; contentDiv.id = 'testId'; @@ -262,7 +271,7 @@ describe('setDOMSelection', () => { }, true ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); expect(contentDiv.id).toBe('testId'); expect(deleteRuleSpy).not.toHaveBeenCalled(); expect(insertRuleSpy).not.toHaveBeenCalled(); @@ -272,6 +281,7 @@ describe('setDOMSelection', () => { const mockedSelection = { type: 'range', range: mockedRange, + isReverted: false, } as any; contentDiv.id = 'testId'; @@ -295,7 +305,7 @@ describe('setDOMSelection', () => { }, true ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); expect(contentDiv.id).toBe('testId_0'); expect(deleteRuleSpy).not.toHaveBeenCalled(); expect(insertRuleSpy).not.toHaveBeenCalled(); @@ -305,6 +315,7 @@ describe('setDOMSelection', () => { const mockedSelection = { type: 'range', range: mockedRange, + isReverted: false, } as any; contentDiv.id = 'testId'; @@ -328,7 +339,7 @@ describe('setDOMSelection', () => { }, true ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); expect(contentDiv.id).toBe('testId_1'); expect(deleteRuleSpy).not.toHaveBeenCalled(); expect(insertRuleSpy).not.toHaveBeenCalled(); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts index ca994716344..d75dc60e4e4 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts @@ -123,6 +123,7 @@ describe('ContentModelCachePlugin', () => { state.cachedSelection = { type: 'range', range: { collapsed: true } as any, + isReverted: false, }; plugin.onPluginEvent({ @@ -143,6 +144,7 @@ describe('ContentModelCachePlugin', () => { state.cachedSelection = { type: 'range', range: { collapsed: false } as any, + isReverted: false, }; plugin.onPluginEvent({ diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts index 3e44b01f148..e73cac4e98b 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts @@ -248,6 +248,7 @@ describe('SelectionPlugin handle image selection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith({ type: 'range', range: mockedRange, + isReverted: false, }); }); @@ -463,6 +464,7 @@ describe('SelectionPlugin handle image selection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith({ type: 'range', range: mockedRange, + isReverted: false, }); }); @@ -500,6 +502,7 @@ describe('SelectionPlugin handle image selection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith({ type: 'range', range: mockedRange, + isReverted: false, }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/areSameRangeExTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/areSameRangeExTest.ts index a97c0de20db..a8fd0ffbb0f 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/areSameRangeExTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/areSameRangeExTest.ts @@ -28,6 +28,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, { type: 'range', @@ -37,6 +38,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, true ); @@ -88,6 +90,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, { type: 'table', @@ -111,6 +114,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, { type: 'image', @@ -148,6 +152,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, { type: 'range', @@ -157,6 +162,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, false ); @@ -172,6 +178,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, { type: 'range', @@ -181,6 +188,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, false ); @@ -196,6 +204,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, { type: 'range', @@ -205,6 +214,7 @@ describe('areSameSelection', () => { startOffset: 3, endOffset, } as any, + isReverted: false, }, false ); @@ -220,6 +230,7 @@ describe('areSameSelection', () => { startOffset, endOffset, } as any, + isReverted: false, }, { type: 'range', @@ -229,6 +240,7 @@ describe('areSameSelection', () => { startOffset, endOffset: 4, } as any, + isReverted: false, }, false ); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts index 3ffde59360a..447252d85d0 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts @@ -200,6 +200,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const newRangeEx: DOMSelection = { type: 'range', range: createRange(node, 2), + isReverted: false, }; const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); @@ -213,6 +214,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const newRangeEx: DOMSelection = { type: 'range', range: createRange(node, 2), + isReverted: false, }; const paragraph = createParagraph(); const segment = createText(''); @@ -259,6 +261,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const newRangeEx: DOMSelection = { type: 'range', range: createRange(node, 1, node, 3), + isReverted: false, }; const paragraph = createParagraph(); const segment = createText(''); @@ -309,6 +312,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const newRangeEx: DOMSelection = { type: 'range', range: createRange(node1, 2, node2, 3), + isReverted: false, }; const paragraph = createParagraph(); const oldSegment1 = createText(''); @@ -378,6 +382,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const newRangeEx: DOMSelection = { type: 'range', range: createRange(node1, 2, parent, 2), + isReverted: false, }; const paragraph = createParagraph(); const oldSegment1 = createText(''); @@ -521,6 +526,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const newRangeEx: DOMSelection = { type: 'range', range: createRange(parent, 1), + isReverted: false, }; const paragraph = createParagraph(); const segment = createBr({ fontFamily: 'Arial' }); @@ -548,10 +554,12 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const oldRangeEx: DOMSelection = { type: 'range', range: createRange(node, 2), + isReverted: false, }; const newRangeEx: DOMSelection = { type: 'range', range: createRange(node, 1, node, 3), + isReverted: false, }; const paragraph = createParagraph(); const oldSegment1 = createText('te'); @@ -597,10 +605,12 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const oldRangeEx: DOMSelection = { type: 'range', range: createRange(node, 1, node, 3), + isReverted: false, }; const newRangeEx: DOMSelection = { type: 'range', range: createRange(node, 2), + isReverted: false, }; const paragraph = createParagraph(); const oldSegment1: ContentModelSegment = { diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts index e759c7b4a19..55089ec522a 100644 --- a/packages-content-model/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts @@ -64,6 +64,7 @@ describe('tablePreProcessor', () => { range: { commonAncestorContainer: txt, } as any, + isReverted: false, }; tablePreProcessor(group, table, context); diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/getSelectionRootNodeTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/getSelectionRootNodeTest.ts index cbb1d37affa..25023764cfd 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/getSelectionRootNodeTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/getSelectionRootNodeTest.ts @@ -14,6 +14,7 @@ describe('getSelectionRootNode', () => { range: { commonAncestorContainer: mockedRoot, } as any, + isReverted: false, }); expect(root).toBe(mockedRoot); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts index e251c3a88b9..d6261cf5be8 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts @@ -89,6 +89,7 @@ describe('createSnapshotSelection - Range selection', () => { type: 'range', start: [], end: [], + isReverted: false, }); }); @@ -102,6 +103,7 @@ describe('createSnapshotSelection - Range selection', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', range: range, + isReverted: false, }); const result = createSnapshotSelection(core); @@ -110,6 +112,7 @@ describe('createSnapshotSelection - Range selection', () => { type: 'range', start: [0, 2], end: [0, 4], + isReverted: false, }); }); @@ -123,6 +126,7 @@ describe('createSnapshotSelection - Range selection', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', range: range, + isReverted: false, }); const result = createSnapshotSelection(core); @@ -131,6 +135,7 @@ describe('createSnapshotSelection - Range selection', () => { type: 'range', start: [0, 0, 2], end: [0, 0, 4], + isReverted: false, }); }); @@ -144,6 +149,7 @@ describe('createSnapshotSelection - Range selection', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', range: range, + isReverted: false, }); const result = createSnapshotSelection(core); @@ -152,6 +158,7 @@ describe('createSnapshotSelection - Range selection', () => { type: 'range', start: [0, 0, 1], end: [1, 0, 0], + isReverted: false, }); }); @@ -168,6 +175,7 @@ describe('createSnapshotSelection - Range selection', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', range: range, + isReverted: false, }); const result = createSnapshotSelection(core); @@ -176,6 +184,7 @@ describe('createSnapshotSelection - Range selection', () => { type: 'range', start: [0, 7], end: [0, 12], + isReverted: false, }); }); @@ -200,6 +209,7 @@ describe('createSnapshotSelection - Range selection', () => { getDOMSelectionSpy.and.returnValue({ type: 'range', range: range, + isReverted: false, }); const result = createSnapshotSelection(core); @@ -208,6 +218,7 @@ describe('createSnapshotSelection - Range selection', () => { type: 'range', start: [1, 0, 7], end: [2, 7], + isReverted: false, }); }); }); @@ -262,6 +273,7 @@ describe('createSnapshotSelection - Normalize Table', () => { type: 'range', start: startPath, end: endPath, + isReverted: false, }); expect(div.innerHTML).toBe(output); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts index f2fa0cfde1c..db6196e2e1c 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts @@ -138,6 +138,7 @@ describe('restoreSnapshotSelection', () => { type: 'range', start: [], end: [], + isReverted: false, }; const snapshot: Snapshot = { selection: snapshotSelection, @@ -148,6 +149,7 @@ describe('restoreSnapshotSelection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, { type: 'range', range: mockedRange, + isReverted: false, }); expect(setStartSpy).toHaveBeenCalledTimes(1); expect(setEndSpy).toHaveBeenCalledTimes(1); @@ -163,6 +165,7 @@ describe('restoreSnapshotSelection', () => { type: 'range', start: [1, 0], end: [1, 1], + isReverted: false, }; const snapshot: Snapshot = { selection: snapshotSelection, @@ -174,6 +177,7 @@ describe('restoreSnapshotSelection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, { type: 'range', range: mockedRange, + isReverted: false, }); expect(setStartSpy).toHaveBeenCalledTimes(1); expect(setEndSpy).toHaveBeenCalledTimes(1); @@ -189,6 +193,7 @@ describe('restoreSnapshotSelection', () => { type: 'range', start: [1, 0, 0], end: [1, 1, 0], + isReverted: false, }; const snapshot: Snapshot = { selection: snapshotSelection, @@ -201,6 +206,7 @@ describe('restoreSnapshotSelection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, { type: 'range', range: mockedRange, + isReverted: false, }); expect(setStartSpy).toHaveBeenCalledTimes(1); expect(setEndSpy).toHaveBeenCalledTimes(1); @@ -216,6 +222,7 @@ describe('restoreSnapshotSelection', () => { type: 'range', start: [1, 0, 0, 1], end: [1, 0, 2, 3], + isReverted: false, }; const snapshot: Snapshot = { selection: snapshotSelection, @@ -227,6 +234,7 @@ describe('restoreSnapshotSelection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, { type: 'range', range: mockedRange, + isReverted: false, }); expect(setStartSpy).toHaveBeenCalledTimes(1); expect(setEndSpy).toHaveBeenCalledTimes(1); @@ -241,6 +249,7 @@ describe('restoreSnapshotSelection', () => { type: 'range', start: [0, 0, 0, 1], end: [0, 2, 2, 3], + isReverted: false, }; const snapshot: Snapshot = { selection: snapshotSelection, @@ -253,6 +262,7 @@ describe('restoreSnapshotSelection', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, { type: 'range', range: mockedRange, + isReverted: false, }); expect(setStartSpy).toHaveBeenCalledTimes(1); expect(setEndSpy).toHaveBeenCalledTimes(1); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts index eba0f04ebeb..7b90a3654d6 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts @@ -56,6 +56,7 @@ function extractSelectionRange(doc: Document, context: ModelToDomContext): DOMSe return { type: 'range', range, + isReverted: false, }; } else if (tableSelection) { return tableSelection; diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts index 69d7e6b96be..f34c80383cc 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts @@ -121,6 +121,7 @@ describe('childProcessor', () => { endOffset: 0, collapsed: false, } as any, + isReverted: false, }; context.pendingFormat = { format: { @@ -166,6 +167,7 @@ describe('childProcessor', () => { endOffset: 0, collapsed: false, } as any, + isReverted: false, }; context.pendingFormat = { format: { @@ -218,6 +220,7 @@ describe('childProcessor', () => { endOffset: 2, collapsed: false, } as any, + isReverted: false, }; childProcessor(doc, div, context); @@ -253,6 +256,7 @@ describe('childProcessor', () => { endOffset: 1, collapsed: true, } as any, + isReverted: false, }; childProcessor(doc, div, context); @@ -288,6 +292,7 @@ describe('childProcessor', () => { endOffset: 10, collapsed: false, } as any, + isReverted: false, }; childProcessor(doc, div, context); @@ -323,6 +328,7 @@ describe('childProcessor', () => { endOffset: 5, collapsed: true, } as any, + isReverted: false, }; childProcessor(doc, div, context); @@ -357,6 +363,7 @@ describe('childProcessor', () => { endOffset: 5, collapsed: false, } as any, + isReverted: false, }; childProcessor(doc, div, context); @@ -403,6 +410,7 @@ describe('childProcessor', () => { endOffset: 0, collapsed: true, } as any, + isReverted: false, }; childProcessor(doc, div, context); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts index b1297ca024b..c4fd0166aa4 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts @@ -41,6 +41,7 @@ describe('delimiterProcessor', () => { context.selection = { type: 'range', range: createRange(text, 0, span2, 0), + isReverted: false, }; delimiterProcessor(doc, span, context); @@ -82,6 +83,7 @@ describe('delimiterProcessor', () => { context.selection = { type: 'range', range: createRange(text1, 2, text2, 3), + isReverted: false, }; delimiterProcessor(doc, span, context); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts index bb215908c18..c52c730bfe2 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts @@ -160,6 +160,7 @@ describe('generalProcessor', () => { endOffset: 3, collapsed: false, } as any, + isReverted: false, }; childProcessor.and.callFake(originalChildProcessor); @@ -227,6 +228,7 @@ describe('generalProcessor', () => { endOffset: 3, collapsed: false, } as any, + isReverted: false, }; context.isInSelection = true; diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts index 4d154e510b9..2bfb3db8ad0 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts @@ -384,6 +384,7 @@ describe('textProcessor', () => { endOffset: 2, collapsed: true, } as any, + isReverted: false, }; textProcessor(doc, text, context); @@ -616,6 +617,7 @@ describe('textProcessor', () => { context.selection = { type: 'range', range: createRange(text, 2), + isReverted: false, }; textProcessor(doc, text, context); @@ -660,6 +662,7 @@ describe('textProcessor', () => { context.selection = { type: 'range', range: createRange(text, 1, text, 3), + isReverted: false, }; textProcessor(doc, text, context); @@ -745,6 +748,7 @@ describe('textProcessor', () => { startOffset: 2, endOffset: 2, } as any, + isReverted: false, }; context.pendingFormat = { format: { @@ -803,6 +807,7 @@ describe('textProcessor', () => { startOffset: 1, endOffset: 3, } as any, + isReverted: false, }; context.pendingFormat = { format: { @@ -863,6 +868,7 @@ describe('textProcessor', () => { startOffset: 2, endOffset: 2, } as any, + isReverted: false, }; context.pendingFormat = { format: { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts index 0a92376edc1..c00066f23ab 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts @@ -70,6 +70,7 @@ export const ensureTypeInContainer: EnsureTypeInContainer = ( api.setDOMSelection(innerCore, { type: 'range', range: createRange(new Position(position)), + isReverted: false, }); } }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts index 937eda1bcd0..c1f40618293 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts @@ -172,6 +172,7 @@ export const insertNode: InsertNode = (core, innerCore, node, option) => { api.setDOMSelection(innerCore, { type: 'range', range: rangeToRestore, + isReverted: false, }); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts index 8f7892d0b81..2ea1d68e87a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts @@ -99,6 +99,7 @@ function convertMetadataToDOMSelection( return { type: 'range', range: createRange(contentDiv, metadata.start, metadata.end), + isReverted: false, }; case SelectionRangeTypes.TableSelection: const table = queryElements(contentDiv, '#' + metadata.tableId)[0] as HTMLTableElement; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts index 287c48e7d8b..6279645eee9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts @@ -24,6 +24,7 @@ export function convertRangeExToDomSelection( ? { type: 'range', range: rangeEx.ranges[0], + isReverted: false, } : null; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts index df95ac3dd7b..edbcf1a1a87 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts @@ -25,6 +25,7 @@ describe('convertRangeExToDomSelection', () => { expect(result).toEqual({ type: 'range', range: mockedRange, + isReverted: false, }); }); @@ -120,6 +121,7 @@ describe('convertDomSelectionToRangeEx', () => { const result = convertDomSelectionToRangeEx({ type: 'range', range: mockedRange, + isReverted: false, }); expect(result).toEqual({ diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts index 4049f8ac232..06f1edd5e85 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts @@ -537,6 +537,7 @@ describe('keyboardDelete', () => { startContainer: document.createTextNode('test'), startOffset: 2, } as any) as Range, + isReverted: false, }; const editor = { formatContentModel: formatWithContentModelSpy, @@ -558,6 +559,7 @@ describe('keyboardDelete', () => { startContainer: document.createTextNode('test'), startOffset: 2, } as any) as Range, + isReverted: false, }; const editor = { formatContentModel: formatWithContentModelSpy, @@ -579,6 +581,7 @@ describe('keyboardDelete', () => { startContainer: document.createTextNode('test'), startOffset: 0, } as any) as Range, + isReverted: false, }; const editor = { @@ -602,6 +605,7 @@ describe('keyboardDelete', () => { startContainer: document.createTextNode('test'), startOffset: 4, } as any) as Range, + isReverted: false, }; const editor = { diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts index fc28c19f77c..0c4d3460060 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts @@ -25,6 +25,12 @@ export interface RangeSnapshotSelection extends SnapshotSelectionBase<'range'> { * End path of selection */ end: number[]; + + /** + * Whether the selection was from left to right (in document order) or + * right to left (reverse of document order) + */ + isReverted: boolean; } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/selection/DOMSelection.ts b/packages-content-model/roosterjs-content-model-types/lib/selection/DOMSelection.ts index 13f7939fb46..85c830efb37 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/selection/DOMSelection.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/selection/DOMSelection.ts @@ -28,6 +28,12 @@ export interface RangeSelection extends SelectionBase<'range'> { * The DOM Range of this selection */ range: Range; + + /** + * Whether the selection was from left to right (in document order) or + * right to left (reverse of document order) + */ + isReverted: boolean; } /** From d073bf772be7c88b6682a45c5e6dd357a1d50f92 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 30 Jan 2024 17:36:55 -0800 Subject: [PATCH 047/112] Code cleanup: Remove NormalizeTablePlugin (#2376) --- .../lib/corePlugins/BridgePlugin.ts | 3 - .../lib/corePlugins/NormalizeTablePlugin.ts | 71 ------------------- .../test/corePlugins/BridgePluginTest.ts | 4 -- 3 files changed, 78 deletions(-) delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts index defb4abd069..7d38348dba5 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts @@ -1,6 +1,5 @@ import { createEditPlugin } from './EditPlugin'; import { createEntityDelimiterPlugin } from './EntityDelimiterPlugin'; -import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; import { newEventToOldEvent, oldEventToNewEvent } from '../editor/utils/eventConverter'; import { PluginEventType } from 'roosterjs-editor-types'; import type { ContentModelCorePluginState } from '../publicTypes/ContentModelCorePlugins'; @@ -29,14 +28,12 @@ export class BridgePlugin implements ContextMenuProvider { constructor(options: ContentModelEditorOptions) { const editPlugin = createEditPlugin(); - const normalizeTablePlugin = createNormalizeTablePlugin(); const entityDelimiterPlugin = createEntityDelimiterPlugin(); this.legacyPlugins = [ editPlugin, ...(options.legacyPlugins ?? []).filter(x => !!x), entityDelimiterPlugin, - normalizeTablePlugin, ]; this.corePluginState = { edit: editPlugin.getState(), diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts deleted file mode 100644 index 70ff8cddb05..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { changeElementTag, safeInstanceOf, toArray } from 'roosterjs-editor-dom'; -import { PluginEventType } from 'roosterjs-editor-types'; -import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; - -/** - * TODO: Rename this plugin since it is not only for table now - * - * NormalizeTable plugin makes sure each table in editor has TBODY/THEAD/TFOOT tag around TR tags - * - * When we retrieve HTML content using innerHTML, browser will always add TBODY around TR nodes if there is not. - * This causes some issue when we restore the HTML content with selection path since the selection path is - * deeply coupled with DOM structure. So we need to always make sure there is already TBODY tag whenever - * new table is inserted, to make sure the selection path we created is correct. - */ -class NormalizeTablePlugin implements EditorPlugin { - /** - * Get a friendly name of this plugin - */ - getName() { - return 'NormalizeTable'; - } - - /** - * The first method that editor will call to a plugin when editor is initializing. - * It will pass in the editor instance, plugin should take this chance to save the - * editor reference so that it can call to any editor method or format API later. - * @param editor The editor object - */ - initialize(editor: IEditor) {} - - /** - * The last method that editor will call to a plugin before it is disposed. - * Plugin can take this chance to clear the reference to editor. After this method is - * called, plugin should not call to any editor method since it will result in error. - */ - dispose() {} - - /** - * Core method for a plugin. Once an event happens in editor, editor will call this - * method of each plugin to handle the event as long as the event is not handled - * exclusively by another plugin. - * @param event The event to handle: - */ - onPluginEvent(event: PluginEvent) { - switch (event.eventType) { - case PluginEventType.ExtractContentWithDom: - normalizeListsForExport(event.clonedRoot); - break; - } - } -} - -function normalizeListsForExport(root: ParentNode) { - toArray(root.querySelectorAll('li')).forEach(li => { - const prevElement = li.previousSibling; - - if (li.style.display == 'block' && safeInstanceOf(prevElement, 'HTMLLIElement')) { - li.style.removeProperty('display'); - - prevElement.appendChild(changeElementTag(li, 'div')); - } - }); -} - -/** - * @internal - * Create a new instance of NormalizeTablePlugin. - */ -export function createNormalizeTablePlugin(): EditorPlugin { - return new NormalizeTablePlugin(); -} diff --git a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts index 15b61c74814..26484b98713 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts @@ -1,6 +1,5 @@ import * as EditPlugin from '../../lib/corePlugins/EditPlugin'; import * as eventConverter from '../../lib/editor/utils/eventConverter'; -import * as NormalizeTablePlugin from '../../lib/corePlugins/NormalizeTablePlugin'; import { BridgePlugin } from '../../lib/corePlugins/BridgePlugin'; import { PluginEventType } from 'roosterjs-editor-types'; @@ -14,9 +13,6 @@ describe('BridgePlugin', () => { } beforeEach(() => { spyOn(EditPlugin, 'createEditPlugin').and.returnValue(createMockedPlugin('edit')); - spyOn(NormalizeTablePlugin, 'createNormalizeTablePlugin').and.returnValue( - createMockedPlugin('normalizeTable') - ); }); it('Ctor and init', () => { From 016b357cebf50591e13a13bda8d73d63825965c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 31 Jan 2024 16:28:54 -0300 Subject: [PATCH 048/112] add normalize content model --- .../lib/autoFormat/keyboardListTrigger.ts | 2 ++ .../test/autoFormat/keyboardListTriggerTest.ts | 14 +++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts index 7e1951bcc8d..5a9c5d5243d 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -1,5 +1,6 @@ import { getListTypeStyle } from './utils/getListTypeStyle'; import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core'; +import { normalizeContentModel } from 'roosterjs-content-model-dom/lib'; import { setListStartNumber, setListStyle, setListType } from 'roosterjs-content-model-api'; import type { ContentModelDocument, IStandaloneEditor } from 'roosterjs-content-model-types'; @@ -26,6 +27,7 @@ export function keyboardListTrigger( const { listType, styleType, index } = listStyleType; triggerList(editor, model, listType, styleType, index); rawEvent.preventDefault(); + normalizeContentModel(model); return true; } return false; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts index 0822ac97084..abdbb18ccdc 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts @@ -1,7 +1,13 @@ +import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { keyboardListTrigger } from '../../lib/autoFormat/keyboardListTrigger'; describe('keyboardListTrigger', () => { + let normalizeContentModelSpy: jasmine.Spy; + beforeEach(() => { + normalizeContentModelSpy = spyOn(normalizeContentModel, 'normalizeContentModel'); + }); + function runTest( input: ContentModelDocument, expectedModel: ContentModelDocument, @@ -32,6 +38,12 @@ describe('keyboardListTrigger', () => { shouldSearchForNumbering ); + if (expectedResult) { + expect(normalizeContentModelSpy).toHaveBeenCalled(); + } else { + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); + } + expect(formatWithContentModelSpy).toHaveBeenCalled(); expect(input).toEqual(expectedModel); } @@ -187,7 +199,7 @@ describe('keyboardListTrigger', () => { segments: [ { segmentType: 'Text', - text: 'test', + text: ' test', format: {}, }, ], From 52313120cb193832256fa24152fc1933c65a7736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 31 Jan 2024 16:33:26 -0300 Subject: [PATCH 049/112] fix build --- .../lib/autoFormat/keyboardListTrigger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts index 5a9c5d5243d..27398185a5f 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -1,6 +1,6 @@ import { getListTypeStyle } from './utils/getListTypeStyle'; import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core'; -import { normalizeContentModel } from 'roosterjs-content-model-dom/lib'; +import { normalizeContentModel } from 'roosterjs-content-model-dom'; import { setListStartNumber, setListStyle, setListType } from 'roosterjs-content-model-api'; import type { ContentModelDocument, IStandaloneEditor } from 'roosterjs-content-model-types'; From 3d93112d0be2c567baae72d16ed32e97938625a5 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 31 Jan 2024 12:35:45 -0800 Subject: [PATCH 050/112] Code cleanup: Replace createContentModel with getContentModelCopy (#2372) * Code cleanup: Remove isContentModelEditor * add buttons * Code cleanup: Replace createContentModel with getContentModelCopy --- .../contentModel/ContentModelPanePlugin.ts | 4 +- .../lib/publicApi/format/getFormatState.ts | 106 +----- .../test/publicApi/block/setAlignmentTest.ts | 9 - .../publicApi/format/getFormatStateTest.ts | 338 +----------------- .../publicApi/link/adjustLinkSelectionTest.ts | 13 +- .../corePlugin/ContentModelCopyPastePlugin.ts | 28 +- .../lib/corePlugin/EntityPlugin.ts | 5 +- .../lib/editor/StandaloneEditor.ts | 60 +++- .../override/reducedModelChildProcessor.ts | 93 +++++ .../test/coreApi/pasteTest.ts | 20 +- .../ContentModelCopyPastePluginTest.ts | 66 +--- .../test/corePlugin/EntityPluginTest.ts | 30 +- .../test/editor/StandaloneEditorTest.ts | 114 +++++- .../reducedModelChildProcessorTest.ts | 322 +++++++++++++++++ .../test/editor/ContentModelEditorTest.ts | 10 +- .../paste/e2e/cmPasteFromExcelOnlineTest.ts | 13 +- .../test/paste/e2e/cmPasteFromExcelTest.ts | 15 +- .../test/paste/e2e/cmPasteFromWacTest.ts | 15 +- .../test/paste/e2e/cmPasteFromWordTest.ts | 22 +- .../test/paste/e2e/cmPasteTest.ts | 9 +- .../lib/editor/IStandaloneEditor.ts | 21 +- 21 files changed, 659 insertions(+), 654 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/override/reducedModelChildProcessor.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/overrides/reducedModelChildProcessorTest.ts diff --git a/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts b/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts index 9333e290c15..7fb8c1ad82b 100644 --- a/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts +++ b/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts @@ -37,7 +37,7 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< if (e.eventType == PluginEventType.ContentChanged && e.source == 'RefreshModel') { this.getComponent(component => { // TODO: Port to use IStandaloneEditor and remove type cast here - const model = (this.editor as IContentModelEditor).createContentModel(); + const model = (this.editor as IContentModelEditor).getContentModelCopy('connected'); component.setContentModel(model); setCurrentContentModel(model); }); @@ -72,7 +72,7 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< private onModelChange = () => { this.getComponent(component => { // TODO: Port to use IStandaloneEditor and remove type cast here - const model = (this.editor as IContentModelEditor).createContentModel(); + const model = (this.editor as IContentModelEditor).getContentModelCopy('connected'); component.setContentModel(model); setCurrentContentModel(model); }); diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts index 42aad59d7c1..4dd0d2f3d56 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts @@ -1,18 +1,5 @@ -import { getSelectionRootNode } from 'roosterjs-content-model-core'; import { retrieveModelFormatState } from '../../modelApi/common/retrieveModelFormatState'; -import type { - IStandaloneEditor, - ContentModelBlockGroup, - ContentModelFormatState, - DomToModelContext, -} from 'roosterjs-content-model-types'; - -import { - getRegularSelectionOffsets, - handleRegularSelection, - isNodeOfType, - processChildNode, -} from 'roosterjs-content-model-dom'; +import type { IStandaloneEditor, ContentModelFormatState } from 'roosterjs-content-model-types'; /** * Get current format state @@ -20,11 +7,7 @@ import { */ export default function getFormatState(editor: IStandaloneEditor): ContentModelFormatState { const pendingFormat = editor.getPendingFormat(); - const model = editor.createContentModel({ - processorOverride: { - child: reducedModelChildProcessor, - }, - }); + const model = editor.getContentModelCopy('reduced'); const manager = editor.getSnapshotsManager(); const result: ContentModelFormatState = { canUndo: manager.hasNewContent || manager.canMove(-1), @@ -37,88 +20,3 @@ export default function getFormatState(editor: IStandaloneEditor): ContentModelF return result; } - -/** - * @internal - */ -interface FormatStateContext extends DomToModelContext { - /** - * An optional stack of parent elements to process. When provided, the child nodes of current parent element will be ignored, - * but use the top element in this stack instead in childProcessor. - */ - nodeStack?: Node[]; -} - -/** - * @internal - * Export for test only - * In order to get format, we can still use the regular child processor. However, to improve performance, we don't need to create - * content model for the whole doc, instead we only need to traverse the tree path that can arrive current selected node. - * This "reduced" child processor will first create a node stack that stores DOM node from root to current common ancestor node of selection, - * then use this stack as a faked DOM tree to create a reduced content model which we can use to retrieve format state - */ -export function reducedModelChildProcessor( - group: ContentModelBlockGroup, - parent: ParentNode, - context: FormatStateContext -) { - if (!context.nodeStack) { - const selectionRootNode = getSelectionRootNode(context.selection); - context.nodeStack = selectionRootNode ? createNodeStack(parent, selectionRootNode) : []; - } - - const stackChild = context.nodeStack.pop(); - - if (stackChild) { - const [nodeStartOffset, nodeEndOffset] = getRegularSelectionOffsets(context, parent); - - // If selection is not on this node, skip getting node index to save some time since we don't need it here - const index = - nodeStartOffset >= 0 || nodeEndOffset >= 0 ? getChildIndex(parent, stackChild) : -1; - - if (index >= 0) { - handleRegularSelection(index, context, group, nodeStartOffset, nodeEndOffset); - } - - processChildNode(group, stackChild, context); - - if (index >= 0) { - handleRegularSelection(index + 1, context, group, nodeStartOffset, nodeEndOffset); - } - } else { - // No child node from node stack, that means we have reached the deepest node of selection. - // Now we can use default child processor to perform full sub tree scanning for content model, - // So that all selected node will be included. - context.defaultElementProcessors.child(group, parent, context); - } -} - -function createNodeStack(root: Node, startNode: Node): Node[] { - const result: Node[] = []; - let node: Node | null = startNode; - - while (node && root != node && root.contains(node)) { - if (isNodeOfType(node, 'ELEMENT_NODE') && node.tagName == 'TABLE') { - // For table, we can't do a reduced model creation since we need to handle their cells and indexes, - // so clean up whatever we already have, and just put table into the stack - result.splice(0, result.length, node); - } else { - result.push(node); - } - - node = node.parentNode; - } - - return result; -} - -function getChildIndex(parent: ParentNode, stackChild: Node) { - let index = 0; - let child = parent.firstChild; - - while (child && child != stackChild) { - index++; - child = child.nextSibling; - } - return index; -} 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 e941f0baf3b..bb8a056b683 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 @@ -416,12 +416,10 @@ describe('setAlignment', () => { describe('setAlignment in table', () => { let editor: IStandaloneEditor; - let createContentModel: jasmine.Spy; let triggerEvent: jasmine.Spy; let getVisibleViewport: jasmine.Spy; beforeEach(() => { - createContentModel = jasmine.createSpy('createContentModel'); triggerEvent = jasmine.createSpy('triggerEvent'); getVisibleViewport = jasmine.createSpy('getVisibleViewport'); @@ -430,7 +428,6 @@ describe('setAlignment in table', () => { editor = ({ focus: () => {}, addUndoSnapshot: (callback: Function) => callback(), - createContentModel, isDarkMode: () => false, triggerEvent, getVisibleViewport, @@ -445,8 +442,6 @@ describe('setAlignment in table', () => { const model = createContentModelDocument(); model.blocks.push(table); - createContentModel.and.returnValue(model); - editor.formatContentModel = jasmine .createSpy('formatContentModel') .and.callFake( @@ -821,19 +816,16 @@ describe('setAlignment in table', () => { describe('setAlignment in list', () => { let editor: IStandaloneEditor; - let createContentModel: jasmine.Spy; let triggerEvent: jasmine.Spy; let getVisibleViewport: jasmine.Spy; beforeEach(() => { - createContentModel = jasmine.createSpy('createContentModel'); triggerEvent = jasmine.createSpy('triggerEvent'); getVisibleViewport = jasmine.createSpy('getVisibleViewport'); editor = ({ focus: () => {}, addUndoSnapshot: (callback: Function) => callback(), - createContentModel, isDarkMode: () => false, triggerEvent, getVisibleViewport, @@ -848,7 +840,6 @@ describe('setAlignment in list', () => { const model = createContentModelDocument(); model.blocks.push(list); - createContentModel.and.returnValue(model); let result: boolean | undefined; editor.formatContentModel = jasmine diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts index 65e891a88db..bd06d37a638 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts @@ -1,20 +1,14 @@ -import * as getSelectionRootNode from 'roosterjs-content-model-core/lib/publicApi/selection/getSelectionRootNode'; import * as retrieveModelFormatState from '../../../lib/modelApi/common/retrieveModelFormatState'; -import { ContentModelFormatState, DomToModelContext } from 'roosterjs-content-model-types'; +import getFormatState from '../../../lib/publicApi/format/getFormatState'; +import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import { ContentModelFormatState } from 'roosterjs-content-model-types'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; -import getFormatState, { - reducedModelChildProcessor, -} from '../../../lib/publicApi/format/getFormatState'; +import { reducedModelChildProcessor } from 'roosterjs-content-model-core/lib/override/reducedModelChildProcessor'; import { createContentModelDocument, createDomToModelContext, normalizeContentModel, } from 'roosterjs-content-model-dom'; -import { - ContentModelDocument, - ContentModelSegmentFormat, - DomToModelOption, -} from 'roosterjs-content-model-types'; const selectedNodeId = 'Selected'; @@ -37,7 +31,7 @@ describe('getFormatState', () => { isDarkMode: () => false, getZoomScale: () => 1, getPendingFormat: () => pendingFormat, - createContentModel: (options: DomToModelOption) => { + getContentModelCopy: () => { const model = createContentModelDocument(); const editorDiv = document.createElement('div'); @@ -45,7 +39,9 @@ describe('getFormatState', () => { const selectedNode = editorDiv.querySelector('#' + selectedNodeId); const context = createDomToModelContext(undefined, { - ...(options || {}), + processorOverride: { + child: reducedModelChildProcessor, + }, }); if (selectedNode) { @@ -179,321 +175,3 @@ describe('getFormatState', () => { ); }); }); - -describe('reducedModelChildProcessor', () => { - let context: DomToModelContext; - let getSelectionRootNodeSpy: jasmine.Spy; - - beforeEach(() => { - context = createDomToModelContext(undefined, { - processorOverride: { - child: reducedModelChildProcessor, - }, - }); - - getSelectionRootNodeSpy = spyOn( - getSelectionRootNode, - 'getSelectionRootNode' - ).and.callThrough(); - }); - - it('Empty DOM', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); - }); - - it('Single child node, with selected Node in context', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - const span = document.createElement('span'); - - div.appendChild(span); - span.textContent = 'test'; - context.selection = { - type: 'range', - range: { - commonAncestorContainer: span, - } as any, - isReverted: false, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'Text', - text: 'test', - format: {}, - }, - ], - }, - ], - }); - expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); - }); - - it('Multiple child nodes, with selected Node in context', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - const span1 = document.createElement('span'); - const span2 = document.createElement('span'); - const span3 = document.createElement('span'); - - div.appendChild(span1); - div.appendChild(span2); - div.appendChild(span3); - span1.textContent = 'test1'; - span2.textContent = 'test2'; - span3.textContent = 'test3'; - context.selection = { - type: 'range', - range: { - commonAncestorContainer: span2, - } as any, - isReverted: false, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'Text', - text: 'test2', - format: {}, - }, - ], - }, - ], - }); - expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); - }); - - it('Multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - const span1 = document.createElement('span'); - const span2 = document.createElement('span'); - const span3 = document.createElement('span'); - - div.appendChild(span1); - div.appendChild(span2); - div.appendChild(span3); - span1.textContent = 'test1'; - span2.innerHTML = '
                  line1
                  line2
                  '; - span3.textContent = 'test3'; - context.selection = { - type: 'range', - range: { - commonAncestorContainer: span2, - } as any, - isReverted: false, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'line1', - format: {}, - }, - ], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - isImplicit: true, - }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'line2', - format: {}, - }, - ], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - isImplicit: true, - }, - ], - }); - expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); - }); - - it('Multiple layer with multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { - const doc = createContentModelDocument(); - const div1 = document.createElement('div'); - const div2 = document.createElement('div'); - const div3 = document.createElement('div'); - const span1 = document.createElement('span'); - const span2 = document.createElement('span'); - const span3 = document.createElement('span'); - - div3.appendChild(span1); - div3.appendChild(span2); - div3.appendChild(span3); - div1.appendChild(div2); - div2.appendChild(div3); - span1.textContent = 'test1'; - span2.innerHTML = '
                  line1
                  line2
                  '; - span3.textContent = 'test3'; - - context.selection = { - type: 'range', - range: { - commonAncestorContainer: span2, - } as any, - isReverted: false, - }; - - reducedModelChildProcessor(doc, div1, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { blockType: 'Paragraph', segments: [], format: {} }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [{ segmentType: 'Text', text: 'line1', format: {} }], - format: {}, - }, - { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, - { - blockType: 'Paragraph', - segments: [{ segmentType: 'Text', text: 'line2', format: {} }], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - isImplicit: true, - }, - { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, - { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, - ], - }); - expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); - }); - - it('With table, need to do format for all table cells', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - div.innerHTML = - 'aa
                  test1test2
                  bb'; - context.selection = { - type: 'range', - range: { - commonAncestorContainer: div.querySelector('#selection') as HTMLElement, - } as any, - isReverted: false, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Table', - rows: [ - { - format: {}, - height: 0, - cells: [ - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test1', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test2', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], - }, - ], - format: {}, - widths: [], - dataset: {}, - }, - ], - }); - expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts index bd4fe07ee36..496055259fb 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts @@ -18,24 +18,19 @@ import { describe('adjustLinkSelection', () => { let editor: IStandaloneEditor; - let createContentModel: jasmine.Spy; let formatContentModel: jasmine.Spy; let formatResult: boolean | undefined; - let model: ContentModelDocument | undefined; + let mockedModel: ContentModelDocument; beforeEach(() => { - createContentModel = jasmine.createSpy('createContentModel'); - - model = undefined; + mockedModel = undefined; formatResult = undefined; formatContentModel = jasmine .createSpy('formatContentModel') .and.callFake( (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - model = createContentModel(); - - formatResult = callback(model, { + formatResult = callback(mockedModel, { newEntities: [], deletedEntities: [], newImages: [], @@ -54,7 +49,7 @@ describe('adjustLinkSelection', () => { expectedText: string, expectedUrl: string | null ) { - createContentModel.and.returnValue(model); + mockedModel = model; const [text, url] = adjustLinkSelection(editor); diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts index 3564b81f6e4..0535b2ec14c 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts @@ -1,13 +1,11 @@ import { addRangeToSelection } from './utils/addRangeToSelection'; import { ChangeSource } from '../constants/ChangeSource'; -import { cloneModel } from '../publicApi/model/cloneModel'; import { deleteEmptyList } from './utils/deleteEmptyList'; import { deleteSelection } from '../publicApi/selection/deleteSelection'; import { extractClipboardItems } from '../utils/extractClipboardItems'; import { getSelectedCells } from '../publicApi/table/getSelectedCells'; import { iterateSelections } from '../publicApi/selection/iterateSelections'; import { onCreateCopyEntityNode } from '../override/pasteCopyBlockEntityParser'; -import { transformColor } from '../publicApi/color/transformColor'; import { contentModelToDom, createModelToDomContext, @@ -110,12 +108,7 @@ class ContentModelCopyPastePlugin implements PluginWithState { @@ -241,25 +234,6 @@ class ContentModelCopyPastePlugin implements PluginWithState { - if (type == 'cache' || !this.editor) { - return undefined; - } - - const result = node.cloneNode(true /*deep*/) as HTMLElement; - const colorHandler = this.editor.getColorManager(); - - transformColor(result, true /*includeSelf*/, 'darkToLight', colorHandler); - - result.style.color = result.style.color || 'inherit'; - result.style.backgroundColor = result.style.backgroundColor || 'inherit'; - - return result; - }; } /** diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts index 62319fc47bc..94eaab50e87 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts @@ -175,7 +175,10 @@ class EntityPlugin implements PluginWithState { private getChangedEntities(editor: IStandaloneEditor): ChangedEntity[] { const result: ChangedEntity[] = []; - findAllEntities(editor.createContentModel(), result); + editor.formatContentModel(model => { + findAllEntities(model, result); + return false; + }); getObjectKeys(this.state.entityMap).forEach(id => { const entry = this.state.entityMap[id]; diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts index 74e4e2a9da0..4e05bee03b0 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts @@ -1,7 +1,10 @@ import { ChangeSource } from '../constants/ChangeSource'; -import { createEmptyModel } from 'roosterjs-content-model-dom'; +import { cloneModel } from '../publicApi/model/cloneModel'; +import { createEmptyModel, tableProcessor } from 'roosterjs-content-model-dom'; import { createStandaloneEditorCore } from './createStandaloneEditorCore'; +import { reducedModelChildProcessor } from '../override/reducedModelChildProcessor'; import { transformColor } from '../publicApi/color/transformColor'; +import type { CachedElementHandler } from '../publicApi/model/cloneModel'; import type { ClipboardData, ContentModelDocument, @@ -11,7 +14,6 @@ import type { DOMEventRecord, DOMHelper, DOMSelection, - DomToModelOption, EditorEnvironment, FormatWithContentModelOptions, IStandaloneEditor, @@ -84,15 +86,38 @@ export class StandaloneEditor implements IStandaloneEditor { /** * Create Content Model from DOM tree in this editor - * @param option The option to customize the behavior of DOM to Content Model conversion + * @param mode What kind of Content Model we want. Currently we support the following values: + * - connected: Returns a connect Content Model object. "Connected" means if there is any entity inside editor, the returned Content Model will + * contain the same wrapper element for entity. This option should only be used in some special cases. In most cases we should use "disconnected" + * to get a fully disconnected Content Model so that any change to the model will not impact editor content. + * - disconnected: Returns a disconnected clone of Content Model from editor which you can do any change on it and it won't impact the editor content. + * If there is any entity in editor, the returned object will contain cloned copy of entity wrapper element. + * If editor is in dark mode, the cloned entity will be converted back to light mode. + * - reduced: Returns a reduced Content Model that only contains the model of current selection. If there is already a up-to-date cached model, use it + * instead to improve performance. This is mostly used for retrieve current format state. */ - createContentModel( - option?: DomToModelOption, - selectionOverride?: DOMSelection - ): ContentModelDocument { + getContentModelCopy(mode: 'connected' | 'disconnected' | 'reduced'): ContentModelDocument { const core = this.getCore(); - return core.api.createContentModel(core, option, selectionOverride); + switch (mode) { + case 'connected': + return core.api.createContentModel(core, { + processorOverride: { + table: tableProcessor, // Use the original table processor to create Content Model with real table content but not just an entity + }, + }); + + case 'disconnected': + return cloneModel(core.api.createContentModel(core), { + includeCachedElement: this.cloneOptionCallback, + }); + case 'reduced': + return core.api.createContentModel(core, { + processorOverride: { + child: reducedModelChildProcessor, + }, + }); + } } /** @@ -396,4 +421,23 @@ export class StandaloneEditor implements IStandaloneEditor { } return this.core; } + + private cloneOptionCallback: CachedElementHandler = (node, type) => { + if (type == 'cache') { + return undefined; + } + + const result = node.cloneNode(true /*deep*/) as HTMLElement; + + if (this.isDarkMode()) { + const colorHandler = this.getColorManager(); + + transformColor(result, true /*includeSelf*/, 'darkToLight', colorHandler); + + result.style.color = result.style.color || 'inherit'; + result.style.backgroundColor = result.style.backgroundColor || 'inherit'; + } + + return result; + }; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/reducedModelChildProcessor.ts b/packages-content-model/roosterjs-content-model-core/lib/override/reducedModelChildProcessor.ts new file mode 100644 index 00000000000..f49536f187e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/override/reducedModelChildProcessor.ts @@ -0,0 +1,93 @@ +import { getSelectionRootNode } from '../publicApi/selection/getSelectionRootNode'; +import { + getRegularSelectionOffsets, + handleRegularSelection, + isNodeOfType, + processChildNode, +} from 'roosterjs-content-model-dom'; +import type { ContentModelBlockGroup, DomToModelContext } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +interface FormatStateContext extends DomToModelContext { + /** + * An optional stack of parent elements to process. When provided, the child nodes of current parent element will be ignored, + * but use the top element in this stack instead in childProcessor. + */ + nodeStack?: Node[]; +} + +/** + * @internal + * Export for test only + * In order to get format, we can still use the regular child processor. However, to improve performance, we don't need to create + * content model for the whole doc, instead we only need to traverse the tree path that can arrive current selected node. + * This "reduced" child processor will first create a node stack that stores DOM node from root to current common ancestor node of selection, + * then use this stack as a faked DOM tree to create a reduced content model which we can use to retrieve format state + */ +export function reducedModelChildProcessor( + group: ContentModelBlockGroup, + parent: ParentNode, + context: FormatStateContext +) { + if (!context.nodeStack) { + const selectionRootNode = getSelectionRootNode(context.selection); + context.nodeStack = selectionRootNode ? createNodeStack(parent, selectionRootNode) : []; + } + + const stackChild = context.nodeStack.pop(); + + if (stackChild) { + const [nodeStartOffset, nodeEndOffset] = getRegularSelectionOffsets(context, parent); + + // If selection is not on this node, skip getting node index to save some time since we don't need it here + const index = + nodeStartOffset >= 0 || nodeEndOffset >= 0 ? getChildIndex(parent, stackChild) : -1; + + if (index >= 0) { + handleRegularSelection(index, context, group, nodeStartOffset, nodeEndOffset); + } + + processChildNode(group, stackChild, context); + + if (index >= 0) { + handleRegularSelection(index + 1, context, group, nodeStartOffset, nodeEndOffset); + } + } else { + // No child node from node stack, that means we have reached the deepest node of selection. + // Now we can use default child processor to perform full sub tree scanning for content model, + // So that all selected node will be included. + context.defaultElementProcessors.child(group, parent, context); + } +} + +function createNodeStack(root: Node, startNode: Node): Node[] { + const result: Node[] = []; + let node: Node | null = startNode; + + while (node && root != node && root.contains(node)) { + if (isNodeOfType(node, 'ELEMENT_NODE') && node.tagName == 'TABLE') { + // For table, we can't do a reduced model creation since we need to handle their cells and indexes, + // so clean up whatever we already have, and just put table into the stack + result.splice(0, result.length, node); + } else { + result.push(node); + } + + node = node.parentNode; + } + + return result; +} + +function getChildIndex(parent: ParentNode, stackChild: Node) { + let index = 0; + let child = parent.firstChild; + + while (child && child != stackChild) { + index++; + child = child.nextSibling; + } + return index; +} diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts index be6038636af..491ace4082f 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts @@ -12,11 +12,9 @@ import * as WordDesktopFile from 'roosterjs-content-model-plugins/lib/paste/Word import { ContentModelPastePlugin } from 'roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin'; import { expectEqual, initEditor } from 'roosterjs-content-model-plugins/test/paste/e2e/testUtils'; import { StandaloneEditor } from '../../lib/editor/StandaloneEditor'; -import { tableProcessor } from 'roosterjs-content-model-dom'; import { ClipboardData, ContentModelDocument, - DomToModelOption, IStandaloneEditor, BeforePasteEvent, PluginEvent, @@ -313,11 +311,7 @@ describe('Paste with clipboardData', () => { editor.pasteFromClipboard(clipboardData); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expectEqual(model, { blockGroupType: 'Document', @@ -370,11 +364,7 @@ describe('Paste with clipboardData', () => { editor.pasteFromClipboard(clipboardData); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expectEqual(model, { blockGroupType: 'Document', @@ -414,11 +404,7 @@ describe('Paste with clipboardData', () => { editor.pasteFromClipboard(clipboardData); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expectEqual(model, { blockGroupType: 'Document', diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts index 6b99ac15cf3..8c9552d8519 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts @@ -1,11 +1,9 @@ import * as addRangeToSelection from '../../lib/corePlugin/utils/addRangeToSelection'; -import * as cloneModelFile from '../../lib/publicApi/model/cloneModel'; import * as contentModelToDomFile from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; import * as deleteSelectionsFile from '../../lib/publicApi/selection/deleteSelection'; import * as extractClipboardItemsFile from '../../lib/utils/extractClipboardItems'; import * as iterateSelectionsFile from '../../lib/publicApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import * as transformColor from '../../lib/publicApi/color/transformColor'; import { createModelToDomContext, createTable, createTableCell } from 'roosterjs-content-model-dom'; import { createRange } from 'roosterjs-content-model-dom/test/testUtils'; import { setEntityElementClasses } from 'roosterjs-content-model-dom/test/domUtils/entityUtilTest'; @@ -67,7 +65,7 @@ describe('ContentModelCopyPastePlugin |', () => { let selectionValue: DOMSelection; let getDOMSelectionSpy: jasmine.Spy; - let createContentModelSpy: jasmine.Spy; + let getContentModelCopySpy: jasmine.Spy; let triggerPluginEventSpy: jasmine.Spy; let focusSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; @@ -75,8 +73,6 @@ describe('ContentModelCopyPastePlugin |', () => { let isDisposed: jasmine.Spy; let pasteSpy: jasmine.Spy; - let cloneModelSpy: jasmine.Spy; - let transformColorSpy: jasmine.Spy; let getVisibleViewportSpy: jasmine.Spy; let formatResult: boolean | undefined; let modelResult: ContentModelDocument | undefined; @@ -90,9 +86,9 @@ describe('ContentModelCopyPastePlugin |', () => { getDOMSelectionSpy = jasmine .createSpy('getDOMSelection') .and.callFake(() => selectionValue); - createContentModelSpy = jasmine - .createSpy('createContentModelSpy') - .and.returnValue(modelValue); + getContentModelCopySpy = jasmine + .createSpy('getContentModelCopy') + .and.returnValue(pasteModelValue); triggerPluginEventSpy = jasmine.createSpy('triggerPluginEventSpy'); focusSpy = jasmine.createSpy('focusSpy'); setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); @@ -100,16 +96,12 @@ describe('ContentModelCopyPastePlugin |', () => { isDisposed = jasmine.createSpy('isDisposed'); getVisibleViewportSpy = jasmine.createSpy('getVisibleViewport'); - cloneModelSpy = spyOn(cloneModelFile, 'cloneModel').and.callFake( - (model: any) => pasteModelValue - ); - transformColorSpy = spyOn(transformColor, 'transformColor'); mockedDarkColorHandler = 'DARKCOLORHANDLER' as any; formatContentModelSpy = jasmine .createSpy('formatContentModel') .and.callFake( (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - modelResult = createContentModelSpy(); + modelResult = modelValue; formatResult = callback(modelResult!, { newEntities: [], deletedEntities: [], @@ -128,7 +120,7 @@ describe('ContentModelCopyPastePlugin |', () => { attachDomEvent: (eventMap: Record) => { domEvents = eventMap; }, - createContentModel: (options: any) => createContentModelSpy(options), + getContentModelCopy: (options: any) => getContentModelCopySpy(options), triggerEvent(eventType: any, data: any, broadcast: any) { triggerPluginEventSpy(eventType, data, broadcast); return data; @@ -173,7 +165,7 @@ describe('ContentModelCopyPastePlugin |', () => { range: { collapsed: true } as any, }; - createContentModelSpy.and.callThrough(); + getContentModelCopySpy.and.callThrough(); triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); setDOMSelectionSpy.and.callThrough(); @@ -181,7 +173,7 @@ describe('ContentModelCopyPastePlugin |', () => { domEvents.copy.beforeDispatch?.({}); expect(getDOMSelectionSpy).toHaveBeenCalled(); - expect(createContentModelSpy).not.toHaveBeenCalled(); + expect(getContentModelCopySpy).not.toHaveBeenCalled(); expect(triggerPluginEventSpy).not.toHaveBeenCalled(); expect(focusSpy).not.toHaveBeenCalled(); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); @@ -215,7 +207,7 @@ describe('ContentModelCopyPastePlugin |', () => { pasteModelValue, { ...createModelToDomContext(), onNodeCreated } ); - expect(createContentModelSpy).toHaveBeenCalled(); + expect(getContentModelCopySpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalled(); expect(focusSpy).toHaveBeenCalled(); @@ -262,7 +254,7 @@ describe('ContentModelCopyPastePlugin |', () => { pasteModelValue, { ...createModelToDomContext(), onNodeCreated } ); - expect(createContentModelSpy).toHaveBeenCalled(); + expect(getContentModelCopySpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalled(); expect(focusSpy).toHaveBeenCalled(); @@ -305,7 +297,7 @@ describe('ContentModelCopyPastePlugin |', () => { pasteModelValue, { ...createModelToDomContext(), onNodeCreated } ); - expect(createContentModelSpy).toHaveBeenCalled(); + expect(getContentModelCopySpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(focusSpy).toHaveBeenCalled(); expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); @@ -341,29 +333,6 @@ describe('ContentModelCopyPastePlugin |', () => { editor.isDarkMode = () => true; - cloneModelSpy.and.callFake((model, options) => { - expect(model).toEqual(modelValue); - expect(typeof options.includeCachedElement).toBe('function'); - - const cloneCache = options.includeCachedElement(wrapper, 'cache'); - const cloneEntity = options.includeCachedElement(wrapper, 'entity'); - - expect(cloneCache).toBeUndefined(); - expect(cloneEntity.outerHTML).toBe( - '' - ); - expect(cloneEntity).not.toBe(wrapper); - expect(transformColorSpy).toHaveBeenCalledTimes(1); - expect(transformColorSpy).toHaveBeenCalledWith( - cloneEntity, - true, - 'darkToLight', - mockedDarkColorHandler - ); - - return pasteModelValue; - }); - // Act domEvents.copy.beforeDispatch?.({}); @@ -376,11 +345,10 @@ describe('ContentModelCopyPastePlugin |', () => { pasteModelValue, { ...createModelToDomContext(), onNodeCreated } ); - expect(createContentModelSpy).toHaveBeenCalled(); + expect(getContentModelCopySpy).toHaveBeenCalledWith('disconnected'); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(focusSpy).toHaveBeenCalled(); expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); - expect(cloneModelSpy).toHaveBeenCalledTimes(1); // On Cut Spy expect(formatContentModelSpy).not.toHaveBeenCalled(); @@ -397,7 +365,7 @@ describe('ContentModelCopyPastePlugin |', () => { range: { collapsed: true } as any, }; - createContentModelSpy.and.callThrough(); + getContentModelCopySpy.and.callThrough(); triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); setDOMSelectionSpy.and.callThrough(); @@ -407,7 +375,7 @@ describe('ContentModelCopyPastePlugin |', () => { // Assert expect(getDOMSelectionSpy).toHaveBeenCalled(); - expect(createContentModelSpy).not.toHaveBeenCalled(); + expect(getContentModelCopySpy).not.toHaveBeenCalled(); expect(triggerPluginEventSpy).not.toHaveBeenCalled(); expect(focusSpy).not.toHaveBeenCalled(); expect(formatContentModelSpy).not.toHaveBeenCalled(); @@ -450,7 +418,7 @@ describe('ContentModelCopyPastePlugin |', () => { pasteModelValue, { ...createModelToDomContext(), onNodeCreated } ); - expect(createContentModelSpy).toHaveBeenCalled(); + expect(getContentModelCopySpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(focusSpy).toHaveBeenCalled(); expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); @@ -499,7 +467,7 @@ describe('ContentModelCopyPastePlugin |', () => { pasteModelValue, { ...createModelToDomContext(), onNodeCreated } ); - expect(createContentModelSpy).toHaveBeenCalled(); + expect(getContentModelCopySpy).toHaveBeenCalled(); expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalled(); expect(focusSpy).toHaveBeenCalled(); expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); @@ -547,7 +515,7 @@ describe('ContentModelCopyPastePlugin |', () => { pasteModelValue, { ...createModelToDomContext(), onNodeCreated } ); - expect(createContentModelSpy).toHaveBeenCalled(); + expect(getContentModelCopySpy).toHaveBeenCalled(); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(focusSpy).toHaveBeenCalled(); expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts index 60df830cfb9..88bba7a708e 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts @@ -3,6 +3,7 @@ import * as transformColor from '../../lib/publicApi/color/transformColor'; import { createContentModelDocument, createEntity } from '../../../roosterjs-content-model-dom/lib'; import { createEntityPlugin } from '../../lib/corePlugin/EntityPlugin'; import { + ContentModelDocument, DarkColorHandler, EntityPluginState, IStandaloneEditor, @@ -12,15 +13,20 @@ import { describe('EntityPlugin', () => { let editor: IStandaloneEditor; let plugin: PluginWithState; - let createContentModelSpy: jasmine.Spy; + let formatContentModelSpy: jasmine.Spy; let triggerPluginEventSpy: jasmine.Spy; let isDarkModeSpy: jasmine.Spy; let isNodeInEditorSpy: jasmine.Spy; let transformColorSpy: jasmine.Spy; let mockedDarkColorHandler: DarkColorHandler; + let mockedModel: ContentModelDocument; beforeEach(() => { - createContentModelSpy = jasmine.createSpy('createContentModel'); + formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: Function) => { + callback(mockedModel); + }); triggerPluginEventSpy = jasmine.createSpy('triggerEvent'); isDarkModeSpy = jasmine.createSpy('isDarkMode'); isNodeInEditorSpy = jasmine.createSpy('isNodeInEditor'); @@ -28,7 +34,7 @@ describe('EntityPlugin', () => { mockedDarkColorHandler = 'COLOR' as any; editor = { - createContentModel: createContentModelSpy, + formatContentModel: formatContentModelSpy, triggerEvent: triggerPluginEventSpy, isDarkMode: isDarkModeSpy, isNodeInEditor: isNodeInEditorSpy, @@ -48,7 +54,7 @@ describe('EntityPlugin', () => { describe('EditorReady event', () => { it('empty doc', () => { - createContentModelSpy.and.returnValue(createContentModelDocument()); + mockedModel = createContentModelDocument(); plugin.onPluginEvent({ eventType: 'editorReady', @@ -68,7 +74,7 @@ describe('EntityPlugin', () => { doc.blocks.push(entity); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; plugin.onPluginEvent({ eventType: 'editorReady', @@ -108,7 +114,7 @@ describe('EntityPlugin', () => { doc.blocks.push(entity); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; triggerPluginEventSpy.and.returnValue({ shouldPersist: true, }); @@ -153,7 +159,7 @@ describe('EntityPlugin', () => { doc.blocks.push(entity); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; plugin.onPluginEvent({ eventType: 'contentChanged', @@ -193,7 +199,7 @@ describe('EntityPlugin', () => { doc.blocks.push(entity); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; isDarkModeSpy.and.returnValue(true); plugin.onPluginEvent({ @@ -240,7 +246,7 @@ describe('EntityPlugin', () => { doc.blocks.push(entity); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; const state = plugin.getState(); const wrapper2 = document.createElement('div'); @@ -298,7 +304,7 @@ describe('EntityPlugin', () => { it('Do not trigger event for already deleted entity', () => { const doc = createContentModelDocument(); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; const state = plugin.getState(); const wrapper2 = document.createElement('div'); @@ -332,7 +338,7 @@ describe('EntityPlugin', () => { doc.blocks.push(entity); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; const state = plugin.getState(); state.entityMap['Entity1'] = { @@ -519,7 +525,7 @@ describe('EntityPlugin', () => { isReadonly: true, }); doc.blocks.push(entity); - createContentModelSpy.and.returnValue(doc); + mockedModel = doc; state.entityMap[id] = { element: wrapper, diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts index 4fd746f92bc..2ebe56dc837 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts @@ -1,8 +1,11 @@ +import * as cloneModel from '../../lib/publicApi/model/cloneModel'; import * as createEmptyModel from 'roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel'; import * as createStandaloneEditorCore from '../../lib/editor/createStandaloneEditorCore'; import * as transformColor from '../../lib/publicApi/color/transformColor'; import { ChangeSource } from '../../lib/constants/ChangeSource'; +import { reducedModelChildProcessor } from '../../lib/override/reducedModelChildProcessor'; import { StandaloneEditor } from '../../lib/editor/StandaloneEditor'; +import { tableProcessor } from 'roosterjs-content-model-dom'; describe('StandaloneEditor', () => { let createEditorCoreSpy: jasmine.Spy; @@ -106,7 +109,7 @@ describe('StandaloneEditor', () => { expect(disposeErrorHandlerSpy).toHaveBeenCalledWith(mockedPlugin2, new Error('test')); }); - it('createContentModel', () => { + it('getContentModelCopy', () => { const div = document.createElement('div'); const mockedModel = 'MODEL' as any; const createContentModelSpy = jasmine @@ -129,25 +132,112 @@ describe('StandaloneEditor', () => { const editor = new StandaloneEditor(div); - const model1 = editor.createContentModel(); + const model1 = editor.getContentModelCopy('connected'); expect(model1).toBe(mockedModel); - expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, undefined, undefined); - - const mockedOptions = 'OPTIONS' as any; - const selectionOverride = 'SELECTION' as any; + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { + processorOverride: { + table: tableProcessor, // Use the original table processor to create Content Model with real table content but not just an entity + }, + }); - const model2 = editor.createContentModel(mockedOptions, selectionOverride); + const model2 = editor.getContentModelCopy('reduced'); expect(model2).toBe(mockedModel); - expect(createContentModelSpy).toHaveBeenCalledWith( - mockedCore, - mockedOptions, - selectionOverride + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { + processorOverride: { + child: reducedModelChildProcessor, + }, + }); + + editor.dispose(); + expect(() => editor.getContentModelCopy('connected')).toThrow(); + expect(() => editor.getContentModelCopy('reduced')).toThrow(); + expect(resetSpy).toHaveBeenCalledWith(); + }); + + it('getContentModelCopy to return disconnected model', () => { + const div = document.createElement('div'); + const mockedModel = 'MODEL' as any; + const mockedClonedModel = 'MODEL2' as any; + const createContentModelSpy = jasmine + .createSpy('createContentModel') + .and.returnValue(mockedModel); + const resetSpy = jasmine.createSpy('reset'); + const mockedCore = { + plugins: [], + darkColorHandler: { + updateKnownColor: updateKnownColorSpy, + reset: resetSpy, + }, + lifecycle: { + isDarkMode: false, + }, + api: { + createContentModel: createContentModelSpy, + setContentModel: setContentModelSpy, + }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + const cloneModelSpy = spyOn(cloneModel, 'cloneModel').and.returnValue(mockedClonedModel); + + const model = editor.getContentModelCopy('disconnected'); + + expect(cloneModelSpy).toHaveBeenCalledWith(mockedModel, { + includeCachedElement: jasmine.anything() as any, + }); + + const transformColorSpy = spyOn(transformColor, 'transformColor'); + const onClone = cloneModelSpy.calls.argsFor(0)[1]! + .includeCachedElement as cloneModel.CachedElementHandler; + + const clonedNode = { + style: { + backgroundColor: 'red', + }, + } as any; + const cloneNodeSpy = jasmine.createSpy('cloneNode').and.returnValue(clonedNode); + const mockedNode = { + cloneNode: cloneNodeSpy, + } as any; + + expect(onClone(mockedNode, 'cache')).toBeUndefined(); + expect(cloneNodeSpy).not.toHaveBeenCalled(); + + // clone entity in light mode + expect(onClone(mockedNode, 'entity')).toBe(clonedNode); + expect(cloneNodeSpy).toHaveBeenCalledWith(true); + + expect(model).toBe(mockedClonedModel); + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore); + expect(transformColorSpy).not.toHaveBeenCalled(); + + // Clone in dark mode + mockedCore.lifecycle.isDarkMode = true; + expect(onClone(mockedNode, 'entity')).toBe(clonedNode); + expect(cloneNodeSpy).toHaveBeenCalledWith(true); + + expect(model).toBe(mockedClonedModel); + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore); + expect(transformColorSpy).toHaveBeenCalledWith( + clonedNode, + true, + 'darkToLight', + mockedCore.darkColorHandler ); + expect(clonedNode).toEqual({ + style: { + color: 'inherit', + backgroundColor: 'red', + }, + }); editor.dispose(); - expect(() => editor.createContentModel()).toThrow(); + expect(() => editor.getContentModelCopy('disconnected')).toThrow(); expect(resetSpy).toHaveBeenCalledWith(); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/reducedModelChildProcessorTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/reducedModelChildProcessorTest.ts new file mode 100644 index 00000000000..bc4f540912e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/reducedModelChildProcessorTest.ts @@ -0,0 +1,322 @@ +import * as getSelectionRootNode from '../../lib/publicApi/selection/getSelectionRootNode'; +import { createContentModelDocument, createDomToModelContext } from 'roosterjs-content-model-dom'; +import { DomToModelContext } from 'roosterjs-content-model-types'; +import { reducedModelChildProcessor } from '../../lib/override/reducedModelChildProcessor'; + +describe('reducedModelChildProcessor', () => { + let context: DomToModelContext; + let getSelectionRootNodeSpy: jasmine.Spy; + + beforeEach(() => { + context = createDomToModelContext(undefined, { + processorOverride: { + child: reducedModelChildProcessor, + }, + }); + + getSelectionRootNodeSpy = spyOn( + getSelectionRootNode, + 'getSelectionRootNode' + ).and.callThrough(); + }); + + it('Empty DOM', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [], + }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); + }); + + it('Single child node, with selected Node in context', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + const span = document.createElement('span'); + + div.appendChild(span); + span.textContent = 'test'; + context.selection = { + type: 'range', + range: { + commonAncestorContainer: span, + } as any, + isReverted: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + }, + ], + }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); + }); + + it('Multiple child nodes, with selected Node in context', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + const span3 = document.createElement('span'); + + div.appendChild(span1); + div.appendChild(span2); + div.appendChild(span3); + span1.textContent = 'test1'; + span2.textContent = 'test2'; + span3.textContent = 'test3'; + context.selection = { + type: 'range', + range: { + commonAncestorContainer: span2, + } as any, + isReverted: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + }, + ], + }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); + }); + + it('Multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + const span3 = document.createElement('span'); + + div.appendChild(span1); + div.appendChild(span2); + div.appendChild(span3); + span1.textContent = 'test1'; + span2.innerHTML = '
                  line1
                  line2
                  '; + span3.textContent = 'test3'; + context.selection = { + type: 'range', + range: { + commonAncestorContainer: span2, + } as any, + isReverted: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'line1', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'line2', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }, + ], + }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); + }); + + it('Multiple layer with multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { + const doc = createContentModelDocument(); + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + const div3 = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + const span3 = document.createElement('span'); + + div3.appendChild(span1); + div3.appendChild(span2); + div3.appendChild(span3); + div1.appendChild(div2); + div2.appendChild(div3); + span1.textContent = 'test1'; + span2.innerHTML = '
                  line1
                  line2
                  '; + span3.textContent = 'test3'; + + context.selection = { + type: 'range', + range: { + commonAncestorContainer: span2, + } as any, + isReverted: false, + }; + + reducedModelChildProcessor(doc, div1, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { blockType: 'Paragraph', segments: [], format: {} }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'line1', format: {} }], + format: {}, + }, + { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'line2', format: {} }], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }, + { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, + { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, + ], + }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); + }); + + it('With table, need to do format for all table cells', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + div.innerHTML = + 'aa
                  test1test2
                  bb'; + context.selection = { + type: 'range', + range: { + commonAncestorContainer: div.querySelector('#selection') as HTMLElement, + } as any, + isReverted: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts index 8ef04ef12cc..55b31bf529c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -36,7 +36,7 @@ describe('ContentModelEditor', () => { }, }); - const model = editor.createContentModel(); + const model = editor.getContentModelCopy('connected'); expect(model).toBe(mockedResult); expect(domToContentModel.domToContentModel).toHaveBeenCalledTimes(1); @@ -73,7 +73,7 @@ describe('ContentModelEditor', () => { }, }); - const model = editor.createContentModel(); + const model = editor.getContentModelCopy('connected'); expect(model).toBe(mockedResult); expect(domToContentModel.domToContentModel).toHaveBeenCalledTimes(1); @@ -103,7 +103,7 @@ describe('ContentModelEditor', () => { }, onPluginEvent: event => { if (event.eventType == PluginEventType.EditorReady) { - model = pluginEditor.createContentModel(); + model = pluginEditor.getContentModelCopy('connected'); } }, }; @@ -144,7 +144,7 @@ describe('ContentModelEditor', () => { spyOn(domToContentModel, 'domToContentModel'); - const model = editor.createContentModel(); + const model = editor.getContentModelCopy('connected'); expect(model).toBe(cachedModel); expect(domToContentModel.domToContentModel).not.toHaveBeenCalled(); @@ -177,7 +177,7 @@ describe('ContentModelEditor', () => { }, }); - const model = editor.createContentModel(); + const model = editor.getContentModelCopy('connected'); expect(model.format).toEqual({ fontWeight: 'bold', diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts index 716e3f26acb..9bb91be927e 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts @@ -1,6 +1,5 @@ import * as processPastedContentFromExcel from '../../../lib/paste/Excel/processPastedContentFromExcel'; import { expectEqual, initEditor } from './testUtils'; -import { tableProcessor } from 'roosterjs-content-model-dom'; import type { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; const ID = 'CM_Paste_From_ExcelOnline_E2E'; @@ -33,11 +32,7 @@ describe(ID, () => { spyOn(processPastedContentFromExcel, 'processPastedContentFromExcel').and.callThrough(); editor.pasteFromClipboard(clipboardData); - editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + editor.getContentModelCopy('connected'); expect(processPastedContentFromExcel.processPastedContentFromExcel).toHaveBeenCalled(); }); @@ -58,11 +53,7 @@ describe(ID, () => { editor.pasteFromClipboard(CD); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expectEqual(model, { blockGroupType: 'Document', diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts index 12232a4b4a8..31c4ca31710 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts @@ -1,7 +1,6 @@ import * as processPastedContentFromExcel from '../../../lib/paste/Excel/processPastedContentFromExcel'; import { expectEqual, initEditor } from './testUtils'; import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; -import { tableProcessor } from 'roosterjs-content-model-dom'; import type { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; const ID = 'CM_Paste_From_Excel_E2E'; @@ -34,7 +33,7 @@ describe(ID, () => { spyOn(processPastedContentFromExcel, 'processPastedContentFromExcel').and.callThrough(); editor.pasteFromClipboard(clipboardData); - editor.createContentModel({}); + editor.getContentModelCopy('connected'); expect(processPastedContentFromExcel.processPastedContentFromExcel).toHaveBeenCalled(); }); @@ -44,11 +43,7 @@ describe(ID, () => { editor.pasteFromClipboard(clipboardData, 'asImage'); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expect(model).toEqual({ blockGroupType: 'Document', @@ -107,11 +102,7 @@ describe(ID, () => { editor.pasteFromClipboard(CD); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expectEqual(model, { blockGroupType: 'Document', diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts index 7b0e240a179..19986e9eb1a 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts @@ -1,7 +1,6 @@ import * as processPastedContentWacComponents from '../../../lib/paste/WacComponents/processPastedContentWacComponents'; -import { ClipboardData, DomToModelOption, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; -import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_From_WORD_Online_E2E'; @@ -38,11 +37,7 @@ describe(ID, () => { ).and.callThrough(); editor.pasteFromClipboard(clipboardData); - editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + editor.getContentModelCopy('connected'); expect( processPastedContentWacComponents.processPastedContentWacComponents @@ -55,11 +50,7 @@ describe(ID, () => { editor.pasteFromClipboard(clipboardData); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expectEqual(model, { blockGroupType: 'Document', diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts index 4f2e16faee7..00865771548 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts @@ -1,9 +1,8 @@ import * as wordFile from '../../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; -import { ClipboardData, DomToModelOption, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; import { cloneModel } from 'roosterjs-content-model-core'; import { expectEqual, initEditor } from './testUtils'; import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; -import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_From_WORD_E2E'; const clipboardData = ({ @@ -36,16 +35,9 @@ describe(ID, () => { '\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n

                  Test

                  \r\n\r\n

                  asdsad

                  \r\n\r\n\r\n\r\n'; editor.pasteFromClipboard(clipboardData); - const model = cloneModel( - editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }), - { - includeCachedElement: false, - } - ); + const model = cloneModel(editor.getContentModelCopy('connected'), { + includeCachedElement: false, + }); expect(wordFile.processPastedContentFromWordDesktop).toHaveBeenCalled(); expect(model).toEqual({ @@ -116,11 +108,7 @@ describe(ID, () => { editor.pasteFromClipboard(clipboardData); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expect(wordFile.processPastedContentFromWordDesktop).toHaveBeenCalled(); expectEqual(model, { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts index 545127f0a84..6fb885a9889 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts @@ -1,8 +1,7 @@ import * as wordFile from '../../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; -import { ClipboardData, DomToModelOption, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { ClipboardData, IStandaloneEditor } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; import { itChromeOnly } from 'roosterjs-content-model-dom/test/testUtils'; -import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_E2E'; @@ -36,11 +35,7 @@ describe(ID, () => { editor.pasteFromClipboard(clipboardData); - const model = editor.createContentModel({ - processorOverride: { - table: tableProcessor, - }, - }); + const model = editor.getContentModelCopy('connected'); expectEqual(model, { blockGroupType: 'Document', diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts index c7f3b44315b..410d2abe58c 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts @@ -9,7 +9,6 @@ import type { Snapshot } from '../parameter/Snapshot'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; import type { DOMSelection } from '../selection/DOMSelection'; -import type { DomToModelOption } from '../context/DomToModelOption'; import type { EditorEnvironment } from '../parameter/EditorEnvironment'; import type { ContentModelFormatter, @@ -25,15 +24,17 @@ import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; export interface IStandaloneEditor { /** * Create Content Model from DOM tree in this editor - * @param rootNode Optional start node. If provided, Content Model will be created from this node (including itself), - * otherwise it will create Content Model for the whole content in editor. - * @param option The options to customize the behavior of DOM to Content Model conversion - * @param selectionOverride When specified, use this selection to override existing selection inside editor - */ - createContentModel( - option?: DomToModelOption, - selectionOverride?: DOMSelection - ): ContentModelDocument; + * @param mode What kind of Content Model we want. Currently we support the following values: + * - connected: Returns a connect Content Model object. "Connected" means if there is any entity inside editor, the returned Content Model will + * contain the same wrapper element for entity. This option should only be used in some special cases. In most cases we should use "disconnected" + * to get a fully disconnected Content Model so that any change to the model will not impact editor content. + * - disconnected: Returns a disconnected clone of Content Model from editor which you can do any change on it and it won't impact the editor content. + * If there is any entity in editor, the returned object will contain cloned copy of entity wrapper element. + * If editor is in dark mode, the cloned entity will be converted back to light mode. + * - reduced: Returns a reduced Content Model that only contains the model of current selection. If there is already a up-to-date cached model, use it + * instead to improve performance. This is mostly used for retrieve current format state. + */ + getContentModelCopy(mode: 'connected' | 'disconnected' | 'reduced'): ContentModelDocument; /** * Get current running environment, such as if editor is running on Mac From 64104f49f432c7a2a3ce8474b068200fec548379 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 31 Jan 2024 14:02:07 -0800 Subject: [PATCH 051/112] Code cleanup: Remove get/setZoomScale (#2378) * Code cleanup: Remove get/setZoomScale * Add calculateZoomScale function * improve --- .../controls/ContentModelEditorMainPane.tsx | 1 - .../controls/StandaloneEditorMainPane.tsx | 1 - .../editor/ContentModelRooster.tsx | 8 +- .../ribbonButtons/contentModel/zoom.ts | 4 +- .../lib/publicApi/format/getFormatState.ts | 1 - .../publicApi/format/getFormatStateTest.ts | 9 +- .../lib/coreApi/createEditorContext.ts | 13 +-- .../lib/editor/DOMHelperImpl.ts | 9 ++ .../lib/editor/StandaloneEditor.ts | 36 -------- .../lib/editor/createStandaloneEditorCore.ts | 1 - .../test/coreApi/createEditorContextTest.ts | 85 ++++++------------- .../test/editor/DOMHelperImplTest.ts | 28 ++++++ .../editor/createStandaloneEditorCoreTest.ts | 43 ++-------- .../lib/editor/ContentModelEditor.ts | 36 +++++++- .../lib/editor/utils/eventConverter.ts | 4 +- .../test/editor/utils/eventConverterTest.ts | 26 +++++- .../lib/editor/IStandaloneEditor.ts | 16 +--- .../lib/editor/StandaloneEditorCore.ts | 8 -- .../lib/editor/StandaloneEditorOptions.ts | 8 -- .../lib/event/ZoomChangedEvent.ts | 5 -- .../lib/parameter/ContentModelFormatState.ts | 5 -- .../lib/parameter/DOMHelper.ts | 5 ++ 22 files changed, 147 insertions(+), 205 deletions(-) diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 7a8fa60a0ce..00bbcdf204a 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -396,7 +396,6 @@ class ContentModelEditorMainPane extends MainPaneBase experimentalFeatures={this.state.initState.experimentalFeatures} snapshots={this.snapshotPlugin.getSnapshots()} trustedHTMLHandler={trustedHTMLHandler} - zoomScale={this.state.scale} initialContent={this.content} editorCreator={this.state.editorCreator} dir={this.state.isRtl ? 'rtl' : 'ltr'} diff --git a/demo/scripts/controls/StandaloneEditorMainPane.tsx b/demo/scripts/controls/StandaloneEditorMainPane.tsx index 5f5f435c141..8cae61e11d4 100644 --- a/demo/scripts/controls/StandaloneEditorMainPane.tsx +++ b/demo/scripts/controls/StandaloneEditorMainPane.tsx @@ -357,7 +357,6 @@ class ContentModelEditorMainPane extends MainPaneBase experimentalFeatures={this.state.initState.experimentalFeatures} snapshots={this.snapshotPlugin.getSnapshots()} trustedHTMLHandler={trustedHTMLHandler} - zoomScale={this.state.scale} initialContent={this.content} editorCreator={this.state.editorCreator} dir={this.state.isRtl ? 'rtl' : 'ltr'} diff --git a/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx b/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx index d31481a5692..717ec81bcfa 100644 --- a/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx +++ b/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx @@ -39,7 +39,7 @@ export default function ContentModelRooster(props: ContentModelRoosterProps) { const editor = React.useRef(null); const theme = useTheme(); - const { focusOnInit, editorCreator, zoomScale, inDarkMode, plugins, legacyPlugins } = props; + const { focusOnInit, editorCreator, inDarkMode, plugins, legacyPlugins } = props; React.useEffect(() => { if (editorDiv.current) { @@ -71,12 +71,6 @@ export default function ContentModelRooster(props: ContentModelRoosterProps) { editor.current?.setDarkModeState(!!inDarkMode); }, [inDarkMode]); - React.useEffect(() => { - if (zoomScale) { - editor.current?.setZoomScale(zoomScale); - } - }, [zoomScale]); - const divProps = getNativeProps>(props, divProperties); return
                  ; } diff --git a/demo/scripts/controls/ribbonButtons/contentModel/zoom.ts b/demo/scripts/controls/ribbonButtons/contentModel/zoom.ts index a4ee19075a7..b102d9c1c31 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/zoom.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/zoom.ts @@ -39,11 +39,13 @@ export const zoom: ContentModelRibbonButton = { }, onClick: (editor, key) => { const zoomScale = DropDownValues[key as keyof typeof DropDownItems]; - editor.setZoomScale(zoomScale); editor.focus(); // Let main pane know this state change so that it can be persisted when pop out/pop in MainPaneBase.getInstance().setScale(zoomScale); + + editor.triggerEvent('zoomChanged', { newZoomScale: zoomScale }); + return true; }, }; diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts index 4dd0d2f3d56..d735115e945 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts @@ -13,7 +13,6 @@ export default function getFormatState(editor: IStandaloneEditor): ContentModelF canUndo: manager.hasNewContent || manager.canMove(-1), canRedo: manager.canMove(1), isDarkMode: editor.isDarkMode(), - zoomScale: editor.getZoomScale(), }; retrieveModelFormatState(model, pendingFormat, result); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts index bd06d37a638..cd065e35e3b 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts @@ -72,7 +72,6 @@ describe('getFormatState', () => { canUndo: false, canRedo: false, isDarkMode: false, - zoomScale: 1, } ); expect(result).toEqual(expectedFormat); @@ -86,7 +85,7 @@ describe('getFormatState', () => { blockGroupType: 'Document', blocks: [], }, - { canUndo: false, canRedo: false, isDarkMode: false, zoomScale: 1 } + { canUndo: false, canRedo: false, isDarkMode: false } ); }); @@ -111,7 +110,7 @@ describe('getFormatState', () => { }, ], }, - { canUndo: false, canRedo: false, isDarkMode: false, zoomScale: 1 } + { canUndo: false, canRedo: false, isDarkMode: false } ); }); @@ -136,7 +135,7 @@ describe('getFormatState', () => { }, ], }, - { canUndo: false, canRedo: false, isDarkMode: false, zoomScale: 1 } + { canUndo: false, canRedo: false, isDarkMode: false } ); }); @@ -171,7 +170,7 @@ describe('getFormatState', () => { }, ], }, - { canUndo: false, canRedo: false, isDarkMode: false, zoomScale: 1 } + { canUndo: false, canRedo: false, isDarkMode: false } ); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts index 020eb6c71dd..2073a4403f5 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts @@ -5,7 +5,7 @@ import type { EditorContext, CreateEditorContext } from 'roosterjs-content-model * Create a EditorContext object used by ContentModel API */ export const createEditorContext: CreateEditorContext = (core, saveIndex) => { - const { lifecycle, format, darkColorHandler, contentDiv, cache } = core; + const { lifecycle, format, darkColorHandler, contentDiv, cache, domHelper } = core; const context: EditorContext = { isDarkMode: lifecycle.isDarkMode, @@ -15,23 +15,14 @@ export const createEditorContext: CreateEditorContext = (core, saveIndex) => { addDelimiterForEntity: true, allowCacheElement: true, domIndexer: saveIndex ? cache.domIndexer : undefined, + zoomScale: domHelper.calculateZoomScale(), }; checkRootRtl(contentDiv, context); - checkZoomScale(contentDiv, context); return context; }; -function checkZoomScale(element: HTMLElement, context: EditorContext) { - const originalWidth = element?.getBoundingClientRect()?.width || 0; - const visualWidth = element.offsetWidth; - - if (visualWidth > 0 && originalWidth > 0) { - context.zoomScale = Math.round((originalWidth / visualWidth) * 100) / 100; - } -} - function checkRootRtl(element: HTMLElement, context: EditorContext) { const style = element?.ownerDocument.defaultView?.getComputedStyle(element); diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts index 4f68d601711..eee15b111c8 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts @@ -7,6 +7,15 @@ class DOMHelperImpl implements DOMHelper { queryElements(selector: string): HTMLElement[] { return toArray(this.contentDiv.querySelectorAll(selector)) as HTMLElement[]; } + + calculateZoomScale(): number { + const originalWidth = this.contentDiv.getBoundingClientRect()?.width || 0; + const visualWidth = this.contentDiv.offsetWidth; + + return visualWidth > 0 && originalWidth > 0 + ? Math.round((originalWidth / visualWidth) * 100) / 100 + : 1; + } } /** diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts index 4e05bee03b0..a65c308dcb0 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts @@ -365,42 +365,6 @@ export class StandaloneEditor implements IStandaloneEditor { return core.contentDiv.contains(node); } - /** - * Get current zoom scale, default value is 1 - * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale - * to let editor behave correctly especially for those mouse drag/drop behaviors - * @returns current zoom scale number - */ - getZoomScale(): number { - return this.getCore().zoomScale; - } - - /** - * Set current zoom scale, default value is 1 - * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale - * to let editor behave correctly especially for those mouse drag/drop behaviors - * @param scale The new scale number to set. It should be positive number and no greater than 10, otherwise it will be ignored. - */ - setZoomScale(scale: number): void { - const core = this.getCore(); - - if (scale > 0 && scale <= 10) { - const oldValue = core.zoomScale; - core.zoomScale = scale; - - if (oldValue != scale) { - this.triggerEvent( - 'zoomChanged', - { - oldZoomScale: oldValue, - newZoomScale: scale, - }, - true /*broadcast*/ - ); - } - } - } - /** * Get a function to convert HTML string to trusted HTML string. * By default it will just return the input HTML directly. To override this behavior, diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts index 1ce982f5be6..b4680abdb9e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts @@ -54,7 +54,6 @@ export function createStandaloneEditorCore( domHelper: createDOMHelper(contentDiv), ...getPluginState(corePlugins), disposeErrorHandler: options.disposeErrorHandler, - zoomScale: (options.zoomScale ?? -1) > 0 ? options.zoomScale! : 1, }; } diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts index fbcaad5743c..7efe27aa876 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts @@ -7,7 +7,7 @@ describe('createEditorContext', () => { const defaultFormat = 'DEFAULTFORMAT' as any; const darkColorHandler = 'DARKHANDLER' as any; const getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); - const getBoundingClientRectSpy = jasmine.createSpy('getBoundingClientRect'); + const calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale').and.returnValue(1); const domIndexer = 'DOMINDEXER' as any; const div = { @@ -16,7 +16,6 @@ describe('createEditorContext', () => { getComputedStyle: getComputedStyleSpy, }, }, - getBoundingClientRect: getBoundingClientRectSpy, }; const core = ({ @@ -31,6 +30,9 @@ describe('createEditorContext', () => { cache: { domIndexer: domIndexer, }, + domHelper: { + calculateZoomScale: calculateZoomScaleSpy, + }, } as any) as StandaloneEditorCore; const context = createEditorContext(core, false); @@ -43,6 +45,7 @@ describe('createEditorContext', () => { allowCacheElement: true, domIndexer: undefined, pendingFormat: undefined, + zoomScale: 1, }); }); @@ -51,7 +54,7 @@ describe('createEditorContext', () => { const defaultFormat = 'DEFAULTFORMAT' as any; const darkColorHandler = 'DARKHANDLER' as any; const getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); - const getBoundingClientRectSpy = jasmine.createSpy('getBoundingClientRect'); + const calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale').and.returnValue(1); const domIndexer = 'DOMINDEXER' as any; const div = { @@ -60,7 +63,6 @@ describe('createEditorContext', () => { getComputedStyle: getComputedStyleSpy, }, }, - getBoundingClientRect: getBoundingClientRectSpy, }; const core = ({ @@ -75,6 +77,9 @@ describe('createEditorContext', () => { cache: { domIndexer, }, + domHelper: { + calculateZoomScale: calculateZoomScaleSpy, + }, } as any) as StandaloneEditorCore; const context = createEditorContext(core, true); @@ -87,6 +92,7 @@ describe('createEditorContext', () => { allowCacheElement: true, domIndexer, pendingFormat: undefined, + zoomScale: 1, }); }); @@ -96,7 +102,7 @@ describe('createEditorContext', () => { const darkColorHandler = 'DARKHANDLER' as any; const mockedPendingFormat = 'PENDINGFORMAT' as any; const getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); - const getBoundingClientRectSpy = jasmine.createSpy('getBoundingClientRect'); + const calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale').and.returnValue(1); const div = { ownerDocument: { @@ -104,7 +110,6 @@ describe('createEditorContext', () => { getComputedStyle: getComputedStyleSpy, }, }, - getBoundingClientRect: getBoundingClientRectSpy, }; const core = ({ @@ -118,6 +123,9 @@ describe('createEditorContext', () => { }, darkColorHandler, cache: {}, + domHelper: { + calculateZoomScale: calculateZoomScaleSpy, + }, } as any) as StandaloneEditorCore; const context = createEditorContext(core, false); @@ -130,6 +138,7 @@ describe('createEditorContext', () => { allowCacheElement: true, domIndexer: undefined, pendingFormat: mockedPendingFormat, + zoomScale: 1, }); }); }); @@ -138,14 +147,14 @@ describe('createEditorContext - checkZoomScale', () => { let core: StandaloneEditorCore; let div: any; let getComputedStyleSpy: jasmine.Spy; - let getBoundingClientRectSpy: jasmine.Spy; + let calculateZoomScaleSpy: jasmine.Spy; const isDarkMode = 'DARKMODE' as any; const defaultFormat = 'DEFAULTFORMAT' as any; const darkColorHandler = 'DARKHANDLER' as any; beforeEach(() => { getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); - getBoundingClientRectSpy = jasmine.createSpy('getBoundingClientRect'); + calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale'); div = { ownerDocument: { @@ -153,7 +162,6 @@ describe('createEditorContext - checkZoomScale', () => { getComputedStyle: getComputedStyleSpy, }, }, - getBoundingClientRect: getBoundingClientRectSpy, }; core = ({ contentDiv: div, @@ -165,34 +173,14 @@ describe('createEditorContext - checkZoomScale', () => { }, darkColorHandler, cache: {}, + domHelper: { + calculateZoomScale: calculateZoomScaleSpy, + }, } as any) as StandaloneEditorCore; }); - it('Zoom scale = 1', () => { - div.offsetWidth = 100; - getBoundingClientRectSpy.and.returnValue({ - width: 100, - }); - - const context = createEditorContext(core, false); - - expect(context).toEqual({ - isDarkMode, - defaultFormat, - darkColorHandler, - addDelimiterForEntity: true, - zoomScale: 1, - allowCacheElement: true, - domIndexer: undefined, - pendingFormat: undefined, - }); - }); - it('Zoom scale = 2', () => { - div.offsetWidth = 50; - getBoundingClientRectSpy.and.returnValue({ - width: 100, - }); + calculateZoomScaleSpy.and.returnValue(2); const context = createEditorContext(core, false); @@ -207,48 +195,26 @@ describe('createEditorContext - checkZoomScale', () => { pendingFormat: undefined, }); }); - - it('Zoom scale = 0.5', () => { - div.offsetWidth = 200; - getBoundingClientRectSpy.and.returnValue({ - width: 100, - }); - - const context = createEditorContext(core, false); - - expect(context).toEqual({ - isDarkMode, - defaultFormat, - darkColorHandler, - addDelimiterForEntity: true, - zoomScale: 0.5, - allowCacheElement: true, - domIndexer: undefined, - pendingFormat: undefined, - }); - }); }); describe('createEditorContext - checkRootDir', () => { let core: StandaloneEditorCore; let div: any; let getComputedStyleSpy: jasmine.Spy; - let getBoundingClientRectSpy: jasmine.Spy; + let calculateZoomScaleSpy: jasmine.Spy; const isDarkMode = 'DARKMODE' as any; const defaultFormat = 'DEFAULTFORMAT' as any; const darkColorHandler = 'DARKHANDLER' as any; beforeEach(() => { getComputedStyleSpy = jasmine.createSpy('getComputedStyleSpy'); - getBoundingClientRectSpy = jasmine.createSpy('getBoundingClientRect'); - + calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale').and.returnValue(1); div = { ownerDocument: { defaultView: { getComputedStyle: getComputedStyleSpy, }, }, - getBoundingClientRect: getBoundingClientRectSpy, }; core = ({ contentDiv: div, @@ -260,6 +226,9 @@ describe('createEditorContext - checkRootDir', () => { }, darkColorHandler, cache: {}, + domHelper: { + calculateZoomScale: calculateZoomScaleSpy, + }, } as any) as StandaloneEditorCore; }); @@ -278,6 +247,7 @@ describe('createEditorContext - checkRootDir', () => { allowCacheElement: true, domIndexer: undefined, pendingFormat: undefined, + zoomScale: 1, }); }); @@ -297,6 +267,7 @@ describe('createEditorContext - checkRootDir', () => { allowCacheElement: true, domIndexer: undefined, pendingFormat: undefined, + zoomScale: 1, }); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts index e5d3b243f96..7edcdbb0a5f 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts @@ -17,4 +17,32 @@ describe('DOMHelperImpl', () => { expect(result).toEqual(mockedResult); expect(querySelectorAllSpy).toHaveBeenCalledWith(mockedSelector); }); + + it('calculateZoomScale 1', () => { + const mockedDiv = { + getBoundingClientRect: () => ({ + width: 1, + }), + offsetWidth: 2, + } as any; + const domHelper = createDOMHelper(mockedDiv); + + const zoomScale = domHelper.calculateZoomScale(); + + expect(zoomScale).toBe(0.5); + }); + + it('calculateZoomScale 2', () => { + const mockedDiv = { + getBoundingClientRect: () => ({ + width: 1, + }), + offsetWidth: 0, // Wrong number, should return 1 as fallback + } as any; + const domHelper = createDOMHelper(mockedDiv); + + const zoomScale = domHelper.calculateZoomScale(); + + expect(zoomScale).toBe(1); + }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts index e55741fbc42..a7e4e383390 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts @@ -101,7 +101,6 @@ describe('createEditorCore', () => { contextMenu: 'contextMenu' as any, domHelper: mockedDOMHelper, disposeErrorHandler: undefined, - zoomScale: 1, ...additionalResult, }); @@ -154,7 +153,6 @@ describe('createEditorCore', () => { getDarkColor: mockedGetDarkColor, trustedHTMLHandler: mockedTrustHtmlHandler, disposeErrorHandler: mockedDisposeErrorHandler, - zoomScale: 2, } as any; runTest(mockedDiv, mockedOptions, { @@ -176,7 +174,6 @@ describe('createEditorCore', () => { darkColorHandler: mockedDarkColorHandler, trustedHTMLHandler: mockedTrustHtmlHandler, disposeErrorHandler: mockedDisposeErrorHandler, - zoomScale: 2, }); expect(DarkColorHandlerImpl.createDarkColorHandler).toHaveBeenCalledWith( @@ -186,26 +183,6 @@ describe('createEditorCore', () => { ); }); - it('Invalid zoom scale', () => { - const mockedDiv = { - ownerDocument: {}, - attributes: { - a: 'b', - }, - } as any; - const mockedOptions = { - zoomScale: -1, - } as any; - - runTest(mockedDiv, mockedOptions, {}); - - expect(DarkColorHandlerImpl.createDarkColorHandler).toHaveBeenCalledWith( - mockedDiv, - getDarkColorFallback, - undefined - ); - }); - it('Android', () => { const mockedDiv = { ownerDocument: { @@ -219,9 +196,7 @@ describe('createEditorCore', () => { a: 'b', }, } as any; - const mockedOptions = { - zoomScale: -1, - } as any; + const mockedOptions = {} as any; runTest(mockedDiv, mockedOptions, { environment: { @@ -251,9 +226,7 @@ describe('createEditorCore', () => { a: 'b', }, } as any; - const mockedOptions = { - zoomScale: -1, - } as any; + const mockedOptions = {} as any; runTest(mockedDiv, mockedOptions, { environment: { @@ -283,9 +256,7 @@ describe('createEditorCore', () => { a: 'b', }, } as any; - const mockedOptions = { - zoomScale: -1, - } as any; + const mockedOptions = {} as any; runTest(mockedDiv, mockedOptions, { environment: { @@ -315,9 +286,7 @@ describe('createEditorCore', () => { a: 'b', }, } as any; - const mockedOptions = { - zoomScale: -1, - } as any; + const mockedOptions = {} as any; runTest(mockedDiv, mockedOptions, { environment: { @@ -347,9 +316,7 @@ describe('createEditorCore', () => { a: 'b', }, } as any; - const mockedOptions = { - zoomScale: -1, - } as any; + const mockedOptions = {} as any; runTest(mockedDiv, mockedOptions, { environment: { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 4fd612d08bc..7e6d166d06a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -126,13 +126,15 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode super(contentDiv, standaloneEditorOptions, () => { const core = this.getCore(); + const sizeTransformer: SizeTransformer = size => + size / this.getDOMHelper().calculateZoomScale(); // Need to create Content Model Editor Core before initialize plugins since some plugins need this object this.contentModelEditorCore = createEditorCore( options, corePluginState, core.darkColorHandler, - size => size / this.getCore().zoomScale + sizeTransformer ); bridgePlugin.setOuterEditor(this); @@ -977,6 +979,38 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode ); } + /** + * Get current zoom scale, default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale + * to let editor behave correctly especially for those mouse drag/drop behaviors + * @returns current zoom scale number + */ + getZoomScale(): number { + return this.getDOMHelper().calculateZoomScale(); + } + + /** + * Set current zoom scale, default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale + * to let editor behave correctly especially for those mouse drag/drop behaviors + * @param scale The new scale number to set. It should be positive number and no greater than 10, otherwise it will be ignored. + */ + setZoomScale(scale: number): void { + if (scale > 0 && scale <= 10) { + const oldValue = this.getZoomScale(); + + if (oldValue != scale) { + this.triggerEvent( + 'zoomChanged', + { + newZoomScale: scale, + }, + true /*broadcast*/ + ); + } + } + } + /** * @deprecated Use getZoomScale() instead */ diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts index c973fc8c0c2..3fd1b936649 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts @@ -313,7 +313,6 @@ export function oldEventToNewEvent( eventType: 'zoomChanged', eventDataCache: input.eventDataCache, newZoomScale: input.newZoomScale, - oldZoomScale: input.oldZoomScale, }; default: @@ -524,7 +523,8 @@ export function newEventToOldEvent(input: NewEvent, refEvent?: OldEvent): OldEve eventType: PluginEventType.ZoomChanged, eventDataCache: input.eventDataCache, newZoomScale: input.newZoomScale, - oldZoomScale: input.oldZoomScale, + oldZoomScale: + refEvent?.eventType == PluginEventType.ZoomChanged ? refEvent.oldZoomScale : 1, // In new ZoomChangedEvent we don't really have oldZoomScale. So if we can't get it, just use 1 instead }; default: diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts index 3adf797f5ec..581971e0e6c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts @@ -623,7 +623,6 @@ describe('oldEventToNewEvent', () => { eventType: 'zoomChanged', eventDataCache: mockedDataCache, newZoomScale: mockedNewZoomScale, - oldZoomScale: mockedOldZoomScale, } ); }); @@ -1212,6 +1211,25 @@ describe('newEventToOldEvent', () => { it('ZoomChanged', () => { const mockedNewZoomScale = 'NEWSCALE' as any; + + runTest( + { + eventType: 'zoomChanged', + eventDataCache: mockedDataCache, + newZoomScale: mockedNewZoomScale, + }, + undefined, + { + eventType: PluginEventType.ZoomChanged, + eventDataCache: mockedDataCache, + newZoomScale: mockedNewZoomScale, + oldZoomScale: 1, + } + ); + }); + + it('ZoomChanged with ref', () => { + const mockedNewZoomScale = 'NEWSCALE' as any; const mockedOldZoomScale = 'OLDSCALE' as any; runTest( @@ -1219,9 +1237,13 @@ describe('newEventToOldEvent', () => { eventType: 'zoomChanged', eventDataCache: mockedDataCache, newZoomScale: mockedNewZoomScale, + }, + { + eventType: PluginEventType.ZoomChanged, + eventDataCache: mockedDataCache, + newZoomScale: mockedNewZoomScale, oldZoomScale: mockedOldZoomScale, }, - undefined, { eventType: PluginEventType.ZoomChanged, eventDataCache: mockedDataCache, diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts index 410d2abe58c..8e0abdf8d41 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts @@ -126,21 +126,6 @@ export interface IStandaloneEditor { */ setDarkModeState(isDarkMode?: boolean): void; - /** - * Get current zoom scale, default value is 1 - * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale - * to let editor behave correctly especially for those mouse drag/drop behaviors - * @returns current zoom scale number - */ - getZoomScale(): number; - - /** - * Set current zoom scale, default value is 1 - * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale - * to let editor behave correctly especially for those mouse drag/drop behaviors - */ - setZoomScale(scale: number): void; - /** * Add a single undo snapshot to undo stack */ @@ -206,6 +191,7 @@ export interface IStandaloneEditor { * Dispose this editor, dispose all plugins and custom data */ dispose(): void; + /** * Check if focus is in editor now * @returns true if focus is in editor, otherwise false diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts index 60035289328..6cdbe0a3115 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts @@ -352,14 +352,6 @@ export interface StandaloneEditorCore extends PluginState { * @param error The error object we got */ readonly disposeErrorHandler?: (plugin: EditorPlugin, error: Error) => void; - - /** - * @deprecated Will be removed soon. - * Current zoom scale, default value is 1 - * When editor is put under a zoomed container, need to pass the zoom scale number using this property - * to let editor behave correctly especially for those mouse drag/drop behaviors - */ - zoomScale: number; } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts index 005c3e8a83f..d3a75d37053 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts @@ -109,12 +109,4 @@ export interface StandaloneEditorOptions { * @param error The error object we got */ disposeErrorHandler?: (plugin: EditorPlugin, error: Error) => void; - - /** - * @deprecated - * Current zoom scale, @default value is 1 - * When editor is put under a zoomed container, need to pass the zoom scale number using this property - * to let editor behave correctly especially for those mouse drag/drop behaviors - */ - zoomScale?: number; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/event/ZoomChangedEvent.ts b/packages-content-model/roosterjs-content-model-types/lib/event/ZoomChangedEvent.ts index 9cc66f7e171..4f5af65db26 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/event/ZoomChangedEvent.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/event/ZoomChangedEvent.ts @@ -6,11 +6,6 @@ import type { BasePluginEvent } from './BasePluginEvent'; * */ export interface ZoomChangedEvent extends BasePluginEvent<'zoomChanged'> { - /** - * Zoom scale value before this change - */ - oldZoomScale: number; - /** * Zoom scale value after this change */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/ContentModelFormatState.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/ContentModelFormatState.ts index e3ae48f49ef..f1fddbd08b6 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/ContentModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/ContentModelFormatState.ts @@ -174,9 +174,4 @@ export interface ContentModelFormatState { * Whether editor is in dark mode */ isDarkMode?: boolean; - - /** - * Current zoom scale of editor - */ - zoomScale?: number; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts index ceb73564a90..64211378437 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts @@ -19,4 +19,9 @@ export interface DOMHelper { * @returns HTML Element array of the query result */ queryElements(selector: string): HTMLElement[]; + + /** + * Calculate current zoom scale of editor + */ + calculateZoomScale(): number; } From cedd6cef84f4ee5d4505e267c88cc118744ffaf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 1 Feb 2024 12:04:35 -0300 Subject: [PATCH 052/112] WIP --- .../getDefaultContentEditFeatureSettings.ts | 14 ++++++++++-- .../lib/autoFormat/utils/getListTypeStyle.ts | 22 +++++++++++++------ .../lib/edit/keyboardDelete.ts | 8 +------ 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts index 9e9a172339e..30f0bb054f0 100644 --- a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts +++ b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts @@ -9,7 +9,17 @@ export default function getDefaultContentEditFeatureSettings(): ContentEditFeatu settings[key] = !allFeatures[key].defaultDisabled; return settings; }, {}), - indentWhenAltShiftRight: true, - outdentWhenAltShiftLeft: true, + indentWhenAltShiftRight: false, + outdentWhenAltShiftLeft: false, + indentWhenTab: false, + outdentWhenShiftTab: false, + outdentWhenBackspaceOnEmptyFirstLine: false, + outdentWhenEnterOnEmptyLine: false, + mergeInNewLineWhenBackspaceOnFirstChar: false, + maintainListChain: false, + maintainListChainWhenDelete: false, + autoNumberingList: false, + autoBulletList: false, + mergeListOnBackspaceAfterList: false, }; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts index 86b8571ae24..324d21eec5e 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts @@ -1,4 +1,3 @@ -import { getIndex } from './getIndex'; import { getNumberingListStyle } from './getNumberingListStyle'; import type { @@ -48,20 +47,18 @@ export function getListTypeStyle( if (bulletType && shouldSearchForBullet) { return { listType: 'UL', styleType: bulletType }; } else if (shouldSearchForNumbering) { - const previousList = getPreviousListLevel(model, paragraph); + const { previousIndex, previousList } = getPreviousListLevel(model, paragraph); const previousListStyle = getPreviousListStyle(previousList); const numberingType = getNumberingListStyle( listMarker, - previousList?.format?.listStyleType - ? getIndex(previousList.format.listStyleType) - : undefined, + previousIndex, previousListStyle ); if (numberingType) { return { listType: 'OL', styleType: numberingType, - index: previousList?.format?.listStyleType ? getIndex(listMarker) : undefined, + index: previousIndex + 1, }; } } @@ -75,6 +72,7 @@ const getPreviousListLevel = (model: ContentModelDocument, paragraph: ContentMod const listBlock = blocks.filter(({ block, parent }) => { return parent.blocks.indexOf(paragraph) > -1; })[0]; + if (listBlock) { const length = listBlock.parent.blocks.length; for (let i = length - 1; i > -1; i--) { @@ -85,7 +83,17 @@ const getPreviousListLevel = (model: ContentModelDocument, paragraph: ContentMod } } } - return listItem; + + const index = blocks.reduce((acc, { block, parent }) => { + parent.blocks.forEach(b => { + if (isBlockGroupOfType(b, 'ListItem')) { + acc++; + } + }); + return acc; + }, 0); + + return { previousList: listItem, previousIndex: index }; }; const getPreviousListStyle = (list?: ContentModelListItem) => { diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts index 8e6ac7e22b4..2ba039ae749 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts @@ -65,13 +65,7 @@ function getDeleteSteps(rawEvent: KeyboardEvent, isMac: boolean): (DeleteSelecti const deleteCollapsedSelection = isForward ? forwardDeleteCollapsedSelection : backwardDeleteCollapsedSelection; - const deleteListStep = !isForward ? deleteList : null; - return [ - deleteAllSegmentBeforeStep, - deleteWordSelection, - deleteCollapsedSelection, - deleteListStep, - ]; + return [deleteAllSegmentBeforeStep, deleteWordSelection, deleteCollapsedSelection, deleteList]; } function shouldDeleteWithContentModel(selection: DOMSelection | null, rawEvent: KeyboardEvent) { From caee33fff5254777aa1e5a156e297b43f47d447a Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 1 Feb 2024 08:46:17 -0800 Subject: [PATCH 053/112] Code cleanup: Move isNodeInEditor into DOMHelper (#2379) --- .../lib/corePlugin/EntityPlugin.ts | 2 +- .../corePlugin/utils/applyDefaultFormat.ts | 2 +- .../lib/editor/DOMHelperImpl.ts | 4 +++ .../lib/editor/StandaloneEditor.ts | 10 ------ .../ContentModelFormatPluginTest.ts | 4 ++- .../test/corePlugin/EntityPluginTest.ts | 4 ++- .../utils/applyDefaultFormatTest.ts | 4 ++- .../test/editor/DOMHelperImplTest.ts | 15 +++++++++ .../test/editor/StandaloneEditorTest.ts | 32 ------------------- .../lib/editor/IStandaloneEditor.ts | 6 ---- .../lib/parameter/DOMHelper.ts | 6 ++++ 11 files changed, 36 insertions(+), 53 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts index 94eaab50e87..cdb1545b433 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts @@ -97,7 +97,7 @@ class EntityPlugin implements PluginWithState { let node: Node | null = rawEvent.target as Node; if (isClicking && this.editor) { - while (node && this.editor.isNodeInEditor(node)) { + while (node && this.editor.getDOMHelper().isNodeInEditor(node)) { if (isEntityElement(node)) { this.triggerEvent(editor, node as HTMLElement, 'click', rawEvent); break; diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts index 56defc297df..3ddd87d15cb 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts @@ -20,7 +20,7 @@ export function applyDefaultFormat( if (posContainer) { let node: Node | null = posContainer; - while (node && editor.isNodeInEditor(node)) { + while (node && editor.getDOMHelper().isNodeInEditor(node)) { if (isNodeOfType(node, 'ELEMENT_NODE')) { if (node.getAttribute?.('style')) { return; diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts index eee15b111c8..7a49f6bbcce 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts @@ -8,6 +8,10 @@ class DOMHelperImpl implements DOMHelper { return toArray(this.contentDiv.querySelectorAll(selector)) as HTMLElement[]; } + isNodeInEditor(node: Node): boolean { + return this.contentDiv.contains(node); + } + calculateZoomScale(): number { const originalWidth = this.contentDiv.getBoundingClientRect()?.width || 0; const visualWidth = this.contentDiv.offsetWidth; diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts index a65c308dcb0..43095b2d18a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts @@ -355,16 +355,6 @@ export class StandaloneEditor implements IStandaloneEditor { return this.getCore().darkColorHandler; } - /** - * Check if the given DOM node is in editor - * @param node The node to check - */ - isNodeInEditor(node: Node): boolean { - const core = this.getCore(); - - return core.contentDiv.contains(node); - } - /** * Get a function to convert HTML string to trusted HTML string. * By default it will just return the input HTML directly. To override this behavior, diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts index a773e8df4fc..bff9bb60866 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts @@ -277,7 +277,9 @@ describe('ContentModelFormatPlugin for default format', () => { contentDiv = document.createElement('div'); editor = ({ - isNodeInEditor: (e: Node) => contentDiv != e && contentDiv.contains(e), + getDOMHelper: () => ({ + isNodeInEditor: (e: Node) => contentDiv != e && contentDiv.contains(e), + }), getDOMSelection, getPendingFormat: getPendingFormatSpy, cacheContentModel: cacheContentModelSpy, diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts index 88bba7a708e..2f3b43156df 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts @@ -37,7 +37,9 @@ describe('EntityPlugin', () => { formatContentModel: formatContentModelSpy, triggerEvent: triggerPluginEventSpy, isDarkMode: isDarkModeSpy, - isNodeInEditor: isNodeInEditorSpy, + getDOMHelper: () => ({ + isNodeInEditor: isNodeInEditorSpy, + }), getColorManager: () => mockedDarkColorHandler, } as any; plugin = createEntityPlugin(); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts index 3c0b2050ddd..47abee4f5f7 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts @@ -64,7 +64,9 @@ describe('applyDefaultFormat', () => { ); editor = { - isNodeInEditor: () => true, + getDOMHelper: () => ({ + isNodeInEditor: () => true, + }), getDOMSelection: getDOMSelectionSpy, formatContentModel: formatContentModelSpy, takeSnapshot: takeSnapshotSpy, diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts index 7edcdbb0a5f..a3913ec6610 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts @@ -1,6 +1,21 @@ import { createDOMHelper } from '../../lib/editor/DOMHelperImpl'; describe('DOMHelperImpl', () => { + it('isNodeInEditor', () => { + const mockedResult = 'RESULT' as any; + const containsSpy = jasmine.createSpy('contains').and.returnValue(mockedResult); + const mockedDiv = { + contains: containsSpy, + } as any; + const domHelper = createDOMHelper(mockedDiv); + const mockedNode = 'NODE' as any; + + const result = domHelper.isNodeInEditor(mockedNode); + + expect(result).toBe(mockedResult); + expect(containsSpy).toHaveBeenCalledWith(mockedNode); + }); + it('queryElements', () => { const mockedResult = ['RESULT'] as any; const querySelectorAllSpy = jasmine diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts index 2ebe56dc837..304b3834b68 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts @@ -780,38 +780,6 @@ describe('StandaloneEditor', () => { expect(() => editor.getColorManager()).toThrow(); }); - it('isNodeInEditor', () => { - const mockedResult = 'RESULT' as any; - const containsSpy = jasmine.createSpy('contains').and.returnValue(mockedResult); - const resetSpy = jasmine.createSpy('reset'); - const div = { - contains: containsSpy, - } as any; - const mockedCore = { - plugins: [], - darkColorHandler: { - updateKnownColor: updateKnownColorSpy, - reset: resetSpy, - }, - contentDiv: div, - api: { setContentModel: setContentModelSpy }, - } as any; - - createEditorCoreSpy.and.returnValue(mockedCore); - - const editor = new StandaloneEditor(div); - const mockedNode = 'NODE' as any; - - const result = editor.isNodeInEditor(mockedNode); - - expect(result).toBe(mockedResult); - expect(containsSpy).toHaveBeenCalledWith(mockedNode); - - editor.dispose(); - expect(resetSpy).toHaveBeenCalledWith(); - expect(() => editor.isNodeInEditor(mockedNode)).toThrow(); - }); - it('dark mode', () => { const transformColorSpy = spyOn(transformColor, 'transformColor'); const triggerEventSpy = jasmine.createSpy('triggerEvent').and.callFake((core, event) => { diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts index 8e0abdf8d41..6810a474c31 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts @@ -169,12 +169,6 @@ export interface IStandaloneEditor { */ stopShadowEdit(): void; - /** - * Check if the given DOM node is in editor - * @param node The node to check - */ - isNodeInEditor(node: Node): boolean; - /** * Paste into editor using a clipboardData object * @param clipboardData Clipboard data retrieved from clipboard diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts index 64211378437..2e8e1e4e2a1 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts @@ -2,6 +2,12 @@ * A helper class to provide DOM access APIs */ export interface DOMHelper { + /** + * Check if the given DOM node is in editor + * @param node The node to check + */ + isNodeInEditor(node: Node): boolean; + /** * Query HTML elements in editor by tag name. * Be careful of this function since it will also return element under entity. From 17f4c871b119b6ff23a5f154d5d16ff47349d1a6 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 1 Feb 2024 08:56:10 -0800 Subject: [PATCH 054/112] Code cleanup: Remove TODO (#2384) --- .../lib/modelApi/block/setModelDirection.ts | 1 - .../modelApi/block/toggleModelBlockQuote.ts | 9 +- .../common/retrieveModelFormatState.ts | 4 - .../lib/modelApi/common/wrapBlock.ts | 21 ++-- .../lib/modelApi/list/setListType.ts | 2 - .../lib/publicApi/block/toggleBlockQuote.ts | 25 ++-- .../publicApi/segment/changeCapitalization.ts | 1 - .../block/toggleModelBlockQuoteTest.ts | 117 ++++++++++++++++-- .../test/modelApi/common/wrapBlockTest.ts | 6 +- .../publicApi/block/toggleBlockQuoteTest.ts | 62 +++++++--- .../lib/coreApi/switchShadowEdit.ts | 4 - .../corePlugin/ContentModelFormatPlugin.ts | 1 - .../lib/corePlugin/SelectionPlugin.ts | 2 +- .../lib/publicApi/domUtils/borderValues.ts | 2 +- .../lib/publicApi/selection/deleteSegment.ts | 2 - .../publicApi/selection/iterateSelections.ts | 2 - .../domToModel/processors/entityProcessor.ts | 1 - .../utils/parseValueWithUnit.ts | 3 - .../lib/edit/ContentModelEditPlugin.ts | 1 - .../lib/edit/keyboardInput.ts | 2 +- .../lib/paste/ContentModelPastePlugin.ts | 1 - 21 files changed, 192 insertions(+), 77 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelDirection.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelDirection.ts index c2170883717..22493f5d061 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelDirection.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelDirection.ts @@ -56,7 +56,6 @@ function internalSetDirection( format.direction = direction; // Adjust margin when change direction - // TODO: make margin and padding direction-aware, like what we did for textAlign. So no need to adjust them here const marginLeft = format.marginLeft; const paddingLeft = format.paddingLeft; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts index f3f25a977f4..6272112a10c 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts @@ -17,7 +17,8 @@ import type { */ export function toggleModelBlockQuote( model: ContentModelDocument, - format: ContentModelFormatContainerFormat + formatLtr: ContentModelFormatContainerFormat, + formatRtl: ContentModelFormatContainerFormat ): boolean { const paragraphOfQuote = getOperationalBlocks< ContentModelFormatContainer | ContentModelListItem @@ -30,12 +31,14 @@ export function toggleModelBlockQuote( }); } else { const step1Results: WrapBlockStep1Result[] = []; - const creator = () => createFormatContainer('blockquote', format); + const creator = (isRtl: boolean) => + createFormatContainer('blockquote', isRtl ? formatRtl : formatLtr); const canMerge = ( + isRtl: boolean, target: ContentModelBlock, current?: ContentModelFormatContainer ): target is ContentModelFormatContainer => - canMergeQuote(target, current?.format || format); + canMergeQuote(target, current?.format || (isRtl ? formatRtl : formatLtr)); paragraphOfQuote.forEach(({ block, parent }) => { if (isQuote(block)) { diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts index 55e499ce428..d04ba929bcf 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts @@ -122,8 +122,6 @@ export function retrieveModelFormatState( firstTableContext = tableContext; } } - - // TODO: Support Code block in format state for Content Model }, { includeListFormatHolder: 'never', @@ -161,8 +159,6 @@ function retrieveSegmentFormat( mergeValue(result, 'backgroundColor', mergedFormat.backgroundColor, isFirst); mergeValue(result, 'textColor', mergedFormat.textColor, isFirst); mergeValue(result, 'fontWeight', mergedFormat.fontWeight, isFirst); - - //TODO: handle block owning segments with different line-heights mergeValue(result, 'lineHeight', mergedFormat.lineHeight, isFirst); } diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/wrapBlock.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/wrapBlock.ts index 6acaa412e3f..23d2aa89756 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/wrapBlock.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/wrapBlock.ts @@ -16,16 +16,19 @@ export function wrapBlockStep1[], parent: ContentModelBlockGroup | null, blockToWrap: ContentModelBlock, - creator: () => T, - canMerge: (target: ContentModelBlock) => target is T + creator: (isRtl: boolean) => T, + canMerge: (isRtl: boolean, target: ContentModelBlock) => target is T ) { const index = parent?.blocks.indexOf(blockToWrap) ?? -1; if (parent && index >= 0) { parent.blocks.splice(index, 1); - const prevBlock = parent.blocks[index - 1]; - const wrapper = canMerge(prevBlock) ? prevBlock : createAndAdd(parent, index, creator); + const prevBlock: ContentModelBlock = parent.blocks[index - 1]; + const isRtl = blockToWrap.format.direction == 'rtl'; + const wrapper = canMerge(isRtl, prevBlock) + ? prevBlock + : createAndAdd(parent, index, creator, isRtl); setParagraphNotImplicit(blockToWrap); addBlock(wrapper, blockToWrap); @@ -40,13 +43,14 @@ export function wrapBlockStep1( step1Result: WrapBlockStep1Result[], - canMerge: (target: ContentModelBlock, current: T) => target is T + canMerge: (isRtl: boolean, target: ContentModelBlock, current: T) => target is T ) { step1Result.forEach(({ parent, wrapper }) => { const index = parent.blocks.indexOf(wrapper); const nextBlock = parent.blocks[index + 1]; + const isRtl = wrapper.format.direction == 'rtl'; - if (index >= 0 && canMerge(nextBlock, wrapper)) { + if (index >= 0 && canMerge(isRtl, nextBlock, wrapper)) { wrapper.blocks.forEach(setParagraphNotImplicit); wrapper.blocks.push(...nextBlock.blocks); parent.blocks.splice(index + 1, 1); @@ -57,9 +61,10 @@ export function wrapBlockStep2( parent: ContentModelBlockGroup, index: number, - creator: () => T + creator: (isRtl: boolean) => T, + isRtl: boolean ): T { - const block = creator(); + const block = creator(isRtl); parent.blocks.splice(index, 0, block); return block; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts index b6fd09587d7..4ffacd55d76 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts @@ -88,8 +88,6 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') } ); - // Since there is only one paragraph under the list item, no need to keep its paragraph element (DIV). - // TODO: Do we need to keep the CSS styles applied to original DIV? if (block.blockType == 'Paragraph') { block.isImplicit = true; } diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/toggleBlockQuote.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/toggleBlockQuote.ts index ec3ac86d7a5..87fa8055e0b 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/toggleBlockQuote.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/toggleBlockQuote.ts @@ -4,8 +4,12 @@ import type { IStandaloneEditor, } from 'roosterjs-content-model-types'; -const DefaultQuoteFormat: ContentModelFormatContainerFormat = { - borderLeft: '3px solid rgb(200, 200, 200)', // TODO: Support RTL +const DefaultQuoteFormatLtr: ContentModelFormatContainerFormat = { + borderLeft: '3px solid rgb(200, 200, 200)', + textColor: 'rgb(102, 102, 102)', +}; +const DefaultQuoteFormatRtl: ContentModelFormatContainerFormat = { + borderRight: '3px solid rgb(200, 200, 200)', textColor: 'rgb(102, 102, 102)', }; const BuildInQuoteFormat: ContentModelFormatContainerFormat = { @@ -13,7 +17,6 @@ const BuildInQuoteFormat: ContentModelFormatContainerFormat = { marginBottom: '1em', marginLeft: '40px', marginRight: '40px', - paddingLeft: '10px', }; /** @@ -25,11 +28,19 @@ const BuildInQuoteFormat: ContentModelFormatContainerFormat = { */ export default function toggleBlockQuote( editor: IStandaloneEditor, - quoteFormat: ContentModelFormatContainerFormat = DefaultQuoteFormat + quoteFormat?: ContentModelFormatContainerFormat, + quoteFormatRtl?: ContentModelFormatContainerFormat ) { - const fullQuoteFormat = { + const fullQuoteFormatLtr: ContentModelFormatContainerFormat = { + ...BuildInQuoteFormat, + paddingLeft: '10px', + ...(quoteFormat ?? DefaultQuoteFormatLtr), + }; + const fullQuoteFormatRtl: ContentModelFormatContainerFormat = { ...BuildInQuoteFormat, - ...quoteFormat, + paddingRight: '10px', + direction: 'rtl', + ...(quoteFormatRtl ?? quoteFormat ?? DefaultQuoteFormatRtl), }; editor.focus(); @@ -38,7 +49,7 @@ export default function toggleBlockQuote( (model, context) => { context.newPendingFormat = 'preserve'; - return toggleModelBlockQuote(model, fullQuoteFormat); + return toggleModelBlockQuote(model, fullQuoteFormatLtr, fullQuoteFormatRtl); }, { apiName: 'toggleBlockQuote', diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeCapitalization.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeCapitalization.ts index 207efe20aae..e5e3e30f96e 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeCapitalization.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeCapitalization.ts @@ -40,7 +40,6 @@ export default function changeCapitalization( break; case 'sentence': - // TODO: Add rules on punctuation for internationalization - TASK 104769 const punctuationMarks = '[\\.\\!\\?]'; // Find a match of a word character either: // - At the beginning of a string with or without preceding whitespace, for diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/toggleModelBlockQuoteTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/toggleModelBlockQuoteTest.ts index 3ba27782d5f..2969ec2145f 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/toggleModelBlockQuoteTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/toggleModelBlockQuoteTest.ts @@ -11,7 +11,7 @@ describe('toggleModelBlockQuote', () => { it('empty model', () => { const doc = createContentModelDocument(); - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -27,7 +27,7 @@ describe('toggleModelBlockQuote', () => { para.segments.push(text); doc.blocks.push(para); - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -56,7 +56,7 @@ describe('toggleModelBlockQuote', () => { doc.blocks.push(para); text.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -99,7 +99,7 @@ describe('toggleModelBlockQuote', () => { text1.isSelected = true; text2.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -140,6 +140,99 @@ describe('toggleModelBlockQuote', () => { }); }); + it('Has multiple selection, with RTL, do not merge', () => { + const doc = createContentModelDocument(); + const para1 = createParagraph(); + const text1 = createText('test1'); + const para2 = createParagraph(); + const text2 = createText('test2'); + const para3 = createParagraph(); + const text3 = createText('test3'); + + para1.segments.push(text1); + para2.segments.push(text2); + para3.segments.push(text3); + + para2.format.direction = 'rtl'; + + doc.blocks.push(para1, para2, para3); + text1.isSelected = true; + text2.isSelected = true; + text3.isSelected = true; + + toggleModelBlockQuote(doc, {}, { direction: 'rtl' }); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + format: { + direction: 'rtl', + }, + blocks: [ + { + blockType: 'Paragraph', + format: { + direction: 'rtl', + }, + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test3', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + ], + }); + }); + it('Has single selection, merge before', () => { const doc = createContentModelDocument(); const para1 = createParagraph(); @@ -155,7 +248,7 @@ describe('toggleModelBlockQuote', () => { doc.blocks.push(para2); text2.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -210,7 +303,7 @@ describe('toggleModelBlockQuote', () => { doc.blocks.push(quote); text1.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -271,7 +364,7 @@ describe('toggleModelBlockQuote', () => { doc.blocks.push(quote3); text2.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -343,7 +436,7 @@ describe('toggleModelBlockQuote', () => { doc.blocks.push(quote3); text2.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -436,7 +529,7 @@ describe('toggleModelBlockQuote', () => { text2.isSelected = true; text3.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -512,7 +605,7 @@ describe('toggleModelBlockQuote', () => { text1.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -575,7 +668,7 @@ describe('toggleModelBlockQuote', () => { text1.isSelected = true; text2.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', @@ -637,7 +730,7 @@ describe('toggleModelBlockQuote', () => { text2.isSelected = true; - toggleModelBlockQuote(doc, {}); + toggleModelBlockQuote(doc, {}, {}); expect(doc).toEqual({ blockGroupType: 'Document', diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/common/wrapBlockTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/common/wrapBlockTest.ts index c7765727613..604c9ef6292 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/common/wrapBlockTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/common/wrapBlockTest.ts @@ -225,7 +225,7 @@ describe('wrapBlockStep2', () => { ], }); expect(canMerge).toHaveBeenCalledTimes(1); - expect(canMerge).toHaveBeenCalledWith(undefined, quote); + expect(canMerge).toHaveBeenCalledWith(false, undefined, quote); }); it('Has results, can merge', () => { @@ -270,8 +270,8 @@ describe('wrapBlockStep2', () => { wrapBlockStep2(result, canMerge as any); expect(canMerge).toHaveBeenCalledTimes(2); - expect(canMerge).toHaveBeenCalledWith(quote4, quote3); - expect(canMerge).toHaveBeenCalledWith(quote2, quote1); + expect(canMerge).toHaveBeenCalledWith(false, quote4, quote3); + expect(canMerge).toHaveBeenCalledWith(false, quote2, quote1); expect(doc).toEqual({ blockGroupType: 'Document', blocks: [ diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts index 6d897dd1b17..a90649ddaf8 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts @@ -39,15 +39,28 @@ describe('toggleBlockQuote', () => { expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledTimes(1); - expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledWith(fakeModel, { - marginTop: '1em', - marginBottom: '1em', - marginLeft: '40px', - marginRight: '40px', - paddingLeft: '10px', - a: 'b', - c: 'd', - } as any); + expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledWith( + fakeModel, + { + marginTop: '1em', + marginBottom: '1em', + marginLeft: '40px', + marginRight: '40px', + paddingLeft: '10px', + a: 'b', + c: 'd', + } as any, + { + marginTop: '1em', + marginBottom: '1em', + marginLeft: '40px', + marginRight: '40px', + paddingRight: '10px', + direction: 'rtl', + a: 'b', + c: 'd', + } as any + ); expect(context).toEqual({ newEntities: [], newImages: [], @@ -63,15 +76,28 @@ describe('toggleBlockQuote', () => { expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledTimes(1); - expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledWith(fakeModel, { - marginTop: '1em', - marginBottom: '1em', - marginLeft: '40px', - marginRight: '40px', - paddingLeft: '10px', - lineHeight: '2', - textColor: 'red', - } as any); + expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledWith( + fakeModel, + { + marginTop: '1em', + marginBottom: '1em', + marginLeft: '40px', + marginRight: '40px', + paddingLeft: '10px', + lineHeight: '2', + textColor: 'red', + } as any, + { + marginTop: '1em', + marginBottom: '1em', + marginLeft: '40px', + marginRight: '40px', + paddingRight: '10px', + lineHeight: '2', + textColor: 'red', + direction: 'rtl', + } + ); expect(context).toEqual({ newEntities: [], newImages: [], diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts index 2829c58c888..c2053bd4351 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts @@ -9,15 +9,11 @@ import type { SwitchShadowEdit } from 'roosterjs-content-model-types'; * @param isOn True to switch On, False to switch Off */ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { - // TODO: Use strong-typed editor core object const core = editorCore; if (isOn != !!core.lifecycle.shadowEditFragment) { if (isOn) { const model = !core.cache.cachedModel ? core.api.createContentModel(core) : null; - - // Fake object, not used in Content Model Editor, just to satisfy original editor code - // TODO: we can remove them once we have standalone Content Model Editor const fragment = core.contentDiv.ownerDocument.createDocumentFragment(); const clonedRoot = core.contentDiv.cloneNode(true /*deep*/); diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts index 9a095d341c6..4219a3728c4 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts @@ -48,7 +48,6 @@ class ContentModelFormatPlugin implements PluginWithState { this.state = { selection: null, selectionStyleNode: null, - imageSelectionBorderColor: options.imageSelectionBorderColor, // TODO: Move to Selection core plugin + imageSelectionBorderColor: options.imageSelectionBorderColor, }; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/borderValues.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/borderValues.ts index 11be7401d45..17fa4c1bd9a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/borderValues.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/borderValues.ts @@ -29,7 +29,7 @@ export function extractBorderValues(combinedBorder?: string): Border { } else if (BorderSizeRegex.test(v) && !result.width) { result.width = v; } else if (v && !result.color) { - result.color = v; // TODO: Do we need to use a regex to match all possible colors? + result.color = v; } }); 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 87ffca8a863..d2064a4e255 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 @@ -83,8 +83,6 @@ export function deleteSegment( segments.splice(index, 1); return true; } else { - // No op if a general segment is not selected, let browser handle general segment - // TODO: Need to revisit this return false; } } diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/iterateSelections.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/iterateSelections.ts index 33f39d2a987..2e1f45d84b9 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/iterateSelections.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/iterateSelections.ts @@ -68,8 +68,6 @@ export function iterateSelections( ): void { const internalCallback: IterateSelectionsCallback = (path, tableContext, block, segments) => { if (!!(block as ContentModelBlockWithCache)?.cachedElement) { - // TODO: This is a temporary solution. A better solution would be making all results from iterationSelection() to be readonly, - // use a util function to change it to be editable before edit them where we clear its cached element delete (block as ContentModelBlockWithCache).cachedElement; } diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts index cfdf01b62e4..feff4584d73 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts @@ -23,7 +23,6 @@ export const entityProcessor: ElementProcessor = (group, element, c parseFormat(element, context.formatParsers.entity, entityModel.entityFormat, context); - // TODO: Need to handle selection for editable entity if (context.isInSelection) { entityModel.isSelected = true; } diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts index bb3eaeefb01..f78aaad8ed9 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts @@ -41,9 +41,6 @@ export function parseValueWithUnit( case 'in': result = num * PixelPerInch; break; - default: - // TODO: Support more unit if need - break; } } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts index 0653fac82f3..a03c145dfc5 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts @@ -30,7 +30,6 @@ export class ContentModelEditPlugin implements EditorPlugin { * @param editor The editor object */ initialize(editor: IStandaloneEditor) { - // TODO: Later we may need a different interface for Content Model editor plugin this.editor = editor; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 70ed141d425..d72a251e179 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -57,7 +57,7 @@ function shouldInputWithContentModel( return ( selection.type != 'range' || (!selection.range.collapsed && !rawEvent.isComposing && !isInIME) - ); // TODO: Also handle Enter key even selection is collapsed + ); } else { return false; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts index 4357e1c5ed4..68aa979ea7e 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts @@ -50,7 +50,6 @@ export class ContentModelPastePlugin implements EditorPlugin { * @param editor The editor object */ initialize(editor: IStandaloneEditor) { - // TODO: Later we may need a different interface for Content Model editor plugin this.editor = editor; } From 82046cb32a24a5314db13456f325f365eeb93b3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 1 Feb 2024 16:44:41 -0300 Subject: [PATCH 055/112] WIP --- .../ContentModelAutoFormatPlugin.ts | 4 ++ .../lib/autoFormat/keyboardAdjustList.ts | 26 +++++++++++ .../lib/autoFormat/keyboardListTrigger.ts | 43 +++++++++++-------- .../autoFormat/utils/getListItemSelected.ts | 13 ++++++ .../lib/autoFormat/utils/getListTypeStyle.ts | 29 +++++++------ .../autoFormat/utils/getNumberingListStyle.ts | 27 ++++++++---- 6 files changed, 102 insertions(+), 40 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardAdjustList.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListItemSelected.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts index 63589ea9e99..b79b883c542 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -1,3 +1,4 @@ +import { keyboardAdjustList } from './keyboardAdjustList'; import { keyboardListTrigger } from './keyboardListTrigger'; import type { EditorPlugin, @@ -95,6 +96,9 @@ export class ContentModelAutoFormatPlugin implements EditorPlugin { keyboardListTrigger(editor, rawEvent, autoBullet, autoNumbering); } break; + case 'Enter': + keyboardAdjustList(editor, rawEvent); + break; } } } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardAdjustList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardAdjustList.ts new file mode 100644 index 00000000000..3001d047058 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardAdjustList.ts @@ -0,0 +1,26 @@ +import { DOMSelection, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { getSelectedListItem } from './utils/getListItemSelected'; +import { normalizeContentModel } from 'roosterjs-content-model-dom/lib'; + +export const keyboardAdjustList = (editor: IStandaloneEditor, rawEvent: KeyboardEvent) => { + const selection = editor.getDOMSelection(); + if (shouldAdjustList(selection, rawEvent)) { + editor.formatContentModel((model, context) => { + const listItem = getSelectedListItem(model); + if (listItem && listItem.format) { + normalizeContentModel(model); + return true; + } + return false; + }); + } +}; + +const shouldAdjustList = (selection: DOMSelection | null, rawEvent: KeyboardEvent) => { + return ( + selection && + selection.type == 'range' && + selection.range.collapsed && + rawEvent.key == 'Enter' + ); +}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts index 27398185a5f..16a893a4e84 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -1,6 +1,6 @@ import { getListTypeStyle } from './utils/getListTypeStyle'; import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core'; -import { normalizeContentModel } from 'roosterjs-content-model-dom'; +import { normalizeContentModel } from 'roosterjs-content-model-dom/lib'; import { setListStartNumber, setListStyle, setListType } from 'roosterjs-content-model-api'; import type { ContentModelDocument, IStandaloneEditor } from 'roosterjs-content-model-types'; @@ -13,25 +13,32 @@ export function keyboardListTrigger( shouldSearchForBullet: boolean = true, shouldSearchForNumbering: boolean = true ) { - editor.formatContentModel((model, _context) => { - const listStyleType = getListTypeStyle( - model, - shouldSearchForBullet, - shouldSearchForNumbering - ); - if (listStyleType) { - const segmentsAndParagraphs = getSelectedSegmentsAndParagraphs(model, false); - if (segmentsAndParagraphs[0] && segmentsAndParagraphs[0][1]) { - segmentsAndParagraphs[0][1].segments.splice(0, 1); + editor.formatContentModel( + (model, _context) => { + const listStyleType = getListTypeStyle( + model, + shouldSearchForBullet, + shouldSearchForNumbering + ); + if (listStyleType) { + const segmentsAndParagraphs = getSelectedSegmentsAndParagraphs(model, false); + if (segmentsAndParagraphs[0] && segmentsAndParagraphs[0][1]) { + segmentsAndParagraphs[0][1].segments.splice(0, 1); + } + const { listType, styleType, index } = listStyleType; + triggerList(editor, model, listType, styleType, index); + + normalizeContentModel(model); + return true; } - const { listType, styleType, index } = listStyleType; - triggerList(editor, model, listType, styleType, index); - rawEvent.preventDefault(); - normalizeContentModel(model); - return true; + return false; + }, + { + apiName: 'keyboardListTrigger', + changeSource: 'keyboard', + onNodeCreated: node => {}, } - return false; - }); + ); } const triggerList = ( diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListItemSelected.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListItemSelected.ts new file mode 100644 index 00000000000..a64b77a586d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListItemSelected.ts @@ -0,0 +1,13 @@ +import { ContentModelDocument, ContentModelListItem } from 'roosterjs-content-model-types'; +import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; + +export const getSelectedListItem = (model: ContentModelDocument) => { + const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); + const lisItem = blocks.filter(({ block, parent }) => { + return ( + isBlockGroupOfType(block, 'ListItem') && + parent.blocks.indexOf(block) > -1 + ); + }); + return lisItem.length == 1 ? lisItem[0].block : undefined; +}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts index 324d21eec5e..f6fede37f22 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts @@ -47,7 +47,8 @@ export function getListTypeStyle( if (bulletType && shouldSearchForBullet) { return { listType: 'UL', styleType: bulletType }; } else if (shouldSearchForNumbering) { - const { previousIndex, previousList } = getPreviousListLevel(model, paragraph); + const previousList = getPreviousListLevel(model, paragraph); + const previousIndex = getPreviousListIndex(model); const previousListStyle = getPreviousListStyle(previousList); const numberingType = getNumberingListStyle( listMarker, @@ -58,7 +59,7 @@ export function getListTypeStyle( return { listType: 'OL', styleType: numberingType, - index: previousIndex + 1, + index: previousListStyle === numberingType ? previousIndex + 1 : undefined, }; } } @@ -66,6 +67,18 @@ export function getListTypeStyle( return undefined; } +const getPreviousListIndex = (model: ContentModelDocument) => { + const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); + return blocks.reduce((acc, { block, parent }) => { + parent.blocks.forEach(b => { + if (isBlockGroupOfType(b, 'ListItem')) { + acc++; + } + }); + return acc; + }, 0); +}; + const getPreviousListLevel = (model: ContentModelDocument, paragraph: ContentModelParagraph) => { const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); let listItem: ContentModelListItem | undefined = undefined; @@ -83,17 +96,7 @@ const getPreviousListLevel = (model: ContentModelDocument, paragraph: ContentMod } } } - - const index = blocks.reduce((acc, { block, parent }) => { - parent.blocks.forEach(b => { - if (isBlockGroupOfType(b, 'ListItem')) { - acc++; - } - }); - return acc; - }, 0); - - return { previousList: listItem, previousIndex: index }; + return listItem; }; const getPreviousListStyle = (list?: ContentModelListItem) => { diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts index bc0de54d63f..b0234547edd 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getNumberingListStyle.ts @@ -43,24 +43,30 @@ const identifyNumberingType = (text: string, previousListStyle?: number) => { return NumberingTypes.Decimal; } else if (/[a-z]+/g.test(text)) { if ( - (previousListStyle != undefined && + (previousListStyle === NumberingTypes.LowerRoman && lowerRomanTypes.indexOf(previousListStyle) > -1 && lowerRomanNumbers.indexOf(text[0]) > -1) || (!previousListStyle && text === 'i') ) { return NumberingTypes.LowerRoman; - } else if (previousListStyle || (!previousListStyle && text === 'a')) { + } else if ( + previousListStyle === NumberingTypes.LowerAlpha || + (!previousListStyle && text === 'a') + ) { return NumberingTypes.LowerAlpha; } } else if (/[A-Z]+/g.test(text)) { if ( - (previousListStyle != undefined && + (previousListStyle == NumberingTypes.UpperRoman && upperRomanTypes.indexOf(previousListStyle) > -1 && upperRomanNumbers.indexOf(text[0]) > -1) || (!previousListStyle && text === 'I') ) { return NumberingTypes.UpperRoman; - } else if (previousListStyle || (!previousListStyle && text === 'A')) { + } else if ( + previousListStyle == NumberingTypes.UpperAlpha || + (!previousListStyle && text === 'A') + ) { return NumberingTypes.UpperAlpha; } } @@ -140,20 +146,23 @@ export function getNumberingListStyle( //The index is always the characters before the last character const listIndex = isDoubleParenthesis ? trigger.slice(1, -1) : trigger.slice(0, -1); const index = getIndex(listIndex); + const isContinuosList = numberingTriggers.indexOf(listIndex) < 0; if ( !index || index < 1 || - (!previousListIndex && numberingTriggers.indexOf(listIndex) < 0) || - (previousListIndex && - numberingTriggers.indexOf(listIndex) < 0 && - !canAppendList(index, previousListIndex)) + (!previousListIndex && isContinuosList) || + (previousListIndex && isContinuosList && !canAppendList(index, previousListIndex)) ) { return undefined; } const numberingType = isValidNumbering(listIndex) - ? identifyNumberingListType(trigger, isDoubleParenthesis, previousListStyle) + ? identifyNumberingListType( + trigger, + isDoubleParenthesis, + isContinuosList ? previousListStyle : undefined + ) : undefined; return numberingType; } From 274ed43bf76319c54828da141c07ddb25c4b418a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 1 Feb 2024 19:03:41 -0300 Subject: [PATCH 056/112] WIP --- .../ContentModelAutoFormatPlugin.ts | 4 -- .../lib/autoFormat/keyboardAdjustList.ts | 26 ------------ .../lib/autoFormat/keyboardListTrigger.ts | 3 +- .../autoFormat/utils/getListItemSelected.ts | 13 ------ .../lib/autoFormat/utils/getListTypeStyle.ts | 23 +++++------ .../lib/edit/handleEnterOnList.ts | 41 +++++++++++++++++++ .../lib/edit/keyboardInput.ts | 21 ++++++++-- 7 files changed, 71 insertions(+), 60 deletions(-) delete mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardAdjustList.ts delete mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListItemSelected.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts index b79b883c542..63589ea9e99 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts @@ -1,4 +1,3 @@ -import { keyboardAdjustList } from './keyboardAdjustList'; import { keyboardListTrigger } from './keyboardListTrigger'; import type { EditorPlugin, @@ -96,9 +95,6 @@ export class ContentModelAutoFormatPlugin implements EditorPlugin { keyboardListTrigger(editor, rawEvent, autoBullet, autoNumbering); } break; - case 'Enter': - keyboardAdjustList(editor, rawEvent); - break; } } } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardAdjustList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardAdjustList.ts deleted file mode 100644 index 3001d047058..00000000000 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardAdjustList.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DOMSelection, IStandaloneEditor } from 'roosterjs-content-model-types'; -import { getSelectedListItem } from './utils/getListItemSelected'; -import { normalizeContentModel } from 'roosterjs-content-model-dom/lib'; - -export const keyboardAdjustList = (editor: IStandaloneEditor, rawEvent: KeyboardEvent) => { - const selection = editor.getDOMSelection(); - if (shouldAdjustList(selection, rawEvent)) { - editor.formatContentModel((model, context) => { - const listItem = getSelectedListItem(model); - if (listItem && listItem.format) { - normalizeContentModel(model); - return true; - } - return false; - }); - } -}; - -const shouldAdjustList = (selection: DOMSelection | null, rawEvent: KeyboardEvent) => { - return ( - selection && - selection.type == 'range' && - selection.range.collapsed && - rawEvent.key == 'Enter' - ); -}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts index 16a893a4e84..7bfa728eb06 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -1,7 +1,7 @@ import { getListTypeStyle } from './utils/getListTypeStyle'; import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core'; -import { normalizeContentModel } from 'roosterjs-content-model-dom/lib'; import { setListStartNumber, setListStyle, setListType } from 'roosterjs-content-model-api'; + import type { ContentModelDocument, IStandaloneEditor } from 'roosterjs-content-model-types'; /** @@ -28,7 +28,6 @@ export function keyboardListTrigger( const { listType, styleType, index } = listStyleType; triggerList(editor, model, listType, styleType, index); - normalizeContentModel(model); return true; } return false; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListItemSelected.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListItemSelected.ts deleted file mode 100644 index a64b77a586d..00000000000 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListItemSelected.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { ContentModelDocument, ContentModelListItem } from 'roosterjs-content-model-types'; -import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; - -export const getSelectedListItem = (model: ContentModelDocument) => { - const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); - const lisItem = blocks.filter(({ block, parent }) => { - return ( - isBlockGroupOfType(block, 'ListItem') && - parent.blocks.indexOf(block) > -1 - ); - }); - return lisItem.length == 1 ? lisItem[0].block : undefined; -}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts index f6fede37f22..564bdb7267b 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts @@ -1,3 +1,4 @@ +import { findListItemsInSameThread } from 'roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread'; import { getNumberingListStyle } from './getNumberingListStyle'; import type { @@ -48,7 +49,7 @@ export function getListTypeStyle( return { listType: 'UL', styleType: bulletType }; } else if (shouldSearchForNumbering) { const previousList = getPreviousListLevel(model, paragraph); - const previousIndex = getPreviousListIndex(model); + const previousIndex = getPreviousListIndex(model, previousList); const previousListStyle = getPreviousListStyle(previousList); const numberingType = getNumberingListStyle( listMarker, @@ -59,7 +60,10 @@ export function getListTypeStyle( return { listType: 'OL', styleType: numberingType, - index: previousListStyle === numberingType ? previousIndex + 1 : undefined, + index: + previousListStyle === numberingType && previousIndex + ? previousIndex + 1 + : undefined, }; } } @@ -67,16 +71,11 @@ export function getListTypeStyle( return undefined; } -const getPreviousListIndex = (model: ContentModelDocument) => { - const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); - return blocks.reduce((acc, { block, parent }) => { - parent.blocks.forEach(b => { - if (isBlockGroupOfType(b, 'ListItem')) { - acc++; - } - }); - return acc; - }, 0); +const getPreviousListIndex = ( + model: ContentModelDocument, + previousListItem?: ContentModelListItem +) => { + return previousListItem ? findListItemsInSameThread(model, previousListItem).length : undefined; }; const getPreviousListLevel = (model: ContentModelDocument, paragraph: ContentModelParagraph) => { diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts new file mode 100644 index 00000000000..c6c091c47b4 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts @@ -0,0 +1,41 @@ +import { DeleteSelectionStep, InsertPoint } from 'roosterjs-content-model-types'; +import { getClosestAncestorBlockGroupIndex } from 'roosterjs-content-model-core'; + +import { + createListItem, + createParagraph, + createSelectionMarker, +} from 'roosterjs-content-model-dom'; + +/** + * @internal + */ +export const handleEnterOnList: DeleteSelectionStep = context => { + if (context.deleteResult == 'notDeleted') { + const { insertPoint } = context; + const { path } = insertPoint; + const index = getClosestAncestorBlockGroupIndex( + path, + ['FormatContainer', 'ListItem'], + ['TableCell'] + ); + const listItem = path[index]; + if (listItem && listItem.blockGroupType === 'ListItem') { + const listParent = path[index + 1]; + const listIndex = listParent.blocks.indexOf(listItem); + const newParagraph = createNewParagraph(insertPoint); + const newListItem = createListItem(listItem.levels, listItem.format); + newListItem.blocks.push(newParagraph); + listParent.blocks.splice(listIndex + 1, 0, newListItem); + context.deleteResult = 'range'; + } + } +}; + +const createNewParagraph = (insertPoint: InsertPoint) => { + const { paragraph, marker } = insertPoint; + const newParagraph = createParagraph(false, paragraph.format, paragraph.segmentFormat); + const selectionMarker = createSelectionMarker(marker.format); + paragraph.segments.push(selectionMarker); + return newParagraph; +}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 70ed141d425..04f79bc48d1 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -1,4 +1,5 @@ import { deleteSelection, isModifierKey } from 'roosterjs-content-model-core'; +import { handleEnterOnList } from './handleEnterOnList'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; import type { DOMSelection, IStandaloneEditor } from 'roosterjs-content-model-types'; @@ -13,7 +14,11 @@ export function keyboardInput(editor: IStandaloneEditor, rawEvent: KeyboardEvent editor.formatContentModel( (model, context) => { - const result = deleteSelection(model, [], context); + const result = deleteSelection( + model, + shouldHandleEnterKey(selection, rawEvent) ? [handleEnterOnList] : [], + context + ); // We have deleted selection then we will let browser to handle the input. // With this combined operation, we don't wan to mass up the cached model so clear it @@ -56,9 +61,19 @@ function shouldInputWithContentModel( ) { return ( selection.type != 'range' || - (!selection.range.collapsed && !rawEvent.isComposing && !isInIME) - ); // TODO: Also handle Enter key even selection is collapsed + (!selection.range.collapsed && !rawEvent.isComposing && !isInIME) || + shouldHandleEnterKey(selection, rawEvent) + ); } else { return false; } } + +const shouldHandleEnterKey = (selection: DOMSelection | null, rawEvent: KeyboardEvent) => { + return ( + selection && + selection.type == 'range' && + selection.range.collapsed && + rawEvent.key == 'Enter' + ); +}; From 4b0f47f3ce49202b3791ab77607c984e3423fcc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 1 Feb 2024 19:30:04 -0300 Subject: [PATCH 057/112] WIP --- .../roosterjs-content-model-dom/lib/index.ts | 1 + .../lib/edit/handleEnterOnList.ts | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) 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 551ce399585..79e478823ae 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -51,6 +51,7 @@ export { createEmptyModel } from './modelApi/creators/createEmptyModel'; export { addBlock } from './modelApi/common/addBlock'; export { addCode } from './modelApi/common/addDecorators'; export { addLink } from './modelApi/common/addDecorators'; +export { normalizeParagraph } from './modelApi/common/normalizeParagraph'; export { normalizeContentModel } from './modelApi/common/normalizeContentModel'; export { isGeneralSegment } from './modelApi/common/isGeneralSegment'; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts index c6c091c47b4..7d9fd97841e 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts @@ -4,7 +4,9 @@ import { getClosestAncestorBlockGroupIndex } from 'roosterjs-content-model-core' import { createListItem, createParagraph, - createSelectionMarker, + setParagraphNotImplicit, + normalizeParagraph, + createBr, } from 'roosterjs-content-model-dom'; /** @@ -28,6 +30,7 @@ export const handleEnterOnList: DeleteSelectionStep = context => { newListItem.blocks.push(newParagraph); listParent.blocks.splice(listIndex + 1, 0, newListItem); context.deleteResult = 'range'; + context.formatContext?.rawEvent?.preventDefault(); } } }; @@ -35,7 +38,18 @@ export const handleEnterOnList: DeleteSelectionStep = context => { const createNewParagraph = (insertPoint: InsertPoint) => { const { paragraph, marker } = insertPoint; const newParagraph = createParagraph(false, paragraph.format, paragraph.segmentFormat); - const selectionMarker = createSelectionMarker(marker.format); - paragraph.segments.push(selectionMarker); + const markerIndex = paragraph.segments.indexOf(marker); + const segments = paragraph.segments.splice( + markerIndex, + paragraph.segments.length - markerIndex + ); + + setParagraphNotImplicit(paragraph); + newParagraph.segments.push(...segments); + if (paragraph.segments.every(x => x.segmentType == 'SelectionMarker')) { + paragraph.segments.push(createBr(marker.format)); + } + + normalizeParagraph(newParagraph); return newParagraph; }; From cd428a110ac187b068e7d63556c143dd44163db3 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 1 Feb 2024 15:42:38 -0800 Subject: [PATCH 058/112] Add write permission to deploy action (#2383) --- .github/workflows/build-and-deploy.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 1bdf7c1b622..7b80d8b6250 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -6,6 +6,8 @@ on: jobs: build-and-deploy: runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout uses: actions/checkout@v2.3.1 From 25845f513b11e7fb2f823bf3b79118cb61b3616f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 2 Feb 2024 11:29:04 -0300 Subject: [PATCH 059/112] WIP --- .../lib/edit/handleEnterOnList.ts | 2 +- .../lib/edit/keyboardInput.ts | 10 +++++----- .../test/edit/keyboardDeleteTest.ts | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts index 7d9fd97841e..c0e237eaa18 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts @@ -1,5 +1,5 @@ -import { DeleteSelectionStep, InsertPoint } from 'roosterjs-content-model-types'; import { getClosestAncestorBlockGroupIndex } from 'roosterjs-content-model-core'; +import type { DeleteSelectionStep, InsertPoint } from 'roosterjs-content-model-types'; import { createListItem, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 04f79bc48d1..d86eb6b5d03 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -14,11 +14,7 @@ export function keyboardInput(editor: IStandaloneEditor, rawEvent: KeyboardEvent editor.formatContentModel( (model, context) => { - const result = deleteSelection( - model, - shouldHandleEnterKey(selection, rawEvent) ? [handleEnterOnList] : [], - context - ); + const result = deleteSelection(model, getInputSteps(selection, rawEvent), context); // We have deleted selection then we will let browser to handle the input. // With this combined operation, we don't wan to mass up the cached model so clear it @@ -48,6 +44,10 @@ export function keyboardInput(editor: IStandaloneEditor, rawEvent: KeyboardEvent } } +function getInputSteps(selection: DOMSelection | null, rawEvent: KeyboardEvent) { + return shouldHandleEnterKey(selection, rawEvent) ? [handleEnterOnList] : []; +} + function shouldInputWithContentModel( selection: DOMSelection | null, rawEvent: KeyboardEvent, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts index 06f1edd5e85..93661226d92 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts @@ -93,7 +93,7 @@ describe('keyboardDelete', () => { blockGroupType: 'Document', blocks: [], }, - [null!, null!, forwardDeleteCollapsedSelection, null!], + [null!, null!, forwardDeleteCollapsedSelection, deleteList], 'notDeleted', true, 0 @@ -131,7 +131,7 @@ describe('keyboardDelete', () => { blockGroupType: 'Document', blocks: [], }, - [null!, forwardDeleteWordSelection, forwardDeleteCollapsedSelection, null!], + [null!, forwardDeleteWordSelection, forwardDeleteCollapsedSelection, deleteList], 'notDeleted', true, 0 @@ -171,7 +171,7 @@ describe('keyboardDelete', () => { blockGroupType: 'Document', blocks: [], }, - [null!, null!, forwardDeleteCollapsedSelection, null!], + [null!, null!, forwardDeleteCollapsedSelection, deleteList], 'notDeleted', true, 0 @@ -233,7 +233,7 @@ describe('keyboardDelete', () => { }, ], }, - [null!, null!, forwardDeleteCollapsedSelection, null!], + [null!, null!, forwardDeleteCollapsedSelection, deleteList], 'notDeleted', true, 0 @@ -327,7 +327,7 @@ describe('keyboardDelete', () => { }, ], }, - [null!, null!, forwardDeleteCollapsedSelection, null!], + [null!, null!, forwardDeleteCollapsedSelection, deleteList], 'singleChar', false, 1 From f57da171aff6222bedb4be978386c5c4a0235add Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Fri, 2 Feb 2024 11:39:15 -0600 Subject: [PATCH 060/112] Revert "Add support to cursor around Block entities. (#2350)" (#2390) This reverts commit 7998d03a7f225e270d5e061926587bf978a27e98. --- .../lib/publicApi/entity/insertEntity.ts | 10 +-- .../test/publicApi/entity/insertEntityTest.ts | 7 +- .../corePlugin/ContentModelCopyPastePlugin.ts | 5 +- .../override/pasteCopyBlockEntityParser.ts | 40 --------- .../lib/utils/paste/mergePasteContent.ts | 2 - .../pasteCopyBlockEntityParserTest.ts | 82 ------------------- .../test/utils/paste/mergePasteContentTest.ts | 2 - .../lib/modelToDom/handlers/handleEntity.ts | 17 +--- 8 files changed, 12 insertions(+), 153 deletions(-) delete mode 100644 packages-content-model/roosterjs-content-model-core/lib/override/pasteCopyBlockEntityParser.ts delete mode 100644 packages-content-model/roosterjs-content-model-core/test/overrides/pasteCopyBlockEntityParserTest.ts diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts index 107bfb31332..2cefb62eb9b 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts @@ -9,6 +9,7 @@ import type { IStandaloneEditor, } from 'roosterjs-content-model-types'; +const BlockEntityTag = 'div'; const InlineEntityTag = 'span'; /** @@ -57,11 +58,10 @@ export default function insertEntity( options?: InsertEntityOptions ): ContentModelEntity | null { const { contentNode, focusAfterEntity, wrapperDisplay, skipUndoSnapshot } = options || {}; - const wrapper = editor.getDocument().createElement(InlineEntityTag); - if (isBlock) { - wrapper.style.width = '100%'; - } - wrapper.style.setProperty('display', wrapperDisplay ?? ('inline-block' || null)); + const wrapper = editor.getDocument().createElement(isBlock ? BlockEntityTag : InlineEntityTag); + const display = wrapperDisplay ?? (isBlock ? undefined : 'inline-block'); + + wrapper.style.setProperty('display', display || null); if (contentNode) { wrapper.appendChild(contentNode); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts index 0ef9c5704fe..d820c74f355 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts @@ -110,11 +110,12 @@ describe('insertEntity', () => { wrapper: wrapper, }); }); + it('block inline entity to root', () => { const entity = insertEntity(editor, type, true, 'root'); - expect(createElementSpy).toHaveBeenCalledWith('span'); - expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block'); + expect(createElementSpy).toHaveBeenCalledWith('div'); + expect(setPropertySpy).toHaveBeenCalledWith('display', null); expect(appendChildSpy).not.toHaveBeenCalled(); expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName); expect(formatWithContentModelSpy.calls.argsFor(0)[1].changeSource).toEqual( @@ -164,7 +165,7 @@ describe('insertEntity', () => { wrapperDisplay: 'none', }); - expect(createElementSpy).toHaveBeenCalledWith('span'); + expect(createElementSpy).toHaveBeenCalledWith('div'); expect(setPropertySpy).toHaveBeenCalledWith('display', 'none'); expect(appendChildSpy).toHaveBeenCalledWith(contentNode); expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName); diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts index 0535b2ec14c..0b79d4cb758 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts @@ -5,7 +5,7 @@ import { deleteSelection } from '../publicApi/selection/deleteSelection'; import { extractClipboardItems } from '../utils/extractClipboardItems'; import { getSelectedCells } from '../publicApi/table/getSelectedCells'; import { iterateSelections } from '../publicApi/selection/iterateSelections'; -import { onCreateCopyEntityNode } from '../override/pasteCopyBlockEntityParser'; + import { contentModelToDom, createModelToDomContext, @@ -299,14 +299,13 @@ function domSelectionToRange(doc: Document, selection: DOMSelection): Range | nu * @internal * Exported only for unit testing */ -export const onNodeCreated: OnNodeCreated = (model, node): void => { +export const onNodeCreated: OnNodeCreated = (_, node): void => { if (isNodeOfType(node, 'ELEMENT_NODE') && isElementOfType(node, 'table')) { wrap(node.ownerDocument, node, 'div'); } if (isNodeOfType(node, 'ELEMENT_NODE') && !node.isContentEditable) { node.removeAttribute('contenteditable'); } - onCreateCopyEntityNode(model, node); }; /** diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/pasteCopyBlockEntityParser.ts b/packages-content-model/roosterjs-content-model-core/lib/override/pasteCopyBlockEntityParser.ts deleted file mode 100644 index 5918f4ffc1a..00000000000 --- a/packages-content-model/roosterjs-content-model-core/lib/override/pasteCopyBlockEntityParser.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { isElementOfType, isNodeOfType } from 'roosterjs-content-model-dom'; -import type { - ContentModelEntity, - EntityInfoFormat, - FormatParser, - OnNodeCreated, -} from 'roosterjs-content-model-types'; - -const BLOCK_ENTITY_CLASS = '_EBlock'; -const ONE_HUNDRED_PERCENT = '100%'; - -/** - * @internal - */ -export const onCreateCopyEntityNode: OnNodeCreated = (model, node) => { - const entityModel = model as ContentModelEntity; - if ( - entityModel && - entityModel.wrapper && - entityModel.segmentType == 'Entity' && - isNodeOfType(node, 'ELEMENT_NODE') && - isElementOfType(node, 'span') && - node.style.width == ONE_HUNDRED_PERCENT && - node.style.display == 'inline-block' - ) { - node.classList.add(BLOCK_ENTITY_CLASS); - node.style.display = 'block'; - } -}; - -/** - * @internal - */ -export const pasteBlockEntityParser: FormatParser = (_, element) => { - if (element.classList.contains(BLOCK_ENTITY_CLASS)) { - element.style.display = 'inline-block'; - element.style.width = ONE_HUNDRED_PERCENT; - element.classList.remove(BLOCK_ENTITY_CLASS); - } -}; 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 9e570619bad..57796bbb053 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 @@ -6,7 +6,6 @@ import { createPasteGeneralProcessor } from '../../override/pasteGeneralProcesso import { getSegmentTextFormat } from '../../publicApi/domUtils/getSegmentTextFormat'; import { getSelectedSegments } from '../../publicApi/selection/collectSelections'; import { mergeModel } from '../../publicApi/model/mergeModel'; -import { pasteBlockEntityParser } from '../../override/pasteCopyBlockEntityParser'; import { pasteDisplayFormatParser } from '../../override/pasteDisplayFormatParser'; import { pasteTextProcessor } from '../../override/pasteTextProcessor'; import type { MergeModelOption } from '../../publicApi/model/mergeModel'; @@ -60,7 +59,6 @@ export function mergePasteContent( }, additionalFormatParsers: { container: [containerSizeFormatParser], - entity: [pasteBlockEntityParser], }, }, domToModelOption diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/pasteCopyBlockEntityParserTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteCopyBlockEntityParserTest.ts deleted file mode 100644 index 3ee0548f219..00000000000 --- a/packages-content-model/roosterjs-content-model-core/test/overrides/pasteCopyBlockEntityParserTest.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { ContentModelEntity } from 'roosterjs-content-model-types'; -import { - onCreateCopyEntityNode, - pasteBlockEntityParser, -} from '../../lib/override/pasteCopyBlockEntityParser'; - -describe('onCreateCopyEntityNode', () => { - it('handle', () => { - const span = document.createElement('span'); - span.style.width = '100%'; - span.style.display = 'inline-block'; - const modelEntity: ContentModelEntity = { - entityFormat: {}, - format: {}, - wrapper: span, - segmentType: 'Entity', - blockType: 'Entity', - }; - - onCreateCopyEntityNode(modelEntity, span); - - expect(span.style.display).toEqual('block'); - expect(span.classList.contains('_EBlock')).toBeTrue(); - }); - - it('Dont handle, no 100% width', () => { - const span = document.createElement('span'); - span.style.display = 'inline-block'; - const modelEntity: ContentModelEntity = { - entityFormat: {}, - format: {}, - wrapper: span, - segmentType: 'Entity', - blockType: 'Entity', - }; - - onCreateCopyEntityNode(modelEntity, span); - - expect(span.style.display).not.toEqual('block'); - expect(span.classList.contains('_EBlock')).not.toBeTrue(); - }); - - it('Dont handle, not inline block', () => { - const span = document.createElement('span'); - span.style.width = '100%'; - const modelEntity: ContentModelEntity = { - entityFormat: {}, - format: {}, - wrapper: span, - segmentType: 'Entity', - blockType: 'Entity', - }; - - onCreateCopyEntityNode(modelEntity, span); - - expect(span.style.display).not.toEqual('block'); - expect(span.classList.contains('_EBlock')).not.toBeTrue(); - }); -}); - -describe('pasteBlockEntityParser', () => { - it('handle', () => { - const span = document.createElement('span'); - span.classList.add('_EBlock'); - - pasteBlockEntityParser({}, span, {}, {}); - - expect(span.style.width).toEqual('100%'); - expect(span.style.display).toEqual('inline-block'); - expect(span.classList.contains('_EBlock')).toBeFalse(); - }); - - it('Dont handle', () => { - const span = document.createElement('span'); - - pasteBlockEntityParser({}, span, {}, {}); - - expect(span.style.width).not.toEqual('100%'); - expect(span.style.display).not.toEqual('inline-block'); - expect(span.classList.contains('_EBlock')).toBeFalse(); - }); -}); 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 2c6abdb2ed0..c0e8c7df151 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 @@ -6,7 +6,6 @@ import * as mergeModelFile from '../../../lib/publicApi/model/mergeModel'; import { containerSizeFormatParser } from '../../../lib/override/containerSizeFormatParser'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { mergePasteContent } from '../../../lib/utils/paste/mergePasteContent'; -import { pasteBlockEntityParser } from '../../../lib/override/pasteCopyBlockEntityParser'; import { pasteDisplayFormatParser } from '../../../lib/override/pasteDisplayFormatParser'; import { pasteTextProcessor } from '../../../lib/override/pasteTextProcessor'; import { @@ -430,7 +429,6 @@ describe('mergePasteContent', () => { }, additionalFormatParsers: { container: [containerSizeFormatParser], - entity: [pasteBlockEntityParser], }, }, mockedDefaultDomToModelOptions diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts index c55a838bf62..740118c4e64 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts @@ -7,7 +7,6 @@ import type { ContentModelBlockHandler, ContentModelEntity, ContentModelSegmentHandler, - ModelToDomContext, } from 'roosterjs-content-model-types'; /** @@ -54,7 +53,7 @@ export const handleEntitySegment: ContentModelSegmentHandler applyFormat(wrapper, context.formatAppliers.entity, entityFormat, context); if (context.addDelimiterForEntity && entityFormat.isReadonly) { - const [after, before] = addDelimiterForEntity(doc, wrapper, context); + const [after, before] = addDelimiters(doc, wrapper); newSegments?.push(after, before); context.regularSelection.current.segment = after; @@ -64,17 +63,3 @@ export const handleEntitySegment: ContentModelSegmentHandler context.onNodeCreated?.(entityModel, wrapper); }; - -function addDelimiterForEntity(doc: Document, wrapper: HTMLElement, context: ModelToDomContext) { - const [after, before] = addDelimiters(doc, wrapper); - - const format = { - ...context.pendingFormat?.format, - ...context.defaultFormat, - }; - - applyFormat(after, context.formatAppliers.segment, format, context); - applyFormat(before, context.formatAppliers.segment, format, context); - - return [after, before]; -} From aae80545e415503a1060de0a0e5360c075f4f0e1 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 2 Feb 2024 09:48:40 -0800 Subject: [PATCH 061/112] Content Model: Fix #2202 (#2389) Co-authored-by: Bryan Valverde U --- .../lib/publicApi/selection/deleteBlock.ts | 3 +++ .../lib/publicApi/selection/deleteSegment.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts index f85b93cf66a..df023246d3f 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts @@ -38,6 +38,9 @@ export function deleteBlock( : undefined; if (operation !== undefined) { + const wrapper = blockToDelete.wrapper; + + wrapper.parentNode?.removeChild(wrapper); replacement ? blocks.splice(index, 1, replacement) : blocks.splice(index, 1); context?.deletedEntities.push({ entity: blockToDelete, 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 d2064a4e255..de146b5d777 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 @@ -48,6 +48,9 @@ export function deleteSegment( ? 'removeFromEnd' : undefined; if (operation !== undefined) { + const wrapper = segmentToDelete.wrapper; + + wrapper.parentNode?.removeChild(wrapper); segments.splice(index, 1); context?.deletedEntities.push({ entity: segmentToDelete, From df322a9e1dc7308a9b14aa15a45130321bc9816e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 2 Feb 2024 15:59:30 -0300 Subject: [PATCH 062/112] WIP --- .../roosterjs-content-model-api/lib/index.ts | 1 + .../list/findListItemsInSameThread.ts | 2 +- .../lib/autoFormat/keyboardListTrigger.ts | 42 ++++++++---------- .../lib/autoFormat/utils/getListTypeStyle.ts | 26 ++++++----- .../lib/edit/handleEnterOnList.ts | 44 ++++++++++++++++--- 5 files changed, 74 insertions(+), 41 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/index.ts b/packages-content-model/roosterjs-content-model-api/lib/index.ts index b5bd38fc378..78e29dfec63 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/index.ts @@ -41,3 +41,4 @@ export { default as setParagraphMargin } from './publicApi/block/setParagraphMar export { default as toggleCode } from './publicApi/segment/toggleCode'; export { default as insertEntity } from './publicApi/entity/insertEntity'; export { setListType } from './modelApi/list/setListType'; +export { findListItemsInSameThread } from './modelApi/list/findListItemsInSameThread'; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts index 4a59423cd94..bdcca1622f5 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts @@ -5,7 +5,7 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * Search for all list items in the same thread as the current list item */ export function findListItemsInSameThread( model: ContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts index 7bfa728eb06..61733761edb 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -1,5 +1,6 @@ import { getListTypeStyle } from './utils/getListTypeStyle'; import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core'; +import { normalizeContentModel } from 'roosterjs-content-model-dom/lib'; import { setListStartNumber, setListStyle, setListType } from 'roosterjs-content-model-api'; import type { ContentModelDocument, IStandaloneEditor } from 'roosterjs-content-model-types'; @@ -13,31 +14,26 @@ export function keyboardListTrigger( shouldSearchForBullet: boolean = true, shouldSearchForNumbering: boolean = true ) { - editor.formatContentModel( - (model, _context) => { - const listStyleType = getListTypeStyle( - model, - shouldSearchForBullet, - shouldSearchForNumbering - ); - if (listStyleType) { - const segmentsAndParagraphs = getSelectedSegmentsAndParagraphs(model, false); - if (segmentsAndParagraphs[0] && segmentsAndParagraphs[0][1]) { - segmentsAndParagraphs[0][1].segments.splice(0, 1); - } - const { listType, styleType, index } = listStyleType; - triggerList(editor, model, listType, styleType, index); - - return true; + editor.formatContentModel((model, _context) => { + const listStyleType = getListTypeStyle( + model, + shouldSearchForBullet, + shouldSearchForNumbering + ); + if (listStyleType) { + normalizeContentModel(model); + const segmentsAndParagraphs = getSelectedSegmentsAndParagraphs(model, false); + if (segmentsAndParagraphs[0] && segmentsAndParagraphs[0][1]) { + segmentsAndParagraphs[0][1].segments.splice(0, 1); } - return false; - }, - { - apiName: 'keyboardListTrigger', - changeSource: 'keyboard', - onNodeCreated: node => {}, + const { listType, styleType, index } = listStyleType; + triggerList(editor, model, listType, styleType, index); + + rawEvent.preventDefault(); + return true; } - ); + return false; + }); } const triggerList = ( diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts index 564bdb7267b..b8edd5e241b 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts @@ -1,6 +1,5 @@ -import { findListItemsInSameThread } from 'roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread'; +import { findListItemsInSameThread } from 'roosterjs-content-model-api'; import { getNumberingListStyle } from './getNumberingListStyle'; - import type { ContentModelDocument, ContentModelListItem, @@ -61,7 +60,9 @@ export function getListTypeStyle( listType: 'OL', styleType: numberingType, index: - previousListStyle === numberingType && previousIndex + !isNewList(listMarker) && + previousListStyle === numberingType && + previousIndex ? previousIndex + 1 : undefined, }; @@ -79,16 +80,13 @@ const getPreviousListIndex = ( }; const getPreviousListLevel = (model: ContentModelDocument, paragraph: ContentModelParagraph) => { - const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); + const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell'])[0]; let listItem: ContentModelListItem | undefined = undefined; - const listBlock = blocks.filter(({ block, parent }) => { - return parent.blocks.indexOf(paragraph) > -1; - })[0]; + const listBlockIndex = blocks.parent.blocks.indexOf(paragraph); - if (listBlock) { - const length = listBlock.parent.blocks.length; - for (let i = length - 1; i > -1; i--) { - const item = listBlock.parent.blocks[i]; + if (listBlockIndex > -1) { + for (let i = listBlockIndex - 1; i > -1; i--) { + const item = blocks.parent.blocks[i]; if (isBlockGroupOfType(item, 'ListItem')) { listItem = item; break; @@ -114,3 +112,9 @@ const bulletListType: Record = { '>': BulletListType.ShortArrow, '—': BulletListType.Hyphen, }; + +const isNewList = (listMarker: string) => { + const marker = listMarker.replace(/[^\w\s]/g, ''); + const pattern = /^[1aAiI]$/; + return pattern.test(marker); +}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts index c0e237eaa18..4038952964c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts @@ -1,5 +1,11 @@ import { getClosestAncestorBlockGroupIndex } from 'roosterjs-content-model-core'; -import type { DeleteSelectionStep, InsertPoint } from 'roosterjs-content-model-types'; +import type { + ContentModelBlockGroup, + ContentModelListItem, + DeleteSelectionStep, + InsertPoint, + ValidDeleteSelectionContext, +} from 'roosterjs-content-model-types'; import { createListItem, @@ -21,20 +27,45 @@ export const handleEnterOnList: DeleteSelectionStep = context => { ['FormatContainer', 'ListItem'], ['TableCell'] ); + const listItem = path[index]; if (listItem && listItem.blockGroupType === 'ListItem') { const listParent = path[index + 1]; - const listIndex = listParent.blocks.indexOf(listItem); - const newParagraph = createNewParagraph(insertPoint); - const newListItem = createListItem(listItem.levels, listItem.format); - newListItem.blocks.push(newParagraph); - listParent.blocks.splice(listIndex + 1, 0, newListItem); + if (isEmptyListItem(listItem)) { + listItem.levels.pop(); + } else { + createNewListItem(context, listItem, listParent); + } + context.deleteResult = 'range'; context.formatContext?.rawEvent?.preventDefault(); } } }; +const isEmptyListItem = (listItem: ContentModelListItem) => { + return ( + listItem.blocks.length === 1 && + listItem.blocks[0].blockType === 'Paragraph' && + listItem.blocks[0].segments.length === 2 && + listItem.blocks[0].segments[0].segmentType === 'SelectionMarker' && + listItem.blocks[0].segments[1].segmentType === 'Br' + ); +}; + +const createNewListItem = ( + context: ValidDeleteSelectionContext, + listItem: ContentModelListItem, + listParent: ContentModelBlockGroup +) => { + const { insertPoint } = context; + const listIndex = listParent.blocks.indexOf(listItem); + const newParagraph = createNewParagraph(insertPoint); + const newListItem = createListItem(listItem.levels, listItem.format); + newListItem.blocks.push(newParagraph); + listParent.blocks.splice(listIndex + 1, 0, newListItem); +}; + const createNewParagraph = (insertPoint: InsertPoint) => { const { paragraph, marker } = insertPoint; const newParagraph = createParagraph(false, paragraph.format, paragraph.segmentFormat); @@ -51,5 +82,6 @@ const createNewParagraph = (insertPoint: InsertPoint) => { } normalizeParagraph(newParagraph); + return newParagraph; }; From 3c1b3accfa95d01494f3829bf12750c17aec7245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 2 Feb 2024 18:25:48 -0300 Subject: [PATCH 063/112] WIP --- .../lib/autoFormat/keyboardListTrigger.ts | 5 +- .../lib/edit/handleEnterOnList.ts | 49 +++++++++++-------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts index 61733761edb..1703c8c8318 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -1,6 +1,6 @@ import { getListTypeStyle } from './utils/getListTypeStyle'; import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core'; -import { normalizeContentModel } from 'roosterjs-content-model-dom/lib'; +import { normalizeContentModel } from 'roosterjs-content-model-dom'; import { setListStartNumber, setListStyle, setListType } from 'roosterjs-content-model-api'; import type { ContentModelDocument, IStandaloneEditor } from 'roosterjs-content-model-types'; @@ -21,14 +21,13 @@ export function keyboardListTrigger( shouldSearchForNumbering ); if (listStyleType) { - normalizeContentModel(model); const segmentsAndParagraphs = getSelectedSegmentsAndParagraphs(model, false); if (segmentsAndParagraphs[0] && segmentsAndParagraphs[0][1]) { segmentsAndParagraphs[0][1].segments.splice(0, 1); } const { listType, styleType, index } = listStyleType; triggerList(editor, model, listType, styleType, index); - + normalizeContentModel(model); rawEvent.preventDefault(); return true; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts index 4038952964c..fd0b1758fdc 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts @@ -1,4 +1,10 @@ import { getClosestAncestorBlockGroupIndex } from 'roosterjs-content-model-core'; +import { + createListItem, + createListLevel, + createParagraph, + normalizeParagraph, +} from 'roosterjs-content-model-dom'; import type { ContentModelBlockGroup, ContentModelListItem, @@ -7,14 +13,6 @@ import type { ValidDeleteSelectionContext, } from 'roosterjs-content-model-types'; -import { - createListItem, - createParagraph, - setParagraphNotImplicit, - normalizeParagraph, - createBr, -} from 'roosterjs-content-model-dom'; - /** * @internal */ @@ -32,13 +30,12 @@ export const handleEnterOnList: DeleteSelectionStep = context => { if (listItem && listItem.blockGroupType === 'ListItem') { const listParent = path[index + 1]; if (isEmptyListItem(listItem)) { - listItem.levels.pop(); + listItem.levels = []; } else { createNewListItem(context, listItem, listParent); } - - context.deleteResult = 'range'; context.formatContext?.rawEvent?.preventDefault(); + context.deleteResult = 'range'; } } }; @@ -47,9 +44,8 @@ const isEmptyListItem = (listItem: ContentModelListItem) => { return ( listItem.blocks.length === 1 && listItem.blocks[0].blockType === 'Paragraph' && - listItem.blocks[0].segments.length === 2 && - listItem.blocks[0].segments[0].segmentType === 'SelectionMarker' && - listItem.blocks[0].segments[1].segmentType === 'Br' + listItem.blocks[0].segments.length === 1 && + listItem.blocks[0].segments[0].segmentType === 'SelectionMarker' ); }; @@ -61,27 +57,38 @@ const createNewListItem = ( const { insertPoint } = context; const listIndex = listParent.blocks.indexOf(listItem); const newParagraph = createNewParagraph(insertPoint); - const newListItem = createListItem(listItem.levels, listItem.format); + const levels = createNewListLevel(listItem); + const newListItem = createListItem(levels, listItem.format); newListItem.blocks.push(newParagraph); listParent.blocks.splice(listIndex + 1, 0, newListItem); }; +const createNewListLevel = (listItem: ContentModelListItem) => { + return listItem.levels.map(level => { + return createListLevel( + level.listType, + { + ...level.format, + startNumberOverride: level.format.startNumberOverride + ? level.format.startNumberOverride + 1 + : undefined, + }, + level.dataset + ); + }); +}; + const createNewParagraph = (insertPoint: InsertPoint) => { const { paragraph, marker } = insertPoint; - const newParagraph = createParagraph(false, paragraph.format, paragraph.segmentFormat); + const newParagraph = createParagraph(true, paragraph.format, paragraph.segmentFormat); const markerIndex = paragraph.segments.indexOf(marker); const segments = paragraph.segments.splice( markerIndex, paragraph.segments.length - markerIndex ); - setParagraphNotImplicit(paragraph); newParagraph.segments.push(...segments); - if (paragraph.segments.every(x => x.segmentType == 'SelectionMarker')) { - paragraph.segments.push(createBr(marker.format)); - } normalizeParagraph(newParagraph); - return newParagraph; }; From d13dd59027cc5cadcfa3d3a5d54a79b4ecd5602e Mon Sep 17 00:00:00 2001 From: Andres-CT98 <107568016+Andres-CT98@users.noreply.github.com> Date: Fri, 2 Feb 2024 15:40:42 -0600 Subject: [PATCH 064/112] Port plugin and roosterjs-editor-dom utilities (#2388) * copy utils * add tests --- .../CreateElement/CreateElementData.ts | 41 +++++ .../CreateElement/createElement.ts | 58 +++++++ .../lib/pluginUtils/Disposable.ts | 10 ++ .../DragAndDrop/DragAndDropHandler.ts | 57 +++++++ .../DragAndDrop/DragAndDropHelper.ts | 149 ++++++++++++++++++ .../pluginUtils/Rect/getIntersectedRect.ts | 46 ++++++ .../lib/pluginUtils/Rect/normalizeRect.ts | 19 +++ .../test/pluginUtils/DragAndDropHelperTest.ts | 141 +++++++++++++++++ .../test/pluginUtils/createElementTest.ts | 70 ++++++++ 9 files changed, 591 insertions(+) create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/CreateElement/CreateElementData.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/CreateElement/createElement.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Disposable.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/DragAndDrop/DragAndDropHandler.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/DragAndDrop/DragAndDropHelper.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Rect/getIntersectedRect.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Rect/normalizeRect.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/pluginUtils/DragAndDropHelperTest.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/pluginUtils/createElementTest.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/CreateElement/CreateElementData.ts b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/CreateElement/CreateElementData.ts new file mode 100644 index 00000000000..80fb8b9d425 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/CreateElement/CreateElementData.ts @@ -0,0 +1,41 @@ +/** + * @internal + * An interface represents the data for creating element used by createElement() + */ +export default interface CreateElementData { + /** + * Tag name of this element. + * It can be just a tag, or in format "namespace:tag" + */ + tag: string; + + /** + * Namespace of this tag + */ + namespace?: string; + + /** + * CSS class name + */ + className?: string; + + /** + * CSS style + */ + style?: string; + + /** + * Dataset of this element + */ + dataset?: Record; + + /** + * Additional attributes of this element + */ + attributes?: Record; + + /** + * Child nodes of this element, can be another CreateElementData, or a string which represents a text node + */ + children?: (CreateElementData | string)[]; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/CreateElement/createElement.ts b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/CreateElement/createElement.ts new file mode 100644 index 00000000000..1e331473ad6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/CreateElement/createElement.ts @@ -0,0 +1,58 @@ +import { getObjectKeys, isNodeOfType } from 'roosterjs-content-model-dom'; +import type CreateElementData from './CreateElementData'; + +/** + * @internal + * Create DOM element from the given CreateElementData + * @param elementData The CreateElementData or an index of a known CreateElementData used for creating this element + * @param document The document to create the element from + * @returns The root DOM element just created + */ +export default function createElement( + elementData: CreateElementData, + document: Document +): Element | null { + if (!elementData || !elementData.tag) { + return null; + } + + const { tag, namespace, className, style, dataset, attributes, children } = elementData; + const result = namespace + ? document.createElementNS(namespace, tag) + : document.createElement(tag); + + if (style) { + result.setAttribute('style', style); + } + + if (className) { + result.className = className; + } + + if (dataset && isNodeOfType(result, 'ELEMENT_NODE')) { + getObjectKeys(dataset).forEach(datasetName => { + result.dataset[datasetName] = dataset[datasetName]; + }); + } + + if (attributes) { + getObjectKeys(attributes).forEach(attrName => { + result.setAttribute(attrName, attributes[attrName]); + }); + } + + if (children) { + children.forEach(child => { + if (typeof child === 'string') { + result.appendChild(document.createTextNode(child)); + } else if (child) { + const childElement = createElement(child, document); + if (childElement) { + result.appendChild(childElement); + } + } + }); + } + + return result; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Disposable.ts b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Disposable.ts new file mode 100644 index 00000000000..06a344e0fb3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Disposable.ts @@ -0,0 +1,10 @@ +/** + * @internal + * Represents a disposable object + */ +export default interface Disposable { + /** + * Dispose this object + */ + dispose: () => void; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/DragAndDrop/DragAndDropHandler.ts b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/DragAndDrop/DragAndDropHandler.ts new file mode 100644 index 00000000000..ff1eed6756e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/DragAndDrop/DragAndDropHandler.ts @@ -0,0 +1,57 @@ +/** + * @internal + * Drag and drop handler interface, used for implementing a handler object and pass into DragAndDropHelper class + */ +export default interface DragAndDropHandler { + /** + * A callback that will be called when user starts to drag (mouse down event from the trigger element) + * @param context The context object that was passed into DragAndDropHelper from its constructor. We can use + * this object to communicate between caller code and this handler + * @param event The mouse event that triggers this callback + * @returns An optional object, which will be passed into onDragging and onDragEnd callback. It normally used + * for passing an initial state of the target object + */ + onDragStart?: (context: TContext, event: MouseEvent) => TInitValue; + + /** + * A callback that will be called when user moves mouse and drag the trigger element. + * @param context The context object that was passed into DragAndDropHelper from its constructor. We can use + * this object to communicate between caller code and this handler.If an object is used as context, here it will + * be the same object that passed into constructor of DragAndDropHelper class. Inside this callback you can change + * its sub value so that caller can get the changed result. + * @param event The mouse event that triggers this callback + * @param initValue The initial value that is returned from onDragStart callback. It normally used + * for passing an initial state of the target object + * @param deltaX x delta value. It equals to current event.pageX - initial pageX (captured when mousedown happens) + * @param deltaY y delta value. It equals to current event.pageY - initial pageY (captured when mousedown happens) + * @returns Whether the onSubmit callback passed into constructor of DragAndDropHelper class should be invoked. + * Returns true will invoke the onSubmit callback, it means this is a meaningful dragging action, something (mostly + * under context object) has been changed, and caller should handle this change. Otherwise, return false. + */ + onDragging?: ( + context: TContext, + event: MouseEvent, + initValue: TInitValue, + deltaX: number, + deltaY: number + ) => boolean; + + /** + * A callback that will be called when user stops dragging the trigger element. + * @param context The context object that was passed into DragAndDropHelper from its constructor. We can use + * this object to communicate between caller code and this handler.If an object is used as context, here it will + * be the same object that passed into constructor of DragAndDropHelper class. Inside this callback you can change + * its sub value so that caller can get the changed result. + * @param event The mouse event that triggers this callback + * @param initValue The initial value that is returned from onDragStart callback. It normally used + * for passing an initial state of the target object + * @returns Whether the onSubmit callback passed into constructor of DragAndDropHelper class should be invoked. + * Returns true will invoke the onSubmit callback, it means this is a meaningful dragging action, something (mostly + * under context object) has been changed, and caller should handle this change. Otherwise, return false. + */ + onDragEnd?: ( + context: TContext, + event: MouseEvent, + initValue: TInitValue | undefined + ) => boolean; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/DragAndDrop/DragAndDropHelper.ts b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/DragAndDrop/DragAndDropHelper.ts new file mode 100644 index 00000000000..e15f30000ed --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/DragAndDrop/DragAndDropHelper.ts @@ -0,0 +1,149 @@ +import type Disposable from '../Disposable'; +import type DragAndDropHandler from './DragAndDropHandler'; + +/** + * @internal + */ +interface MouseEventMoves { + MOUSEDOWN: string; + MOUSEMOVE: string; + MOUSEUP: string; +} + +/** + * @internal + */ +interface MouseEventInfo extends MouseEventMoves { + getPageXY: (e: MouseEvent) => number[]; +} + +/** + * @internal + * Compatible mouse event names for different platform + */ +interface TouchEventInfo extends MouseEventMoves { + getPageXY: (e: TouchEvent) => number[]; +} + +/** + * Generate event names and getXY function based on different platforms to be compatible with desktop and mobile browsers + */ +const MOUSE_EVENT_INFO_DESKTOP: MouseEventInfo = (() => { + return { + MOUSEDOWN: 'mousedown', + MOUSEMOVE: 'mousemove', + MOUSEUP: 'mouseup', + getPageXY: getMouseEventPageXY, + }; +})(); + +const MOUSE_EVENT_INFO_MOBILE: TouchEventInfo = (() => { + return { + MOUSEDOWN: 'touchstart', + MOUSEMOVE: 'touchmove', + MOUSEUP: 'touchend', + getPageXY: getTouchEventPageXY, + }; +})(); + +function getMouseEventPageXY(e: MouseEvent): [number, number] { + return [e.pageX, e.pageY]; +} + +function getTouchEventPageXY(e: TouchEvent): [number, number] { + let pageX = 0; + let pageY = 0; + if (e.targetTouches && e.targetTouches.length > 0) { + const touch = e.targetTouches[0]; + pageX = touch.pageX; + pageY = touch.pageY; + } + return [pageX, pageY]; +} + +/** + * @internal + * A helper class to help manage drag and drop to an HTML element + */ +export default class DragAndDropHelper implements Disposable { + private initX: number = 0; + private initY: number = 0; + private initValue: TInitValue | undefined = undefined; + private dndMouse: MouseEventInfo | TouchEventInfo; + + /** + * Create a new instance of DragAndDropHelper class + * @param trigger The trigger element. When user start drag on this element, + * events will be fired to the handler object + * @param context Context object that will be passed to handler function when event is fired, + * so that the handler object knows which element it is triggered from. + * @param onSubmit A callback that will be invoked when event handler in handler object returns true + * @param handler The event handler object, see DragAndDropHandler interface for more information + * @param zoomScale The zoom scale of the editor + * @param forceMobile A boolean to force the use of touch controls for the helper + */ + constructor( + private trigger: HTMLElement, + private context: TContext, + private onSubmit: (context: TContext, trigger: HTMLElement) => void, + private handler: DragAndDropHandler, + private zoomScale: number, + forceMobile?: boolean + ) { + this.dndMouse = forceMobile ? MOUSE_EVENT_INFO_MOBILE : MOUSE_EVENT_INFO_DESKTOP; + trigger.addEventListener(this.dndMouse.MOUSEDOWN, this.onMouseDown); + } + + /** + * Dispose this object, remove all event listeners that has been attached + */ + dispose() { + this.trigger.removeEventListener(this.dndMouse.MOUSEDOWN, this.onMouseDown); + this.removeDocumentEvents(); + } + + public get mouseType(): string { + return this.dndMouse == MOUSE_EVENT_INFO_MOBILE ? 'touch' : 'mouse'; + } + + private addDocumentEvents() { + const doc = this.trigger.ownerDocument; + doc.addEventListener(this.dndMouse.MOUSEMOVE, this.onMouseMove, true /*useCapture*/); + doc.addEventListener(this.dndMouse.MOUSEUP, this.onMouseUp, true /*useCapture*/); + } + + private removeDocumentEvents() { + const doc = this.trigger.ownerDocument; + doc.removeEventListener(this.dndMouse.MOUSEMOVE, this.onMouseMove, true /*useCapture*/); + doc.removeEventListener(this.dndMouse.MOUSEUP, this.onMouseUp, true /*useCapture*/); + } + + private onMouseDown = (e: Event) => { + e.preventDefault(); + e.stopPropagation(); + this.addDocumentEvents(); + [this.initX, this.initY] = this.dndMouse.getPageXY(e as MouseEvent & TouchEvent); + this.initValue = this.handler.onDragStart?.(this.context, e as MouseEvent); + }; + + private onMouseMove = (e: Event) => { + e.preventDefault(); + const [pageX, pageY] = this.dndMouse.getPageXY(e as MouseEvent & TouchEvent); + const deltaX = (pageX - this.initX) / this.zoomScale; + const deltaY = (pageY - this.initY) / this.zoomScale; + if ( + this.initValue && + this.handler.onDragging?.(this.context, e as MouseEvent, this.initValue, deltaX, deltaY) + ) { + this.onSubmit?.(this.context, this.trigger); + } + }; + + private onMouseUp = (e: Event) => { + e.preventDefault(); + this.removeDocumentEvents(); + if (this.handler.onDragEnd?.(this.context, e as MouseEvent, this.initValue)) { + this.onSubmit?.(this.context, this.trigger); + } + }; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Rect/getIntersectedRect.ts b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Rect/getIntersectedRect.ts new file mode 100644 index 00000000000..4845fcaef6b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Rect/getIntersectedRect.ts @@ -0,0 +1,46 @@ +import normalizeRect from './normalizeRect'; +import type { Rect } from 'roosterjs-content-model-types'; + +/** + * Get the intersected Rect of elements provided + * + * @example + * The result of the following Elements Rects would be: + { + top: Element2.top, + bottom: Element1.bottom, + left: Element2.left, + right: Element2.right + } + +-------------------------+ + | Element 1 | + | +-----------------+ | + | | Element2 | | + | | | | + | | | | + +-------------------------+ + | | + +-----------------+ + * @internal + * @param elements Elements to use. + * @param additionalRects additional rects to use + * @returns If the Rect is valid return the rect, if not, return null. + */ +export default function getIntersectedRect( + elements: HTMLElement[], + additionalRects: Rect[] = [] +): Rect | null { + const rects = elements + .map(element => normalizeRect(element.getBoundingClientRect())) + .concat(additionalRects) + .filter(element => !!element) as Rect[]; + + const result: Rect = { + top: Math.max(...rects.map(r => r.top)), + bottom: Math.min(...rects.map(r => r.bottom)), + left: Math.max(...rects.map(r => r.left)), + right: Math.min(...rects.map(r => r.right)), + }; + + return result.top < result.bottom && result.left < result.right ? result : null; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Rect/normalizeRect.ts b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Rect/normalizeRect.ts new file mode 100644 index 00000000000..dca5dcf9af0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/pluginUtils/Rect/normalizeRect.ts @@ -0,0 +1,19 @@ +import type { Rect } from 'roosterjs-content-model-types'; + +/** + * @internal + * A ClientRect of all 0 is possible. i.e. chrome returns a ClientRect of 0 when the cursor is on an empty p + * We validate that and only return a rect when the passed in ClientRect is valid + */ +export default function normalizeRect(clientRect: DOMRect): Rect | null { + const { left, right, top, bottom } = + clientRect || { left: 0, right: 0, top: 0, bottom: 0 }; + return left === 0 && right === 0 && top === 0 && bottom === 0 + ? null + : { + left: Math.round(left), + right: Math.round(right), + top: Math.round(top), + bottom: Math.round(bottom), + }; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/test/pluginUtils/DragAndDropHelperTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/pluginUtils/DragAndDropHelperTest.ts new file mode 100644 index 00000000000..b2222c8374e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/pluginUtils/DragAndDropHelperTest.ts @@ -0,0 +1,141 @@ +import DragAndDropHelper from '../../lib/pluginUtils/DragAndDrop/DragAndDropHelper'; + +interface DragAndDropContext { + node: HTMLElement; +} + +interface DragAndDropInitValue { + originalRect: DOMRect; +} + +describe('DragAndDropHelper |', () => { + let id = 'DragAndDropHelperId'; + let dndHelper: DragAndDropHelper; + + beforeEach(() => { + //Empty Div for dragging + let node = document.createElement('div'); + node.id = id; + //Start as black square + node.style.width = '50px'; + node.style.height = '50px'; + node.style.backgroundColor = 'black'; + node.style.position = 'fixed'; + node.style.top = '0px'; + node.style.left = '0px'; + + //Put node on top of body + document.body.insertBefore(node, document.body.childNodes[0]); + }); + + //Creates the DragAndDropHelper for testing + function createDnD(node: HTMLElement, mobile: boolean) { + dndHelper = new DragAndDropHelper( + node, + { node }, + () => {}, + { + onDragEnd(context: DragAndDropContext) { + //Red indicates dragging stopped + context.node.style.backgroundColor = 'red'; + return true; + }, + onDragStart(context: DragAndDropContext) { + //Green indicates dragging started + context.node.style.backgroundColor = 'green'; + return { originalRect: context.node.getBoundingClientRect() }; + }, + onDragging(context: DragAndDropContext, event: MouseEvent) { + //Yellow indicates dragging is happening + context.node.style.backgroundColor = 'yellow'; + context.node.style.left = event.pageX + 'px'; + context.node.style.top = event.pageY + 'px'; + return true; + }, + }, + 1, + mobile + ); + } + + afterEach(() => { + dndHelper.dispose(); + }); + + it('mouse movement', () => { + // Arrange + const target = document.getElementById(id); + createDnD(target, false); + let targetEnd = target; + targetEnd.style.top = 50 + 'px'; + + // Assert + expect(dndHelper.mouseType).toBe('mouse'); + + // Act + simulateMouseEvent('mousedown', target); + + // Assert + expect(target?.style.backgroundColor).toBe('green'); + + // Act + simulateMouseEvent('mousemove', targetEnd); + + // Assert + expect(target?.style.backgroundColor).toBe('yellow'); + + // Act + simulateMouseEvent('mouseup', targetEnd); + + // Assert + expect(target?.style.backgroundColor).toBe('red'); + }); + + it('touch movement', () => { + // Arrange + const target = document.getElementById(id); + createDnD(target, true); + let targetEnd = target; + targetEnd.style.left = 50 + 'px'; + + // Assert + expect(dndHelper.mouseType).toBe('touch'); + + // Act + simulateTouchEvent('touchstart', target); + + // Assert + expect(target?.style.backgroundColor).toBe('green'); + + // Act + simulateTouchEvent('touchmove', targetEnd); + + // Assert + expect(target?.style.backgroundColor).toBe('yellow'); + + // Act + simulateTouchEvent('touchend', targetEnd); + + // Assert + expect(target?.style.backgroundColor).toBe('red'); + }); +}); + +function simulateMouseEvent(type: string, target: HTMLElement, shiftKey: boolean = false) { + const rect = target.getBoundingClientRect(); + var event = new MouseEvent(type, { + view: window, + bubbles: true, + cancelable: true, + clientX: rect.left, + clientY: rect.top, + shiftKey, + }); + target.dispatchEvent(event); +} + +function simulateTouchEvent(type: string, target: HTMLElement) { + var event = (new Event(type) as any) as TouchEvent; + + target.dispatchEvent(event); +} diff --git a/packages-content-model/roosterjs-content-model-plugins/test/pluginUtils/createElementTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/pluginUtils/createElementTest.ts new file mode 100644 index 00000000000..488770f69d8 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/pluginUtils/createElementTest.ts @@ -0,0 +1,70 @@ +import createElement from '../../lib/pluginUtils/CreateElement/createElement'; +import CreateElementData from '../../lib/pluginUtils/CreateElement/CreateElementData'; + +describe('createElement', () => { + function runTest(input: CreateElementData, output: string) { + const result = createElement(input, document); + const html = result ? result.outerHTML : null; + expect(html).toBe(output); + } + + it('null', () => { + runTest(null, null); + }); + + it('create by tag', () => { + runTest({ tag: 'div' }, '
                  '); + }); + + it('create by tag and namespace', () => { + runTest({ tag: 'svg', namespace: 'http://www.w3.org/2000/svg' }, ''); + }); + it('create by tag and class', () => { + runTest({ tag: 'div', className: 'test' }, '
                  '); + }); + it('create by tag and style', () => { + runTest( + { tag: 'div', style: 'position: absolute' }, + '
                  ' + ); + }); + it('create by tag and dataset', () => { + runTest({ tag: 'div', dataset: null }, '
                  '); + runTest({ tag: 'div', dataset: {} }, '
                  '); + runTest({ tag: 'div', dataset: { x: '1', y: '2' } }, '
                  '); + }); + it('create by tag and attributes', () => { + runTest({ tag: 'div', attributes: null }, '
                  '); + runTest({ tag: 'div', attributes: {} }, '
                  '); + runTest( + { tag: 'div', attributes: { contenteditable: 'true', align: 'left' } }, + '
                  ' + ); + }); + + it('create by tag and everthing', () => { + runTest( + { + tag: 'div', + className: 'test1', + style: 'position:absolute', + dataset: { x: '1' }, + attributes: { contenteditable: 'true' }, + }, + '
                  ' + ); + }); + it('create by tag and children', () => { + runTest({ tag: 'div', children: null }, '
                  '); + runTest({ tag: 'div', children: [] }, '
                  '); + runTest({ tag: 'div', children: ['text'] }, '
                  text
                  '); + runTest( + { tag: 'div', children: [{ tag: 'span' }, 'text', { tag: 'span' }] }, + '
                  text
                  ' + ); + runTest( + { tag: 'div', children: [null, 'text', { tag: 'span' }, ''] }, + '
                  text
                  ' + ); + }); +}); From 7e71fe8c62f32c019fc44c529faaffdd31c29336 Mon Sep 17 00:00:00 2001 From: Julia Roldi Date: Fri, 2 Feb 2024 18:58:38 -0300 Subject: [PATCH 065/112] fix build --- .../lib/modelApi/common/normalizeParagraph.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 1edd90acb8b..7090cae4361 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 @@ -4,8 +4,9 @@ import { isSegmentEmpty } from './isEmpty'; import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; import { normalizeAllSegments } from './normalizeSegment'; import type { ContentModelParagraph } from 'roosterjs-content-model-types'; + /** - * @internal + * Normalize a paragraph. If it is empty, add a BR segment to make sure it can insert content */ export function normalizeParagraph(paragraph: ContentModelParagraph) { const segments = paragraph.segments; From 32dd63c78d8dcf0652264ceda431a39069d06e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 5 Feb 2024 12:52:12 -0300 Subject: [PATCH 066/112] add unit test --- .../{ => inputSteps}/handleEnterOnList.ts | 0 .../lib/edit/keyboardInput.ts | 2 +- .../autoFormat/keyboardListTriggerTest.ts | 647 +++++++++++++++ .../edit/inputSteps/handleEnterOnListTest.ts | 764 ++++++++++++++++++ .../test/edit/keyboardInputTest.ts | 44 + 5 files changed, 1456 insertions(+), 1 deletion(-) rename packages-content-model/roosterjs-content-model-plugins/lib/edit/{ => inputSteps}/handleEnterOnList.ts (100%) create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-plugins/lib/edit/handleEnterOnList.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index d86eb6b5d03..86d04cf9385 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -1,5 +1,5 @@ import { deleteSelection, isModifierKey } from 'roosterjs-content-model-core'; -import { handleEnterOnList } from './handleEnterOnList'; +import { handleEnterOnList } from './inputSteps/handleEnterOnList'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; import type { DOMSelection, IStandaloneEditor } from 'roosterjs-content-model-types'; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts index abdbb18ccdc..512e7c50aae 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts @@ -457,4 +457,651 @@ describe('keyboardListTrigger', () => { false ); }); + + it('trigger continued numbering list between lists', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2) "', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '3)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"A) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"B) "', + }, + }, + ], + format: {}, + }, + + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 3, + marginTop: '0px', + marginBottom: '0px', + direction: undefined, + textAlign: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + }, + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"A) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"B) "', + }, + }, + ], + format: {}, + }, + true + ); + }); + + it('trigger a new numbering list after a numbering list', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2) "', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'A)', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2) "', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + marginTop: '0px', + marginBottom: '0px', + direction: undefined, + textAlign: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + }, + format: {}, + }, + ], + format: {}, + }, + true + ); + }); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts new file mode 100644 index 00000000000..317b083a04e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -0,0 +1,764 @@ +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { deleteSelection } from 'roosterjs-content-model-core'; +import { handleEnterOnList } from '../../../lib/edit/inputSteps/handleEnterOnList'; +import { normalizeContentModel } from 'roosterjs-content-model-dom'; + +describe('handleEnterOnList', () => { + function runTest( + model: ContentModelDocument, + expectedModel: ContentModelDocument, + expectedResult: 'notDeleted' | 'range' + ) { + const result = deleteSelection(model, [handleEnterOnList]); + normalizeContentModel(model); + + expect(model).toEqual(expectedModel); + expect(result.deleteResult).toBe(expectedResult); + } + + it('no list item', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + }; + runTest(model, model, 'notDeleted'); + }); + + it('empty list item', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + isImplicit: false, + format: {}, + }, + ], + format: {}, + }; + runTest(model, expectedModel, 'range'); + }); + + it('enter on middle list item', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + startNumberOverride: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(model, expectedModel, 'range'); + }); + + it('enter on last list item', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + startNumberOverride: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + + runTest(model, expectedModel, 'range'); + }); + + it('enter on last list item of second list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test ', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + ], + format: {}, + }; + + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + startNumberOverride: 2, + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + + runTest(model, expectedModel, 'range'); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts index 89ca280fa1e..c83c4adece7 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts @@ -1,5 +1,6 @@ import * as deleteSelection from 'roosterjs-content-model-core/lib/publicApi/selection/deleteSelection'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; +import { handleEnterOnList } from '../../lib/edit/inputSteps/handleEnterOnList'; import { keyboardInput } from '../../lib/edit/keyboardInput'; import { ContentModelDocument, @@ -385,4 +386,47 @@ describe('keyboardInput', () => { }); expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); }); + + it('Enter key input on collapsed range', () => { + const mockedFormat = 'FORMAT' as any; + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: true, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + insertPoint: { + marker: { + format: mockedFormat, + }, + }, + }); + + const rawEvent = { + key: 'Enter', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(takeSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith( + mockedModel, + [handleEnterOnList], + mockedContext + ); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + newPendingFormat: mockedFormat, + }); + expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); + }); }); From 6eb187603e1c26004a29a2d93f78efc5ee482c18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 5 Feb 2024 13:25:24 -0300 Subject: [PATCH 067/112] fixes --- .../lib/edit/inputSteps/handleEnterOnList.ts | 2 +- .../edit/inputSteps/handleEnterOnListTest.ts | 120 +++++++++++++++--- 2 files changed, 100 insertions(+), 22 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts index fd0b1758fdc..f61e73feaca 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -58,7 +58,7 @@ const createNewListItem = ( const listIndex = listParent.blocks.indexOf(listItem); const newParagraph = createNewParagraph(insertPoint); const levels = createNewListLevel(listItem); - const newListItem = createListItem(levels, listItem.format); + const newListItem = createListItem(levels, insertPoint.marker.format); newListItem.blocks.push(newParagraph); listParent.blocks.splice(listIndex + 1, 0, newListItem); }; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts index 317b083a04e..0e10e57a563 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -485,10 +485,9 @@ describe('handleEnterOnList', () => { format: { marginTop: '0px', marginBottom: '0px', - listStyleType: 'decimal', }, dataset: { - editingInfo: '{"orderedStyleType":1}', + editingInfo: '{"orderedStyleType":3}', }, }, ], @@ -497,7 +496,9 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: {}, + format: { + listStyleType: '"1) "', + }, }, { blockType: 'BlockGroup', @@ -508,7 +509,7 @@ describe('handleEnterOnList', () => { segments: [ { segmentType: 'Text', - text: 'test ', + text: 'test', format: {}, }, ], @@ -521,10 +522,9 @@ describe('handleEnterOnList', () => { format: { marginTop: '0px', marginBottom: '0px', - listStyleType: 'decimal', }, dataset: { - editingInfo: '{"orderedStyleType":1}', + editingInfo: '{"orderedStyleType":3}', }, }, ], @@ -533,7 +533,9 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: {}, + format: { + listStyleType: '"2) "', + }, }, { blockType: 'Paragraph', @@ -545,6 +547,45 @@ describe('handleEnterOnList', () => { ], format: {}, }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"A) "', + }, + }, { blockType: 'BlockGroup', blockGroupType: 'ListItem', @@ -564,19 +605,17 @@ describe('handleEnterOnList', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ { listType: 'OL', format: { - startNumberOverride: 1, marginTop: '0px', marginBottom: '0px', }, dataset: { - editingInfo: '{"orderedStyleType":3}', + editingInfo: '{"orderedStyleType":10}', }, }, ], @@ -586,7 +625,7 @@ describe('handleEnterOnList', () => { format: {}, }, format: { - listStyleType: '"1) "', + listStyleType: '"B) "', }, }, ], @@ -619,10 +658,9 @@ describe('handleEnterOnList', () => { format: { marginTop: '0px', marginBottom: '0px', - listStyleType: 'decimal', }, dataset: { - editingInfo: '{"orderedStyleType":1}', + editingInfo: '{"orderedStyleType":3}', }, }, ], @@ -631,7 +669,9 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: {}, + format: { + listStyleType: '"1) "', + }, }, { blockType: 'BlockGroup', @@ -655,10 +695,9 @@ describe('handleEnterOnList', () => { format: { marginTop: '0px', marginBottom: '0px', - listStyleType: 'decimal', }, dataset: { - editingInfo: '{"orderedStyleType":1}', + editingInfo: '{"orderedStyleType":3}', }, }, ], @@ -667,7 +706,9 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: {}, + format: { + listStyleType: '"2) "', + }, }, { blockType: 'Paragraph', @@ -705,7 +746,7 @@ describe('handleEnterOnList', () => { marginBottom: '0px', }, dataset: { - editingInfo: '{"orderedStyleType":3}', + editingInfo: '{"orderedStyleType":10}', }, }, ], @@ -715,7 +756,44 @@ describe('handleEnterOnList', () => { format: {}, }, format: { - listStyleType: '"1) "', + listStyleType: '"A) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":10}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"B) "', }, }, { @@ -741,10 +819,10 @@ describe('handleEnterOnList', () => { format: { marginTop: '0px', marginBottom: '0px', - startNumberOverride: 2, + startNumberOverride: undefined, }, dataset: { - editingInfo: '{"orderedStyleType":3}', + editingInfo: '{"orderedStyleType":10}', }, }, ], From 2532280ec5743eb83d9f09632fc3f646c23b5f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 5 Feb 2024 13:35:01 -0300 Subject: [PATCH 068/112] enable list features again --- .../getDefaultContentEditFeatureSettings.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts index 30f0bb054f0..9e9a172339e 100644 --- a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts +++ b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts @@ -9,17 +9,7 @@ export default function getDefaultContentEditFeatureSettings(): ContentEditFeatu settings[key] = !allFeatures[key].defaultDisabled; return settings; }, {}), - indentWhenAltShiftRight: false, - outdentWhenAltShiftLeft: false, - indentWhenTab: false, - outdentWhenShiftTab: false, - outdentWhenBackspaceOnEmptyFirstLine: false, - outdentWhenEnterOnEmptyLine: false, - mergeInNewLineWhenBackspaceOnFirstChar: false, - maintainListChain: false, - maintainListChainWhenDelete: false, - autoNumberingList: false, - autoBulletList: false, - mergeListOnBackspaceAfterList: false, + indentWhenAltShiftRight: true, + outdentWhenAltShiftLeft: true, }; } From 32d2a2485d8914f3a586a669e6c1f4983bc91cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 5 Feb 2024 14:13:20 -0300 Subject: [PATCH 069/112] WIP --- .../getDefaultContentEditFeatureSettings.ts | 4 ++-- .../lib/edit/handleShiftTab.ts | 21 +++++++++++++++++++ .../lib/edit/keyboardInput.ts | 15 +++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/edit/handleShiftTab.ts diff --git a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts index 9e9a172339e..af7bf909cf3 100644 --- a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts +++ b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts @@ -9,7 +9,7 @@ export default function getDefaultContentEditFeatureSettings(): ContentEditFeatu settings[key] = !allFeatures[key].defaultDisabled; return settings; }, {}), - indentWhenAltShiftRight: true, - outdentWhenAltShiftLeft: true, + indentWhenAltShiftRight: false, + outdentWhenAltShiftLeft: false, }; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleShiftTab.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleShiftTab.ts new file mode 100644 index 00000000000..9d4e201c68e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleShiftTab.ts @@ -0,0 +1,21 @@ +import { getClosestAncestorBlockGroupIndex } from 'roosterjs-content-model-core'; +import type { DeleteSelectionStep } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export const handleShiftTab: DeleteSelectionStep = context => { + if (context.deleteResult == 'nothingToDelete' || context.deleteResult == 'notDeleted') { + const { insertPoint } = context; + const { path } = insertPoint; + const index = getClosestAncestorBlockGroupIndex( + path, + ['FormatContainer', 'ListItem'], + ['TableCell'] + ); + + const listItem = path[index]; + if (listItem.blockGroupType === 'ListItem') { + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index d72a251e179..8cbe5f462de 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -1,4 +1,5 @@ import { deleteSelection, isModifierKey } from 'roosterjs-content-model-core'; +import { handleShiftTab } from './handleShiftTab'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; import type { DOMSelection, IStandaloneEditor } from 'roosterjs-content-model-types'; @@ -7,13 +8,14 @@ import type { DOMSelection, IStandaloneEditor } from 'roosterjs-content-model-ty */ export function keyboardInput(editor: IStandaloneEditor, rawEvent: KeyboardEvent) { const selection = editor.getDOMSelection(); + const isShiftTab = shouldHandleShiftTab(rawEvent, selection); - if (shouldInputWithContentModel(selection, rawEvent, editor.isInIME())) { + if (shouldInputWithContentModel(selection, rawEvent, editor.isInIME()) || isShiftTab) { editor.takeSnapshot(); editor.formatContentModel( (model, context) => { - const result = deleteSelection(model, [], context); + const result = deleteSelection(model, isShiftTab ? [handleShiftTab] : [], context); // We have deleted selection then we will let browser to handle the input. // With this combined operation, we don't wan to mass up the cached model so clear it @@ -62,3 +64,12 @@ function shouldInputWithContentModel( return false; } } + +function shouldHandleShiftTab(rawEvent: KeyboardEvent, selection: DOMSelection | null) { + return ( + rawEvent.key == 'Tab' && + selection && + selection?.type == 'range' && + selection.range.collapsed + ); +} From a8dcff46d004fe6c020415254155adfc01d4d175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 5 Feb 2024 19:18:13 -0300 Subject: [PATCH 070/112] WIP --- .../getDefaultContentEditFeatureSettings.ts | 11 + .../lib/modelApi/block/setModelIndentation.ts | 75 +- .../modelApi/block/setModelIndentationTest.ts | 44 +- .../lib/edit/ContentModelEditPlugin.ts | 5 +- .../lib/edit/handleShiftTab.ts | 21 - .../lib/edit/keyboardInput.ts | 15 +- .../lib/edit/keyboardTab.ts | 61 ++ .../test/edit/ContentModelEditPluginTest.ts | 19 + .../test/edit/keyboardTabTest.ts | 870 ++++++++++++++++++ 9 files changed, 1058 insertions(+), 63 deletions(-) delete mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/edit/handleShiftTab.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts diff --git a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts index af7bf909cf3..d8e4adda71d 100644 --- a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts +++ b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts @@ -11,5 +11,16 @@ export default function getDefaultContentEditFeatureSettings(): ContentEditFeatu }, {}), indentWhenAltShiftRight: false, outdentWhenAltShiftLeft: false, + autoBullet: false, + indentWhenTab: false, + outdentWhenShiftTab: false, + outdentWhenBackspaceOnEmptyFirstLine: false, + outdentWhenEnterOnEmptyLine: false, + mergeInNewLineWhenBackspaceOnFirstChar: false, + maintainListChain: false, + maintainListChainWhenDelete: false, + autoNumberingList: false, + autoBulletList: false, + mergeListOnBackspaceAfterList: false, }; } diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts index 97fa93cc7a8..86785eabe8a 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts @@ -1,6 +1,8 @@ import { createListLevel, parseValueWithUnit } from 'roosterjs-content-model-dom'; +import { findListItemsInSameThread } from '../list/findListItemsInSameThread'; import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; import type { + ContentModelBlockFormat, ContentModelDocument, ContentModelListItem, ContentModelListLevel, @@ -25,30 +27,40 @@ export function setModelIndentation( paragraphOrListItem.forEach(({ block }) => { if (isBlockGroupOfType(block, 'ListItem')) { - if (isIndent) { - const lastLevel = block.levels[block.levels.length - 1]; - const newLevel: ContentModelListLevel = createListLevel( - lastLevel?.listType || 'UL', - lastLevel?.format - ); + if (isFirstItemSelected(model, block)) { + if (!isIndent && block.levels.length == 1) { + block.levels.pop(); + } else { + const level = block.levels[0]; + const { format } = level; + const newValue = calculateMarginValue(format, isIndent, length); + const isRtl = format.direction == 'rtl'; + if (isRtl) { + level.format.marginRight = newValue + 'px'; + } else { + level.format.marginLeft = newValue + 'px'; + } + } + } else { + if (isIndent) { + const lastLevel = block.levels[block.levels.length - 1]; + const newLevel: ContentModelListLevel = createListLevel( + lastLevel?.listType || 'UL', + lastLevel?.format + ); - // New level is totally new, no need to have these attributes for now - delete newLevel.format.startNumberOverride; + // New level is totally new, no need to have these attributes for now + delete newLevel.format.startNumberOverride; - block.levels.push(newLevel); - } else { - block.levels.pop(); + block.levels.push(newLevel); + } else { + block.levels.pop(); + } } } else if (block) { const { format } = block; - const { marginLeft, marginRight, direction } = format; - const isRtl = direction == 'rtl'; - const originalValue = parseValueWithUnit(isRtl ? marginRight : marginLeft); - let newValue = (isIndent ? Math.ceil : Math.floor)(originalValue / length) * length; - - if (newValue == originalValue) { - newValue = Math.max(newValue + length * (isIndent ? 1 : -1), 0); - } + const newValue = calculateMarginValue(format, isIndent, length); + const isRtl = format.direction == 'rtl'; if (isRtl) { format.marginRight = newValue + 'px'; @@ -60,3 +72,28 @@ export function setModelIndentation( return paragraphOrListItem.length > 0; } + +function isFirstItemSelected(model: ContentModelDocument, listItem: ContentModelListItem) { + const thread = findListItemsInSameThread(model, listItem); + return thread[0].blocks.some(block => { + if (block.blockType == 'Paragraph') { + return block.segments.some(segment => segment.isSelected); + } + }); +} + +function calculateMarginValue( + format: ContentModelBlockFormat, + isIndent: boolean, + length: number = IndentStepInPixel +) { + const { marginLeft, marginRight, direction } = format; + const isRtl = direction == 'rtl'; + const originalValue = parseValueWithUnit(isRtl ? marginRight : marginLeft); + let newValue = (isIndent ? Math.ceil : Math.floor)(originalValue / length) * length; + + if (newValue == originalValue) { + newValue = Math.max(newValue + length * (isIndent ? 1 : -1), 0); + } + return newValue; +} diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts index 5d475c47c98..d456160e93b 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts @@ -283,8 +283,13 @@ describe('indent', () => { blockGroupType: 'ListItem', blockType: 'BlockGroup', levels: [ - { listType: 'OL', dataset: {}, format: {} }, - { listType: 'OL', dataset: {}, format: {} }, + { + listType: 'OL', + dataset: {}, + format: { + marginLeft: '40px', + }, + }, ], blocks: [para1, para2, para3], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -344,9 +349,9 @@ describe('indent', () => { }, format: { startNumberOverride: 2, + marginLeft: '40px', }, }, - { listType: 'OL', dataset: {}, format: {} }, ], blocks: [para1, para2, para3], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -502,8 +507,13 @@ describe('indent', () => { blockGroupType: 'ListItem', blockType: 'BlockGroup', levels: [ - { listType: 'UL', dataset: {}, format: {} }, - { listType: 'UL', dataset: {}, format: {} }, + { + listType: 'UL', + dataset: {}, + format: { + marginLeft: '40px', + }, + }, ], blocks: [para3], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -550,8 +560,13 @@ describe('indent', () => { blockGroupType: 'ListItem', blockType: 'BlockGroup', levels: [ - { listType: 'OL', dataset: {}, format: {} }, - { listType: 'OL', dataset: {}, format: {} }, + { + listType: 'OL', + dataset: {}, + format: { + marginLeft: '40px', + }, + }, ], blocks: [para1], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -561,8 +576,13 @@ describe('indent', () => { blockGroupType: 'ListItem', blockType: 'BlockGroup', levels: [ - { listType: 'UL', dataset: {}, format: {} }, - { listType: 'UL', dataset: {}, format: {} }, + { + listType: 'UL', + dataset: {}, + format: { + marginLeft: '40px', + }, + }, ], blocks: [para2], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -888,8 +908,14 @@ describe('outdent', () => { }, format: { startNumberOverride: 1, + marginLeft: '0px', }, }, + { + listType: 'UL', + dataset: {}, + format: {}, + }, ], }, ], diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts index a03c145dfc5..ba81065a781 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts @@ -1,5 +1,6 @@ import { keyboardDelete } from './keyboardDelete'; import { keyboardInput } from './keyboardInput'; +import { keyboardTab } from './keyboardTab'; import type { EditorPlugin, IStandaloneEditor, @@ -12,6 +13,7 @@ import type { * This includes: * 1. Delete Key * 2. Backspace Key + * 3. Tab Key */ export class ContentModelEditPlugin implements EditorPlugin { private editor: IStandaloneEditor | null = null; @@ -70,7 +72,8 @@ export class ContentModelEditPlugin implements EditorPlugin { keyboardDelete(editor, rawEvent); break; - case 'Enter': + case 'Tab': + keyboardTab(editor, rawEvent); default: keyboardInput(editor, rawEvent); break; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleShiftTab.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleShiftTab.ts deleted file mode 100644 index 9d4e201c68e..00000000000 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleShiftTab.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { getClosestAncestorBlockGroupIndex } from 'roosterjs-content-model-core'; -import type { DeleteSelectionStep } from 'roosterjs-content-model-types'; - -/** - * @internal - */ -export const handleShiftTab: DeleteSelectionStep = context => { - if (context.deleteResult == 'nothingToDelete' || context.deleteResult == 'notDeleted') { - const { insertPoint } = context; - const { path } = insertPoint; - const index = getClosestAncestorBlockGroupIndex( - path, - ['FormatContainer', 'ListItem'], - ['TableCell'] - ); - - const listItem = path[index]; - if (listItem.blockGroupType === 'ListItem') { - } - } -}; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 8cbe5f462de..d72a251e179 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -1,5 +1,4 @@ import { deleteSelection, isModifierKey } from 'roosterjs-content-model-core'; -import { handleShiftTab } from './handleShiftTab'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; import type { DOMSelection, IStandaloneEditor } from 'roosterjs-content-model-types'; @@ -8,14 +7,13 @@ import type { DOMSelection, IStandaloneEditor } from 'roosterjs-content-model-ty */ export function keyboardInput(editor: IStandaloneEditor, rawEvent: KeyboardEvent) { const selection = editor.getDOMSelection(); - const isShiftTab = shouldHandleShiftTab(rawEvent, selection); - if (shouldInputWithContentModel(selection, rawEvent, editor.isInIME()) || isShiftTab) { + if (shouldInputWithContentModel(selection, rawEvent, editor.isInIME())) { editor.takeSnapshot(); editor.formatContentModel( (model, context) => { - const result = deleteSelection(model, isShiftTab ? [handleShiftTab] : [], context); + const result = deleteSelection(model, [], context); // We have deleted selection then we will let browser to handle the input. // With this combined operation, we don't wan to mass up the cached model so clear it @@ -64,12 +62,3 @@ function shouldInputWithContentModel( return false; } } - -function shouldHandleShiftTab(rawEvent: KeyboardEvent, selection: DOMSelection | null) { - return ( - rawEvent.key == 'Tab' && - selection && - selection?.type == 'range' && - selection.range.collapsed - ); -} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts new file mode 100644 index 00000000000..db03e9df607 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts @@ -0,0 +1,61 @@ +import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; +import { setIndentation } from 'roosterjs-content-model-api'; + +import type { + ContentModelDocument, + ContentModelListItem, + DOMSelection, + IStandaloneEditor, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function keyboardTab(editor: IStandaloneEditor, rawEvent: KeyboardEvent) { + const selection = editor.getDOMSelection(); + + if (shouldHandleTab(rawEvent, selection)) { + editor.takeSnapshot(); + + editor.formatContentModel( + (model, _context) => { + return handleTabOnList(editor, model, rawEvent); + }, + { + rawEvent, + } + ); + + return true; + } +} + +function shouldHandleTab(rawEvent: KeyboardEvent, selection: DOMSelection | null) { + return rawEvent.key == 'Tab' && selection && selection?.type == 'range'; +} + +function isMarkerAtStartOfBlock(listItem: ContentModelListItem) { + return ( + listItem.blocks[0].blockType == 'Paragraph' && + listItem.blocks[0].segments[0].segmentType == 'SelectionMarker' + ); +} + +function handleTabOnList( + editor: IStandaloneEditor, + model: ContentModelDocument, + rawEvent: KeyboardEvent +) { + const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); + const listItem = blocks[0].block; + + if ( + isBlockGroupOfType(listItem, 'ListItem') && + isMarkerAtStartOfBlock(listItem) + ) { + setIndentation(editor, rawEvent.shiftKey ? 'outdent' : 'indent'); + rawEvent.preventDefault(); + return true; + } + return false; +} diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts index 9a7c0b5f7a6..9165a6fcdbc 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts @@ -1,5 +1,6 @@ import * as keyboardDelete from '../../lib/edit/keyboardDelete'; import * as keyboardInput from '../../lib/edit/keyboardInput'; +import * as keyboardTab from '../../lib/edit/keyboardTab'; import { ContentModelEditPlugin } from '../../lib/edit/ContentModelEditPlugin'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; @@ -18,10 +19,12 @@ describe('ContentModelEditPlugin', () => { describe('onPluginEvent', () => { let keyboardDeleteSpy: jasmine.Spy; let keyboardInputSpy: jasmine.Spy; + let keyboardTabSpy: jasmine.Spy; beforeEach(() => { keyboardDeleteSpy = spyOn(keyboardDelete, 'keyboardDelete'); keyboardInputSpy = spyOn(keyboardInput, 'keyboardInput'); + keyboardTabSpy = spyOn(keyboardTab, 'keyboardTab'); }); it('Backspace', () => { @@ -54,6 +57,22 @@ describe('ContentModelEditPlugin', () => { expect(keyboardInputSpy).not.toHaveBeenCalled(); }); + it('Tab', () => { + const plugin = new ContentModelEditPlugin(); + const rawEvent = { key: 'Tab' } as any; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(keyboardTabSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardInputSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + }); + it('Other key', () => { const plugin = new ContentModelEditPlugin(); const rawEvent = { which: 41, key: 'A' } as any; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts new file mode 100644 index 00000000000..620a67bcb1a --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts @@ -0,0 +1,870 @@ +import * as setIndentation from '../../../roosterjs-content-model-api/lib/publicApi/block/setIndentation'; +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { keyboardTab } from '../../lib/edit/keyboardTab'; + +describe('keyboardTab', () => { + let takeSnapshotSpy: jasmine.Spy; + let setIndentationSpy: jasmine.Spy; + + beforeEach(() => { + takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); + setIndentationSpy = spyOn(setIndentation, 'default'); + }); + + function runTest( + input: ContentModelDocument, + indent: 'outdent' | 'indent' | undefined, + shiftKey: boolean, + expectedResult: boolean + ) { + const formatWithContentModelSpy = jasmine + .createSpy('formatWithContentModel') + .and.callFake((callback, options) => { + const result = callback(input, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + expect(result).toBe(expectedResult); + }); + + const editor = { + focus: () => {}, + formatContentModel: formatWithContentModelSpy, + takeSnapshot: takeSnapshotSpy, + getDOMSelection: () => { + return { + type: 'range', + }; + }, + }; + + keyboardTab( + editor as any, + { + key: 'Tab', + shiftKey: shiftKey, + preventDefault: () => {}, + } as KeyboardEvent + ); + + expect(formatWithContentModelSpy).toHaveBeenCalled(); + if (indent) { + expect(setIndentationSpy).toHaveBeenCalledWith(editor as any, indent); + } else { + expect(setIndentationSpy).not.toHaveBeenCalled(); + } + } + + it('tab on paragraph', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + runTest(model, undefined, false, false); + }); + + it('tab on empty list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: 1, + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + + runTest(model, 'indent', false, true); + }); + + it('tab on the start first item on the list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + + runTest(model, 'indent', false, true); + }); + + it('tab on the end first item on the list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined, false, false); + }); + + it('tab on the start second item on the list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(model, 'indent', false, true); + }); + + it('tab on the end second item on the list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined, false, false); + }); + + it('shift tab on empty list item', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + segmentFormat: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(model, 'outdent', true, true); + }); + + it('shift tab on the start first item on the list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'testdsadas', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'dsadasdasdas', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(model, 'outdent', true, true); + }); + + it('shift tab on the middle first item on the list', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'testd', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'sadas', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'dsadasdasdas', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(model, undefined, true, false); + }); +}); From db497d54de8af256ebbfe0009e0bcb0971b0087f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 5 Feb 2024 19:22:06 -0300 Subject: [PATCH 071/112] WIP --- .../lib/edit/ContentModelEditPlugin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts index ba81065a781..a9656532b9c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts @@ -74,6 +74,7 @@ export class ContentModelEditPlugin implements EditorPlugin { case 'Tab': keyboardTab(editor, rawEvent); + break; default: keyboardInput(editor, rawEvent); break; From b9383ed5018904452743ef60b61be09a1f92f290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 6 Feb 2024 12:18:32 -0300 Subject: [PATCH 072/112] WIP --- .../lib/modelApi/block/setModelIndentation.ts | 32 +++++++++---------- .../modelApi/block/setModelIndentationTest.ts | 26 ++++++++++----- .../test/edit/ContentModelEditPluginTest.ts | 2 +- 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts index 86785eabe8a..908e69abe46 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts @@ -25,21 +25,22 @@ export function setModelIndentation( ); const isIndent = indentation == 'indent'; - paragraphOrListItem.forEach(({ block }) => { + paragraphOrListItem.forEach(({ block }, index) => { if (isBlockGroupOfType(block, 'ListItem')) { - if (isFirstItemSelected(model, block)) { - if (!isIndent && block.levels.length == 1) { - block.levels.pop(); + const thread = findListItemsInSameThread(model, block); + const firstItem = thread[0]; + if ( + isFirstItemSelected(firstItem) && + !(index == 0 && thread.length == 1 && firstItem.levels.length > 1) + ) { + const level = block.levels[0]; + const { format } = level; + const newValue = calculateMarginValue(format, isIndent, length); + const isRtl = format.direction == 'rtl'; + if (isRtl) { + level.format.marginRight = newValue + 'px'; } else { - const level = block.levels[0]; - const { format } = level; - const newValue = calculateMarginValue(format, isIndent, length); - const isRtl = format.direction == 'rtl'; - if (isRtl) { - level.format.marginRight = newValue + 'px'; - } else { - level.format.marginLeft = newValue + 'px'; - } + level.format.marginLeft = newValue + 'px'; } } else { if (isIndent) { @@ -73,9 +74,8 @@ export function setModelIndentation( return paragraphOrListItem.length > 0; } -function isFirstItemSelected(model: ContentModelDocument, listItem: ContentModelListItem) { - const thread = findListItemsInSameThread(model, listItem); - return thread[0].blocks.some(block => { +function isFirstItemSelected(listItem: ContentModelListItem) { + return listItem.blocks.some(block => { if (block.blockType == 'Paragraph') { return block.segments.some(segment => segment.isSelected); } diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts index d456160e93b..ea5b2bd6752 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts @@ -863,7 +863,15 @@ describe('outdent', () => { blocks: [ { ...listItem, - levels: [], + levels: [ + { + listType: 'OL', + dataset: {}, + format: { + marginLeft: '0px', + }, + }, + ], }, ], }); @@ -908,14 +916,8 @@ describe('outdent', () => { }, format: { startNumberOverride: 1, - marginLeft: '0px', }, }, - { - listType: 'UL', - dataset: {}, - format: {}, - }, ], }, ], @@ -953,7 +955,15 @@ describe('outdent', () => { blocks: [ { ...listItem, - levels: [], + levels: [ + { + listType: 'UL', + dataset: {}, + format: { + marginLeft: '0px', + }, + }, + ], }, { blockType: 'Paragraph', diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts index 9165a6fcdbc..58f21cd8def 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts @@ -69,7 +69,7 @@ describe('ContentModelEditPlugin', () => { }); expect(keyboardTabSpy).toHaveBeenCalledWith(editor, rawEvent); - expect(keyboardInputSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardInputSpy).not.toHaveBeenCalled(); expect(keyboardDeleteSpy).not.toHaveBeenCalled(); }); From 34c44c88b612da6d1175b6810f8d349e573a9e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 6 Feb 2024 13:22:20 -0300 Subject: [PATCH 073/112] WIP --- .../list/findListItemsInSameThread.ts | 2 ++ .../lib/modelApi/common/normalizeParagraph.ts | 1 + .../lib/autoFormat/keyboardListTrigger.ts | 3 ++- .../lib/autoFormat/utils/getListTypeStyle.ts | 23 ++++++++++++------- .../lib/edit/inputSteps/handleEnterOnList.ts | 20 ++++++++-------- .../lib/edit/keyboardInput.ts | 4 ++-- 6 files changed, 31 insertions(+), 22 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts index bdcca1622f5..55511429be8 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts @@ -5,6 +5,8 @@ import type { } from 'roosterjs-content-model-types'; /** + * @param model The content model + * @param currentItem The current list item * Search for all list items in the same thread as the current list item */ export function findListItemsInSameThread( 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 7090cae4361..8f9a9d41849 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 @@ -6,6 +6,7 @@ import { normalizeAllSegments } from './normalizeSegment'; import type { ContentModelParagraph } from 'roosterjs-content-model-types'; /** + * @param paragraph The paragraph to normalize * Normalize a paragraph. If it is empty, add a BR segment to make sure it can insert content */ export function normalizeParagraph(paragraph: ContentModelParagraph) { diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts index 1703c8c8318..900c0c10900 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/keyboardListTrigger.ts @@ -27,8 +27,9 @@ export function keyboardListTrigger( } const { listType, styleType, index } = listStyleType; triggerList(editor, model, listType, styleType, index); - normalizeContentModel(model); rawEvent.preventDefault(); + normalizeContentModel(model); + return true; } return false; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts index b8edd5e241b..89c76020365 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/utils/getListTypeStyle.ts @@ -80,19 +80,26 @@ const getPreviousListIndex = ( }; const getPreviousListLevel = (model: ContentModelDocument, paragraph: ContentModelParagraph) => { - const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell'])[0]; + const blocks = getOperationalBlocks( + model, + ['ListItem'], + ['TableCell'] + )[0]; let listItem: ContentModelListItem | undefined = undefined; - const listBlockIndex = blocks.parent.blocks.indexOf(paragraph); + if (blocks) { + const listBlockIndex = blocks.parent.blocks.indexOf(paragraph); - if (listBlockIndex > -1) { - for (let i = listBlockIndex - 1; i > -1; i--) { - const item = blocks.parent.blocks[i]; - if (isBlockGroupOfType(item, 'ListItem')) { - listItem = item; - break; + if (listBlockIndex > -1) { + for (let i = listBlockIndex - 1; i > -1; i--) { + const item = blocks.parent.blocks[i]; + if (isBlockGroupOfType(item, 'ListItem')) { + listItem = item; + break; + } } } } + return listItem; }; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts index f61e73feaca..92ce2c3e58a 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -17,20 +17,16 @@ import type { * @internal */ export const handleEnterOnList: DeleteSelectionStep = context => { - if (context.deleteResult == 'notDeleted') { + if (context.deleteResult == 'nothingToDelete' || context.deleteResult == 'notDeleted') { const { insertPoint } = context; const { path } = insertPoint; - const index = getClosestAncestorBlockGroupIndex( - path, - ['FormatContainer', 'ListItem'], - ['TableCell'] - ); + const index = getClosestAncestorBlockGroupIndex(path, ['ListItem'], ['TableCell']); const listItem = path[index]; if (listItem && listItem.blockGroupType === 'ListItem') { const listParent = path[index + 1]; if (isEmptyListItem(listItem)) { - listItem.levels = []; + listItem.levels.pop(); } else { createNewListItem(context, listItem, listParent); } @@ -69,9 +65,7 @@ const createNewListLevel = (listItem: ContentModelListItem) => { level.listType, { ...level.format, - startNumberOverride: level.format.startNumberOverride - ? level.format.startNumberOverride + 1 - : undefined, + startNumberOverride: undefined, }, level.dataset ); @@ -80,7 +74,11 @@ const createNewListLevel = (listItem: ContentModelListItem) => { const createNewParagraph = (insertPoint: InsertPoint) => { const { paragraph, marker } = insertPoint; - const newParagraph = createParagraph(true, paragraph.format, paragraph.segmentFormat); + const newParagraph = createParagraph( + false /*isImplicit*/, + paragraph.format, + paragraph.segmentFormat + ); const markerIndex = paragraph.segments.indexOf(marker); const segments = paragraph.segments.splice( markerIndex, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 86d04cf9385..e13171353f5 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -53,7 +53,7 @@ function shouldInputWithContentModel( rawEvent: KeyboardEvent, isInIME: boolean ) { - if (!selection) { + if (!selection || isInIME) { return false; // Nothing to delete } else if ( !isModifierKey(rawEvent) && @@ -61,7 +61,7 @@ function shouldInputWithContentModel( ) { return ( selection.type != 'range' || - (!selection.range.collapsed && !rawEvent.isComposing && !isInIME) || + (!selection.range.collapsed && !rawEvent.isComposing) || shouldHandleEnterKey(selection, rawEvent) ); } else { From 030793276c6859fd438cbdbdb511fdffc78bde3d Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 6 Feb 2024 09:00:41 -0800 Subject: [PATCH 074/112] StandaloneEditor: Support get/setDOMAttribute (#2396) * StandaloneEditor: Support get/setDOMAttribute * improve --- .../lib/editor/DOMHelperImpl.ts | 12 +++++++ .../test/editor/DOMHelperImplTest.ts | 36 +++++++++++++++++++ .../lib/editor/ContentModelEditor.ts | 8 ++--- .../lib/parameter/DOMHelper.ts | 13 +++++++ 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts index 7a49f6bbcce..d5b201f2c42 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts @@ -20,6 +20,18 @@ class DOMHelperImpl implements DOMHelper { ? Math.round((originalWidth / visualWidth) * 100) / 100 : 1; } + + setDomAttribute(name: string, value: string | null) { + if (value === null) { + this.contentDiv.removeAttribute(name); + } else { + this.contentDiv.setAttribute(name, value); + } + } + + getDomAttribute(name: string): string | null { + return this.contentDiv.getAttribute(name); + } } /** diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts index a3913ec6610..5704869a9dd 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts @@ -60,4 +60,40 @@ describe('DOMHelperImpl', () => { expect(zoomScale).toBe(1); }); + + it('getDomAttribute', () => { + const mockedAttr = 'ATTR'; + const mockedValue = 'VALUE'; + const getAttributeSpy = jasmine.createSpy('getAttribute').and.returnValue(mockedValue); + const mockedDiv = { + getAttribute: getAttributeSpy, + } as any; + + const domHelper = createDOMHelper(mockedDiv); + const result = domHelper.getDomAttribute(mockedAttr); + + expect(result).toBe(mockedValue); + expect(getAttributeSpy).toHaveBeenCalledWith(mockedAttr); + }); + + it('setDomAttribute', () => { + const mockedAttr1 = 'ATTR1'; + const mockedAttr2 = 'ATTR2'; + const mockedValue = 'VALUE'; + const setAttributeSpy = jasmine.createSpy('setAttribute'); + const removeAttributeSpy = jasmine.createSpy('removeAttribute'); + const mockedDiv = { + setAttribute: setAttributeSpy, + removeAttribute: removeAttributeSpy, + } as any; + + const domHelper = createDOMHelper(mockedDiv); + domHelper.setDomAttribute(mockedAttr1, mockedValue); + + expect(setAttributeSpy).toHaveBeenCalledWith(mockedAttr1, mockedValue); + expect(removeAttributeSpy).not.toHaveBeenCalled(); + + domHelper.setDomAttribute(mockedAttr2, null); + expect(removeAttributeSpy).toHaveBeenCalledWith(mockedAttr2); + }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 7e6d166d06a..b1de6094518 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -828,11 +828,7 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode * @param value Value of the attribute */ setEditorDomAttribute(name: string, value: string | null) { - if (value === null) { - this.getCore().contentDiv.removeAttribute(name); - } else { - this.getCore().contentDiv.setAttribute(name, value); - } + this.getDOMHelper().setDomAttribute(name, value); } /** @@ -840,7 +836,7 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode * @param name Name of the attribute */ getEditorDomAttribute(name: string): string | null { - return this.getCore().contentDiv.getAttribute(name); + return this.getDOMHelper().getDomAttribute(name); } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts index 2e8e1e4e2a1..e1e0b274f0b 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts @@ -30,4 +30,17 @@ export interface DOMHelper { * Calculate current zoom scale of editor */ calculateZoomScale(): number; + + /** + * Set DOM attribute of editor content DIV + * @param name Name of the attribute + * @param value Value of the attribute + */ + setDomAttribute(name: string, value: string | null): void; + + /** + * Get DOM attribute of editor content DIV, null if there is no such attribute. + * @param name Name of the attribute + */ + getDomAttribute(name: string): string | null; } From 28642ec18c991a30140db82144480b983a4b2ff4 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 6 Feb 2024 09:17:24 -0800 Subject: [PATCH 075/112] Fix list style: do not auto apply list style if not allowed (#2393) * Fix list style * fix test * improve --- .../lib/modelApi/block/setModelIndentation.ts | 12 +- .../modelApi/block/setModelIndentationTest.ts | 54 ++++- .../lib/metadata/updateListMetadata.ts | 53 +++-- .../handleListItemWithMetadataTest.ts | 6 +- .../metadata/handleListWithMetadataTest.ts | 39 +--- .../test/metadata/updateListMetadataTest.ts | 214 ++++++++++++++++++ .../test/endToEndTest.ts | 48 ++++ .../paste/processPastedContentFromWacTest.ts | 64 +++--- .../lib/format/metadata/ListMetadataFormat.ts | 7 + 9 files changed, 400 insertions(+), 97 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts index 97fa93cc7a8..db0a112bf13 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts @@ -1,5 +1,9 @@ import { createListLevel, parseValueWithUnit } from 'roosterjs-content-model-dom'; -import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; +import { + getOperationalBlocks, + isBlockGroupOfType, + updateListMetadata, +} from 'roosterjs-content-model-core'; import type { ContentModelDocument, ContentModelListItem, @@ -32,6 +36,12 @@ export function setModelIndentation( lastLevel?.format ); + updateListMetadata(newLevel, metadata => { + metadata = metadata || {}; + metadata.applyListStyleFromLevel = true; + return metadata; + }); + // New level is totally new, no need to have these attributes for now delete newLevel.format.startNumberOverride; diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts index 5d475c47c98..3a54c57140c 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts @@ -284,7 +284,11 @@ describe('indent', () => { blockType: 'BlockGroup', levels: [ { listType: 'OL', dataset: {}, format: {} }, - { listType: 'OL', dataset: {}, format: {} }, + { + listType: 'OL', + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, + format: {}, + }, ], blocks: [para1, para2, para3], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -346,7 +350,11 @@ describe('indent', () => { startNumberOverride: 2, }, }, - { listType: 'OL', dataset: {}, format: {} }, + { + listType: 'OL', + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, + format: {}, + }, ], blocks: [para1, para2, para3], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -384,7 +392,11 @@ describe('indent', () => { blockType: 'BlockGroup', levels: [ { listType: 'OL', dataset: {}, format: {} }, - { listType: 'OL', dataset: {}, format: {} }, + { + listType: 'OL', + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, + format: {}, + }, ], blocks: [para2], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -433,7 +445,11 @@ describe('indent', () => { blockType: 'BlockGroup', levels: [ { listType: 'OL', dataset: {}, format: {} }, - { listType: 'OL', dataset: {}, format: {} }, + { + listType: 'OL', + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, + format: {}, + }, ], blocks: [para1, para2], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -444,7 +460,11 @@ describe('indent', () => { blockType: 'BlockGroup', levels: [ { listType: 'OL', dataset: {}, format: {} }, - { listType: 'OL', dataset: {}, format: {} }, + { + listType: 'OL', + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, + format: {}, + }, ], blocks: [para3], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -492,7 +512,11 @@ describe('indent', () => { blockType: 'BlockGroup', levels: [ { listType: 'OL', dataset: {}, format: {} }, - { listType: 'OL', dataset: {}, format: {} }, + { + listType: 'OL', + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, + format: {}, + }, ], blocks: [para1, para2], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -503,7 +527,11 @@ describe('indent', () => { blockType: 'BlockGroup', levels: [ { listType: 'UL', dataset: {}, format: {} }, - { listType: 'UL', dataset: {}, format: {} }, + { + listType: 'UL', + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, + format: {}, + }, ], blocks: [para3], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -551,7 +579,11 @@ describe('indent', () => { blockType: 'BlockGroup', levels: [ { listType: 'OL', dataset: {}, format: {} }, - { listType: 'OL', dataset: {}, format: {} }, + { + listType: 'OL', + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, + format: {}, + }, ], blocks: [para1], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, @@ -562,7 +594,11 @@ describe('indent', () => { blockType: 'BlockGroup', levels: [ { listType: 'UL', dataset: {}, format: {} }, - { listType: 'UL', dataset: {}, format: {} }, + { + listType: 'UL', + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, + format: {}, + }, ], blocks: [para2], formatHolder: { segmentType: 'SelectionMarker', isSelected: true, format: {} }, diff --git a/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts index 2fafbc9cc07..562a36304d5 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts @@ -1,7 +1,11 @@ import { BulletListType } from '../constants/BulletListType'; -import { createNumberDefinition, createObjectDefinition } from './definitionCreators'; import { getObjectKeys, updateMetadata } from 'roosterjs-content-model-dom'; import { NumberingListType } from '../constants/NumberingListType'; +import { + createBooleanDefinition, + createNumberDefinition, + createObjectDefinition, +} from './definitionCreators'; import type { ContentModelListItemFormat, ContentModelListItemLevelFormat, @@ -123,6 +127,7 @@ const listMetadataDefinition = createObjectDefinition( BulletListType.Min, BulletListType.Max ), + applyListStyleFromLevel: createBooleanDefinition(true /*isOptional*/), }, true /** isOptional */, true /** allowNull */ @@ -133,15 +138,19 @@ function shouldApplyToItem(listStyleType: string) { } function getRawListStyleType(listType: 'OL' | 'UL', metadata: ListMetadataFormat, depth: number) { - const { orderedStyleType, unorderedStyleType } = metadata; + const { orderedStyleType, unorderedStyleType, applyListStyleFromLevel } = metadata; if (listType == 'OL') { - return orderedStyleType === undefined + return typeof orderedStyleType == 'number' + ? OrderedMap[orderedStyleType] + : applyListStyleFromLevel ? DefaultOrderedListStyles[depth % DefaultOrderedListStyles.length] - : OrderedMap[orderedStyleType]; + : undefined; } else { - return unorderedStyleType === undefined + return typeof unorderedStyleType == 'number' + ? UnorderedMap[unorderedStyleType] + : applyListStyleFromLevel ? DefaultUnorderedListStyles[depth % DefaultUnorderedListStyles.length] - : UnorderedMap[unorderedStyleType]; + : undefined; } } @@ -176,16 +185,18 @@ export const listItemMetadataApplier: MetadataApplier< const listType = context.listFormat.nodeStack[depth + 1].listType ?? 'OL'; const listStyleType = getRawListStyleType(listType, metadata ?? {}, depth); - if (listStyleType && shouldApplyToItem(listStyleType)) { - format.listStyleType = - listType == 'OL' - ? getOrderedListStyleValue( - listStyleType, - context.listFormat.threadItemCounts[depth] - ) - : listStyleType; - } else { - delete format.listStyleType; + if (listStyleType) { + if (shouldApplyToItem(listStyleType)) { + format.listStyleType = + listType == 'OL' + ? getOrderedListStyleValue( + listStyleType, + context.listFormat.threadItemCounts[depth] + ) + : listStyleType; + } else { + delete format.listStyleType; + } } } }, @@ -206,10 +217,12 @@ export const listLevelMetadataApplier: MetadataApplier< const listType = context.listFormat.nodeStack[depth + 1].listType ?? 'OL'; const listStyleType = getRawListStyleType(listType, metadata ?? {}, depth); - if (listStyleType && !shouldApplyToItem(listStyleType)) { - format.listStyleType = listStyleType; - } else { - delete format.listStyleType; + if (listStyleType) { + if (!shouldApplyToItem(listStyleType)) { + format.listStyleType = listStyleType; + } else { + delete format.listStyleType; + } } } }, diff --git a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts index 3ec21ecffb4..9793327b3bf 100644 --- a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts @@ -244,7 +244,7 @@ describe('handleListItem with metadata', () => { handleListItem(document, parent, listItem, context, null); const expectedResult = [ - '
                  ', + '
                  ', ]; expectHtml(parent.outerHTML, expectedResult); @@ -257,9 +257,7 @@ describe('handleListItem with metadata', () => { { node: parent.firstChild as HTMLOListElement, listType: 'OL', - format: { - listStyleType: 'decimal', - }, + format: {}, dataset: {}, }, ], diff --git a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts index 2f713dab8d1..93c99464b63 100644 --- a/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts @@ -46,7 +46,7 @@ describe('handleList with metadata', () => { handleList(document, parent, listItem, context, null); - expect(parent.outerHTML).toBe('
                    '); + expect(parent.outerHTML).toBe('
                      '); expect(context.listFormat).toEqual({ threadItemCounts: [], nodeStack: [ @@ -57,9 +57,7 @@ describe('handleList with metadata', () => { listType: 'UL', node: parent.firstChild as HTMLElement, dataset: {}, - format: { - listStyleType: 'disc', - }, + format: {}, }, ], }); @@ -69,9 +67,7 @@ describe('handleList with metadata', () => { const listItem = createListItem([createListLevel('OL')]); handleList(document, parent, listItem, context, null); - const possibleResults = [ - '
                        ', - ]; + const possibleResults = ['
                          ']; expectHtml(parent.outerHTML, possibleResults); expect(context.listFormat).toEqual({ @@ -84,9 +80,7 @@ describe('handleList with metadata', () => { listType: 'OL', node: parent.firstChild as HTMLElement, dataset: {}, - format: { - listStyleType: 'decimal', - }, + format: {}, }, ], }); @@ -211,7 +205,7 @@ describe('handleList with metadata', () => { expectHtml(parent.outerHTML, [ '
                              ', - '
                                  ', + '
                                      ', ]); expect(context.listFormat).toEqual({ threadItemCounts: [1, 0], @@ -223,17 +217,13 @@ describe('handleList with metadata', () => { listType: 'OL', node: existingOL.nextSibling as HTMLElement, dataset: { editingInfo: JSON.stringify({ unorderedStyleType: 3 }) }, - format: { - listStyleType: 'decimal', - }, + format: {}, }, { listType: 'OL', node: (existingOL.nextSibling as HTMLElement).firstChild as HTMLElement, dataset: {}, - format: { - listStyleType: 'lower-alpha', - }, + format: {}, }, ], }); @@ -287,9 +277,7 @@ describe('handleList with metadata', () => { ]; handleList(document, parent, listItem, context, null); - const possibleResults = [ - '
                                          ', - ]; + const possibleResults = ['
                                              ']; expectHtml(parent.outerHTML, possibleResults); @@ -303,9 +291,7 @@ describe('handleList with metadata', () => { listType: 'OL', node: existingOL1.nextSibling as HTMLElement, dataset: {}, - format: { - listStyleType: 'decimal', - }, + format: {}, }, ], }); @@ -331,9 +317,7 @@ describe('handleList with metadata', () => { handleList(document, parent, listItem, context, null); - const possibleResults = [ - '
                                                  ', - ]; + const possibleResults = ['
                                                      ']; expectHtml(parent.outerHTML, possibleResults); @@ -353,7 +337,6 @@ describe('handleList with metadata', () => { dataset: {}, format: { startNumberOverride: 3, - listStyleType: 'lower-alpha', }, }, ], @@ -377,7 +360,7 @@ describe('handleList with metadata', () => { handleList(document, parent, listItem, context, null); expect(parent.outerHTML).toBe( - '
                                                        ' + '
                                                          ' ); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts index 64ba07e807a..07297d6f726 100644 --- a/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts @@ -308,6 +308,111 @@ describe('listItemMetadataApplier', () => { }); }); + it('Has metadata, has start number, apply list style from level, no existing style', () => { + context.listFormat.threadItemCounts = [2]; + context.listFormat.nodeStack = [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ]; + + listItemMetadataApplier.applierFunction( + { + applyListStyleFromLevel: true, + }, + format, + context + ); + + expect(context.listFormat).toEqual({ + threadItemCounts: [2], + nodeStack: [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ], + }); + expect(format).toEqual({}); + }); + + it('Has metadata, has start number, apply list style from level, has existing style', () => { + context.listFormat.threadItemCounts = [2]; + context.listFormat.nodeStack = [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ]; + + format.listStyleType = 'test'; + + listItemMetadataApplier.applierFunction( + { + applyListStyleFromLevel: true, + }, + format, + context + ); + + expect(context.listFormat).toEqual({ + threadItemCounts: [2], + nodeStack: [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ], + }); + expect(format).toEqual({}); + }); + + it('Has metadata, has start number, do not apply list style from level, has existing style', () => { + context.listFormat.threadItemCounts = [2]; + context.listFormat.nodeStack = [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ]; + + format.listStyleType = 'test'; + + listItemMetadataApplier.applierFunction( + { + applyListStyleFromLevel: false, + }, + format, + context + ); + + expect(context.listFormat).toEqual({ + threadItemCounts: [2], + nodeStack: [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ], + }); + expect(format).toEqual({ + listStyleType: 'test', + }); + }); + it('UL has metadata', () => { context.listFormat.nodeStack = [ { @@ -660,6 +765,115 @@ describe('listLevelMetadataApplier', () => { expect(format).toEqual({}); }); + it('Has metadata, has start number, apply list style from level, no existing style', () => { + context.listFormat.threadItemCounts = [2]; + context.listFormat.nodeStack = [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ]; + + listLevelMetadataApplier.applierFunction( + { + applyListStyleFromLevel: true, + }, + format, + context + ); + + expect(context.listFormat).toEqual({ + threadItemCounts: [2], + nodeStack: [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ], + }); + expect(format).toEqual({ + listStyleType: 'decimal', + }); + }); + + it('Has metadata, has start number, apply list style from level, has existing style', () => { + context.listFormat.threadItemCounts = [2]; + context.listFormat.nodeStack = [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ]; + + format.listStyleType = 'test'; + + listLevelMetadataApplier.applierFunction( + { + applyListStyleFromLevel: true, + }, + format, + context + ); + + expect(context.listFormat).toEqual({ + threadItemCounts: [2], + nodeStack: [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ], + }); + expect(format).toEqual({ + listStyleType: 'decimal', + }); + }); + + it('Has metadata, has start number, do not apply list style from level, has existing style', () => { + context.listFormat.threadItemCounts = [2]; + context.listFormat.nodeStack = [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ]; + + format.listStyleType = 'test'; + + listLevelMetadataApplier.applierFunction( + { + applyListStyleFromLevel: false, + }, + format, + context + ); + + expect(context.listFormat).toEqual({ + threadItemCounts: [2], + nodeStack: [ + { + node: {} as Node, + }, + { + node: {} as Node, + }, + ], + }); + expect(format).toEqual({ + listStyleType: 'test', + }); + }); + it('UL has metadata', () => { context.listFormat.nodeStack = [ { diff --git a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts index b86beaf0ddc..3ed0c1357f0 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts @@ -1990,4 +1990,52 @@ describe('End to end test for DOM => Model', () => { '
                                                          abc
                                                          ' ); }); + + it('list with list style', () => { + runTest( + '
                                                            1. test
                                                          ', + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + isImplicit: true, + format: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + { + listType: 'OL', + format: { listStyleType: '"1) "' }, + dataset: {}, + }, + ], + format: {}, + }, + ], + }, + '
                                                            1. test
                                                          ' + ); + }); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts index e6832379ba0..6bfb1a13ecf 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts @@ -101,40 +101,34 @@ describe('processPastedContentFromWacTest', () => { it('Single DIV with child LI', () => { runTest( '
                                                          • 1
                                                          • 2
                                                          ', - '
                                                          • 1
                                                          • 2
                                                          ' + '
                                                          • 1
                                                          • 2
                                                          ' ); }); it('Single DIV with deeper child LI', () => { runTest( '
                                                          • 1
                                                          • 2
                                                          ', - '
                                                          • 1
                                                          • 2
                                                          ' + '
                                                          • 1
                                                          • 2
                                                          ' ); }); it('Single DIV with text and LI', () => { runTest( '
                                                          test
                                                          • 1
                                                          ', - 'test
                                                          • 1
                                                          ' + 'test
                                                          • 1
                                                          ' ); }); it('Single LI', () => { - runTest('
                                                          • 1
                                                          ', '
                                                          • 1
                                                          '); + runTest('
                                                          • 1
                                                          ', '
                                                          • 1
                                                          '); }); it('Single LI and text', () => { - runTest( - '
                                                          • 1
                                                          test', - '
                                                          • 1
                                                          test' - ); + runTest('
                                                          • 1
                                                          test', '
                                                          • 1
                                                          test'); }); it('Multiple LI', () => { - runTest( - '
                                                          • 1
                                                          • 2
                                                          ', - '
                                                          • 1
                                                          • 2
                                                          ' - ); + runTest('
                                                          • 1
                                                          • 2
                                                          ', '
                                                          • 1
                                                          • 2
                                                          '); }); }); @@ -203,7 +197,7 @@ describe('wordOnlineHandler', () => { it('has all list items on the same level', () => { runTest( '
                                                          • A
                                                          • B
                                                          • C
                                                          ', - '
                                                          • A
                                                          • B
                                                            • C
                                                          ', + '
                                                          • A
                                                          • B
                                                            • C
                                                          ', { blockGroupType: 'Document', blocks: [ @@ -329,7 +323,7 @@ describe('wordOnlineHandler', () => { it('List items on different level but only going on direction in terms of depth', () => { runTest( '
                                                          • A
                                                          • B
                                                          • C
                                                          ', - '
                                                          • A
                                                            • B
                                                              • C
                                                          ', + '
                                                          • A
                                                            • B
                                                              • C
                                                          ', { blockGroupType: 'Document', blocks: [ @@ -474,7 +468,7 @@ describe('wordOnlineHandler', () => { it('List items on different level but have different branch in each level', () => { runTest( '
                                                          • A
                                                          • B
                                                          • C
                                                          • D
                                                          • E
                                                          ', - '
                                                          • A
                                                            • B
                                                              • C
                                                            • D
                                                              • E
                                                          ', + '
                                                          • A
                                                            • B
                                                              • C
                                                            • D
                                                              • E
                                                          ', { blockGroupType: 'Document', blocks: [ @@ -728,7 +722,7 @@ describe('wordOnlineHandler', () => { it('List items on different level with different branch with a combination of order and unordered list items', () => { runTest( '
                                                          • A
                                                          • B
                                                          1. C1
                                                          1. C2
                                                          • D
                                                          ', - '
                                                          • A
                                                            • B
                                                              1. C1
                                                              2. C2
                                                            • D
                                                          ', + '
                                                          • A
                                                            • B
                                                              1. C1
                                                              2. C2
                                                            • D
                                                          ', { blockGroupType: 'Document', blocks: [ @@ -984,7 +978,7 @@ describe('wordOnlineHandler', () => { it('only has text and list', () => { runTest( '

                                                          asdfasdf

                                                          • A
                                                          • B
                                                          1. C1
                                                          1. C2
                                                          • D

                                                          asdfasdf

                                                          ', - '

                                                          asdfasdf

                                                          • A
                                                            • B
                                                              1. C1
                                                              2. C2
                                                            • D

                                                          asdfasdf

                                                          ' + '

                                                          asdfasdf

                                                          • A
                                                            • B
                                                              1. C1
                                                              2. C2
                                                            • D

                                                          asdfasdf

                                                          ' ); }); @@ -1007,7 +1001,7 @@ describe('wordOnlineHandler', () => { it('fragments contains text, list and table that consist of list 2', () => { runTest( '

                                                          asdfasdf

                                                          • A
                                                          • B
                                                          1. C1
                                                          1. C2
                                                          • D

                                                          asdfasdf

                                                          asdfasdf

                                                          • A
                                                          • B
                                                          • C
                                                          • D

                                                          ', - '

                                                          asdfasdf

                                                          • A
                                                            • B
                                                              1. C1
                                                              1. C2
                                                            • D

                                                          asdfasdf

                                                          asdfasdf

                                                          • A
                                                          • B
                                                          • C
                                                          • D
                                                          ' + '

                                                          asdfasdf

                                                          • A
                                                            • B
                                                              1. C1
                                                              1. C2
                                                            • D

                                                          asdfasdf

                                                          asdfasdf

                                                          • A
                                                          • B
                                                          • C
                                                          • D
                                                          ' ); }); // e.g. @@ -1019,7 +1013,7 @@ describe('wordOnlineHandler', () => { it('fragments contains text, list and table that consist of list', () => { runTest( '

                                                          asdfasdf

                                                          asdfasdf222

                                                          • A
                                                          • A
                                                          ', - '

                                                          asdfasdf

                                                          asdfasdf222

                                                          • A
                                                          • A
                                                          ' + '

                                                          asdfasdf

                                                          asdfasdf222

                                                          • A
                                                          • A
                                                          ' ); }); }); @@ -1027,14 +1021,14 @@ describe('wordOnlineHandler', () => { it('does not have list container', () => { runTest( '
                                                          • A
                                                          • B
                                                          • C
                                                          • D
                                                          • E
                                                          ', - '
                                                          • A
                                                            • B
                                                              • C
                                                            • D
                                                              • E
                                                          ' + '
                                                          • A
                                                            • B
                                                              • C
                                                            • D
                                                              • E
                                                          ' ); }); it('does not have BulletListStyle or NumberListStyle but has ListContainerWrapper', () => { runTest( '
                                                          • A
                                                          • B
                                                          • C
                                                          ', - '
                                                          • A
                                                            • B
                                                              • C
                                                          ' + '
                                                          • A
                                                            • B
                                                              • C
                                                          ' ); }); @@ -1180,7 +1174,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if ListContainerWrapper contains two UL', () => { runTest( '
                                                          • A
                                                          • B
                                                          • C
                                                          ', - '
                                                          • A
                                                          • B
                                                          • C
                                                          ' + '
                                                          • A
                                                          • B
                                                          • C
                                                          ' ); }); @@ -1222,7 +1216,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if ListContainerWrapper contains list that is already well formatted', () => { runTest( '
                                                          • A
                                                            • B
                                                              • C
                                                            • D
                                                              • E
                                                          ', - '
                                                          • A
                                                            • B
                                                              • C
                                                            • D
                                                              • E
                                                          ' + '
                                                          • A
                                                            • B
                                                              • C
                                                            • D
                                                              • E
                                                          ' ); }); @@ -1240,7 +1234,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if there are multiple list item in ol (word online has one list item in each ol for ordered list)', () => { runTest( '
                                                          1. A
                                                          2. B
                                                          1. C
                                                          ', - '
                                                          1. A
                                                          2. B
                                                          1. C
                                                          ' + '
                                                          1. A
                                                          2. B
                                                          1. C
                                                          ' ); }); @@ -1273,7 +1267,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if ListContainerWrapper contains well formated UL and non formated ol', () => { runTest( '
                                                          • A
                                                          1. B
                                                          ', - '
                                                          • A
                                                          1. B
                                                          ' + '
                                                          • A
                                                          1. B
                                                          ' ); }); @@ -1292,7 +1286,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if ListContainerWrapper contains two OL', () => { runTest( '
                                                          • A
                                                          1. B
                                                          1. C
                                                          ', - '
                                                          • A
                                                          1. B
                                                          2. C
                                                          ' + '
                                                          • A
                                                          1. B
                                                          2. C
                                                          ' ); }); @@ -1309,7 +1303,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if ListContainerWrapper contains two OL and one UL', () => { runTest( '
                                                          • A
                                                          1. B
                                                          1. C
                                                          ', - '
                                                          • A
                                                          1. B
                                                          1. C
                                                          ' + '
                                                          • A
                                                          1. B
                                                          1. C
                                                          ' ); }); @@ -1324,7 +1318,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if there are list not in the ListContainerWrapper', () => { runTest( '
                                                          1. C
                                                          • A
                                                          ', - '
                                                          1. C
                                                          • A
                                                          ' + '
                                                          1. C
                                                          • A
                                                          ' ); }); @@ -1343,7 +1337,7 @@ describe('wordOnlineHandler', () => { it('should process html properly, if ListContainerWrapper contains two UL', () => { runTest( '
                                                          1. C
                                                          • A
                                                          • A
                                                          • A
                                                          ', - '
                                                          1. C
                                                          • A
                                                          • A
                                                          • A
                                                          ' + '
                                                          1. C
                                                          • A
                                                          • A
                                                          • A
                                                          ' ); }); @@ -1355,7 +1349,7 @@ describe('wordOnlineHandler', () => { it('should retain all text, if ListContainerWrapper contains Elements before li and ul', () => { runTest( '

                                                          paragraph

                                                          1. C
                                                          ', - '

                                                          paragraph

                                                          1. C
                                                          ' + '

                                                          paragraph

                                                          1. C
                                                          ' ); }); @@ -1367,7 +1361,7 @@ describe('wordOnlineHandler', () => { it('should retain all text, if ListContainerWrapper contains Elements after li and ul', () => { runTest( '
                                                          1. C

                                                          paragraph

                                                          ', - '
                                                          1. C

                                                          paragraph

                                                          ' + '
                                                          1. C

                                                          paragraph

                                                          ' ); }); }); @@ -1425,7 +1419,7 @@ describe('wordOnlineHandler', () => { it('List directly under fragment', () => { runTest( '
                                                          • A

                                                          B

                                                          ', - '
                                                          • A

                                                          B

                                                          ' + '
                                                          • A

                                                          B

                                                          ' ); }); @@ -1438,7 +1432,7 @@ describe('wordOnlineHandler', () => { it('should remove the display and margin styles from the element', () => { runTest( '
                                                          • A

                                                          • B

                                                          • C

                                                            1. D

                                                          ', - '
                                                          • A

                                                          • B

                                                          • C

                                                            1. D

                                                          ' + '
                                                          • A

                                                          • B

                                                          • C

                                                            1. D

                                                          ' ); }); }); @@ -1533,7 +1527,7 @@ describe('wordOnlineHandler', () => { it('Text between lists', () => { runTest( '
                                                          • List1

                                                          Text

                                                          • List2
                                                          ', - '
                                                          • List1

                                                          Text

                                                          • List2
                                                          ', + '
                                                          • List1

                                                          Text

                                                          • List2
                                                          ', { blockGroupType: 'Document', blocks: [ @@ -1629,7 +1623,7 @@ describe('wordOnlineHandler', () => { it('Remove temp marker from Word Online', () => { runTest( '

                                                          it went:  

                                                          1. Test

                                                          1. Test. 


                                                          ', - '

                                                          it went:  

                                                          1. Test

                                                          2. Test. 


                                                          ' + '

                                                          it went:  

                                                          1. Test

                                                          2. Test. 


                                                          ' ); }); diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts index 45bdb0514ae..2f9307e1d91 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts @@ -11,4 +11,11 @@ export type ListMetadataFormat = { * Style type for Unordered list. Use value of constant BulletListType as value. */ unorderedStyleType?: number; + + /** + * When set to true, if there is no orderedStyleType (for OL) or unorderedStyleType (for UL) specified, use the list from its level + * For ordered list, the default list styles from levels are: 'decimal', 'lower-alpha', 'lower-roman', then loop + * For unordered list, the default list styles from levels are: 'disc', 'circle', 'square', then loop + */ + applyListStyleFromLevel?: boolean; }; From a1902f0759f9e664455b6f9744f56acb6cb03721 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 6 Feb 2024 09:29:51 -0800 Subject: [PATCH 076/112] Fix 252481 (#2395) --- .../lib/config/defaultHTMLStyleMap.ts | 4 +- .../block/paddingFormatHandler.ts | 52 +++++- .../block/paddingFormatHandlerTest.ts | 150 +++++++++++++++++- 3 files changed, 194 insertions(+), 12 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/config/defaultHTMLStyleMap.ts b/packages-content-model/roosterjs-content-model-dom/lib/config/defaultHTMLStyleMap.ts index 49559477a1e..5ee760791ee 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/config/defaultHTMLStyleMap.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/config/defaultHTMLStyleMap.ts @@ -77,7 +77,7 @@ export const defaultHTMLStyleMap: DefaultStyleMap = { }, main: blockElement, nav: blockElement, - ol: blockElement, + ol: { ...blockElement, paddingInlineStart: '40px' }, p: { display: 'block', marginTop: '1em', @@ -121,5 +121,5 @@ export const defaultHTMLStyleMap: DefaultStyleMap = { u: { textDecoration: 'underline', }, - ul: blockElement, + ul: { ...blockElement, paddingInlineStart: '40px' }, }; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/paddingFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/paddingFormatHandler.ts index 33338ca3638..afaa6dbd19a 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/paddingFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/paddingFormatHandler.ts @@ -1,5 +1,6 @@ +import { directionFormatHandler } from './directionFormatHandler'; import type { FormatHandler } from '../FormatHandler'; -import type { PaddingFormat } from 'roosterjs-content-model-types'; +import type { DirectionFormat, PaddingFormat } from 'roosterjs-content-model-types'; const PaddingKeys: (keyof PaddingFormat & keyof CSSStyleDeclaration)[] = [ 'paddingTop', @@ -8,16 +9,42 @@ const PaddingKeys: (keyof PaddingFormat & keyof CSSStyleDeclaration)[] = [ 'paddingLeft', ]; +const AlternativeKeyLtr: Partial> = { + paddingLeft: 'paddingInlineStart', +}; + +const AlternativeKeyRtl: Partial> = { + paddingRight: 'paddingInlineStart', +}; + /** * @internal */ -export const paddingFormatHandler: FormatHandler = { - parse: (format, element, _, defaultStyle) => { +export const paddingFormatHandler: FormatHandler = { + parse: (format, element, context, defaultStyle) => { + directionFormatHandler.parse(format, element, context, defaultStyle); + PaddingKeys.forEach(key => { let value = element.style[key]; - const defaultValue = defaultStyle[key] ?? '0px'; + const alterativeKey = (format.direction == 'rtl' + ? AlternativeKeyRtl + : AlternativeKeyLtr)[key]; + const defaultValue: string = + (defaultStyle[key] ?? + (alterativeKey ? defaultStyle[alterativeKey] : undefined) ?? + '0px') + ''; - if (value == '0') { + if (!value) { + value = defaultValue; + } + + if (!value || value == '0') { value = '0px'; } @@ -26,10 +53,21 @@ export const paddingFormatHandler: FormatHandler = { } }); }, - apply: (format, element) => { + apply: (format, element, context) => { PaddingKeys.forEach(key => { const value = format[key]; - if (value) { + let defaultValue: string | undefined = undefined; + + if (element.tagName == 'OL' || element.tagName == 'UL') { + if ( + (format.direction == 'rtl' && key == 'paddingRight') || + (format.direction != 'rtl' && key == 'paddingLeft') + ) { + defaultValue = '40px'; + } + } + + if (value && value != defaultValue) { element.style[key] = value; } }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/paddingFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/paddingFormatHandlerTest.ts index 501a476b8cd..20b54725185 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/paddingFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/paddingFormatHandlerTest.ts @@ -1,11 +1,17 @@ import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; -import { DomToModelContext, ModelToDomContext, PaddingFormat } from 'roosterjs-content-model-types'; +import { defaultHTMLStyleMap } from '../../../lib/config/defaultHTMLStyleMap'; import { paddingFormatHandler } from '../../../lib/formatHandlers/block/paddingFormatHandler'; +import { + DirectionFormat, + DomToModelContext, + ModelToDomContext, + PaddingFormat, +} from 'roosterjs-content-model-types'; describe('paddingFormatHandler.parse', () => { let div: HTMLElement; - let format: PaddingFormat; + let format: PaddingFormat & DirectionFormat; let context: DomToModelContext; beforeEach(() => { @@ -47,11 +53,89 @@ describe('paddingFormatHandler.parse', () => { paddingBottom: '20px', }); }); + + it('Default padding in OL, LTR', () => { + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ol!); + + expect(format).toEqual({}); + }); + + it('Default padding in OL, RTL', () => { + div.style.direction = 'rtl'; + + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ol!); + + expect(format).toEqual({ + direction: 'rtl', + }); + }); + + it('Default padding in UL, LTR', () => { + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ul!); + + expect(format).toEqual({}); + }); + + it('Default padding in UL, RTL', () => { + div.style.direction = 'rtl'; + + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ul!); + + expect(format).toEqual({ + direction: 'rtl', + }); + }); + + it('Customized padding in OL, LTR', () => { + div.style.paddingLeft = '0'; + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ol!); + + expect(format).toEqual({ + paddingLeft: '0px', + }); + }); + + it('Customized padding in OL, RTL', () => { + div.style.direction = 'rtl'; + div.style.paddingLeft = '0'; + div.style.paddingRight = '20px'; + + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ol!); + + expect(format).toEqual({ + direction: 'rtl', + paddingRight: '20px', + }); + }); + + it('Customized padding in UL, LTR', () => { + div.style.paddingLeft = '20px'; + + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ul!); + + expect(format).toEqual({ + paddingLeft: '20px', + }); + }); + + it('Customized padding in UL, RTL', () => { + div.style.direction = 'rtl'; + div.style.paddingLeft = '20px'; + div.style.paddingRight = '60px'; + + paddingFormatHandler.parse(format, div, context, defaultHTMLStyleMap.ul!); + + expect(format).toEqual({ + direction: 'rtl', + paddingLeft: '20px', + paddingRight: '60px', + }); + }); }); describe('paddingFormatHandler.apply', () => { let div: HTMLElement; - let format: PaddingFormat; + let format: PaddingFormat & DirectionFormat; let context: ModelToDomContext; beforeEach(() => { @@ -75,4 +159,64 @@ describe('paddingFormatHandler.apply', () => { expect(div.outerHTML).toBe('
                                                          '); }); + + it('OL has no padding', () => { + const ol = document.createElement('ol'); + + paddingFormatHandler.apply(format, ol, context); + + expect(ol.outerHTML).toBe('
                                                            '); + }); + + it('OL has default padding', () => { + const ol = document.createElement('ol'); + + format.paddingLeft = '40px'; + + paddingFormatHandler.apply(format, ol, context); + + expect(ol.outerHTML).toBe('
                                                              '); + }); + + it('OL has padding', () => { + const ol = document.createElement('ol'); + + format.paddingLeft = '60px'; + + paddingFormatHandler.apply(format, ol, context); + + expect(ol.outerHTML).toBe('
                                                                '); + }); + + it('UL has padding', () => { + const ul = document.createElement('ul'); + + format.paddingLeft = '60px'; + + paddingFormatHandler.apply(format, ul, context); + + expect(ul.outerHTML).toBe('
                                                                  '); + }); + + it('OL has padding-left in RTL', () => { + const ol = document.createElement('ol'); + + format.paddingLeft = '40px'; + format.direction = 'rtl'; + + paddingFormatHandler.apply(format, ol, context); + + expect(ol.outerHTML).toBe('
                                                                    '); + }); + + it('OL has padding-right in RTL', () => { + const ol = document.createElement('ol'); + + format.paddingRight = '40px'; + format.direction = 'rtl'; + + paddingFormatHandler.apply(format, ol, context); + + expect(ol.outerHTML).toBe('
                                                                      '); + }); }); From ac315a0d2d067302301d57687631219c1c47ed45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 6 Feb 2024 14:32:57 -0300 Subject: [PATCH 077/112] fixes --- .../lib/edit/inputSteps/handleEnterOnList.ts | 14 ++++++++-- .../edit/inputSteps/handleEnterOnListTest.ts | 28 ++++++++----------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts index 92ce2c3e58a..c9d8cae94ed 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -1,9 +1,11 @@ import { getClosestAncestorBlockGroupIndex } from 'roosterjs-content-model-core'; import { + createBr, createListItem, createListLevel, createParagraph, normalizeParagraph, + setParagraphNotImplicit, } from 'roosterjs-content-model-dom'; import type { ContentModelBlockGroup, @@ -40,8 +42,9 @@ const isEmptyListItem = (listItem: ContentModelListItem) => { return ( listItem.blocks.length === 1 && listItem.blocks[0].blockType === 'Paragraph' && - listItem.blocks[0].segments.length === 1 && - listItem.blocks[0].segments[0].segmentType === 'SelectionMarker' + listItem.blocks[0].segments.length === 2 && + listItem.blocks[0].segments[0].segmentType === 'SelectionMarker' && + listItem.blocks[0].segments[1].segmentType === 'Br' ); }; @@ -79,12 +82,19 @@ const createNewParagraph = (insertPoint: InsertPoint) => { paragraph.format, paragraph.segmentFormat ); + const markerIndex = paragraph.segments.indexOf(marker); const segments = paragraph.segments.splice( markerIndex, paragraph.segments.length - markerIndex ); + setParagraphNotImplicit(paragraph); + + if (paragraph.segments.every(x => x.segmentType == 'SelectionMarker')) { + paragraph.segments.push(createBr(marker.format)); + } + newParagraph.segments.push(...segments); normalizeParagraph(newParagraph); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts index 0e10e57a563..949d2007c42 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -54,7 +54,6 @@ describe('handleEnterOnList', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -89,9 +88,12 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, + { + segmentType: 'Br', + format: {}, + }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -134,7 +136,6 @@ describe('handleEnterOnList', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -170,7 +171,6 @@ describe('handleEnterOnList', () => { format: {}, }, ], - isImplicit: false, format: {}, }, ], @@ -207,7 +207,6 @@ describe('handleEnterOnList', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -250,7 +249,6 @@ describe('handleEnterOnList', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -292,8 +290,6 @@ describe('handleEnterOnList', () => { }, ], format: {}, - - isImplicit: true, }, ], levels: [ @@ -346,7 +342,6 @@ describe('handleEnterOnList', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -390,7 +385,6 @@ describe('handleEnterOnList', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -425,9 +419,12 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, + { + segmentType: 'Br', + format: {}, + }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -476,7 +473,6 @@ describe('handleEnterOnList', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -561,7 +557,6 @@ describe('handleEnterOnList', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -649,7 +644,6 @@ describe('handleEnterOnList', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -734,7 +728,6 @@ describe('handleEnterOnList', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -808,9 +801,12 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, + { + segmentType: 'Br', + format: {}, + }, ], format: {}, - isImplicit: true, }, ], levels: [ From 6ee4b38c9dc761b865674ac3b6e50d82f2151937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 6 Feb 2024 15:42:20 -0300 Subject: [PATCH 078/112] handle range --- .../lib/edit/inputSteps/handleEnterOnList.ts | 18 +- .../lib/edit/keyboardInput.ts | 7 +- .../edit/inputSteps/handleEnterOnListTest.ts | 249 ++++++++++++++++++ 3 files changed, 266 insertions(+), 8 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts index c9d8cae94ed..863b6af7703 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -19,17 +19,22 @@ import type { * @internal */ export const handleEnterOnList: DeleteSelectionStep = context => { - if (context.deleteResult == 'nothingToDelete' || context.deleteResult == 'notDeleted') { + if ( + context.deleteResult == 'nothingToDelete' || + context.deleteResult == 'notDeleted' || + context.deleteResult == 'range' + ) { const { insertPoint } = context; const { path } = insertPoint; const index = getClosestAncestorBlockGroupIndex(path, ['ListItem'], ['TableCell']); const listItem = path[index]; + console; if (listItem && listItem.blockGroupType === 'ListItem') { const listParent = path[index + 1]; if (isEmptyListItem(listItem)) { listItem.levels.pop(); - } else { + } else if (!isSelectionMarker(listItem)) { createNewListItem(context, listItem, listParent); } context.formatContext?.rawEvent?.preventDefault(); @@ -38,6 +43,15 @@ export const handleEnterOnList: DeleteSelectionStep = context => { } }; +const isSelectionMarker = (listItem: ContentModelListItem) => { + return ( + listItem.blocks.length === 1 && + listItem.blocks[0].blockType === 'Paragraph' && + listItem.blocks[0].segments.length === 1 && + listItem.blocks[0].segments[0].segmentType === 'SelectionMarker' + ); +}; + const isEmptyListItem = (listItem: ContentModelListItem) => { return ( listItem.blocks.length === 1 && diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index e13171353f5..31620f830bc 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -70,10 +70,5 @@ function shouldInputWithContentModel( } const shouldHandleEnterKey = (selection: DOMSelection | null, rawEvent: KeyboardEvent) => { - return ( - selection && - selection.type == 'range' && - selection.range.collapsed && - rawEvent.key == 'Enter' - ); + return selection && selection.type == 'range' && rawEvent.key == 'Enter'; }; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts index 949d2007c42..822f1dcbe4a 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -835,4 +835,253 @@ describe('handleEnterOnList', () => { runTest(model, expectedModel, 'range'); }); + + it('enter on list item with selected text', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'fdsfsdf', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1. "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'fsdfsd', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2. "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'fsdf', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"3. "', + }, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'fdsfsdf', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1. "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2. "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'fsdf', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"3. "', + }, + }, + ], + format: {}, + }; + runTest(model, expectedModel, 'range'); + }); }); From a6ed52a2c4954f13d7fbfce5fafdaf8b25de067a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 6 Feb 2024 15:47:52 -0300 Subject: [PATCH 079/112] remove console --- .../lib/edit/inputSteps/handleEnterOnList.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts index 863b6af7703..c2c394f92f2 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -29,7 +29,7 @@ export const handleEnterOnList: DeleteSelectionStep = context => { const index = getClosestAncestorBlockGroupIndex(path, ['ListItem'], ['TableCell']); const listItem = path[index]; - console; + if (listItem && listItem.blockGroupType === 'ListItem') { const listParent = path[index + 1]; if (isEmptyListItem(listItem)) { From 9425940edb186ea91a0acf37af3d6af2ddd421c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 6 Feb 2024 18:11:45 -0300 Subject: [PATCH 080/112] handle enter expanded collapsed --- .../lib/edit/inputSteps/handleEnterOnList.ts | 11 +- .../edit/inputSteps/handleEnterOnListTest.ts | 368 +++++++++++++++++- 2 files changed, 366 insertions(+), 13 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts index c2c394f92f2..048d24d8a71 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -34,7 +34,7 @@ export const handleEnterOnList: DeleteSelectionStep = context => { const listParent = path[index + 1]; if (isEmptyListItem(listItem)) { listItem.levels.pop(); - } else if (!isSelectionMarker(listItem)) { + } else { createNewListItem(context, listItem, listParent); } context.formatContext?.rawEvent?.preventDefault(); @@ -43,15 +43,6 @@ export const handleEnterOnList: DeleteSelectionStep = context => { } }; -const isSelectionMarker = (listItem: ContentModelListItem) => { - return ( - listItem.blocks.length === 1 && - listItem.blocks[0].blockType === 'Paragraph' && - listItem.blocks[0].segments.length === 1 && - listItem.blocks[0].segments[0].segmentType === 'SelectionMarker' - ); -}; - const isEmptyListItem = (listItem: ContentModelListItem) => { return ( listItem.blocks.length === 1 && diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts index 822f1dcbe4a..6032669a107 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -999,6 +999,43 @@ describe('handleEnterOnList', () => { listStyleType: '"1. "', }, }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2. "', + }, + }, { blockType: 'BlockGroup', blockGroupType: 'ListItem', @@ -1026,6 +1063,7 @@ describe('handleEnterOnList', () => { marginTop: '0px', marginBottom: '0px', listStyleType: 'decimal', + startNumberOverride: undefined, }, dataset: { editingInfo: '{"orderedStyleType":1,"unorderedStyleType":1}', @@ -1037,9 +1075,7 @@ describe('handleEnterOnList', () => { isSelected: true, format: {}, }, - format: { - listStyleType: '"2. "', - }, + format: {}, }, { blockType: 'BlockGroup', @@ -1084,4 +1120,330 @@ describe('handleEnterOnList', () => { }; runTest(model, expectedModel, 'range'); }); + + it('enter on multiple list items with selected text', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"3) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"4) "', + }, + }, + ], + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"1) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"2) "', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + startNumberOverride: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + }, + dataset: { + editingInfo: '{"orderedStyleType":3,"unorderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + listStyleType: '"4) "', + }, + }, + ], + format: {}, + }; + runTest(model, expectedModel, 'range'); + }); }); From 2f084ed5add644cf5b14d52e29919359b17a56e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 6 Feb 2024 19:22:51 -0300 Subject: [PATCH 081/112] add more tests --- .../lib/modelApi/block/setModelIndentation.ts | 10 ++- .../modelApi/block/setModelIndentationTest.ts | 67 ++++++++++++++----- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts index e57da0d4a80..f80bc8565e5 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts @@ -42,10 +42,14 @@ export function setModelIndentation( const { format } = level; const newValue = calculateMarginValue(format, isIndent, length); const isRtl = format.direction == 'rtl'; - if (isRtl) { - level.format.marginRight = newValue + 'px'; + if (!isIndent && newValue == 0) { + block.levels.pop(); } else { - level.format.marginLeft = newValue + 'px'; + if (isRtl) { + level.format.marginRight = newValue + 'px'; + } else { + level.format.marginLeft = newValue + 'px'; + } } } else { if (isIndent) { diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts index b20e54a81b6..67d4f5a0423 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts @@ -879,15 +879,7 @@ describe('outdent', () => { blocks: [ { ...listItem, - levels: [ - { - listType: 'OL', - dataset: {}, - format: { - marginLeft: '0px', - }, - }, - ], + levels: [], }, ], }); @@ -971,15 +963,7 @@ describe('outdent', () => { blocks: [ { ...listItem, - levels: [ - { - listType: 'UL', - dataset: {}, - format: { - marginLeft: '0px', - }, - }, - ], + levels: [], }, { blockType: 'Paragraph', @@ -1059,4 +1043,51 @@ describe('outdent', () => { }); expect(result).toBeTrue(); }); + + it('Group with list with no indention selected', () => { + const group = createContentModelDocument(); + const listItem = createListItem([createListLevel('UL')]); + const listItem2 = createListItem([createListLevel('UL')]); + const listItem3 = createListItem([createListLevel('UL')]); + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + text1.isSelected = true; + text2.isSelected = true; + text3.isSelected = true; + para1.segments.push(text1); + para2.segments.push(text2); + para3.segments.push(text3); + listItem.blocks.push(para1); + listItem2.blocks.push(para2); + listItem3.blocks.push(para3); + group.blocks.push(listItem); + group.blocks.push(listItem2); + group.blocks.push(listItem3); + + const result = setModelIndentation(group, 'outdent'); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + ...listItem, + levels: [], + }, + { + ...listItem2, + levels: [], + }, + { + ...listItem3, + levels: [], + }, + ], + }); + + expect(result).toBeTrue(); + }); }); From 90437f3b0eb5f87371fb80585d06793b86227f6e Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 6 Feb 2024 14:27:50 -0800 Subject: [PATCH 082/112] Code cleanup: code rename (#2375) * Code cleanup: code rename * add more rename * change name of plugins * revert some renames * improve --- .../controls/ContentModelEditorMainPane.tsx | 18 ++--- .../controls/StandaloneEditorMainPane.tsx | 13 ++-- .../editorOptions/codes/SimplePluginCode.ts | 2 +- .../lib/modelApi/entity/insertEntityModel.ts | 4 +- .../publicApi/block/paragraphTestCommon.ts | 4 +- .../test/publicApi/block/setAlignmentTest.ts | 36 +++++----- .../publicApi/block/setIndentationTest.ts | 7 +- .../publicApi/block/toggleBlockQuoteTest.ts | 7 +- .../test/publicApi/entity/insertEntityTest.ts | 8 +-- .../test/publicApi/format/clearFormatTest.ts | 12 ++-- .../test/publicApi/image/changeImageTest.ts | 20 +++--- .../test/publicApi/image/insertImageTest.ts | 18 +++-- .../publicApi/link/adjustLinkSelectionTest.ts | 18 +++-- .../test/publicApi/link/insertLinkTest.ts | 34 +++++----- .../test/publicApi/link/removeLinkTest.ts | 20 +++--- .../publicApi/list/setListStartNumberTest.ts | 22 +++---- .../test/publicApi/list/toggleBulletTest.ts | 26 ++++---- .../publicApi/list/toggleNumberingTest.ts | 26 ++++---- .../publicApi/segment/changeFontSizeTest.ts | 18 +++-- .../publicApi/segment/segmentTestCommon.ts | 4 +- .../table/applyTableBorderFormatTest.ts | 18 +++-- .../publicApi/table/setTableCellShadeTest.ts | 18 +++-- .../utils/formatImageWithContentModelTest.ts | 4 +- .../formatParagraphWithContentModelTest.ts | 28 ++++---- .../formatSegmentWithContentModelTest.ts | 26 ++++---- .../lib/coreApi/formatContentModel.ts | 15 ++--- ...tentModelCachePlugin.ts => CachePlugin.ts} | 24 +++---- ...lCopyPastePlugin.ts => CopyPastePlugin.ts} | 10 +-- ...ntModelFormatPlugin.ts => FormatPlugin.ts} | 22 +++---- .../createStandaloneEditorCorePlugins.ts | 12 ++-- ...ntModelDomIndexer.ts => domIndexerImpl.ts} | 6 +- .../lib/editor/StandaloneEditor.ts | 6 +- .../modelApi/edit/deleteExpandedSelection.ts | 4 +- .../lib/publicApi/model/mergeModel.ts | 6 +- .../lib/publicApi/selection/deleteBlock.ts | 4 +- .../lib/publicApi/selection/deleteSegment.ts | 4 +- .../publicApi/selection/deleteSelection.ts | 4 +- .../test/coreApi/formatContentModelTest.ts | 32 ++++----- .../test/coreApi/pasteTest.ts | 6 +- ...lCachePluginTest.ts => CachePluginTest.ts} | 18 ++--- ...tePluginTest.ts => CopyPastePluginTest.ts} | 34 +++++----- ...ormatPluginTest.ts => FormatPluginTest.ts} | 34 +++++----- .../utils/applyDefaultFormatTest.ts | 8 +-- .../utils/applyPendingFormatTest.ts | 50 ++++++-------- ...delDomIndexerTest.ts => domIndexerTest.ts} | 66 +++++++++---------- .../test/publicApi/model/mergeModelTest.ts | 12 ++-- .../test/utils/paste/mergePasteContentTest.ts | 8 +-- .../domToModel/processors/brProcessorTest.ts | 4 +- .../processors/entityProcessorTest.ts | 4 +- .../processors/generalProcessorTest.ts | 4 +- .../processors/imageProcessorTest.ts | 4 +- .../processors/tableProcessorTest.ts | 4 +- .../processors/textProcessorTest.ts | 8 +-- .../handlers/handleParagraphTest.ts | 6 +- .../modelToDom/handlers/handleTableTest.ts | 4 +- ...utoFormatPlugin.ts => AutoFormatPlugin.ts} | 4 +- ...ontentModelEditPlugin.ts => EditPlugin.ts} | 4 +- .../lib/edit/handleKeyboardEventCommon.ts | 4 +- .../lib/index.ts | 9 +-- ...tentModelPastePlugin.ts => PastePlugin.ts} | 4 +- ...tPluginTest.ts => AutoFormatPluginTest.ts} | 4 +- ...delEditPluginTest.ts => EditPluginTest.ts} | 14 ++-- .../test/edit/editingTestCommon.ts | 4 +- .../edit/handleKeyboardEventCommonTest.ts | 10 +-- .../test/edit/keyboardInputTest.ts | 4 +- .../test/paste/ContentModelPastePluginTest.ts | 6 +- .../test/paste/e2e/testUtils.ts | 4 +- ...ontentModelDomIndexer.ts => DomIndexer.ts} | 2 +- .../lib/context/EditorContext.ts | 6 +- .../lib/editor/IStandaloneEditor.ts | 11 ++-- .../lib/editor/StandaloneEditorCore.ts | 12 ++-- .../lib/editor/StandaloneEditorCorePlugins.ts | 8 +-- .../lib/event/ContentChangedEvent.ts | 2 +- .../lib/index.ts | 17 ++--- .../lib/parameter/DeleteSelectionStep.ts | 4 +- ...ontext.ts => FormatContentModelContext.ts} | 2 +- ...ptions.ts => FormatContentModelOptions.ts} | 6 +- .../lib/parameter/Snapshot.ts | 2 +- ...achePluginState.ts => CachePluginState.ts} | 10 +-- ...matPluginState.ts => FormatPluginState.ts} | 2 +- .../lib/createEditor.ts | 8 +-- 81 files changed, 459 insertions(+), 538 deletions(-) rename packages-content-model/roosterjs-content-model-core/lib/corePlugin/{ContentModelCachePlugin.ts => CachePlugin.ts} (88%) rename packages-content-model/roosterjs-content-model-core/lib/corePlugin/{ContentModelCopyPastePlugin.ts => CopyPastePlugin.ts} (97%) rename packages-content-model/roosterjs-content-model-core/lib/corePlugin/{ContentModelFormatPlugin.ts => FormatPlugin.ts} (88%) rename packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/{contentModelDomIndexer.ts => domIndexerImpl.ts} (98%) rename packages-content-model/roosterjs-content-model-core/test/corePlugin/{ContentModelCachePluginTest.ts => CachePluginTest.ts} (95%) rename packages-content-model/roosterjs-content-model-core/test/corePlugin/{ContentModelCopyPastePluginTest.ts => CopyPastePluginTest.ts} (98%) rename packages-content-model/roosterjs-content-model-core/test/corePlugin/{ContentModelFormatPluginTest.ts => FormatPluginTest.ts} (94%) rename packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/{contentModelDomIndexerTest.ts => domIndexerTest.ts} (88%) rename packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/{ContentModelAutoFormatPlugin.ts => AutoFormatPlugin.ts} (96%) rename packages-content-model/roosterjs-content-model-plugins/lib/edit/{ContentModelEditPlugin.ts => EditPlugin.ts} (96%) rename packages-content-model/roosterjs-content-model-plugins/lib/paste/{ContentModelPastePlugin.ts => PastePlugin.ts} (98%) rename packages-content-model/roosterjs-content-model-plugins/test/autoFormat/{ContentModelAutoFormatPluginTest.ts => AutoFormatPluginTest.ts} (96%) rename packages-content-model/roosterjs-content-model-plugins/test/edit/{ContentModelEditPluginTest.ts => EditPluginTest.ts} (89%) rename packages-content-model/roosterjs-content-model-types/lib/context/{ContentModelDomIndexer.ts => DomIndexer.ts} (97%) rename packages-content-model/roosterjs-content-model-types/lib/parameter/{FormatWithContentModelContext.ts => FormatContentModelContext.ts} (98%) rename packages-content-model/roosterjs-content-model-types/lib/parameter/{FormatWithContentModelOptions.ts => FormatContentModelOptions.ts} (89%) rename packages-content-model/roosterjs-content-model-types/lib/pluginState/{ContentModelCachePluginState.ts => CachePluginState.ts} (66%) rename packages-content-model/roosterjs-content-model-types/lib/pluginState/{ContentModelFormatPluginState.ts => FormatPluginState.ts} (93%) diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 00bbcdf204a..7232d0e5c2f 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -20,6 +20,7 @@ import { alignJustifyButton } from './ribbonButtons/contentModel/alignJustifyBut import { alignLeftButton } from './ribbonButtons/contentModel/alignLeftButton'; import { alignRightButton } from './ribbonButtons/contentModel/alignRightButton'; import { arrayPush } from 'roosterjs-editor-dom'; +import { AutoFormatPlugin, EditPlugin, PastePlugin } from 'roosterjs-content-model-plugins'; import { backgroundColorButton } from './ribbonButtons/contentModel/backgroundColorButton'; import { blockQuoteButton } from './ribbonButtons/contentModel/blockQuoteButton'; import { boldButton } from './ribbonButtons/contentModel/boldButton'; @@ -91,11 +92,6 @@ import { tableMergeButton, tableSplitButton, } from './ribbonButtons/contentModel/tableEditButtons'; -import { - ContentModelAutoFormatPlugin, - ContentModelEditPlugin, - ContentModelPastePlugin, -} from 'roosterjs-content-model-plugins'; import { ContentModelEditor, ContentModelEditorOptions, @@ -168,15 +164,15 @@ class ContentModelEditorMainPane extends MainPaneBase private eventViewPlugin: ContentModelEventViewPlugin; private apiPlaygroundPlugin: ApiPlaygroundPlugin; private contentModelPanePlugin: ContentModelPanePlugin; - private contentModelEditPlugin: ContentModelEditPlugin; - private contentModelAutoFormatPlugin: ContentModelAutoFormatPlugin; + private contentModelEditPlugin: EditPlugin; + private contentModelAutoFormatPlugin: AutoFormatPlugin; private contentModelRibbonPlugin: RibbonPlugin; private pasteOptionPlugin: EditorPlugin; private emojiPlugin: EditorPlugin; private snapshotPlugin: ContentModelSnapshotPlugin; private toggleablePlugins: EditorPlugin[] | null = null; private formatPainterPlugin: ContentModelFormatPainterPlugin; - private pastePlugin: ContentModelPastePlugin; + private pastePlugin: PastePlugin; private sampleEntityPlugin: SampleEntityPlugin; private snapshots: Snapshots; private buttons: ContentModelRibbonButton[] = [ @@ -261,13 +257,13 @@ class ContentModelEditorMainPane extends MainPaneBase this.apiPlaygroundPlugin = new ApiPlaygroundPlugin(); this.snapshotPlugin = new ContentModelSnapshotPlugin(this.snapshots); this.contentModelPanePlugin = new ContentModelPanePlugin(); - this.contentModelEditPlugin = new ContentModelEditPlugin(); - this.contentModelAutoFormatPlugin = new ContentModelAutoFormatPlugin(); + this.contentModelEditPlugin = new EditPlugin(); + this.contentModelAutoFormatPlugin = new AutoFormatPlugin(); this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.pasteOptionPlugin = createPasteOptionPlugin(); this.emojiPlugin = createEmojiPlugin(); this.formatPainterPlugin = new ContentModelFormatPainterPlugin(); - this.pastePlugin = new ContentModelPastePlugin(); + this.pastePlugin = new PastePlugin(); this.sampleEntityPlugin = new SampleEntityPlugin(); this.state = { showSidePane: window.location.hash != '', diff --git a/demo/scripts/controls/StandaloneEditorMainPane.tsx b/demo/scripts/controls/StandaloneEditorMainPane.tsx index 8cae61e11d4..e2bf0f19e7b 100644 --- a/demo/scripts/controls/StandaloneEditorMainPane.tsx +++ b/demo/scripts/controls/StandaloneEditorMainPane.tsx @@ -17,6 +17,7 @@ import { alignCenterButton } from './ribbonButtons/contentModel/alignCenterButto import { alignJustifyButton } from './ribbonButtons/contentModel/alignJustifyButton'; import { alignLeftButton } from './ribbonButtons/contentModel/alignLeftButton'; import { alignRightButton } from './ribbonButtons/contentModel/alignRightButton'; +import { AutoFormatPlugin, EditPlugin } from 'roosterjs-content-model-plugins'; import { backgroundColorButton } from './ribbonButtons/contentModel/backgroundColorButton'; import { blockQuoteButton } from './ribbonButtons/contentModel/blockQuoteButton'; import { boldButton } from './ribbonButtons/contentModel/boldButton'; @@ -93,10 +94,6 @@ import { tableMergeButton, tableSplitButton, } from './ribbonButtons/contentModel/tableEditButtons'; -import { - ContentModelAutoFormatPlugin, - ContentModelEditPlugin, -} from 'roosterjs-content-model-plugins'; const styles = require('./StandaloneEditorMainPane.scss'); @@ -164,9 +161,9 @@ class ContentModelEditorMainPane extends MainPaneBase private eventViewPlugin: ContentModelEventViewPlugin; private apiPlaygroundPlugin: ApiPlaygroundPlugin; private contentModelPanePlugin: ContentModelPanePlugin; - private contentModelEditPlugin: ContentModelEditPlugin; + private contentModelEditPlugin: EditPlugin; private contentModelRibbonPlugin: RibbonPlugin; - private contentAutoFormatPlugin: ContentModelAutoFormatPlugin; + private contentAutoFormatPlugin: AutoFormatPlugin; private snapshotPlugin: ContentModelSnapshotPlugin; private formatPainterPlugin: ContentModelFormatPainterPlugin; private snapshots: Snapshots; @@ -252,8 +249,8 @@ class ContentModelEditorMainPane extends MainPaneBase this.apiPlaygroundPlugin = new ApiPlaygroundPlugin(); this.snapshotPlugin = new ContentModelSnapshotPlugin(this.snapshots); this.contentModelPanePlugin = new ContentModelPanePlugin(); - this.contentModelEditPlugin = new ContentModelEditPlugin(); - this.contentAutoFormatPlugin = new ContentModelAutoFormatPlugin(); + this.contentModelEditPlugin = new EditPlugin(); + this.contentAutoFormatPlugin = new AutoFormatPlugin(); this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.formatPainterPlugin = new ContentModelFormatPainterPlugin(); this.state = { diff --git a/demo/scripts/controls/sidePane/editorOptions/codes/SimplePluginCode.ts b/demo/scripts/controls/sidePane/editorOptions/codes/SimplePluginCode.ts index fc2f013995a..442679efc19 100644 --- a/demo/scripts/controls/sidePane/editorOptions/codes/SimplePluginCode.ts +++ b/demo/scripts/controls/sidePane/editorOptions/codes/SimplePluginCode.ts @@ -18,7 +18,7 @@ export class PasteCode extends SimplePluginCode { export class ContentModelPasteCode extends SimplePluginCode { constructor() { - super('ContentModelPastePlugin', 'roosterjsContentModel'); + super('PastePlugin', 'roosterjsContentModel'); } } diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts index 5a52bfb959e..aa5ccdaff80 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts @@ -16,7 +16,7 @@ import type { ContentModelEntity, ContentModelParagraph, DeleteSelectionResult, - FormatWithContentModelContext, + FormatContentModelContext, InsertEntityPosition, } from 'roosterjs-content-model-types'; @@ -29,7 +29,7 @@ export function insertEntityModel( position: InsertEntityPosition, isBlock: boolean, focusAfterEntity?: boolean, - context?: FormatWithContentModelContext + context?: FormatContentModelContext ) { let blockParent: ContentModelBlockGroup | undefined; let blockIndex = -1; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/paragraphTestCommon.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/paragraphTestCommon.ts index c521a94a52f..fff6d7b07d2 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/paragraphTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/paragraphTestCommon.ts @@ -2,7 +2,7 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; export function paragraphTestCommon( @@ -15,7 +15,7 @@ export function paragraphTestCommon( let formatResult: boolean | undefined; const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake((callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { formatResult = callback(model, { newEntities: [], deletedEntities: [], 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 bb8a056b683..a2bc884ffc4 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 @@ -8,7 +8,7 @@ import { ContentModelListItem, ContentModelTable, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('setAlignment', () => { @@ -444,15 +444,13 @@ describe('setAlignment in table', () => { editor.formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); setAlignment(editor, alignment); @@ -844,16 +842,14 @@ describe('setAlignment in list', () => { editor.formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - result = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + result = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }); + }); setAlignment(editor, alignment); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setIndentationTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setIndentationTest.ts index adddd59431e..a016d66f7db 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setIndentationTest.ts @@ -1,16 +1,13 @@ import * as setModelIndentation from '../../../lib/modelApi/block/setModelIndentation'; import setIndentation from '../../../lib/publicApi/block/setIndentation'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; -import { - ContentModelFormatter, - FormatWithContentModelContext, -} from 'roosterjs-content-model-types'; +import { ContentModelFormatter, FormatContentModelContext } from 'roosterjs-content-model-types'; describe('setIndentation', () => { const fakeModel: any = { a: 'b' }; let editor: IStandaloneEditor; let formatContentModelSpy: jasmine.Spy; - let context: FormatWithContentModelContext; + let context: FormatContentModelContext; beforeEach(() => { context = undefined!; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts index a90649ddaf8..5479f7988f0 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts @@ -1,16 +1,13 @@ import * as toggleModelBlockQuote from '../../../lib/modelApi/block/toggleModelBlockQuote'; import toggleBlockQuote from '../../../lib/publicApi/block/toggleBlockQuote'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; -import { - ContentModelFormatter, - FormatWithContentModelContext, -} from 'roosterjs-content-model-types'; +import { ContentModelFormatter, FormatContentModelContext } from 'roosterjs-content-model-types'; describe('toggleBlockQuote', () => { const fakeModel: any = { a: 'b' }; let editor: IStandaloneEditor; let formatContentModelSpy: jasmine.Spy; - let context: FormatWithContentModelContext; + let context: FormatContentModelContext; beforeEach(() => { context = undefined!; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts index d820c74f355..8ac06260d5b 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts @@ -4,13 +4,13 @@ import insertEntity from '../../../lib/publicApi/entity/insertEntity'; import { ChangeSource } from 'roosterjs-content-model-core'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { - FormatWithContentModelContext, - FormatWithContentModelOptions, + FormatContentModelContext, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('insertEntity', () => { let editor: IStandaloneEditor; - let context: FormatWithContentModelContext; + let context: FormatContentModelContext; let wrapper: HTMLElement; const model = 'MockedModel' as any; @@ -48,7 +48,7 @@ describe('insertEntity', () => { formatWithContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake((formatter: Function, options: FormatWithContentModelOptions) => { + .and.callFake((formatter: Function, options: FormatContentModelOptions) => { formatter(model, context); }); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts index 7a5893e74dd..2ab4ed941fe 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts @@ -5,7 +5,7 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('clearFormat', () => { @@ -13,12 +13,10 @@ describe('clearFormat', () => { const model = ('Model' as any) as ContentModelDocument; const formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - expect(options.apiName).toEqual('clearFormat'); - callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('clearFormat'); + callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); + }); const editor = ({ focus: () => {}, diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/image/changeImageTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/changeImageTest.ts index 2f5e29fdea4..65aa6f3d944 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/image/changeImageTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/changeImageTest.ts @@ -4,7 +4,7 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { addSegment, @@ -33,16 +33,14 @@ describe('changeImage', () => { let formatResult: boolean | undefined; const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }); + }); const editor = ({ focus: jasmine.createSpy(), diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/image/insertImageTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/insertImageTest.ts index 81043a949a1..fb061333a62 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/image/insertImageTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/insertImageTest.ts @@ -4,7 +4,7 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { addSegment, @@ -26,15 +26,13 @@ describe('insertImage', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); const editor = ({ focus: jasmine.createSpy(), isDisposed: () => false, diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts index 496055259fb..ea34577e818 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts @@ -4,7 +4,7 @@ import { ContentModelDocument, ContentModelLink, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { addLink, @@ -28,15 +28,13 @@ describe('adjustLinkSelection', () => { formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(mockedModel, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(mockedModel, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); editor = ({ formatContentModel, diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts index 96e9ed0ac03..7d12c8d9c99 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts @@ -5,7 +5,7 @@ import { ContentModelDocument, ContentModelLink, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { addSegment, @@ -37,15 +37,13 @@ describe('insertLink', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); editor.formatContentModel = formatContentModel; @@ -412,15 +410,13 @@ describe('insertLink', () => { let formatResult: boolean | undefined; const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(doc, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(doc, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); editor.formatContentModel = formatContentModel; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts index d50ab264da6..7ee4421aa21 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts @@ -4,7 +4,7 @@ import { ContentModelDocument, ContentModelLink, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { addLink, @@ -28,16 +28,14 @@ describe('removeLink', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }); + }); editor.formatContentModel = formatContentModel; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/list/setListStartNumberTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/setListStartNumberTest.ts index a049ab7ad31..3e2dddecb53 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/list/setListStartNumberTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/setListStartNumberTest.ts @@ -2,7 +2,7 @@ import setListStartNumber from '../../../lib/publicApi/list/setListStartNumber'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('setListStartNumber', () => { @@ -13,18 +13,16 @@ describe('setListStartNumber', () => { ) { let formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - expect(options.apiName).toBe('setListStartNumber'); - const result = callback(input, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toBe('setListStartNumber'); + const result = callback(input, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); - expect(result).toBe(expectedResult); - } - ); + expect(result).toBe(expectedResult); + }); setListStartNumber( { diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleBulletTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleBulletTest.ts index 243258dc5f9..aff7fbd3260 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleBulletTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleBulletTest.ts @@ -4,8 +4,8 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelContext, - FormatWithContentModelOptions, + FormatContentModelContext, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('toggleBullet', () => { @@ -13,7 +13,7 @@ describe('toggleBullet', () => { let formatContentModel: jasmine.Spy; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; - let context: FormatWithContentModelContext; + let context: FormatContentModelContext; beforeEach(() => { mockedModel = ({} as any) as ContentModelDocument; @@ -21,17 +21,15 @@ describe('toggleBullet', () => { context = undefined!; formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - context = { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }; - callback(mockedModel, context); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + context = { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }; + callback(mockedModel, context); + }); focus = jasmine.createSpy('focus'); editor = ({ diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleNumberingTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleNumberingTest.ts index 20f6f6f92da..131a7d011cc 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleNumberingTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleNumberingTest.ts @@ -4,15 +4,15 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelContext, - FormatWithContentModelOptions, + FormatContentModelContext, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('toggleNumbering', () => { let editor = ({} as any) as IStandaloneEditor; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; - let context: FormatWithContentModelContext; + let context: FormatContentModelContext; beforeEach(() => { mockedModel = ({} as any) as ContentModelDocument; @@ -22,17 +22,15 @@ describe('toggleNumbering', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - context = { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }; - callback(mockedModel, context); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + context = { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }; + callback(mockedModel, context); + }); editor = ({ focus, diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts index b0ce78a6754..647b6db1e06 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts @@ -7,7 +7,7 @@ import { ContentModelDocument, ContentModelSegmentFormat, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('changeFontSize', () => { @@ -349,15 +349,13 @@ describe('changeFontSize', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); const editor = ({ formatContentModel, diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts index 99205b8e38b..fcb001b9300 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts @@ -2,7 +2,7 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; export function segmentTestCommon( @@ -15,7 +15,7 @@ export function segmentTestCommon( let formatResult: boolean | undefined; const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake((callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { expect(options.apiName).toBe(apiName); formatResult = callback(model, { newEntities: [], diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts index 6d65809ef1b..17208920825 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts @@ -9,7 +9,7 @@ import { ContentModelTable, ContentModelTableCell, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('applyTableBorderFormat', () => { @@ -59,15 +59,13 @@ describe('applyTableBorderFormat', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); editor.formatContentModel = formatContentModel; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/table/setTableCellShadeTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/setTableCellShadeTest.ts index e1b7c52b50c..c3ae1bbbaf6 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/table/setTableCellShadeTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/setTableCellShadeTest.ts @@ -5,7 +5,7 @@ import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelTable, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; describe('setTableCellShade', () => { @@ -31,15 +31,13 @@ describe('setTableCellShade', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); editor.formatContentModel = formatContentModel; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatImageWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatImageWithContentModelTest.ts index 37f40684079..987776d7e06 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatImageWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatImageWithContentModelTest.ts @@ -4,7 +4,7 @@ import { ContentModelDocument, ContentModelImage, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { addSegment, @@ -204,7 +204,7 @@ function segmentTestForPluginEvent( let formatResult: boolean | undefined; const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake((callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { expect(options.apiName).toBe(apiName); formatResult = callback(model, { newEntities: [], diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatParagraphWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatParagraphWithContentModelTest.ts index d5e2cdc1823..341e6d3a259 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatParagraphWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatParagraphWithContentModelTest.ts @@ -4,8 +4,8 @@ import { ContentModelDocument, ContentModelParagraph, ContentModelFormatter, - FormatWithContentModelContext, - FormatWithContentModelOptions, + FormatContentModelContext, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { createContentModelDocument, @@ -16,7 +16,7 @@ import { describe('formatParagraphWithContentModel', () => { let editor: IStandaloneEditor; let model: ContentModelDocument; - let context: FormatWithContentModelContext; + let context: FormatContentModelContext; const mockedContainer = 'C' as any; const mockedOffset = 'O' as any; @@ -28,18 +28,16 @@ describe('formatParagraphWithContentModel', () => { const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - context = { - newEntities: [], - newImages: [], - deletedEntities: [], - rawEvent: options.rawEvent, - }; - - callback(model, context); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + context = { + newEntities: [], + newImages: [], + deletedEntities: [], + rawEvent: options.rawEvent, + }; + + callback(model, context); + }); editor = ({ getFocusedPosition: () => ({ node: mockedContainer, offset: mockedOffset }), diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts index 40c96e29b0b..3b87fecf7b5 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts @@ -4,8 +4,8 @@ import { ContentModelDocument, ContentModelSegmentFormat, ContentModelFormatter, - FormatWithContentModelContext, - FormatWithContentModelOptions, + FormatContentModelContext, + FormatContentModelOptions, } from 'roosterjs-content-model-types'; import { createContentModelDocument, @@ -14,13 +14,13 @@ import { createText, } from 'roosterjs-content-model-dom'; -describe('formatSegmentWithContentModel', () => { +describe('formatSegment', () => { let editor: IStandaloneEditor; let focus: jasmine.Spy; let model: ContentModelDocument; let formatContentModel: jasmine.Spy; let formatResult: boolean | undefined; - let context: FormatWithContentModelContext | undefined; + let context: FormatContentModelContext | undefined; const apiName = 'mockedApi'; @@ -31,16 +31,14 @@ describe('formatSegmentWithContentModel', () => { formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - context = { - newEntities: [], - deletedEntities: [], - newImages: [], - }; - formatResult = callback(model, context); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + context = { + newEntities: [], + deletedEntities: [], + newImages: [], + }; + formatResult = callback(model, context); + }); editor = ({ focus, diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts index dd11ef571ac..2d21506fcff 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts @@ -4,7 +4,7 @@ import type { ContentChangedEvent, DOMSelection, FormatContentModel, - FormatWithContentModelContext, + FormatContentModelContext, StandaloneEditorCore, } from 'roosterjs-content-model-types'; @@ -16,13 +16,13 @@ import type { * If there is cached model, it will be used and updated. * @param core The StandaloneEditorCore object * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions + * @param options More options, see FormatContentModelOptions */ export const formatContentModel: FormatContentModel = (core, formatter, options) => { const { apiName, onNodeCreated, getChangeData, changeSource, rawEvent, selectionOverride } = options || {}; const model = core.api.createContentModel(core, undefined /*option*/, selectionOverride); - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { newEntities: [], deletedEntities: [], rawEvent, @@ -96,7 +96,7 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) } }; -function handleImages(core: StandaloneEditorCore, context: FormatWithContentModelContext) { +function handleImages(core: StandaloneEditorCore, context: FormatContentModelContext) { if (context.newImages.length > 0) { const viewport = core.api.getVisibleViewport(core); @@ -113,7 +113,7 @@ function handleImages(core: StandaloneEditorCore, context: FormatWithContentMode function handlePendingFormat( core: StandaloneEditorCore, - context: FormatWithContentModelContext, + context: FormatContentModelContext, selection?: DOMSelection | null ) { const pendingFormat = @@ -130,10 +130,7 @@ function handlePendingFormat( } } -function getChangedEntities( - context: FormatWithContentModelContext, - rawEvent?: Event -): ChangedEntity[] { +function getChangedEntities(context: FormatContentModelContext, rawEvent?: Event): ChangedEntity[] { return context.newEntities .map( (entity): ChangedEntity => ({ diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts similarity index 88% rename from packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts index 5d42d070a08..90c2c28980b 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts @@ -1,8 +1,8 @@ import { areSameSelection } from './utils/areSameSelection'; -import { contentModelDomIndexer } from './utils/contentModelDomIndexer'; import { createTextMutationObserver } from './utils/textMutationObserver'; +import { domIndexerImpl } from './utils/domIndexerImpl'; import type { - ContentModelCachePluginState, + CachePluginState, IStandaloneEditor, PluginEvent, PluginWithState, @@ -12,19 +12,19 @@ import type { /** * ContentModel cache plugin manages cached Content Model, and refresh the cache when necessary */ -class ContentModelCachePlugin implements PluginWithState { +class CachePlugin implements PluginWithState { private editor: IStandaloneEditor | null = null; - private state: ContentModelCachePluginState; + private state: CachePluginState; /** - * Construct a new instance of ContentModelEditPlugin class + * Construct a new instance of CachePlugin class * @param option The editor option * @param contentDiv The editor content DIV */ constructor(option: StandaloneEditorOptions, contentDiv: HTMLDivElement) { this.state = option.cacheModel ? { - domIndexer: contentModelDomIndexer, + domIndexer: domIndexerImpl, textMutationObserver: createTextMutationObserver(contentDiv, this.onMutation), } : {}; @@ -34,7 +34,7 @@ class ContentModelCachePlugin implements PluginWithState { - return new ContentModelCachePlugin(option, contentDiv); +): PluginWithState { + return new CachePlugin(option, contentDiv); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts similarity index 97% rename from packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts index 0b79d4cb758..7d032880801 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts @@ -34,7 +34,7 @@ import type { /** * Copy and paste plugin for handling onCopy and onPaste event */ -class ContentModelCopyPastePlugin implements PluginWithState { +class CopyPastePlugin implements PluginWithState { private editor: IStandaloneEditor | null = null; private disposer: (() => void) | null = null; private state: CopyPastePluginState; @@ -54,7 +54,7 @@ class ContentModelCopyPastePlugin implements PluginWithState { - return new ContentModelCopyPastePlugin(option); + return new CopyPastePlugin(option); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts similarity index 88% rename from packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts index 4219a3728c4..7fbacf0b734 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts @@ -3,7 +3,7 @@ import { applyPendingFormat } from './utils/applyPendingFormat'; import { getObjectKeys } from 'roosterjs-content-model-dom'; import { isCharacterValue, isCursorMovingKey } from '../publicApi/domUtils/eventUtils'; import type { - ContentModelFormatPluginState, + FormatPluginState, IStandaloneEditor, PluginEvent, PluginWithState, @@ -14,17 +14,17 @@ import type { const ProcessKey = 'Process'; /** - * ContentModelFormat plugins helps editor to do formatting on top of content model. + * FormatPlugin plugins helps editor to do formatting on top of content model. * This includes: * 1. Handle pending format changes when selection is collapsed */ -class ContentModelFormatPlugin implements PluginWithState { +class FormatPlugin implements PluginWithState { private editor: IStandaloneEditor | null = null; private hasDefaultFormat = false; - private state: ContentModelFormatPluginState; + private state: FormatPluginState; /** - * Construct a new instance of ContentModelEditPlugin class + * Construct a new instance of FormatPlugin class * @param option The editor option */ constructor(option: StandaloneEditorOptions) { @@ -38,7 +38,7 @@ class ContentModelFormatPlugin implements PluginWithState { - return new ContentModelFormatPlugin(option); +): PluginWithState { + return new FormatPlugin(option); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts index c329d7a33d1..eb8692305e5 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts @@ -1,9 +1,9 @@ -import { createContentModelCachePlugin } from './ContentModelCachePlugin'; -import { createContentModelCopyPastePlugin } from './ContentModelCopyPastePlugin'; -import { createContentModelFormatPlugin } from './ContentModelFormatPlugin'; +import { createCachePlugin } from './CachePlugin'; import { createContextMenuPlugin } from './ContextMenuPlugin'; +import { createCopyPastePlugin } from './CopyPastePlugin'; import { createDOMEventPlugin } from './DOMEventPlugin'; import { createEntityPlugin } from './EntityPlugin'; +import { createFormatPlugin } from './FormatPlugin'; import { createLifecyclePlugin } from './LifecyclePlugin'; import { createSelectionPlugin } from './SelectionPlugin'; import { createUndoPlugin } from './UndoPlugin'; @@ -22,9 +22,9 @@ export function createStandaloneEditorCorePlugins( contentDiv: HTMLDivElement ): StandaloneEditorCorePlugins { return { - cache: createContentModelCachePlugin(options, contentDiv), - format: createContentModelFormatPlugin(options), - copyPaste: createContentModelCopyPastePlugin(options), + cache: createCachePlugin(options, contentDiv), + format: createFormatPlugin(options), + copyPaste: createCopyPastePlugin(options), domEvent: createDOMEventPlugin(options, contentDiv), lifecycle: createLifecyclePlugin(options, contentDiv), entity: createEntityPlugin(), diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/contentModelDomIndexer.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/domIndexerImpl.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/contentModelDomIndexer.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/domIndexerImpl.ts index 4b15da73392..fe26cd3e311 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/contentModelDomIndexer.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/domIndexerImpl.ts @@ -2,13 +2,13 @@ import { createSelectionMarker, createText, isNodeOfType } from 'roosterjs-conte import { setSelection } from '../../publicApi/selection/setSelection'; import type { ContentModelDocument, - ContentModelDomIndexer, ContentModelParagraph, ContentModelSegment, ContentModelSelectionMarker, ContentModelTable, ContentModelTableRow, ContentModelText, + DomIndexer, DOMSelection, Selectable, } from 'roosterjs-content-model-types'; @@ -268,9 +268,9 @@ function reconcileTextSelection( /** * @internal - * Implementation of ContentModelDomIndexer + * Implementation of DomIndexer */ -export const contentModelDomIndexer: ContentModelDomIndexer = { +export const domIndexerImpl: DomIndexer = { onSegment, onParagraph, onTable, diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts index 43095b2d18a..463ab92041a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts @@ -15,7 +15,7 @@ import type { DOMHelper, DOMSelection, EditorEnvironment, - FormatWithContentModelOptions, + FormatContentModelOptions, IStandaloneEditor, PasteType, PluginEventData, @@ -152,11 +152,11 @@ export class StandaloneEditor implements IStandaloneEditor { * to do format change. Then according to the return value, write back the modified content model into editor. * If there is cached model, it will be used and updated. * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions + * @param options More options, see FormatContentModelOptions */ formatContentModel( formatter: ContentModelFormatter, - options?: FormatWithContentModelOptions + options?: FormatContentModelOptions ): void { const core = this.getCore(); diff --git a/packages-content-model/roosterjs-content-model-core/lib/modelApi/edit/deleteExpandedSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/modelApi/edit/deleteExpandedSelection.ts index 568938abbbb..8edc146005a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/modelApi/edit/deleteExpandedSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/modelApi/edit/deleteExpandedSelection.ts @@ -9,7 +9,7 @@ import type { ContentModelParagraph, ContentModelSelectionMarker, DeleteSelectionContext, - FormatWithContentModelContext, + FormatContentModelContext, InsertPoint, TableSelectionContext, } from 'roosterjs-content-model-types'; @@ -33,7 +33,7 @@ const DeleteSelectionIteratingOptions: IterateSelectionsOption = { */ export function deleteExpandedSelection( model: ContentModelDocument, - formatContext?: FormatWithContentModelContext + formatContext?: FormatContentModelContext ): DeleteSelectionContext { const context: DeleteSelectionContext = { deleteResult: 'notDeleted', diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/mergeModel.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/mergeModel.ts index abb7648dd89..2a6621fa30f 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/mergeModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/mergeModel.ts @@ -20,7 +20,7 @@ import type { ContentModelParagraph, ContentModelSegmentFormat, ContentModelTable, - FormatWithContentModelContext, + FormatContentModelContext, InsertPoint, } from 'roosterjs-content-model-types'; @@ -66,7 +66,7 @@ export interface MergeModelOption { export function mergeModel( target: ContentModelDocument, source: ContentModelDocument, - context?: FormatWithContentModelContext, + context?: FormatContentModelContext, options?: MergeModelOption ): InsertPoint | null { const insertPosition = @@ -131,7 +131,7 @@ function mergeParagraph( markerPosition: InsertPoint, newPara: ContentModelParagraph, mergeToCurrentParagraph: boolean, - context?: FormatWithContentModelContext, + context?: FormatContentModelContext, option?: MergeModelOption ) { const { paragraph, marker } = markerPosition; diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts index df023246d3f..0b48a50f51e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts @@ -1,7 +1,7 @@ import type { ContentModelBlock, EntityRemovalOperation, - FormatWithContentModelContext, + FormatContentModelContext, } from 'roosterjs-content-model-types'; /** @@ -17,7 +17,7 @@ export function deleteBlock( blocks: ContentModelBlock[], blockToDelete: ContentModelBlock, replacement?: ContentModelBlock, - context?: FormatWithContentModelContext, + context?: FormatContentModelContext, direction?: 'forward' | 'backward' ): boolean { const index = blocks.indexOf(blockToDelete); 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 de146b5d777..64bccd128ff 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 @@ -5,7 +5,7 @@ import type { ContentModelParagraph, ContentModelSegment, EntityRemovalOperation, - FormatWithContentModelContext, + FormatContentModelContext, } from 'roosterjs-content-model-types'; /** @@ -19,7 +19,7 @@ import type { export function deleteSegment( paragraph: ContentModelParagraph, segmentToDelete: ContentModelSegment, - context?: FormatWithContentModelContext, + context?: FormatContentModelContext, direction?: 'forward' | 'backward' ): boolean { const segments = paragraph.segments; diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts index e011679664d..144dd8e5d1e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts @@ -4,7 +4,7 @@ import type { DeleteSelectionContext, DeleteSelectionResult, DeleteSelectionStep, - FormatWithContentModelContext, + FormatContentModelContext, ValidDeleteSelectionContext, } from 'roosterjs-content-model-types'; @@ -18,7 +18,7 @@ import type { export function deleteSelection( model: ContentModelDocument, additionalSteps: (DeleteSelectionStep | null)[] = [], - formatContext?: FormatWithContentModelContext + formatContext?: FormatContentModelContext ): DeleteSelectionResult { const context = deleteExpandedSelection(model, formatContext); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts index b8255fdf113..daeb9897c7b 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts @@ -5,7 +5,7 @@ import { formatContentModel } from '../../lib/coreApi/formatContentModel'; import { ContentModelDocument, ContentModelSegmentFormat, - FormatWithContentModelContext, + FormatContentModelContext, StandaloneEditorCore, } from 'roosterjs-content-model-types'; @@ -741,12 +741,10 @@ describe('formatContentModel', () => { const mockedEntityState = 'STATE' as any; const callback = jasmine .createSpy('callback') - .and.callFake( - (model: ContentModelDocument, context: FormatWithContentModelContext) => { - context.entityStates = mockedEntityState; - return true; - } - ); + .and.callFake((model: ContentModelDocument, context: FormatContentModelContext) => { + context.entityStates = mockedEntityState; + return true; + }); formatContentModel(core, callback); @@ -765,12 +763,10 @@ describe('formatContentModel', () => { it('trigger addUndoSnapshot when has canUndoByBackspace', () => { const callback = jasmine .createSpy('callback') - .and.callFake( - (model: ContentModelDocument, context: FormatWithContentModelContext) => { - context.canUndoByBackspace = true; - return true; - } - ); + .and.callFake((model: ContentModelDocument, context: FormatContentModelContext) => { + context.canUndoByBackspace = true; + return true; + }); formatContentModel(core, callback); @@ -788,12 +784,10 @@ describe('formatContentModel', () => { it('trigger addUndoSnapshot when has canUndoByBackspace and has valid range selection', () => { const callback = jasmine .createSpy('callback') - .and.callFake( - (model: ContentModelDocument, context: FormatWithContentModelContext) => { - context.canUndoByBackspace = true; - return true; - } - ); + .and.callFake((model: ContentModelDocument, context: FormatContentModelContext) => { + context.canUndoByBackspace = true; + return true; + }); setContentModel.and.returnValue({ type: 'range', diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts index 491ace4082f..3592b897491 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/pasteTest.ts @@ -9,8 +9,8 @@ import * as PPT from 'roosterjs-content-model-plugins/lib/paste/PowerPoint/proce import * as setProcessorF from 'roosterjs-content-model-plugins/lib/paste/utils/setProcessor'; import * as WacComponents from 'roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents'; import * as WordDesktopFile from 'roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop'; -import { ContentModelPastePlugin } from 'roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin'; import { expectEqual, initEditor } from 'roosterjs-content-model-plugins/test/paste/e2e/testUtils'; +import { PastePlugin } from 'roosterjs-content-model-plugins/lib/paste/PastePlugin'; import { StandaloneEditor } from '../../lib/editor/StandaloneEditor'; import { ClipboardData, @@ -73,7 +73,7 @@ describe('Paste ', () => { ]); editor = new StandaloneEditor(div, { - plugins: [new ContentModelPastePlugin()], + plugins: [new PastePlugin()], coreApiOverride: { focus, createContentModel, @@ -112,7 +112,7 @@ describe('paste with content model & paste plugin', () => { div = document.createElement('div'); document.body.appendChild(div); editor = new StandaloneEditor(div, { - plugins: [new ContentModelPastePlugin()], + plugins: [new PastePlugin()], }); spyOn(addParserF, 'default').and.callThrough(); spyOn(setProcessorF, 'setProcessor').and.callThrough(); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts rename to packages-content-model/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts index d75dc60e4e4..29463824edc 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts @@ -1,16 +1,16 @@ import * as textMutationObserver from '../../lib/corePlugin/utils/textMutationObserver'; -import { contentModelDomIndexer } from '../../lib/corePlugin/utils/contentModelDomIndexer'; -import { createContentModelCachePlugin } from '../../lib/corePlugin/ContentModelCachePlugin'; +import { createCachePlugin } from '../../lib/corePlugin/CachePlugin'; +import { domIndexerImpl } from '../../lib/corePlugin/utils/domIndexerImpl'; import { - ContentModelCachePluginState, - ContentModelDomIndexer, + CachePluginState, + DomIndexer, IStandaloneEditor, PluginWithState, StandaloneEditorOptions, } from 'roosterjs-content-model-types'; -describe('ContentModelCachePlugin', () => { - let plugin: PluginWithState; +describe('CachePlugin', () => { + let plugin: PluginWithState; let editor: IStandaloneEditor; let addEventListenerSpy: jasmine.Spy; @@ -18,7 +18,7 @@ describe('ContentModelCachePlugin', () => { let getDOMSelectionSpy: jasmine.Spy; let reconcileSelectionSpy: jasmine.Spy; let isInShadowEditSpy: jasmine.Spy; - let domIndexer: ContentModelDomIndexer; + let domIndexer: DomIndexer; let contentDiv: HTMLDivElement; function init(option: StandaloneEditorOptions) { @@ -45,7 +45,7 @@ describe('ContentModelCachePlugin', () => { }, } as any) as IStandaloneEditor; - plugin = createContentModelCachePlugin(option, contentDiv); + plugin = createCachePlugin(option, contentDiv); plugin.initialize(editor); } @@ -75,7 +75,7 @@ describe('ContentModelCachePlugin', () => { }); expect(addEventListenerSpy).toHaveBeenCalledWith('selectionchange', jasmine.anything()); expect(plugin.getState()).toEqual({ - domIndexer: contentModelDomIndexer, + domIndexer: domIndexerImpl, textMutationObserver: mockedObserver, }); expect(startObservingSpy).toHaveBeenCalledTimes(1); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts rename to packages-content-model/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts index 8c9552d8519..4b55955fa54 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts @@ -11,7 +11,7 @@ import { ContentModelDocument, DOMSelection, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, IStandaloneEditor, DOMEventRecord, ClipboardData, @@ -21,10 +21,10 @@ import { } from 'roosterjs-content-model-types'; import { adjustSelectionForCopyCut, - createContentModelCopyPastePlugin, + createCopyPastePlugin, onNodeCreated, preprocessTable, -} from '../../lib/corePlugin/ContentModelCopyPastePlugin'; +} from '../../lib/corePlugin/CopyPastePlugin'; const modelValue = 'model' as any; const pasteModelValue = 'pasteModelValue' as any; @@ -33,9 +33,9 @@ const deleteResultValue = 'deleteResult' as any; const allowedCustomPasteType = ['Test']; -describe('ContentModelCopyPastePlugin.Ctor', () => { +describe('CopyPastePlugin.Ctor', () => { it('Ctor without options', () => { - const plugin = createContentModelCopyPastePlugin({}); + const plugin = createCopyPastePlugin({}); const state = plugin.getState(); expect(state).toEqual({ @@ -45,7 +45,7 @@ describe('ContentModelCopyPastePlugin.Ctor', () => { }); it('Ctor with options', () => { - const plugin = createContentModelCopyPastePlugin({ + const plugin = createCopyPastePlugin({ allowedCustomPasteType, }); const state = plugin.getState(); @@ -57,7 +57,7 @@ describe('ContentModelCopyPastePlugin.Ctor', () => { }); }); -describe('ContentModelCopyPastePlugin |', () => { +describe('CopyPastePlugin |', () => { let editor: IStandaloneEditor = null!; let plugin: PluginWithState; let domEvents: Record = {}; @@ -99,20 +99,18 @@ describe('ContentModelCopyPastePlugin |', () => { mockedDarkColorHandler = 'DARKCOLORHANDLER' as any; formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - modelResult = modelValue; - formatResult = callback(modelResult!, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + modelResult = modelValue; + formatResult = callback(modelResult!, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); spyOn(addRangeToSelection, 'addRangeToSelection'); - plugin = createContentModelCopyPastePlugin({ + plugin = createCopyPastePlugin({ allowedCustomPasteType, }); plugin.getState().tempDiv = div; diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts similarity index 94% rename from packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts rename to packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts index bff9bb60866..e55c3a1eb56 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts @@ -1,5 +1,5 @@ import * as applyPendingFormat from '../../lib/corePlugin/utils/applyPendingFormat'; -import { createContentModelFormatPlugin } from '../../lib/corePlugin/ContentModelFormatPlugin'; +import { createFormatPlugin } from '../../lib/corePlugin/FormatPlugin'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { addSegment, @@ -7,7 +7,7 @@ import { createSelectionMarker, } from 'roosterjs-content-model-dom'; -describe('ContentModelFormatPlugin', () => { +describe('FormatPlugin', () => { const mockedFormat = { fontSize: '10px', }; @@ -21,7 +21,7 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, isDarkMode: () => false, } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); + const plugin = createFormatPlugin({}); plugin.initialize(editor); plugin.onPluginEvent({ @@ -43,7 +43,7 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, getEnvironment: () => ({}), } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); + const plugin = createFormatPlugin({}); const model = createContentModelDocument(); const state = plugin.getState(); @@ -80,7 +80,7 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, getEnvironment: () => ({}), } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); + const plugin = createFormatPlugin({}); plugin.initialize(editor); const state = plugin.getState(); @@ -114,7 +114,7 @@ describe('ContentModelFormatPlugin', () => { triggerEvent, getVisibleViewport, } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); + const plugin = createFormatPlugin({}); const state = plugin.getState(); state.pendingFormat = { @@ -144,7 +144,7 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); + const plugin = createFormatPlugin({}); plugin.initialize(editor); const state = plugin.getState(); @@ -176,7 +176,7 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); + const plugin = createFormatPlugin({}); const state = plugin.getState(); state.pendingFormat = { @@ -204,7 +204,7 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, cacheContentModel: () => {}, } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); + const plugin = createFormatPlugin({}); const state = plugin.getState(); @@ -234,7 +234,7 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, getEnvironment: () => ({}), } as any) as IStandaloneEditor; - const plugin = createContentModelFormatPlugin({}); + const plugin = createFormatPlugin({}); const state = plugin.getState(); state.pendingFormat = { @@ -258,7 +258,7 @@ describe('ContentModelFormatPlugin', () => { }); }); -describe('ContentModelFormatPlugin for default format', () => { +describe('FormatPlugin for default format', () => { let editor: IStandaloneEditor; let contentDiv: HTMLDivElement; let getDOMSelection: jasmine.Spy; @@ -289,7 +289,7 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, text input, under editor directly', () => { - const plugin = createContentModelFormatPlugin({ + const plugin = createFormatPlugin({ defaultSegmentFormat: { fontFamily: 'Arial', }, @@ -345,7 +345,7 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Expanded range, text input, under editor directly', () => { - const plugin = createContentModelFormatPlugin({ + const plugin = createFormatPlugin({ defaultSegmentFormat: { fontFamily: 'Arial', }, @@ -398,7 +398,7 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, IME input, under editor directly', () => { - const plugin = createContentModelFormatPlugin({ + const plugin = createFormatPlugin({ defaultSegmentFormat: { fontFamily: 'Arial', }, @@ -453,7 +453,7 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, other input, under editor directly', () => { - const plugin = createContentModelFormatPlugin({ + const plugin = createFormatPlugin({ defaultSegmentFormat: { fontFamily: 'Arial', }, @@ -504,7 +504,7 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, normal input, not under editor directly, no style', () => { - const plugin = createContentModelFormatPlugin({ + const plugin = createFormatPlugin({ defaultSegmentFormat: { fontFamily: 'Arial', }, @@ -561,7 +561,7 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, text input, under editor directly, has pending format', () => { - const plugin = createContentModelFormatPlugin({ + const plugin = createFormatPlugin({ defaultSegmentFormat: { fontFamily: 'Arial', }, diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts index 47abee4f5f7..75d7329794a 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts @@ -5,8 +5,8 @@ import { ContentModelDocument, ContentModelFormatter, ContentModelSegmentFormat, - FormatWithContentModelContext, - FormatWithContentModelOptions, + FormatContentModelContext, + FormatContentModelOptions, IStandaloneEditor, InsertPoint, } from 'roosterjs-content-model-types'; @@ -28,7 +28,7 @@ describe('applyDefaultFormat', () => { let takeSnapshotSpy: jasmine.Spy; let getPendingFormatSpy: jasmine.Spy; - let context: FormatWithContentModelContext | undefined; + let context: FormatContentModelContext | undefined; let model: ContentModelDocument; let formatResult: boolean | undefined; @@ -52,7 +52,7 @@ describe('applyDefaultFormat', () => { formatContentModelSpy = jasmine .createSpy('formatContentModelSpy') .and.callFake( - (formatter: ContentModelFormatter, options: FormatWithContentModelOptions) => { + (formatter: ContentModelFormatter, options: FormatContentModelOptions) => { context = { deletedEntities: [], newEntities: [], diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts index 01b26ea8e36..78bbab041f0 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts @@ -7,7 +7,7 @@ import { ContentModelSelectionMarker, ContentModelText, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, IStandaloneEditor, } from 'roosterjs-content-model-types'; import { @@ -41,16 +41,14 @@ describe('applyPendingFormat', () => { const formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - expect(options.apiName).toEqual('applyPendingFormat'); - callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + }); const editor = ({ formatContentModel: formatContentModelSpy, @@ -119,12 +117,10 @@ describe('applyPendingFormat', () => { const formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - expect(options.apiName).toEqual('applyPendingFormat'); - callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); + }); const editor = ({ formatContentModel: formatContentModelSpy, @@ -238,12 +234,10 @@ describe('applyPendingFormat', () => { const formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - expect(options.apiName).toEqual('applyPendingFormat'); - callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); + }); const editor = ({ formatContentModel: formatContentModelSpy, @@ -289,12 +283,10 @@ describe('applyPendingFormat', () => { const formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - expect(options.apiName).toEqual('applyPendingFormat'); - callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); - } - ); + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); + }); const editor = ({ formatContentModel: formatContentModelSpy, diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/domIndexerTest.ts similarity index 88% rename from packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts rename to packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/domIndexerTest.ts index 447252d85d0..90e0b391221 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/domIndexerTest.ts @@ -1,6 +1,6 @@ import * as setSelection from '../../../lib/publicApi/selection/setSelection'; -import { contentModelDomIndexer } from '../../../lib/corePlugin/utils/contentModelDomIndexer'; import { createRange } from 'roosterjs-content-model-dom/test/testUtils'; +import { domIndexerImpl } from '../../../lib/corePlugin/utils/domIndexerImpl'; import { ContentModelDocument, ContentModelSegment, @@ -17,13 +17,13 @@ import { createText, } from 'roosterjs-content-model-dom'; -describe('contentModelDomIndexer.onSegment', () => { +describe('domIndexerImpl.onSegment', () => { it('onSegment', () => { const node = {} as any; const paragraph = 'Paragraph' as any; const segment = 'Segment' as any; - contentModelDomIndexer.onSegment(node, paragraph, [segment]); + domIndexerImpl.onSegment(node, paragraph, [segment]); expect(node).toEqual({ __roosterjsContentModel: { paragraph: 'Paragraph', segments: ['Segment'] }, @@ -31,11 +31,11 @@ describe('contentModelDomIndexer.onSegment', () => { }); }); -describe('contentModelDomIndexer.onParagraph', () => { +describe('domIndexerImpl.onParagraph', () => { it('Paragraph, no child', () => { const node = document.createElement('div'); - contentModelDomIndexer.onParagraph(node); + domIndexerImpl.onParagraph(node); expect(node.outerHTML).toBe('
                                                                      '); }); @@ -52,7 +52,7 @@ describe('contentModelDomIndexer.onParagraph', () => { }; node.appendChild(text); - contentModelDomIndexer.onParagraph(node); + domIndexerImpl.onParagraph(node); expect(text.__roosterjsContentModel).toEqual({ paragraph, @@ -87,7 +87,7 @@ describe('contentModelDomIndexer.onParagraph', () => { node.appendChild(text2); node.appendChild(text3); - contentModelDomIndexer.onParagraph(node); + domIndexerImpl.onParagraph(node); expect(text1.__roosterjsContentModel).toEqual({ paragraph, @@ -139,7 +139,7 @@ describe('contentModelDomIndexer.onParagraph', () => { node.appendChild(text3); node.appendChild(text4); - contentModelDomIndexer.onParagraph(node); + domIndexerImpl.onParagraph(node); expect(text1.__roosterjsContentModel).toEqual({ paragraph, @@ -161,7 +161,7 @@ describe('contentModelDomIndexer.onParagraph', () => { }); }); -describe('contentModelDomIndexer.onTable', () => { +describe('domIndexerImpl.onTable', () => { it('onTable', () => { const node = {} as any; const rows = 'ROWS' as any; @@ -169,7 +169,7 @@ describe('contentModelDomIndexer.onTable', () => { rows: rows, } as any; - contentModelDomIndexer.onTable(node, table); + domIndexerImpl.onTable(node, table); expect(node).toEqual({ __roosterjsContentModel: { tableRows: rows }, @@ -177,7 +177,7 @@ describe('contentModelDomIndexer.onTable', () => { }); }); -describe('contentModelDomIndexer.reconcileSelection', () => { +describe('domIndexerImpl.reconcileSelection', () => { let setSelectionSpy: jasmine.Spy; let model: ContentModelDocument; @@ -189,7 +189,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { it('no old range, fake range', () => { const newRangeEx = {} as any; - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); expect(result).toBeFalse(); expect(setSelectionSpy).not.toHaveBeenCalled(); @@ -203,7 +203,7 @@ describe('contentModelDomIndexer.reconcileSelection', () => { isReverted: false, }; - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); expect(result).toBeFalse(); expect(setSelectionSpy).not.toHaveBeenCalled(); @@ -220,9 +220,9 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const segment = createText(''); paragraph.segments.push(segment); - contentModelDomIndexer.onSegment(node, paragraph, [segment]); + domIndexerImpl.onSegment(node, paragraph, [segment]); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); const segment1: ContentModelSegment = { segmentType: 'Text', @@ -267,9 +267,9 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const segment = createText(''); paragraph.segments.push(segment); - contentModelDomIndexer.onSegment(node, paragraph, [segment]); + domIndexerImpl.onSegment(node, paragraph, [segment]); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); const segment1: ContentModelSegment = { segmentType: 'Text', @@ -319,11 +319,11 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const oldSegment2 = createText(''); paragraph.segments.push(oldSegment1, oldSegment2); - contentModelDomIndexer.onSegment(node1, paragraph, [oldSegment1]); - contentModelDomIndexer.onSegment(node2, paragraph, [oldSegment2]); + domIndexerImpl.onSegment(node1, paragraph, [oldSegment1]); + domIndexerImpl.onSegment(node2, paragraph, [oldSegment2]); model.blocks.push(paragraph); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); const segment1: ContentModelSegment = { segmentType: 'Text', @@ -389,11 +389,11 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const oldSegment2 = createBr(); paragraph.segments.push(oldSegment1, oldSegment2); - contentModelDomIndexer.onSegment(node1, paragraph, [oldSegment1]); - contentModelDomIndexer.onSegment(node2, paragraph, [oldSegment2]); + domIndexerImpl.onSegment(node1, paragraph, [oldSegment1]); + domIndexerImpl.onSegment(node2, paragraph, [oldSegment2]); model.blocks.push(paragraph); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); const segment1: ContentModelSegment = { segmentType: 'Text', @@ -445,10 +445,10 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const oldSegment1 = createImage('test'); paragraph.segments.push(oldSegment1); - contentModelDomIndexer.onSegment(node1, paragraph, [oldSegment1]); + domIndexerImpl.onSegment(node1, paragraph, [oldSegment1]); model.blocks.push(paragraph); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); expect(result).toBeFalse(); expect(node1.__roosterjsContentModel).toEqual({ @@ -501,10 +501,10 @@ describe('contentModelDomIndexer.reconcileSelection', () => { tableModel.rows[1].cells.push(cell10, cell11, cell12); tableModel.rows[2].cells.push(cell20, cell21, cell22); - contentModelDomIndexer.onTable(node1, tableModel); + domIndexerImpl.onTable(node1, tableModel); model.blocks.push(tableModel); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); expect(result).toBeFalse(); expect(node1.__roosterjsContentModel).toEqual({ @@ -532,9 +532,9 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const segment = createBr({ fontFamily: 'Arial' }); paragraph.segments.push(segment); - contentModelDomIndexer.onSegment(node, paragraph, [segment]); + domIndexerImpl.onSegment(node, paragraph, [segment]); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); expect(result).toBeTrue(); expect(node.__roosterjsContentModel).toEqual({ @@ -566,9 +566,9 @@ describe('contentModelDomIndexer.reconcileSelection', () => { const oldSegment2 = createText('st'); paragraph.segments.push(oldSegment1, createSelectionMarker(), oldSegment2); - contentModelDomIndexer.onSegment(node, paragraph, [oldSegment1, oldSegment2]); + domIndexerImpl.onSegment(node, paragraph, [oldSegment1, oldSegment2]); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx, oldRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx, oldRangeEx); const segment1: ContentModelSegment = { segmentType: 'Text', @@ -631,9 +631,9 @@ describe('contentModelDomIndexer.reconcileSelection', () => { }; paragraph.segments.push(oldSegment1, oldSegment2, oldSegment3); - contentModelDomIndexer.onSegment(node, paragraph, [oldSegment1, oldSegment2, oldSegment3]); + domIndexerImpl.onSegment(node, paragraph, [oldSegment1, oldSegment2, oldSegment3]); - const result = contentModelDomIndexer.reconcileSelection(model, newRangeEx, oldRangeEx); + const result = domIndexerImpl.reconcileSelection(model, newRangeEx, oldRangeEx); const segment1 = createText('te'); const segment2 = createText('st'); diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/model/mergeModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/mergeModelTest.ts index 90401cf5b54..83e0c4112d6 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/model/mergeModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/mergeModelTest.ts @@ -9,7 +9,7 @@ import { ContentModelSelectionMarker, ContentModelTable, ContentModelTableCell, - FormatWithContentModelContext, + FormatContentModelContext, } from 'roosterjs-content-model-types'; import { createBr, @@ -3057,7 +3057,7 @@ describe('mergeModel', () => { textColor: 'aliceblue', italic: true, }); - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { deletedEntities: [], newImages: [], newEntities: [], @@ -3153,7 +3153,7 @@ describe('mergeModel', () => { newPara.segments.push(newEntity1, text, newEntity2); sourceModel.blocks.push(newPara); - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { deletedEntities: [], newImages: [], newEntities: [], @@ -3218,7 +3218,7 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { deletedEntities: [], newImages: [], newEntities: [], @@ -3289,7 +3289,7 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { deletedEntities: [], newImages: [], newEntities: [], @@ -3361,7 +3361,7 @@ describe('mergeModel', () => { para1.segments.push(image, marker); majorModel.blocks.push(para1); - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { deletedEntities: [], newEntities: [], newImages: [image], 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 c0e8c7df151..a9f80f52a11 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 @@ -12,15 +12,15 @@ import { ContentModelDocument, ContentModelFormatter, ContentModelSegmentFormat, - FormatWithContentModelContext, - FormatWithContentModelOptions, + FormatContentModelContext, + FormatContentModelOptions, InsertPoint, StandaloneEditorCore, } from 'roosterjs-content-model-types'; describe('mergePasteContent', () => { let formatResult: boolean | undefined; - let context: FormatWithContentModelContext | undefined; + let context: FormatContentModelContext | undefined; let formatContentModel: jasmine.Spy; let sourceModel: ContentModelDocument; let core: StandaloneEditorCore; @@ -36,7 +36,7 @@ describe('mergePasteContent', () => { ( core: any, callback: ContentModelFormatter, - options: FormatWithContentModelOptions + options: FormatContentModelOptions ) => { context = { newEntities: [], diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts index 4ff587c7ffd..52367fcc9ba 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts @@ -3,7 +3,7 @@ import { createContentModelDocument } from '../../../lib/modelApi/creators/creat import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { ContentModelBr, - ContentModelDomIndexer, + DomIndexer, ContentModelParagraph, DomToModelContext, } from 'roosterjs-content-model-types'; @@ -69,7 +69,7 @@ describe('brProcessor', () => { const doc = createContentModelDocument(); const br = document.createElement('br'); const onSegmentSpy = jasmine.createSpy('onSegment'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: onSegmentSpy, onTable: null!, diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts index 26826c063a4..b2c9cbd524d 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts @@ -3,7 +3,7 @@ import { createDomToModelContext } from '../../../lib/domToModel/context/createD import { entityProcessor } from '../../../lib/domToModel/processors/entityProcessor'; import { setEntityElementClasses } from '../../domUtils/entityUtilTest'; import { - ContentModelDomIndexer, + DomIndexer, ContentModelEntity, ContentModelParagraph, DomToModelContext, @@ -253,7 +253,7 @@ describe('entityProcessor', () => { setEntityElementClasses(span, 'entity', true, 'entity_1'); const onSegmentSpy = jasmine.createSpy('onSegment'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: onSegmentSpy, onTable: null!, diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts index c52c730bfe2..2d13ef933a2 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/generalProcessorTest.ts @@ -5,7 +5,7 @@ import { createContentModelDocument } from '../../../lib/modelApi/creators/creat import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { generalProcessor } from '../../../lib/domToModel/processors/generalProcessor'; import { - ContentModelDomIndexer, + DomIndexer, ContentModelGeneralBlock, ContentModelGeneralSegment, ContentModelParagraph, @@ -329,7 +329,7 @@ describe('generalProcessor', () => { spyOn(createGeneralSegment, 'createGeneralSegment').and.returnValue(segment); const onSegmentSpy = jasmine.createSpy('onSegment'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: onSegmentSpy, onTable: null!, diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts index 4fc31b81bbb..b3fda940b4d 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts @@ -2,7 +2,7 @@ import { createContentModelDocument } from '../../../lib/modelApi/creators/creat import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { imageProcessor } from '../../../lib/domToModel/processors/imageProcessor'; import { - ContentModelDomIndexer, + DomIndexer, ContentModelImage, ContentModelParagraph, DomToModelContext, @@ -318,7 +318,7 @@ describe('imageProcessor', () => { const doc = createContentModelDocument(); const img = document.createElement('img'); const onSegmentSpy = jasmine.createSpy('onSegment'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: onSegmentSpy, onTable: null!, diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts index aced3efb708..8b3563d4c5a 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts @@ -8,7 +8,7 @@ import { createTableCell } from '../../../lib/modelApi/creators/createTableCell' import { tableProcessor } from '../../../lib/domToModel/processors/tableProcessor'; import { ContentModelBlock, - ContentModelDomIndexer, + DomIndexer, ContentModelTable, DomToModelContext, ElementProcessor, @@ -285,7 +285,7 @@ describe('tableProcessor', () => { const doc = createContentModelDocument(); const div = document.createElement('div'); const onTableSpy = jasmine.createSpy('onTable'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: null!, onTable: onTableSpy, diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts index 2bfb3db8ad0..b84b8a14172 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/textProcessorTest.ts @@ -9,7 +9,7 @@ import { createSelectionMarker } from '../../../lib/modelApi/creators/createSele import { createText } from '../../../lib/modelApi/creators/createText'; import { textProcessor } from '../../../lib/domToModel/processors/textProcessor'; import { - ContentModelDomIndexer, + DomIndexer, ContentModelParagraph, ContentModelText, DomToModelContext, @@ -572,7 +572,7 @@ describe('textProcessor', () => { const doc = createContentModelDocument(); const text = document.createTextNode('test'); const onSegmentSpy = jasmine.createSpy('onSegment'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: onSegmentSpy, onTable: null!, @@ -606,7 +606,7 @@ describe('textProcessor', () => { const doc = createContentModelDocument(); const text = document.createTextNode('test'); const onSegmentSpy = jasmine.createSpy('onSegment'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: onSegmentSpy, onTable: null!, @@ -651,7 +651,7 @@ describe('textProcessor', () => { const doc = createContentModelDocument(); const text = document.createTextNode('test'); const onSegmentSpy = jasmine.createSpy('onSegment'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: onSegmentSpy, onTable: null!, diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts index 83d9f1566b5..a05f99d3398 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts @@ -7,7 +7,7 @@ import { handleParagraph } from '../../../lib/modelToDom/handlers/handleParagrap import { handleSegment as originalHandleSegment } from '../../../lib/modelToDom/handlers/handleSegment'; import { optimize } from '../../../lib/modelToDom/optimizers/optimize'; import { - ContentModelDomIndexer, + DomIndexer, ContentModelParagraph, ContentModelSegment, ContentModelSegmentHandler, @@ -576,7 +576,7 @@ describe('handleParagraph', () => { }; const onSegmentSpy = jasmine.createSpy('onSegment'); const onParagraphSpy = jasmine.createSpy('onParagraph'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: onParagraphSpy, onSegment: onSegmentSpy, onTable: null!, @@ -619,7 +619,7 @@ describe('handleParagraph', () => { }; const onSegmentSpy = jasmine.createSpy('onSegment'); const onParagraphSpy = jasmine.createSpy('onParagraph'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: onParagraphSpy, onSegment: onSegmentSpy, onTable: null!, diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts index eef92223996..4c437eaef13 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts @@ -4,7 +4,7 @@ import { createTable } from '../../../lib/modelApi/creators/createTable'; import { createTableCell } from '../../../lib/modelApi/creators/createTableCell'; import { handleTable } from '../../../lib/modelToDom/handlers/handleTable'; import { - ContentModelDomIndexer, + DomIndexer, ContentModelTable, ContentModelTableRow, ModelToDomContext, @@ -596,7 +596,7 @@ describe('handleTable', () => { dataset: {}, }; const onTableSpy = jasmine.createSpy('onTable'); - const domIndexer: ContentModelDomIndexer = { + const domIndexer: DomIndexer = { onParagraph: null!, onSegment: null!, onTable: onTableSpy, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts index 63589ea9e99..b4f1a4bd98b 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/ContentModelAutoFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/autoFormat/AutoFormatPlugin.ts @@ -33,7 +33,7 @@ const DefaultOptions: Required = { * Auto Format plugin handles auto formatting, such as transforming * characters into a bullet list. * It can be customized with options to enable or disable auto list features. */ -export class ContentModelAutoFormatPlugin implements EditorPlugin { +export class AutoFormatPlugin implements EditorPlugin { private editor: IStandaloneEditor | null = null; /** @@ -47,7 +47,7 @@ export class ContentModelAutoFormatPlugin implements EditorPlugin { * Get name of this plugin */ getName() { - return 'ContentModelAutoFormat'; + return 'AutoFormat'; } /** diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index a03c145dfc5..b997549a4bb 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -13,14 +13,14 @@ import type { * 1. Delete Key * 2. Backspace Key */ -export class ContentModelEditPlugin implements EditorPlugin { +export class EditPlugin implements EditorPlugin { private editor: IStandaloneEditor | null = null; /** * Get name of this plugin */ getName() { - return 'ContentModelEdit'; + return 'Edit'; } /** diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleKeyboardEventCommon.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleKeyboardEventCommon.ts index 538b7bc9a8e..5520d6f566a 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleKeyboardEventCommon.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleKeyboardEventCommon.ts @@ -2,7 +2,7 @@ import { normalizeContentModel } from 'roosterjs-content-model-dom'; import type { ContentModelDocument, DeleteResult, - FormatWithContentModelContext, + FormatContentModelContext, IStandaloneEditor, } from 'roosterjs-content-model-types'; @@ -15,7 +15,7 @@ export function handleKeyboardEventResult( model: ContentModelDocument, rawEvent: KeyboardEvent, result: DeleteResult, - context: FormatWithContentModelContext + context: FormatContentModelContext ): boolean { context.skipUndoSnapshot = true; context.clearModelCache = false; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts index 1128be25a61..fe5a21770a7 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts @@ -1,6 +1,3 @@ -export { ContentModelPastePlugin } from './paste/ContentModelPastePlugin'; -export { ContentModelEditPlugin } from './edit/ContentModelEditPlugin'; -export { - ContentModelAutoFormatPlugin, - AutoFormatOptions, -} from './autoFormat/ContentModelAutoFormatPlugin'; +export { PastePlugin } from './paste/PastePlugin'; +export { EditPlugin } from './edit/EditPlugin'; +export { AutoFormatPlugin, AutoFormatOptions } from './autoFormat/AutoFormatPlugin'; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/PastePlugin.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/PastePlugin.ts index 68aa979ea7e..dac08441684 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/PastePlugin.ts @@ -26,7 +26,7 @@ import type { * 4. Content copied from Power Point * (This class is still under development, and may still be changed in the future with some breaking changes) */ -export class ContentModelPastePlugin implements EditorPlugin { +export class PastePlugin implements EditorPlugin { private editor: IStandaloneEditor | null = null; /** @@ -40,7 +40,7 @@ export class ContentModelPastePlugin implements EditorPlugin { * Get name of this plugin */ getName() { - return 'ContentModelPaste'; + return 'Paste'; } /** diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts index 3060811a4b3..fa4309d80dd 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/ContentModelAutoFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts @@ -1,5 +1,5 @@ import * as keyboardTrigger from '../../lib/autoFormat/keyboardListTrigger'; -import { ContentModelAutoFormatPlugin } from '../../lib/autoFormat/ContentModelAutoFormatPlugin'; +import { AutoFormatPlugin } from '../../lib/autoFormat/AutoFormatPlugin'; import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { KeyDownEvent } from 'roosterjs-content-model-types'; @@ -28,7 +28,7 @@ describe('Content Model Auto Format Plugin Test', () => { shouldCallTrigger: boolean, options?: { autoBullet: boolean; autoNumbering: boolean } ) { - const plugin = new ContentModelAutoFormatPlugin(options); + const plugin = new AutoFormatPlugin(options); plugin.initialize(editor); plugin.onPluginEvent(event); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts similarity index 89% rename from packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 9a7c0b5f7a6..2fbc8984507 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -1,9 +1,9 @@ import * as keyboardDelete from '../../lib/edit/keyboardDelete'; import * as keyboardInput from '../../lib/edit/keyboardInput'; -import { ContentModelEditPlugin } from '../../lib/edit/ContentModelEditPlugin'; +import { EditPlugin } from '../../lib/edit/EditPlugin'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; -describe('ContentModelEditPlugin', () => { +describe('EditPlugin', () => { let editor: IStandaloneEditor; beforeEach(() => { @@ -25,7 +25,7 @@ describe('ContentModelEditPlugin', () => { }); it('Backspace', () => { - const plugin = new ContentModelEditPlugin(); + const plugin = new EditPlugin(); const rawEvent = { key: 'Backspace' } as any; plugin.initialize(editor); @@ -40,7 +40,7 @@ describe('ContentModelEditPlugin', () => { }); it('Delete', () => { - const plugin = new ContentModelEditPlugin(); + const plugin = new EditPlugin(); const rawEvent = { key: 'Delete' } as any; plugin.initialize(editor); @@ -55,7 +55,7 @@ describe('ContentModelEditPlugin', () => { }); it('Other key', () => { - const plugin = new ContentModelEditPlugin(); + const plugin = new EditPlugin(); const rawEvent = { which: 41, key: 'A' } as any; const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); @@ -73,7 +73,7 @@ describe('ContentModelEditPlugin', () => { }); it('Default prevented', () => { - const plugin = new ContentModelEditPlugin(); + const plugin = new EditPlugin(); const rawEvent = { key: 'Delete', defaultPrevented: true } as any; plugin.initialize(editor); @@ -87,7 +87,7 @@ describe('ContentModelEditPlugin', () => { }); it('Trigger entity event first', () => { - const plugin = new ContentModelEditPlugin(); + const plugin = new EditPlugin(); const wrapper = 'WRAPPER' as any; plugin.initialize(editor); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts index 1885fef65d4..340b927f43a 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts @@ -1,7 +1,7 @@ import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelOptions, + FormatContentModelOptions, IStandaloneEditor, } from 'roosterjs-content-model-types'; @@ -18,7 +18,7 @@ export function editingTestCommon( const formatContentModel = jasmine .createSpy('formatContentModel') - .and.callFake((callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + .and.callFake((callback: ContentModelFormatter, options: FormatContentModelOptions) => { expect(options.apiName).toBe(apiName); formatResult = callback(model, { newEntities: [], diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/handleKeyboardEventCommonTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/handleKeyboardEventCommonTest.ts index ab0f9f9a561..5c7f56a94f0 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/handleKeyboardEventCommonTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/handleKeyboardEventCommonTest.ts @@ -1,5 +1,5 @@ import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import { FormatWithContentModelContext, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { FormatContentModelContext, IStandaloneEditor } from 'roosterjs-content-model-types'; import { handleKeyboardEventResult, shouldDeleteAllSegmentsBefore, @@ -39,7 +39,7 @@ describe('handleKeyboardEventResult', () => { const mockedModel = 'MODEL' as any; const which = 'WHICH' as any; (mockedEvent).which = which; - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { newEntities: [], deletedEntities: [], newImages: [], @@ -66,7 +66,7 @@ describe('handleKeyboardEventResult', () => { it('notDeleted', () => { const mockedModel = 'MODEL' as any; - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { newEntities: [], deletedEntities: [], newImages: [], @@ -91,7 +91,7 @@ describe('handleKeyboardEventResult', () => { it('range', () => { const mockedModel = 'MODEL' as any; - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { newEntities: [], deletedEntities: [], newImages: [], @@ -118,7 +118,7 @@ describe('handleKeyboardEventResult', () => { it('nothingToDelete', () => { const mockedModel = 'MODEL' as any; - const context: FormatWithContentModelContext = { + const context: FormatContentModelContext = { newEntities: [], deletedEntities: [], newImages: [], diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts index 89ca280fa1e..bb1c1ad4c24 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts @@ -4,7 +4,7 @@ import { keyboardInput } from '../../lib/edit/keyboardInput'; import { ContentModelDocument, ContentModelFormatter, - FormatWithContentModelContext, + FormatContentModelContext, IStandaloneEditor, } from 'roosterjs-content-model-types'; @@ -17,7 +17,7 @@ describe('keyboardInput', () => { let isInIMESpy: jasmine.Spy; let mockedModel: ContentModelDocument; let normalizeContentModelSpy: jasmine.Spy; - let mockedContext: FormatWithContentModelContext; + let mockedContext: FormatContentModelContext; let formatResult: boolean | undefined; beforeEach(() => { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts index 643adbf380d..5af29820fa0 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts @@ -5,7 +5,7 @@ import * as PowerPointFile from '../../lib/paste/PowerPoint/processPastedContent import * as setProcessor from '../../lib/paste/utils/setProcessor'; import * as WacFile from '../../lib/paste/WacComponents/processPastedContentWacComponents'; import { BeforePasteEvent, IStandaloneEditor } from 'roosterjs-content-model-types'; -import { ContentModelPastePlugin } from '../../lib/paste/ContentModelPastePlugin'; +import { PastePlugin } from '../../lib/paste/PastePlugin'; import { PastePropertyNames } from '../../lib/paste/pasteSourceValidations/constants'; const trustedHTMLHandler = (val: string) => val; @@ -25,10 +25,10 @@ describe('Content Model Paste Plugin Test', () => { let event: BeforePasteEvent; describe('onPluginEvent', () => { - let plugin = new ContentModelPastePlugin(); + let plugin = new PastePlugin(); beforeEach(() => { - plugin = new ContentModelPastePlugin(); + plugin = new PastePlugin(); event = { eventType: 'beforePaste', diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts index 44724e566c8..7cdc51cda32 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts @@ -1,5 +1,5 @@ import { cloneModel, StandaloneEditor } from 'roosterjs-content-model-core'; -import { ContentModelPastePlugin } from '../../../lib/paste/ContentModelPastePlugin'; +import { PastePlugin } from '../../../lib/paste/PastePlugin'; import { ContentModelDocument, IStandaloneEditor, @@ -12,7 +12,7 @@ export function initEditor(id: string): IStandaloneEditor { document.body.insertBefore(node, document.body.childNodes[0]); let options: StandaloneEditorOptions = { - plugins: [new ContentModelPastePlugin()], + plugins: [new PastePlugin()], coreApiOverride: { getVisibleViewport: () => { return { diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/ContentModelDomIndexer.ts b/packages-content-model/roosterjs-content-model-types/lib/context/DomIndexer.ts similarity index 97% rename from packages-content-model/roosterjs-content-model-types/lib/context/ContentModelDomIndexer.ts rename to packages-content-model/roosterjs-content-model-types/lib/context/DomIndexer.ts index c4383e2d3ac..43058b6ac74 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/ContentModelDomIndexer.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/DomIndexer.ts @@ -8,7 +8,7 @@ import type { DOMSelection } from '../selection/DOMSelection'; * Represents an indexer object which provides methods to help build backward relationship * from DOM node to Content Model */ -export interface ContentModelDomIndexer { +export interface DomIndexer { /** * Invoked when processing a segment * @param segmentNode The new DOM node for this segment diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts b/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts index ac6eecfc00f..2cbc3468272 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts @@ -1,7 +1,7 @@ import type { DarkColorHandler } from './DarkColorHandler'; -import type { ContentModelDomIndexer } from './ContentModelDomIndexer'; +import type { DomIndexer } from './DomIndexer'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; -import type { PendingFormat } from '../pluginState/ContentModelFormatPluginState'; +import type { PendingFormat } from '../pluginState/FormatPluginState'; /** * An editor context interface used by ContentModel PAI @@ -51,5 +51,5 @@ export interface EditorContext { /** * @optional Indexer for content model, to help build backward relationship from DOM node to Content Model */ - domIndexer?: ContentModelDomIndexer; + domIndexer?: DomIndexer; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts index 6810a474c31..d4210aa8389 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts @@ -12,8 +12,8 @@ import type { DOMSelection } from '../selection/DOMSelection'; import type { EditorEnvironment } from '../parameter/EditorEnvironment'; import type { ContentModelFormatter, - FormatWithContentModelOptions, -} from '../parameter/FormatWithContentModelOptions'; + FormatContentModelOptions, +} from '../parameter/FormatContentModelOptions'; import type { DarkColorHandler } from '../context/DarkColorHandler'; import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; @@ -60,12 +60,9 @@ export interface IStandaloneEditor { * to do format change. Then according to the return value, write back the modified content model into editor. * If there is cached model, it will be used and updated. * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions + * @param options More options, see FormatContentModelOptions */ - formatContentModel( - formatter: ContentModelFormatter, - options?: FormatWithContentModelOptions - ): void; + formatContentModel(formatter: ContentModelFormatter, options?: FormatContentModelOptions): void; /** * Get pending format of editor if any, or return null diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts index 6cdbe0a3115..a8450f864f5 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts @@ -6,7 +6,7 @@ import type { ClipboardData } from '../parameter/ClipboardData'; import type { PasteType } from '../enum/PasteType'; import type { DOMEventRecord } from '../parameter/DOMEventRecord'; import type { Snapshot } from '../parameter/Snapshot'; -import type { EntityState } from '../parameter/FormatWithContentModelContext'; +import type { EntityState } from '../parameter/FormatContentModelContext'; import type { DarkColorHandler } from '../context/DarkColorHandler'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { DOMSelection } from '../selection/DOMSelection'; @@ -20,8 +20,8 @@ import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; import type { Rect } from '../parameter/Rect'; import type { ContentModelFormatter, - FormatWithContentModelOptions, -} from '../parameter/FormatWithContentModelOptions'; + FormatContentModelOptions, +} from '../parameter/FormatContentModelOptions'; /** * Create a EditorContext object used by ContentModel API @@ -81,12 +81,12 @@ export type SetDOMSelection = ( * If there is cached model, it will be used and updated. * @param core The StandaloneEditorCore object * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions + * @param options More options, see FormatContentModelOptions */ export type FormatContentModel = ( core: StandaloneEditorCore, formatter: ContentModelFormatter, - options?: FormatWithContentModelOptions + options?: FormatContentModelOptions ) => void; /** @@ -218,7 +218,7 @@ export interface StandaloneCoreApiMap { * If there is cached model, it will be used and updated. * @param core The StandaloneEditorCore object * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions + * @param options More options, see FormatContentModelOptions */ formatContentModel: FormatContentModel; diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts index 3ef59057fd2..9b3394c214f 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts @@ -6,8 +6,8 @@ import type { SelectionPluginState } from '../pluginState/SelectionPluginState'; import type { EntityPluginState } from '../pluginState/EntityPluginState'; import type { LifecyclePluginState } from '../pluginState/LifecyclePluginState'; import type { DOMEventPluginState } from '../pluginState/DOMEventPluginState'; -import type { ContentModelCachePluginState } from '../pluginState/ContentModelCachePluginState'; -import type { ContentModelFormatPluginState } from '../pluginState/ContentModelFormatPluginState'; +import type { CachePluginState } from '../pluginState/CachePluginState'; +import type { FormatPluginState } from '../pluginState/FormatPluginState'; /** * Core plugins for standalone editor @@ -16,12 +16,12 @@ export interface StandaloneEditorCorePlugins { /** * ContentModel cache plugin manages cached Content Model, and refresh the cache when necessary */ - readonly cache: PluginWithState; + readonly cache: PluginWithState; /** * ContentModelFormat plugins helps editor to do formatting on top of content model. */ - readonly format: PluginWithState; + readonly format: PluginWithState; /** * Copy and paste plugin for handling onCopy and onPaste event diff --git a/packages-content-model/roosterjs-content-model-types/lib/event/ContentChangedEvent.ts b/packages-content-model/roosterjs-content-model-types/lib/event/ContentChangedEvent.ts index bac9d637942..45e52c946f6 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/event/ContentChangedEvent.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/event/ContentChangedEvent.ts @@ -1,6 +1,6 @@ import type { AnnounceData } from '../parameter/AnnounceData'; import type { BasePluginEvent } from './BasePluginEvent'; -import type { EntityState } from '../parameter/FormatWithContentModelContext'; +import type { EntityState } from '../parameter/FormatContentModelContext'; import type { ContentModelEntity } from '../entity/ContentModelEntity'; import type { EntityRemovalOperation } from '../enum/EntityOperation'; import type { ContentModelDocument } from '../group/ContentModelDocument'; 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 48047b40c35..41a1a461251 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -183,7 +183,7 @@ export { } from './context/ContentModelHandler'; export { DomToModelOption } from './context/DomToModelOption'; export { ModelToDomOption } from './context/ModelToDomOption'; -export { ContentModelDomIndexer } from './context/ContentModelDomIndexer'; +export { DomIndexer } from './context/DomIndexer'; export { TextMutationObserver } from './context/TextMutationObserver'; export { DefinitionType } from './metadata/DefinitionType'; @@ -227,11 +227,8 @@ export { EditorPlugin } from './editor/EditorPlugin'; export { PluginWithState } from './editor/PluginWithState'; export { ContextMenuProvider } from './editor/ContextMenuProvider'; -export { ContentModelCachePluginState } from './pluginState/ContentModelCachePluginState'; -export { - ContentModelFormatPluginState, - PendingFormat, -} from './pluginState/ContentModelFormatPluginState'; +export { CachePluginState } from './pluginState/CachePluginState'; +export { FormatPluginState, PendingFormat } from './pluginState/FormatPluginState'; export { CopyPastePluginState } from './pluginState/CopyPastePluginState'; export { DOMEventPluginState } from './pluginState/DOMEventPluginState'; export { LifecyclePluginState } from './pluginState/LifecyclePluginState'; @@ -252,12 +249,12 @@ export { EditorEnvironment } from './parameter/EditorEnvironment'; export { EntityState, DeletedEntity, - FormatWithContentModelContext, -} from './parameter/FormatWithContentModelContext'; + FormatContentModelContext, +} from './parameter/FormatContentModelContext'; export { - FormatWithContentModelOptions, + FormatContentModelOptions, ContentModelFormatter, -} from './parameter/FormatWithContentModelOptions'; +} from './parameter/FormatContentModelOptions'; export { ContentModelFormatState } from './parameter/ContentModelFormatState'; export { ImageFormatState } from './parameter/ImageFormatState'; export { Border } from './parameter/Border'; diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts index 84a0976f326..b6801a01148 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts @@ -1,6 +1,6 @@ import type { ContentModelParagraph } from '../block/ContentModelParagraph'; import type { DeleteResult } from '../enum/DeleteResult'; -import type { FormatWithContentModelContext } from './FormatWithContentModelContext'; +import type { FormatContentModelContext } from './FormatContentModelContext'; import type { InsertPoint } from '../selection/InsertPoint'; import type { TableSelectionContext } from '../selection/TableSelectionContext'; @@ -36,7 +36,7 @@ export interface DeleteSelectionContext extends DeleteSelectionResult { /** * Format context provided by formatContentModel API */ - formatContext?: FormatWithContentModelContext; + formatContext?: FormatContentModelContext; } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelContext.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelContext.ts rename to packages-content-model/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts index 9a54666c7ac..3c895186c3c 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelContext.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatContentModelContext.ts @@ -43,7 +43,7 @@ export interface DeletedEntity { /** * Context object for API formatWithContentModel */ -export interface FormatWithContentModelContext { +export interface FormatContentModelContext { /** * New entities added during the format process */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts similarity index 89% rename from packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelOptions.ts rename to packages-content-model/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts index cfcb8736a7c..33bf1158b8b 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts @@ -1,12 +1,12 @@ import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { DOMSelection } from '../selection/DOMSelection'; -import type { FormatWithContentModelContext } from './FormatWithContentModelContext'; +import type { FormatContentModelContext } from './FormatContentModelContext'; import type { OnNodeCreated } from '../context/ModelToDomSettings'; /** * Options for API formatWithContentModel */ -export interface FormatWithContentModelOptions { +export interface FormatContentModelOptions { /** * Name of the format API */ @@ -48,5 +48,5 @@ export interface FormatWithContentModelOptions { */ export type ContentModelFormatter = ( model: ContentModelDocument, - context: FormatWithContentModelContext + context: FormatContentModelContext ) => boolean; diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts index 0c4d3460060..6f8b58d3aab 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts @@ -1,5 +1,5 @@ import type { TableSelectionCoordinates } from '../selection/TableSelectionCoordinates'; -import type { EntityState } from './FormatWithContentModelContext'; +import type { EntityState } from './FormatContentModelContext'; import type { SelectionType } from '../selection/DOMSelection'; /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/CachePluginState.ts similarity index 66% rename from packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts rename to packages-content-model/roosterjs-content-model-types/lib/pluginState/CachePluginState.ts index 82353224c75..5b4d898937c 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/CachePluginState.ts @@ -1,12 +1,12 @@ import type { TextMutationObserver } from '../context/TextMutationObserver'; import type { ContentModelDocument } from '../group/ContentModelDocument'; -import type { ContentModelDomIndexer } from '../context/ContentModelDomIndexer'; +import type { DomIndexer } from '../context/DomIndexer'; import type { DOMSelection } from '../selection/DOMSelection'; /** - * Plugin state for ContentModelEditPlugin + * Plugin state for CacheEditPlugin */ -export interface ContentModelCachePluginState { +export interface CachePluginState { /** * Cached selection */ @@ -18,9 +18,9 @@ export interface ContentModelCachePluginState { cachedModel?: ContentModelDocument; /** - * @optional Indexer for content model, to help build backward relationship from DOM node to Content Model + * @optional Indexer for CachePlugin, to help build backward relationship from DOM node to Content Model */ - domIndexer?: ContentModelDomIndexer; + domIndexer?: DomIndexer; /** * @optional A wrapper of MutationObserver to help detect text changes in editor diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelFormatPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts similarity index 93% rename from packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelFormatPluginState.ts rename to packages-content-model/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts index 13a058373ab..05749a346f6 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelFormatPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/FormatPluginState.ts @@ -23,7 +23,7 @@ export interface PendingFormat { /** * Plugin state for ContentModelFormatPlugin */ -export interface ContentModelFormatPluginState { +export interface FormatPluginState { /** * Default format of this editor */ diff --git a/packages-content-model/roosterjs-content-model/lib/createEditor.ts b/packages-content-model/roosterjs-content-model/lib/createEditor.ts index dc7c2e1b807..880d3c2aad3 100644 --- a/packages-content-model/roosterjs-content-model/lib/createEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createEditor.ts @@ -1,4 +1,4 @@ -import { ContentModelEditPlugin, ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; +import { EditPlugin, PastePlugin } from 'roosterjs-content-model-plugins'; import { StandaloneEditor } from 'roosterjs-content-model-core'; import type { ContentModelDocument, @@ -20,11 +20,7 @@ export function createEditor( additionalPlugins?: EditorPlugin[], initialModel?: ContentModelDocument ): IStandaloneEditor { - const plugins = [ - new ContentModelPastePlugin(), - new ContentModelEditPlugin(), - ...(additionalPlugins ?? []), - ]; + const plugins = [new PastePlugin(), new EditPlugin(), ...(additionalPlugins ?? [])]; const options: StandaloneEditorOptions = { plugins: plugins, From 3be4faaafe74e552858676a20777dc569ee4e030 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 6 Feb 2024 15:12:43 -0800 Subject: [PATCH 083/112] Content Model: Fix #2380 (#2397) * Content Model: Fix #2380 * add test --- .../domToModel/processors/generalProcessor.ts | 5 ++ .../formatHandlers/defaultFormatHandlers.ts | 1 + .../modelToDom/handlers/handleGeneralModel.ts | 9 ++- .../processors/generalProcessorTest.ts | 60 +++++++++++++++++++ .../modelToDom/handlers/handleBlockTest.ts | 2 +- .../handlers/handleGeneralModelTest.ts | 32 +++++++++- .../lib/format/ContentModelFormatMap.ts | 5 ++ 7 files changed, 109 insertions(+), 5 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/generalProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/generalProcessor.ts index edc8afd725b..64fc34ae27e 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/generalProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/generalProcessor.ts @@ -4,6 +4,7 @@ import { addSegment } from '../../modelApi/common/addSegment'; import { createGeneralBlock } from '../../modelApi/creators/createGeneralBlock'; import { createGeneralSegment } from '../../modelApi/creators/createGeneralSegment'; import { isBlockElement } from '../utils/isBlockElement'; +import { parseFormat } from '../utils/parseFormat'; import { stackFormat } from '../utils/stackFormat'; import type { ElementProcessor } from 'roosterjs-content-model-types'; @@ -21,6 +22,8 @@ const generalBlockProcessor: ElementProcessor = (group, element, co () => { addBlock(group, block); + parseFormat(element, context.formatParsers.general, block.format, context); + context.elementProcessors.child(block, element, context); } ); @@ -45,6 +48,8 @@ const generalSegmentProcessor: ElementProcessor = (group, element, 'empty' /*clearFormat, General segment will include all properties and styles when generate back to HTML, so no need to carry over existing segment format*/, }, () => { + parseFormat(element, context.formatParsers.general, segment.format, context); + context.elementProcessors.child(segment, element, context); } ); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts index a5b55f69d23..4d2ac993d31 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts @@ -204,6 +204,7 @@ export const defaultFormatKeysPerCategory: { divider: [...sharedBlockFormats, ...sharedContainerFormats, 'display', 'size', 'htmlAlign'], container: [...sharedContainerFormats, 'htmlAlign', 'size', 'display'], entity: ['entity'], + general: ['textColor', 'backgroundColor'], // General model still need to do color transformation in dark mode }; /** diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts index a21a4f8acc8..42dae971d96 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleGeneralModel.ts @@ -1,3 +1,4 @@ +import { applyFormat } from '../utils/applyFormat'; import { handleSegmentCommon } from '../utils/handleSegmentCommon'; import { isNodeOfType } from '../../domUtils/isNodeOfType'; import { reuseCachedElement } from '../../domUtils/reuseCachedElement'; @@ -19,14 +20,16 @@ export const handleGeneralBlock: ContentModelBlockHandler { - let node: Node = group.element; + let node: HTMLElement = group.element; if (refNode && node.parentNode == parent) { refNode = reuseCachedElement(parent, node, refNode); } else { - node = node.cloneNode(); + node = node.cloneNode() as HTMLElement; group.element = node as HTMLElement; + applyFormat(node, context.formatAppliers.general, group.format, context); + parent.insertBefore(node, refNode); } @@ -54,6 +57,8 @@ export const handleGeneralSegment: ContentModelSegmentHandler { expect(childProcessor).toHaveBeenCalledWith(block, div, context); }); + it('Process a DIV element with color', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + + div.style.color = 'red'; + div.style.backgroundColor = 'green'; + + generalProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'General', + element: div, + blocks: [], + format: { + textColor: 'red', + backgroundColor: 'green', + }, + }, + ], + }); + }); + it('Process a SPAN element', () => { const doc = createContentModelDocument(); const span = document.createElement('span'); @@ -83,6 +109,40 @@ describe('generalProcessor', () => { expect(childProcessor).toHaveBeenCalledWith(segment, span, context); }); + it('Process a SPAN element with color', () => { + const doc = createContentModelDocument(); + const span = document.createElement('span'); + + span.style.color = 'red'; + span.style.backgroundColor = 'green'; + + generalProcessor(doc, span, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + isImplicit: true, + format: {}, + segments: [ + { + segmentType: 'General', + blockType: 'BlockGroup', + blockGroupType: 'General', + element: span, + blocks: [], + format: { + textColor: 'red', + backgroundColor: 'green', + }, + }, + ], + }, + ], + }); + }); + it('Process a SPAN element with format', () => { const doc = createContentModelDocument(); const span = document.createElement('span'); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockTest.ts index dc3a20e4785..4afdbb5ab5b 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockTest.ts @@ -131,7 +131,7 @@ describe('handleBlock', () => { expect(parent.innerHTML).toBe(''); expect(parent.firstChild).not.toBe(element); expect(context.regularSelection.current.segment).toBe(parent.firstChild!.firstChild); - expect(applyFormat.applyFormat).not.toHaveBeenCalled(); + expect(applyFormat.applyFormat).toHaveBeenCalledTimes(1); runTestWithRefNode(block, '
                                                                      '); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts index b8aade3edeb..dd4f1b3b26f 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleGeneralModelTest.ts @@ -60,7 +60,21 @@ describe('handleBlockGroup', () => { group, context ); - expect(applyFormat.applyFormat).not.toHaveBeenCalled(); + expect(applyFormat.applyFormat).toHaveBeenCalledTimes(1); + }); + + it('General block with color', () => { + const child = document.createElement('span'); + const group = createGeneralBlock(child); + + group.format.textColor = 'red'; + group.format.backgroundColor = 'green'; + + handleGeneralBlock(document, parent, group, context, null); + + expect(parent.outerHTML).toBe( + '
                                                                      ' + ); }); it('General segment: empty element', () => { @@ -89,6 +103,20 @@ describe('handleBlockGroup', () => { expect(applyFormat.applyFormat).toHaveBeenCalled(); }); + it('General segment: element with color', () => { + const child = document.createElement('span'); + const group = createGeneralSegment(child); + + group.format.textColor = 'red'; + group.format.textColor = 'green'; + + handleGeneralSegment(document, parent, group, context, []); + + expect(parent.outerHTML).toBe( + '
                                                                      ' + ); + }); + it('General segment: element with child', () => { const clonedChild = document.createElement('span'); const childMock = ({ @@ -199,7 +227,7 @@ describe('handleBlockGroup', () => { group, context ); - expect(applyFormat.applyFormat).not.toHaveBeenCalled(); + expect(applyFormat.applyFormat).toHaveBeenCalledTimes(1); expect(result).toBe(br); expect(group.element).toBe(clonedChild); }); diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelFormatMap.ts b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelFormatMap.ts index 50c8c7518ce..893e4978b79 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelFormatMap.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelFormatMap.ts @@ -141,4 +141,9 @@ export interface ContentModelFormatMap { * Format type for entity */ entity: ContentModelEntityFormat; + + /** + * Format type for general model + */ + general: ContentModelSegmentFormat; } From 3ed92bd9a848b506deb2abb80e1f3f6e50b949fa Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 6 Feb 2024 15:37:45 -0800 Subject: [PATCH 084/112] Add API formatTableWithContentModel (#2399) * Add API formatTableWithContentModel * fix build * add test --- .../roosterjs-content-model-api/lib/index.ts | 2 + .../lib/publicApi/table/editTable.ts | 170 ++++------- .../utils/formatSegmentWithContentModel.ts | 1 + .../utils/formatTableWithContentModel.ts | 68 +++++ .../test/publicApi/table/editTableTest.ts | 243 +++++++++++++++ .../utils/formatTableWithContentModelTest.ts | 284 ++++++++++++++++++ 6 files changed, 661 insertions(+), 107 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatTableWithContentModel.ts create mode 100644 packages-content-model/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts create mode 100644 packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatTableWithContentModelTest.ts diff --git a/packages-content-model/roosterjs-content-model-api/lib/index.ts b/packages-content-model/roosterjs-content-model-api/lib/index.ts index b5bd38fc378..d2b85850720 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/index.ts @@ -40,4 +40,6 @@ export { default as adjustImageSelection } from './publicApi/image/adjustImageSe export { default as setParagraphMargin } from './publicApi/block/setParagraphMargin'; export { default as toggleCode } from './publicApi/segment/toggleCode'; export { default as insertEntity } from './publicApi/entity/insertEntity'; + +export { formatTableWithContentModel } from './publicApi/utils/formatTableWithContentModel'; export { setListType } from './modelApi/list/setListType'; diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts index 78adfc5d5f1..5f63f760b54 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts @@ -2,7 +2,7 @@ import { alignTable } from '../../modelApi/table/alignTable'; import { deleteTable } from '../../modelApi/table/deleteTable'; import { deleteTableColumn } from '../../modelApi/table/deleteTableColumn'; import { deleteTableRow } from '../../modelApi/table/deleteTableRow'; -import { ensureFocusableParagraphForTable } from '../../modelApi/table/ensureFocusableParagraphForTable'; +import { formatTableWithContentModel } from '../utils/formatTableWithContentModel'; import { insertTableColumn } from '../../modelApi/table/insertTableColumn'; import { insertTableRow } from '../../modelApi/table/insertTableRow'; import { mergeTableCells } from '../../modelApi/table/mergeTableCells'; @@ -10,24 +10,11 @@ import { mergeTableColumn } from '../../modelApi/table/mergeTableColumn'; import { mergeTableRow } from '../../modelApi/table/mergeTableRow'; import { splitTableCellHorizontally } from '../../modelApi/table/splitTableCellHorizontally'; import { splitTableCellVertically } from '../../modelApi/table/splitTableCellVertically'; - -import { - hasSelectionInBlock, - applyTableFormat, - getFirstSelectedTable, - normalizeTable, - setSelection, -} from 'roosterjs-content-model-core'; import type { TableOperation, IStandaloneEditor } from 'roosterjs-content-model-types'; import { alignTableCellHorizontally, alignTableCellVertically, } from '../../modelApi/table/alignTableCell'; -import { - createSelectionMarker, - hasMetadata, - setParagraphNotImplicit, -} from 'roosterjs-content-model-dom'; /** * Format current focused table with the given format @@ -37,98 +24,67 @@ import { export default function editTable(editor: IStandaloneEditor, operation: TableOperation) { editor.focus(); - editor.formatContentModel( - model => { - const [tableModel, path] = getFirstSelectedTable(model); - - if (tableModel) { - switch (operation) { - case 'alignCellLeft': - case 'alignCellCenter': - case 'alignCellRight': - alignTableCellHorizontally(tableModel, operation); - break; - case 'alignCellTop': - case 'alignCellMiddle': - case 'alignCellBottom': - alignTableCellVertically(tableModel, operation); - break; - case 'alignCenter': - case 'alignLeft': - case 'alignRight': - alignTable(tableModel, operation); - break; - - case 'deleteColumn': - deleteTableColumn(tableModel); - break; - - case 'deleteRow': - deleteTableRow(tableModel); - break; - - case 'deleteTable': - deleteTable(tableModel); - break; - - case 'insertAbove': - case 'insertBelow': - insertTableRow(tableModel, operation); - break; - - case 'insertLeft': - case 'insertRight': - insertTableColumn(tableModel, operation); - break; - - case 'mergeAbove': - case 'mergeBelow': - mergeTableRow(tableModel, operation); - break; - - case 'mergeCells': - mergeTableCells(tableModel); - break; - - case 'mergeLeft': - case 'mergeRight': - mergeTableColumn(tableModel, operation); - break; - - case 'splitHorizontally': - splitTableCellHorizontally(tableModel); - break; - - case 'splitVertically': - splitTableCellVertically(tableModel); - break; - } - - if (!hasSelectionInBlock(tableModel)) { - const paragraph = ensureFocusableParagraphForTable(model, path, tableModel); - - if (paragraph) { - const marker = createSelectionMarker(model.format); - - paragraph.segments.unshift(marker); - setParagraphNotImplicit(paragraph); - setSelection(model, marker); - } - } - - normalizeTable(tableModel, model.format); - - if (hasMetadata(tableModel)) { - applyTableFormat(tableModel, undefined /*newFormat*/, true /*keepCellShade*/); - } - - return true; - } else { - return false; - } - }, - { - apiName: 'editTable', + formatTableWithContentModel(editor, 'editTable', tableModel => { + switch (operation) { + case 'alignCellLeft': + case 'alignCellCenter': + case 'alignCellRight': + alignTableCellHorizontally(tableModel, operation); + break; + case 'alignCellTop': + case 'alignCellMiddle': + case 'alignCellBottom': + alignTableCellVertically(tableModel, operation); + break; + case 'alignCenter': + case 'alignLeft': + case 'alignRight': + alignTable(tableModel, operation); + break; + + case 'deleteColumn': + deleteTableColumn(tableModel); + break; + + case 'deleteRow': + deleteTableRow(tableModel); + break; + + case 'deleteTable': + deleteTable(tableModel); + break; + + case 'insertAbove': + case 'insertBelow': + insertTableRow(tableModel, operation); + break; + + case 'insertLeft': + case 'insertRight': + insertTableColumn(tableModel, operation); + break; + + case 'mergeAbove': + case 'mergeBelow': + mergeTableRow(tableModel, operation); + break; + + case 'mergeCells': + mergeTableCells(tableModel); + break; + + case 'mergeLeft': + case 'mergeRight': + mergeTableColumn(tableModel, operation); + break; + + case 'splitHorizontally': + splitTableCellHorizontally(tableModel); + break; + + case 'splitVertically': + splitTableCellVertically(tableModel); + break; } - ); + }); } diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts index 91a3a121696..45f9653674e 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts @@ -7,6 +7,7 @@ import type { ContentModelSegmentFormat, IStandaloneEditor, } from 'roosterjs-content-model-types'; + /** * @internal */ diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatTableWithContentModel.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatTableWithContentModel.ts new file mode 100644 index 00000000000..fa33f6ed282 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatTableWithContentModel.ts @@ -0,0 +1,68 @@ +import { ensureFocusableParagraphForTable } from '../../modelApi/table/ensureFocusableParagraphForTable'; +import { + createSelectionMarker, + hasMetadata, + setParagraphNotImplicit, +} from 'roosterjs-content-model-dom'; +import { + hasSelectionInBlock, + applyTableFormat, + getFirstSelectedTable, + normalizeTable, + setSelection, +} from 'roosterjs-content-model-core'; +import type { + ContentModelTable, + IStandaloneEditor, + TableSelection, +} from 'roosterjs-content-model-types'; + +/** + * Invoke a callback to format the selected table using Content Model + * @param editor The editor object + * @param apiName Name of API this calling this function. This is mostly for logging. + * @param callback The callback to format the table. It will be called with current selected table. If no table is selected, it will not be called. + * @param selectionOverride Override the current selection. If we want to format a table even currently it is not selected, we can use this parameter to override current selection + */ +export function formatTableWithContentModel( + editor: IStandaloneEditor, + apiName: string, + callback: (tableModel: ContentModelTable) => void, + selectionOverride?: TableSelection +) { + editor.formatContentModel( + model => { + const [tableModel, path] = getFirstSelectedTable(model); + + if (tableModel) { + callback(tableModel); + + if (!hasSelectionInBlock(tableModel)) { + const paragraph = ensureFocusableParagraphForTable(model, path, tableModel); + + if (paragraph) { + const marker = createSelectionMarker(model.format); + + paragraph.segments.unshift(marker); + setParagraphNotImplicit(paragraph); + setSelection(model, marker); + } + } + + normalizeTable(tableModel, model.format); + + if (hasMetadata(tableModel)) { + applyTableFormat(tableModel, undefined /*newFormat*/, true /*keepCellShade*/); + } + + return true; + } else { + return false; + } + }, + { + apiName, + selectionOverride, + } + ); +} diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts new file mode 100644 index 00000000000..7593be0a2ed --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/editTableTest.ts @@ -0,0 +1,243 @@ +import * as alignTable from '../../../lib/modelApi/table/alignTable'; +import * as alignTableCell from '../../../lib/modelApi/table/alignTableCell'; +import * as deleteTable from '../../../lib/modelApi/table/deleteTable'; +import * as deleteTableColumn from '../../../lib/modelApi/table/deleteTableColumn'; +import * as deleteTableRow from '../../../lib/modelApi/table/deleteTableRow'; +import * as formatTableWithContentModel from '../../../lib/publicApi/utils/formatTableWithContentModel'; +import * as insertTableColumn from '../../../lib/modelApi/table/insertTableColumn'; +import * as insertTableRow from '../../../lib/modelApi/table/insertTableRow'; +import * as mergeTableCells from '../../../lib/modelApi/table/mergeTableCells'; +import * as mergeTableColumn from '../../../lib/modelApi/table/mergeTableColumn'; +import * as mergeTableRow from '../../../lib/modelApi/table/mergeTableRow'; +import * as splitTableCellHorizontally from '../../../lib/modelApi/table/splitTableCellHorizontally'; +import * as splitTableCellVertically from '../../../lib/modelApi/table/splitTableCellVertically'; +import editTable from '../../../lib/publicApi/table/editTable'; +import { IStandaloneEditor, TableOperation } from 'roosterjs-content-model-types'; + +describe('editTable', () => { + let editor: IStandaloneEditor; + let focusSpy: jasmine.Spy; + let formatTableWithContentModelSpy: jasmine.Spy; + const mockedTable = 'TABLE' as any; + + function runTest(operation: TableOperation, expectedSpy: jasmine.Spy, ...parameters: string[]) { + editTable(editor, operation); + + expect(formatTableWithContentModelSpy).toHaveBeenCalledWith( + editor, + 'editTable', + jasmine.anything() + ); + expect(expectedSpy).toHaveBeenCalledWith(mockedTable, ...parameters); + } + + beforeEach(() => { + focusSpy = jasmine.createSpy('focus'); + formatTableWithContentModelSpy = spyOn( + formatTableWithContentModel, + 'formatTableWithContentModel' + ).and.callFake((editorParam, apiParam, callback) => { + callback(mockedTable); + }); + + editor = { + focus: focusSpy, + } as any; + }); + + describe('alignTableCellHorizontally', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(alignTableCell, 'alignTableCellHorizontally'); + }); + + it('alignCellLeft', () => { + runTest('alignCellLeft', spy, 'alignCellLeft'); + }); + + it('alignCellCenter', () => { + runTest('alignCellCenter', spy, 'alignCellCenter'); + }); + + it('alignCellRight', () => { + runTest('alignCellRight', spy, 'alignCellRight'); + }); + }); + + describe('alignTableCellVertically', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(alignTableCell, 'alignTableCellVertically'); + }); + + it('alignCellTop', () => { + runTest('alignCellTop', spy, 'alignCellTop'); + }); + + it('alignCellMiddle', () => { + runTest('alignCellMiddle', spy, 'alignCellMiddle'); + }); + + it('alignCellBottom', () => { + runTest('alignCellBottom', spy, 'alignCellBottom'); + }); + }); + + describe('alignTable', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(alignTable, 'alignTable'); + }); + + it('alignCenter', () => { + runTest('alignCenter', spy, 'alignCenter'); + }); + + it('alignLeft', () => { + runTest('alignLeft', spy, 'alignLeft'); + }); + + it('alignRight', () => { + runTest('alignRight', spy, 'alignRight'); + }); + }); + + describe('deleteTableColumn', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(deleteTableColumn, 'deleteTableColumn'); + }); + + it('deleteColumn', () => { + runTest('deleteColumn', spy); + }); + }); + + describe('deleteTableRow', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(deleteTableRow, 'deleteTableRow'); + }); + + it('deleteRow', () => { + runTest('deleteRow', spy); + }); + }); + + describe('deleteTable', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(deleteTable, 'deleteTable'); + }); + + it('deleteTable', () => { + runTest('deleteTable', spy); + }); + }); + + describe('insertTableRow', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(insertTableRow, 'insertTableRow'); + }); + + it('insertAbove', () => { + runTest('insertAbove', spy, 'insertAbove'); + }); + + it('insertBelow', () => { + runTest('insertBelow', spy, 'insertBelow'); + }); + }); + + describe('insertTableColumn', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(insertTableColumn, 'insertTableColumn'); + }); + + it('insertLeft', () => { + runTest('insertLeft', spy, 'insertLeft'); + }); + + it('insertRight', () => { + runTest('insertRight', spy, 'insertRight'); + }); + }); + + describe('mergeTableRow', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(mergeTableRow, 'mergeTableRow'); + }); + + it('mergeAbove', () => { + runTest('mergeAbove', spy, 'mergeAbove'); + }); + + it('mergeBelow', () => { + runTest('mergeBelow', spy, 'mergeBelow'); + }); + }); + + describe('mergeTableCells', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(mergeTableCells, 'mergeTableCells'); + }); + + it('mergeCells', () => { + runTest('mergeCells', spy); + }); + }); + + describe('mergeTableColumn', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(mergeTableColumn, 'mergeTableColumn'); + }); + + it('mergeLeft', () => { + runTest('mergeLeft', spy, 'mergeLeft'); + }); + + it('mergeRight', () => { + runTest('mergeRight', spy, 'mergeRight'); + }); + }); + + describe('splitTableCellHorizontally', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(splitTableCellHorizontally, 'splitTableCellHorizontally'); + }); + + it('splitHorizontally', () => { + runTest('splitHorizontally', spy); + }); + }); + + describe('splitTableCellVertically', () => { + let spy: jasmine.Spy; + + beforeEach(() => { + spy = spyOn(splitTableCellVertically, 'splitTableCellVertically'); + }); + + it('splitVertically', () => { + runTest('splitVertically', spy); + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatTableWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatTableWithContentModelTest.ts new file mode 100644 index 00000000000..377317b6d43 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatTableWithContentModelTest.ts @@ -0,0 +1,284 @@ +import * as applyTableFormat from 'roosterjs-content-model-core/lib/publicApi/table/applyTableFormat'; +import * as ensureFocusableParagraphForTable from '../../../lib/modelApi/table/ensureFocusableParagraphForTable'; +import * as hasSelectionInBlock from 'roosterjs-content-model-core/lib/publicApi/selection/hasSelectionInBlock'; +import * as normalizeTable from 'roosterjs-content-model-core/lib/publicApi/table/normalizeTable'; +import { ContentModelDocument, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { formatTableWithContentModel } from '../../../lib/publicApi/utils/formatTableWithContentModel'; +import { + createContentModelDocument, + createTable, + createTableCell, +} from 'roosterjs-content-model-dom'; + +describe('formatTableWithContentModel', () => { + let editor: IStandaloneEditor; + let formatContentModelSpy: jasmine.Spy; + let model: ContentModelDocument; + let formatResult: boolean | undefined; + + beforeEach(() => { + formatResult = undefined; + formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: Function) => { + formatResult = callback(model); + }); + editor = { + formatContentModel: formatContentModelSpy, + } as any; + }); + + it('Empty model', () => { + model = createContentModelDocument(); + const callback = jasmine.createSpy('callback'); + + formatTableWithContentModel(editor, 'editTable', callback); + + expect(callback).not.toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledWith(jasmine.anything(), { + apiName: 'editTable', + selectionOverride: undefined, + }); + expect(formatResult).toBeFalse(); + }); + + it('Model with table but not selected', () => { + model = createContentModelDocument(); + const table = createTable(1); + const tableCell = createTableCell(); + + table.rows[0].cells.push(tableCell); + model.blocks.push(table); + + const callback = jasmine.createSpy('callback'); + + formatTableWithContentModel(editor, 'editTable', callback); + + expect(callback).not.toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledWith(jasmine.anything(), { + apiName: 'editTable', + selectionOverride: undefined, + }); + expect(formatResult).toBeFalse(); + }); + + it('Model with selected table, has selection in block, no metadata', () => { + model = createContentModelDocument(); + const table = createTable(1); + const tableCell = createTableCell(); + + tableCell.isSelected = true; + + table.rows[0].cells.push(tableCell); + model.blocks.push(table); + + spyOn(hasSelectionInBlock, 'default').and.returnValue(true); + spyOn(ensureFocusableParagraphForTable, 'ensureFocusableParagraphForTable'); + spyOn(normalizeTable, 'normalizeTable'); + spyOn(applyTableFormat, 'applyTableFormat'); + + const callback = jasmine.createSpy('callback'); + + formatTableWithContentModel(editor, 'editTable', callback); + + expect(callback).toHaveBeenCalledWith(table); + expect(formatContentModelSpy).toHaveBeenCalledWith(jasmine.anything(), { + apiName: 'editTable', + selectionOverride: undefined, + }); + expect( + ensureFocusableParagraphForTable.ensureFocusableParagraphForTable + ).not.toHaveBeenCalled(); + expect(normalizeTable.normalizeTable).toHaveBeenCalledWith(table, undefined); + expect(applyTableFormat.applyTableFormat).not.toHaveBeenCalled(); + expect(formatResult).toBeTrue(); + }); + + it('Model with selected table, no selection in block, no metadata', () => { + model = createContentModelDocument(); + const table = createTable(1); + const tableCell = createTableCell(); + + tableCell.isSelected = true; + + table.rows[0].cells.push(tableCell); + model.blocks.push(table); + + spyOn(hasSelectionInBlock, 'default').and.returnValue(false); + spyOn( + ensureFocusableParagraphForTable, + 'ensureFocusableParagraphForTable' + ).and.callThrough(); + spyOn(normalizeTable, 'normalizeTable'); + spyOn(applyTableFormat, 'applyTableFormat'); + + const callback = jasmine.createSpy('callback'); + + formatTableWithContentModel(editor, 'editTable', callback); + + expect(callback).toHaveBeenCalledWith(table); + expect(formatContentModelSpy).toHaveBeenCalledWith(jasmine.anything(), { + apiName: 'editTable', + selectionOverride: undefined, + }); + expect( + ensureFocusableParagraphForTable.ensureFocusableParagraphForTable + ).toHaveBeenCalledWith(model, [model], table); + expect(normalizeTable.normalizeTable).toHaveBeenCalledWith(table, undefined); + expect(applyTableFormat.applyTableFormat).not.toHaveBeenCalled(); + expect(formatResult).toBeTrue(); + expect(tableCell).toEqual({ + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }); + }); + + it('Model with selected table, no selection in block, has metadata', () => { + model = createContentModelDocument(); + const table = createTable(1); + const tableCell = createTableCell(); + + tableCell.isSelected = true; + + table.dataset.editingInfo = '{}'; + table.rows[0].cells.push(tableCell); + model.blocks.push(table); + + spyOn(hasSelectionInBlock, 'default').and.returnValue(false); + spyOn( + ensureFocusableParagraphForTable, + 'ensureFocusableParagraphForTable' + ).and.callThrough(); + spyOn(normalizeTable, 'normalizeTable'); + spyOn(applyTableFormat, 'applyTableFormat'); + + const callback = jasmine.createSpy('callback'); + + formatTableWithContentModel(editor, 'editTable', callback); + + expect(callback).toHaveBeenCalledWith(table); + expect(formatContentModelSpy).toHaveBeenCalledWith(jasmine.anything(), { + apiName: 'editTable', + selectionOverride: undefined, + }); + expect( + ensureFocusableParagraphForTable.ensureFocusableParagraphForTable + ).toHaveBeenCalledWith(model, [model], table); + expect(normalizeTable.normalizeTable).toHaveBeenCalledWith(table, undefined); + expect(applyTableFormat.applyTableFormat).toHaveBeenCalledWith(table, undefined, true); + expect(formatResult).toBeTrue(); + expect(tableCell).toEqual({ + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }); + }); + + it('With default format and additional parameters', () => { + model = createContentModelDocument({ + fontSize: '10pt', + }); + const table = createTable(1); + const tableCell = createTableCell(); + + tableCell.isSelected = true; + + table.dataset.editingInfo = '{}'; + table.rows[0].cells.push(tableCell); + model.blocks.push(table); + + spyOn(hasSelectionInBlock, 'default').and.returnValue(false); + spyOn( + ensureFocusableParagraphForTable, + 'ensureFocusableParagraphForTable' + ).and.callThrough(); + spyOn(normalizeTable, 'normalizeTable'); + spyOn(applyTableFormat, 'applyTableFormat'); + + const callback = jasmine.createSpy('callback'); + const mockedSelection = 'SELECTION' as any; + + formatTableWithContentModel(editor, 'editTable', callback, mockedSelection); + + expect(callback).toHaveBeenCalledWith(table); + expect(formatContentModelSpy).toHaveBeenCalledWith(jasmine.anything(), { + apiName: 'editTable', + selectionOverride: mockedSelection, + }); + expect( + ensureFocusableParagraphForTable.ensureFocusableParagraphForTable + ).toHaveBeenCalledWith(model, [model], table); + expect(normalizeTable.normalizeTable).toHaveBeenCalledWith(table, { fontSize: '10pt' }); + expect(applyTableFormat.applyTableFormat).toHaveBeenCalledWith(table, undefined, true); + expect(formatResult).toBeTrue(); + expect(tableCell).toEqual({ + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontSize: '10pt' }, + }, + { + segmentType: 'Br', + format: { fontSize: '10pt' }, + }, + ], + format: {}, + segmentFormat: { + fontSize: '10pt', + }, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }); + }); +}); From a10cb39e491dad6ceaccb5df230f19ec1a2def69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 7 Feb 2024 10:51:15 -0300 Subject: [PATCH 085/112] keyboard and IME --- .../lib/edit/keyboardInput.ts | 4 +- .../test/edit/keyboardInputTest.ts | 75 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 31620f830bc..e04765eed38 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -53,7 +53,7 @@ function shouldInputWithContentModel( rawEvent: KeyboardEvent, isInIME: boolean ) { - if (!selection || isInIME) { + if (!selection || isInIME || rawEvent.isComposing) { return false; // Nothing to delete } else if ( !isModifierKey(rawEvent) && @@ -61,7 +61,7 @@ function shouldInputWithContentModel( ) { return ( selection.type != 'range' || - (!selection.range.collapsed && !rawEvent.isComposing) || + !selection.range.collapsed || shouldHandleEnterKey(selection, rawEvent) ); } else { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts index c83c4adece7..1d66e40775a 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts @@ -429,4 +429,79 @@ describe('keyboardInput', () => { }); expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); }); + + it('Enter key input with IME', () => { + const mockedFormat = 'FORMAT' as any; + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: true, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + insertPoint: { + marker: { + format: mockedFormat, + }, + }, + }); + isInIMESpy.and.returnValue(true); + + const rawEvent = { + key: 'Enter', + isComposing: false, + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(takeSnapshotSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(deleteSelectionSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeUndefined(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Enter key input with isComposing', () => { + const mockedFormat = 'FORMAT' as any; + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: true, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + insertPoint: { + marker: { + format: mockedFormat, + }, + }, + }); + + const rawEvent = { + key: 'Enter', + isComposing: true, + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(takeSnapshotSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(deleteSelectionSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeUndefined(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); + }); }); From 05e206f32026f4a09344a68ca3f19ccefc3aac43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 7 Feb 2024 14:12:54 -0300 Subject: [PATCH 086/112] fixes --- .../getDefaultContentEditFeatureSettings.ts | 15 +--- .../lib/modelApi/block/setModelIndentation.ts | 41 ++++++++--- .../modelApi/block/setModelIndentationTest.ts | 71 +++++++++++++++++++ .../lib/edit/keyboardTab.ts | 9 ++- 4 files changed, 114 insertions(+), 22 deletions(-) diff --git a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts index d8e4adda71d..9e9a172339e 100644 --- a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts +++ b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts @@ -9,18 +9,7 @@ export default function getDefaultContentEditFeatureSettings(): ContentEditFeatu settings[key] = !allFeatures[key].defaultDisabled; return settings; }, {}), - indentWhenAltShiftRight: false, - outdentWhenAltShiftLeft: false, - autoBullet: false, - indentWhenTab: false, - outdentWhenShiftTab: false, - outdentWhenBackspaceOnEmptyFirstLine: false, - outdentWhenEnterOnEmptyLine: false, - mergeInNewLineWhenBackspaceOnFirstChar: false, - maintainListChain: false, - maintainListChainWhenDelete: false, - autoNumberingList: false, - autoBulletList: false, - mergeListOnBackspaceAfterList: false, + indentWhenAltShiftRight: true, + outdentWhenAltShiftLeft: true, }; } diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts index f80bc8565e5..39853cde852 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts @@ -8,6 +8,7 @@ import { import type { ContentModelBlockFormat, + ContentModelBlockGroup, ContentModelDocument, ContentModelListItem, ContentModelListLevel, @@ -30,19 +31,20 @@ export function setModelIndentation( ); const isIndent = indentation == 'indent'; - paragraphOrListItem.forEach(({ block }, index) => { + paragraphOrListItem.forEach(({ block, parent }, index) => { if (isBlockGroupOfType(block, 'ListItem')) { const thread = findListItemsInSameThread(model, block); const firstItem = thread[0]; - if ( - isFirstItemSelected(firstItem) && - !(index == 0 && thread.length == 1 && firstItem.levels.length > 1) - ) { + + if (isSelected(firstItem) && firstItem.levels.length == 1) { const level = block.levels[0]; const { format } = level; + const { marginLeft, marginRight } = format; const newValue = calculateMarginValue(format, isIndent, length); const isRtl = format.direction == 'rtl'; - if (!isIndent && newValue == 0) { + const originalValue = parseValueWithUnit(isRtl ? marginRight : marginLeft); + + if (!isIndent && originalValue == 0) { block.levels.pop(); } else { if (isRtl) { @@ -51,7 +53,7 @@ export function setModelIndentation( level.format.marginLeft = newValue + 'px'; } } - } else { + } else if (block.levels.length == 1 || !multilevelSelection(model, block, parent)) { if (isIndent) { const lastLevel = block.levels[block.levels.length - 1]; const newLevel: ContentModelListLevel = createListLevel( @@ -89,7 +91,7 @@ export function setModelIndentation( return paragraphOrListItem.length > 0; } -function isFirstItemSelected(listItem: ContentModelListItem) { +function isSelected(listItem: ContentModelListItem) { return listItem.blocks.some(block => { if (block.blockType == 'Paragraph') { return block.segments.some(segment => segment.isSelected); @@ -97,6 +99,29 @@ function isFirstItemSelected(listItem: ContentModelListItem) { }); } +function multilevelSelection( + model: ContentModelDocument, + listItem: ContentModelListItem, + parent: ContentModelBlockGroup +) { + const listIndex = parent.blocks.indexOf(listItem); + for (let i = listIndex - 1; i >= 0; i--) { + const block = parent.blocks[i]; + if ( + isBlockGroupOfType(block, 'ListItem') && + block.levels.length == 1 && + isSelected(block) + ) { + const firstItem = findListItemsInSameThread(model, block)[0]; + return isSelected(firstItem); + } + + if (!isBlockGroupOfType(block, 'ListItem')) { + return false; + } + } +} + function calculateMarginValue( format: ContentModelBlockFormat, isIndent: boolean, diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts index 67d4f5a0423..5c5149f5190 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts @@ -643,6 +643,77 @@ describe('indent', () => { }); expect(result).toBeTrue(); }); + + it('Group with list with first item selected', () => { + const group = createContentModelDocument(); + const listItem = createListItem([createListLevel('UL')]); + const listItem2 = createListItem([createListLevel('UL')]); + const listItem3 = createListItem([createListLevel('UL')]); + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + text1.isSelected = true; + text2.isSelected = true; + text3.isSelected = true; + para1.segments.push(text1); + para2.segments.push(text2); + para3.segments.push(text3); + listItem.blocks.push(para1); + listItem2.blocks.push(para2); + listItem3.blocks.push(para3); + group.blocks.push(listItem); + group.blocks.push(listItem2); + group.blocks.push(listItem3); + + const result = setModelIndentation(group, 'indent'); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + ...listItem, + levels: [ + { + listType: 'UL', + dataset: {}, + format: { + marginLeft: '40px', + }, + }, + ], + }, + { + ...listItem2, + levels: [ + { + listType: 'UL', + dataset: {}, + format: { + marginLeft: '40px', + }, + }, + ], + }, + { + ...listItem3, + levels: [ + { + listType: 'UL', + dataset: {}, + format: { + marginLeft: '40px', + }, + }, + ], + }, + ], + }); + + expect(result).toBeTrue(); + }); }); describe('outdent', () => { diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts index db03e9df607..abe931d119b 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts @@ -31,7 +31,14 @@ export function keyboardTab(editor: IStandaloneEditor, rawEvent: KeyboardEvent) } function shouldHandleTab(rawEvent: KeyboardEvent, selection: DOMSelection | null) { - return rawEvent.key == 'Tab' && selection && selection?.type == 'range'; + return ( + (rawEvent.key == 'Tab' || + (rawEvent.shiftKey && + (rawEvent.altKey || rawEvent.metaKey) && + (rawEvent.key == 'ArrowRight' || rawEvent.key == 'ArrowLeft'))) && + selection && + selection?.type == 'range' + ); } function isMarkerAtStartOfBlock(listItem: ContentModelListItem) { From 4ef988ce97d7bb27d0918d7affbd605171cff126 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 7 Feb 2024 09:17:35 -0800 Subject: [PATCH 087/112] Code cleanup: Remove unnecessary core API from ContentModelEditor (#2377) * Code cleanup: Remove isContentModelEditor * add buttons * Code cleanup: Replace createContentModel with getContentModelCopy * Remove unnecessary core API from ContentModelEditor * Support exportContent * add test * Remove setContent core API * fix test --- .../ribbonButtons/contentModel/export.ts | 57 +----- .../lib/publicApi/format/getFormatState.ts | 2 +- .../publicApi/format/getFormatStateTest.ts | 2 +- .../lib/editor/DOMHelperImpl.ts | 4 + .../roosterjs-content-model-core/lib/index.ts | 3 + .../format}/retrieveModelFormatState.ts | 17 +- .../publicApi/model/createModelFromHtml.ts | 10 +- .../lib/publicApi/model/exportContent.ts | 35 ++++ .../test/editor/DOMHelperImplTest.ts | 12 ++ .../format}/retrieveModelFormatStateTest.ts | 2 +- .../test/publicApi/model/exportContentTest.ts | 88 ++++++++++ .../roosterjs-content-model-dom/lib/index.ts | 1 + .../lib/modelToText/contentModelToText.ts | 70 ++++++++ .../test/endToEndTest.ts | 50 +++++- .../modelToText/contentModelToTextTest.ts | 162 ++++++++++++++++++ .../lib/coreApi/coreApiMap.ts | 8 - .../lib/coreApi/ensureTypeInContainer.ts | 84 --------- .../lib/coreApi/getContent.ts | 82 --------- .../lib/coreApi/getStyleBasedFormatState.ts | 82 --------- .../lib/coreApi/setContent.ts | 130 -------------- .../lib/editor/ContentModelEditor.ts | 112 +++++++++--- .../editor/utils/getPendableFormatState.ts | 84 --------- .../lib/index.ts | 4 - .../lib/publicTypes/ContentModelEditorCore.ts | 98 ----------- .../lib/enum/ExportContentMode.ts | 18 ++ .../lib/index.ts | 1 + .../lib/parameter/DOMHelper.ts | 5 + 27 files changed, 550 insertions(+), 673 deletions(-) rename packages-content-model/{roosterjs-content-model-api/lib/modelApi/common => roosterjs-content-model-core/lib/publicApi/format}/retrieveModelFormatState.ts (93%) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/publicApi/model/exportContent.ts rename packages-content-model/{roosterjs-content-model-api/test/modelApi/common => roosterjs-content-model-core/test/publicApi/format}/retrieveModelFormatStateTest.ts (99%) create mode 100644 packages-content-model/roosterjs-content-model-core/test/publicApi/model/exportContentTest.ts create mode 100644 packages-content-model/roosterjs-content-model-dom/lib/modelToText/contentModelToText.ts create mode 100644 packages-content-model/roosterjs-content-model-dom/test/modelToText/contentModelToTextTest.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/enum/ExportContentMode.ts diff --git a/demo/scripts/controls/ribbonButtons/contentModel/export.ts b/demo/scripts/controls/ribbonButtons/contentModel/export.ts index b9db78b0149..0c09ac7edea 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/export.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/export.ts @@ -1,11 +1,5 @@ import ContentModelRibbonButton from './ContentModelRibbonButton'; -import { cloneModel } from 'roosterjs-content-model-core'; -import { ContentModelEntityFormat } from 'roosterjs-content-model-types'; -import { - contentModelToDom, - createModelToDomContext, - parseEntityClassName, -} from 'roosterjs-content-model-dom'; +import { exportContent as exportContentApi } from 'roosterjs-content-model-core'; /** * Key of localized strings of Zoom button @@ -21,53 +15,8 @@ export const exportContent: ContentModelRibbonButton = { iconName: 'Export', flipWhenRtl: true, onClick: editor => { - // TODO: We need a export function in dev code to handle this feature const win = editor.getDocument().defaultView.open(); - - editor.formatContentModel(model => { - const clonedModel = cloneModel(model, { - includeCachedElement: (node, type) => { - switch (type) { - case 'cache': - return undefined; - - case 'general': - return node.cloneNode() as HTMLElement; - - case 'entity': - const clonedRoot = node.cloneNode(true) as HTMLElement; - const format: ContentModelEntityFormat = {}; - let isEntity = false; - - clonedRoot.classList.forEach(name => { - isEntity = parseEntityClassName(name, format) || isEntity; - }); - - if (isEntity && format.id && format.entityType) { - editor.triggerEvent('entityOperation', { - operation: 'replaceTemporaryContent', - entity: { - wrapper: clonedRoot, - id: format.id, - type: format.entityType, - isReadonly: !!format.isReadonly, - }, - }); - } - - return clonedRoot; - } - }, - }); - - contentModelToDom( - win.document, - win.document.body, - clonedModel, - createModelToDomContext() - ); - - return false; - }); + const html = exportContentApi(editor); + win.document.write(editor.getTrustedHTMLHandler()(html)); }, }; diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts index d735115e945..e97cc925430 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts @@ -1,4 +1,4 @@ -import { retrieveModelFormatState } from '../../modelApi/common/retrieveModelFormatState'; +import { retrieveModelFormatState } from 'roosterjs-content-model-core'; import type { IStandaloneEditor, ContentModelFormatState } from 'roosterjs-content-model-types'; /** diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts index cd065e35e3b..5ba4c29395c 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts @@ -1,4 +1,4 @@ -import * as retrieveModelFormatState from '../../../lib/modelApi/common/retrieveModelFormatState'; +import * as retrieveModelFormatState from 'roosterjs-content-model-core/lib/publicApi/format/retrieveModelFormatState'; import getFormatState from '../../../lib/publicApi/format/getFormatState'; import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { ContentModelFormatState } from 'roosterjs-content-model-types'; diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts index d5b201f2c42..ef6ac71492e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts @@ -8,6 +8,10 @@ class DOMHelperImpl implements DOMHelper { return toArray(this.contentDiv.querySelectorAll(selector)) as HTMLElement[]; } + getTextContent(): string { + return this.contentDiv.textContent || ''; + } + isNodeInEditor(node: Node): boolean { return this.contentDiv.contains(node); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/index.ts b/packages-content-model/roosterjs-content-model-core/lib/index.ts index b1489354c9c..743e4314697 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/index.ts @@ -7,6 +7,7 @@ export { } from './publicApi/model/getClosestAncestorBlockGroupIndex'; export { isBold } from './publicApi/model/isBold'; export { createModelFromHtml } from './publicApi/model/createModelFromHtml'; +export { exportContent } from './publicApi/model/exportContent'; export { iterateSelections, @@ -47,6 +48,8 @@ export { undo } from './publicApi/undo/undo'; export { redo } from './publicApi/undo/redo'; export { transformColor } from './publicApi/color/transformColor'; +export { retrieveModelFormatState } from './publicApi/format/retrieveModelFormatState'; + export { updateImageMetadata } from './metadata/updateImageMetadata'; export { updateTableCellMetadata } from './metadata/updateTableCellMetadata'; export { updateTableMetadata } from './metadata/updateTableMetadata'; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/format/retrieveModelFormatState.ts similarity index 93% rename from packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/format/retrieveModelFormatState.ts index d04ba929bcf..80f4653d4b7 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/format/retrieveModelFormatState.ts @@ -1,11 +1,9 @@ +import { extractBorderValues } from '../domUtils/borderValues'; +import { getClosestAncestorBlockGroupIndex } from '../model/getClosestAncestorBlockGroupIndex'; +import { isBold } from '../model/isBold'; +import { iterateSelections } from '../selection/iterateSelections'; import { parseValueWithUnit } from 'roosterjs-content-model-dom'; -import { - extractBorderValues, - getClosestAncestorBlockGroupIndex, - isBold, - iterateSelections, - updateTableMetadata, -} from 'roosterjs-content-model-core'; +import { updateTableMetadata } from '../../metadata/updateTableMetadata'; import type { ContentModelFormatState, ContentModelBlock, @@ -20,7 +18,10 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * Retrieve format state from the given Content Model + * @param model The Content Model to retrieve format state from + * @param pendingFormat Existing pending format, if any + * @param formatState Existing format state object, used for receiving the result */ export function retrieveModelFormatState( model: ContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts index fe5fd1d8e4e..e8f0c3b8c1e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts @@ -1,4 +1,8 @@ -import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; +import { + createDomToModelContext, + createEmptyModel, + domToContentModel, +} from 'roosterjs-content-model-dom'; import type { ContentModelDocument, ContentModelSegmentFormat, @@ -18,7 +22,7 @@ export function createModelFromHtml( options?: DomToModelOption, trustedHTMLHandler?: TrustedHTMLHandler, defaultSegmentFormat?: ContentModelSegmentFormat -): ContentModelDocument | undefined { +): ContentModelDocument { const doc = new DOMParser().parseFromString(trustedHTMLHandler?.(html) ?? html, 'text/html'); return doc?.body @@ -31,5 +35,5 @@ export function createModelFromHtml( options ) ) - : undefined; + : createEmptyModel(defaultSegmentFormat); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/exportContent.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/exportContent.ts new file mode 100644 index 00000000000..83e3c8c6184 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/exportContent.ts @@ -0,0 +1,35 @@ +import { + contentModelToDom, + contentModelToText, + createModelToDomContext, +} from 'roosterjs-content-model-dom'; +import type { ExportContentMode, IStandaloneEditor } from 'roosterjs-content-model-types'; + +/** + * Export string content of editor + * @param editor The editor to get content from + * @param mode Mode of content to export. It supports: + * - HTML: Export HTML content. If there are entities, this will cause EntityOperation event with option = 'replaceTemporaryContent' to get a dehydrated entity + * - PlainText: Export plain text content + * - PlainTextFast: Export plain text using editor's textContent property directly + */ +export function exportContent(editor: IStandaloneEditor, mode: ExportContentMode = 'HTML'): string { + if (mode == 'PlainTextFast') { + return editor.getDOMHelper().getTextContent(); + } else { + const model = editor.getContentModelCopy('disconnected'); + + if (mode == 'PlainText') { + return contentModelToText(model); + } else { + const doc = editor.getDocument(); + const div = doc.createElement('div'); + + contentModelToDom(doc, div, model, createModelToDomContext()); + + editor.triggerEvent('extractContentWithDom', { clonedRoot: div }, true /*broadcast*/); + + return div.innerHTML; + } + } +} diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts index 5704869a9dd..31e8892c686 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts @@ -33,6 +33,18 @@ describe('DOMHelperImpl', () => { expect(querySelectorAllSpy).toHaveBeenCalledWith(mockedSelector); }); + it('getTextContent', () => { + const mockedTextContent = 'TEXT'; + const mockedDiv: HTMLDivElement = { + textContent: mockedTextContent, + } as any; + const domHelper = createDOMHelper(mockedDiv); + + const result = domHelper.getTextContent(); + + expect(result).toBe(mockedTextContent); + }); + it('calculateZoomScale 1', () => { const mockedDiv = { getBoundingClientRect: () => ({ diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/common/retrieveModelFormatStateTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/format/retrieveModelFormatStateTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-api/test/modelApi/common/retrieveModelFormatStateTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/format/retrieveModelFormatStateTest.ts index 21dc7d91fd5..dc51343582e 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/common/retrieveModelFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/format/retrieveModelFormatStateTest.ts @@ -1,7 +1,7 @@ import * as iterateSelections from 'roosterjs-content-model-core/lib/publicApi/selection/iterateSelections'; import { applyTableFormat } from 'roosterjs-content-model-core'; import { ContentModelFormatState, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import { retrieveModelFormatState } from '../../../lib/modelApi/common/retrieveModelFormatState'; +import { retrieveModelFormatState } from '../../../lib/publicApi/format/retrieveModelFormatState'; import { addCode, addSegment, diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/model/exportContentTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/exportContentTest.ts new file mode 100644 index 00000000000..c944d4f93de --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/exportContentTest.ts @@ -0,0 +1,88 @@ +import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; +import * as contentModelToText from 'roosterjs-content-model-dom/lib/modelToText/contentModelToText'; +import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; +import { exportContent } from '../../../lib/publicApi/model/exportContent'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; + +describe('exportContent', () => { + it('PlainTextFast', () => { + const mockedTextContent = 'TEXT'; + const getTextContentSpy = jasmine + .createSpy('getTextContent') + .and.returnValue(mockedTextContent); + const editor: IStandaloneEditor = { + getDOMHelper: () => ({ + getTextContent: getTextContentSpy, + }), + } as any; + + const text = exportContent(editor, 'PlainTextFast'); + + expect(text).toBe(mockedTextContent); + expect(getTextContentSpy).toHaveBeenCalledTimes(1); + }); + + it('PlainText', () => { + const mockedModel = 'MODEL' as any; + const getContentModelCopySpy = jasmine + .createSpy('getContentModelCopy') + .and.returnValue(mockedModel); + const editor: IStandaloneEditor = { + getContentModelCopy: getContentModelCopySpy, + } as any; + const mockedText = 'TEXT'; + const contentModelToTextSpy = spyOn( + contentModelToText, + 'contentModelToText' + ).and.returnValue(mockedText); + + const text = exportContent(editor, 'PlainText'); + + expect(text).toBe(mockedText); + expect(getContentModelCopySpy).toHaveBeenCalledWith('disconnected'); + expect(contentModelToTextSpy).toHaveBeenCalledWith(mockedModel); + }); + + it('HTML', () => { + const mockedModel = 'MODEL' as any; + const getContentModelCopySpy = jasmine + .createSpy('getContentModelCopy') + .and.returnValue(mockedModel); + const mockedHTML = 'HTML'; + const mockedDiv = { + innerHTML: mockedHTML, + } as any; + const mockedDoc = { + createElement: () => mockedDiv, + } as any; + const triggerEventSpy = jasmine.createSpy('triggerEvent'); + const editor: IStandaloneEditor = { + getContentModelCopy: getContentModelCopySpy, + getDocument: () => mockedDoc, + triggerEvent: triggerEventSpy, + } as any; + const contentModelToDomSpy = spyOn(contentModelToDom, 'contentModelToDom'); + const mockedContext = 'CONTEXT' as any; + const createModelToDomContextSpy = spyOn( + createModelToDomContext, + 'createModelToDomContext' + ).and.returnValue(mockedContext); + + const html = exportContent(editor, 'HTML'); + + expect(html).toBe(mockedHTML); + expect(getContentModelCopySpy).toHaveBeenCalledWith('disconnected'); + expect(createModelToDomContextSpy).toHaveBeenCalledWith(); + expect(contentModelToDomSpy).toHaveBeenCalledWith( + mockedDoc, + mockedDiv, + mockedModel, + mockedContext + ); + expect(triggerEventSpy).toHaveBeenCalledWith( + 'extractContentWithDom', + { clonedRoot: mockedDiv }, + true + ); + }); +}); 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 551ce399585..3cbb323ff43 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -1,5 +1,6 @@ export { domToContentModel } from './domToModel/domToContentModel'; export { contentModelToDom } from './modelToDom/contentModelToDom'; +export { contentModelToText } from './modelToText/contentModelToText'; export { childProcessor, diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToText/contentModelToText.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToText/contentModelToText.ts new file mode 100644 index 00000000000..a119bc70079 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToText/contentModelToText.ts @@ -0,0 +1,70 @@ +import type { ContentModelBlockGroup, ContentModelDocument } from 'roosterjs-content-model-types'; + +/** + * Convert Content Model to plain text + * @param model The source Content Model + * @param [separator='\r\n'] The separator string used for connect lines + */ +export function contentModelToText( + model: ContentModelDocument, + separator: string = '\r\n' +): string { + const textArray: string[] = []; + + contentModelToTextArray(model, textArray); + + return textArray.join(separator); +} + +function contentModelToTextArray(group: ContentModelBlockGroup, textArray: string[]) { + group.blocks.forEach(block => { + switch (block.blockType) { + case 'Paragraph': + let text = ''; + + block.segments.forEach(segment => { + switch (segment.segmentType) { + case 'Br': + textArray.push(text); + text = ''; + break; + + case 'Entity': + text += segment.wrapper.textContent || ''; + break; + + case 'General': + text += segment.element.textContent || ''; + break; + + case 'Text': + text += segment.text; + break; + + case 'Image': + text += ' '; + break; + } + }); + textArray.push(text); + break; + + case 'Divider': + case 'Entity': + textArray.push(''); + break; + + case 'Table': + block.rows.forEach(row => + row.cells.forEach(cell => { + contentModelToTextArray(cell, textArray); + }) + ); + break; + + case 'BlockGroup': + contentModelToTextArray(block, textArray); + break; + } + }); +} diff --git a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts index 3ed0c1357f0..37c555d2953 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts @@ -1,6 +1,6 @@ import * as createGeneralBlock from '../lib/modelApi/creators/createGeneralBlock'; import { contentModelToDom } from '../lib/modelToDom/contentModelToDom'; -import { createDomToModelContext, createModelToDomContext } from '../lib'; +import { contentModelToText, createDomToModelContext, createModelToDomContext } from '../lib'; import { domToContentModel } from '../lib/domToModel/domToContentModel'; import { expectHtml } from './testUtils'; import { @@ -9,12 +9,12 @@ import { ContentModelGeneralBlock, } from 'roosterjs-content-model-types'; -describe('End to end test for DOM => Model', () => { +describe('End to end test for DOM => Model => DOM/TEXT', () => { function runTest( html: string, expectedModel: ContentModelDocument, - expectedHtml: string, - expectedHTMLFirefox?: string + expectedText: string, + ...expectedHtml: string[] ) { const div1 = document.createElement('div'); div1.innerHTML = html; @@ -26,12 +26,10 @@ describe('End to end test for DOM => Model', () => { const div2 = document.createElement('div'); contentModelToDom(document, div2, model, createModelToDomContext()); - const possibleHTML = [ - expectedHtml, //chrome or firefox - expectedHTMLFirefox, //firefox - ]; + const text = contentModelToText(model); - expectHtml(div2.innerHTML, possibleHTML); + expect(text).toBe(expectedText); + expectHtml(div2.innerHTML, expectedHtml); } it('List with margin', () => { @@ -126,6 +124,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + '1\r\n2', '
                                                                      • 1
                                                                      • 2
                                                                      ' ); }); @@ -223,6 +222,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + '1\r\na\r\nb\r\n2', '
                                                                      1. 1
                                                                        1. a
                                                                      2. b
                                                                      3. 2
                                                                      ' ); }); @@ -297,6 +297,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\nbb\r\ncc\ndd\r\nee', '
                                                                      aa\nbb
                                                                      cc\ndd
                                                                      ee
                                                                      ' ); }); @@ -357,6 +358,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'test1\r\ntest2', '
                                                                      test1
                                                                      test2
                                                                      ' ); }); @@ -412,6 +414,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\r\nbb\r\ncc', 'aa
                                                                      bb
                                                                      cc' ); }); @@ -492,6 +495,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\r\nbb\r\ncc', 'aa
                                                                      bb
                                                                      cc' ); }); @@ -581,6 +585,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\r\nbb\r\ncc', '
                                                                      aa
                                                                      bb
                                                                      cc
                                                                      ' ); }); @@ -634,6 +639,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\r\naa', '
                                                                      aa
                                                                      aa
                                                                      ' ); }); @@ -666,6 +672,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa', '
                                                                      aa
                                                                      ' ); }); @@ -714,6 +721,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaa\r\nbbb', '

                                                                      aaa

                                                                      bbb

                                                                      ' ); }); @@ -766,6 +774,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaa\r\nbbb', '

                                                                      aaa

                                                                      bbb

                                                                      ' ); }); @@ -837,6 +846,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\r\nbb\r\ncc', '

                                                                      aa

                                                                      bb

                                                                      cc

                                                                      ' ); }); @@ -864,6 +874,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'test', '

                                                                      test

                                                                      ' ); }); @@ -933,6 +944,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaa\nbbb\r\naaa\nbb', '
                                                                      aaa\nbbb
                                                                      aaa\nbb
                                                                      ' ); }); @@ -999,6 +1011,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaabbbccc\r\naaabbbccc', '
                                                                      aaabbbccc
                                                                      aaabbbccc
                                                                      ' ); }); @@ -1111,6 +1124,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaaa\r\nbbbbbb\r\ncccc\r\naaaa\r\nbbbbbb\r\ncccc', '
                                                                      aaaa
                                                                      bbbbbb
                                                                      cccc
                                                                      aaaa
                                                                      bbbbbb
                                                                      cccc
                                                                      ' ); }); @@ -1178,6 +1192,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaa\r\nbbb\r\nccc', '
                                                                      aaa
                                                                      bbb
                                                                      ccc
                                                                      ' ); }); @@ -1219,6 +1234,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaa\r\nbbb', '
                                                                      aaa
                                                                      bbb
                                                                      ' ); }); @@ -1258,6 +1274,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'test1\r\ntest2', '
                                                                      test1
                                                                      test2', '
                                                                      test1
                                                                      test2' ); @@ -1359,6 +1376,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'test1\r\ntest2\r\ntest3', '
                                                                      test1
                                                                      test2
                                                                      test3
                                                                      ' ); @@ -1444,6 +1462,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaa\r\nbbb\r\nccc\r\nddd\r\neee', '
                                                                      aaa
                                                                      bbb
                                                                      ccc
                                                                      ddd
                                                                      eee
                                                                      ' ); }); @@ -1471,6 +1490,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'test', '
                                                                      test
                                                                      ' ); }); @@ -1519,6 +1539,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + '', '
                                                                      ' ); }); @@ -1552,6 +1573,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'test', 'test', 'test' ); @@ -1587,6 +1609,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'test', '
                                                                      test
                                                                      ' ); }); @@ -1624,6 +1647,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaabbb\r\nccc\r\ndddeeee', 'aaabbb
                                                                      ccc
                                                                      dddeeee' ); }); @@ -1651,6 +1675,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'www.bing.com', '' ); }); @@ -1701,6 +1726,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aaa\r\nbbb', '

                                                                      aaa

                                                                      bbb

                                                                      ' ); }); @@ -1747,6 +1773,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'beforetestafter', 'beforetestafter' ); }); @@ -1775,6 +1802,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\r\nbb\r\ncc', '
                                                                      aa
                                                                      bb
                                                                      cc' ); }); @@ -1803,6 +1831,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'aa\r\nbb\r\ncc', '
                                                                      aa
                                                                      bb
                                                                      cc
                                                                      ' ); }); @@ -1895,6 +1924,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'a\r\nb\r\nc', '
                                                                      abc
                                                                      ' ); }); @@ -1987,6 +2017,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'a\r\nb\r\nc', '
                                                                      abc
                                                                      ' ); }); @@ -2035,6 +2066,7 @@ describe('End to end test for DOM => Model', () => { }, ], }, + 'test', '
                                                                        1. test
                                                                      ' ); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToText/contentModelToTextTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToText/contentModelToTextTest.ts new file mode 100644 index 00000000000..d7490edbae5 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToText/contentModelToTextTest.ts @@ -0,0 +1,162 @@ +import { contentModelToText } from '../../lib/modelToText/contentModelToText'; +import { createBr } from '../../lib/modelApi/creators/createBr'; +import { createContentModelDocument } from '../../lib/modelApi/creators/createContentModelDocument'; +import { createDivider } from '../../lib/modelApi/creators/createDivider'; +import { createEntity } from '../../lib/modelApi/creators/createEntity'; +import { createImage } from '../../lib/modelApi/creators/createImage'; +import { createListItem } from '../../lib/modelApi/creators/createListItem'; +import { createListLevel } from '../../lib/modelApi/creators/createListLevel'; +import { createParagraph } from '../../lib/modelApi/creators/createParagraph'; +import { createTable } from '../../lib/modelApi/creators/createTable'; +import { createTableCell } from '../../lib/modelApi/creators/createTableCell'; +import { createText } from '../../lib/modelApi/creators/createText'; + +describe('modelToText', () => { + it('Empty model', () => { + const model = createContentModelDocument(); + + const text = contentModelToText(model); + + expect(text).toBe(''); + }); + + it('model with paragraphs', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(createText('text1')); + para2.segments.push(createText('text2')); + + model.blocks.push(para1, para2); + + const text = contentModelToText(model); + + expect(text).toBe('text1\r\ntext2'); + }); + + it('model with paragraphs and customized separator', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(createText('text1')); + para2.segments.push(createText('text2')); + + model.blocks.push(para1, para2); + + const text = contentModelToText(model, '-'); + + expect(text).toBe('text1-text2'); + }); + + it('model with paragraph and br', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(createText('text1'), createBr(), createText('text2')); + + model.blocks.push(para1); + + const text = contentModelToText(model); + + expect(text).toBe('text1\r\ntext2'); + }); + + it('model with paragraph and image', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(createText('text1'), createImage('src'), createText('text2')); + + model.blocks.push(para1); + + const text = contentModelToText(model); + + expect(text).toBe('text1 text2'); + }); + + it('model with divider', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(createText('text1')); + para2.segments.push(createText('text2')); + + model.blocks.push(para1, createDivider('div'), para2); + + const text = contentModelToText(model); + + expect(text).toBe('text1\r\n\r\ntext2'); + }); + + it('model with list', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const list1 = createListItem([createListLevel('OL')]); + const list2 = createListItem([createListLevel('UL')]); + + para1.segments.push(createText('text1')); + para2.segments.push(createText('text2')); + + list1.blocks.push(para1); + list2.blocks.push(para2); + + model.blocks.push(list1, list2); + + const text = contentModelToText(model); + + expect(text).toBe('text1\r\ntext2'); + }); + + it('model with table', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const para4 = createParagraph(); + const cell1 = createTableCell(); + const cell2 = createTableCell(); + const cell3 = createTableCell(); + const cell4 = createTableCell(); + const table = createTable(2); + + para1.segments.push(createText('text1')); + para2.segments.push(createText('text2')); + para3.segments.push(createText('text3')); + para4.segments.push(createText('text4')); + + cell1.blocks.push(para1); + cell2.blocks.push(para2); + cell3.blocks.push(para3); + cell4.blocks.push(para4); + + table.rows[0].cells.push(cell1, cell2); + table.rows[1].cells.push(cell3, cell4); + + model.blocks.push(table); + + const text = contentModelToText(model); + + expect(text).toBe('text1\r\ntext2\r\ntext3\r\ntext4'); + }); + + it('model with entity', () => { + const div = document.createElement('div'); + + div.innerText = 'test entity'; + + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(createText('text1'), createEntity(div), createText('text2')); + + model.blocks.push(para1); + + const text = contentModelToText(model); + + expect(text).toBe('text1test entitytext2'); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts index 6d8ebc84677..444cd5277b3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts @@ -1,17 +1,9 @@ -import { ensureTypeInContainer } from './ensureTypeInContainer'; -import { getContent } from './getContent'; -import { getStyleBasedFormatState } from './getStyleBasedFormatState'; import { insertNode } from './insertNode'; -import { setContent } from './setContent'; import type { ContentModelCoreApiMap } from '../publicTypes/ContentModelEditorCore'; /** * @internal */ export const coreApiMap: ContentModelCoreApiMap = { - ensureTypeInContainer, - getContent, - getStyleBasedFormatState, insertNode, - setContent, }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts deleted file mode 100644 index c00066f23ab..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { ContentPosition, KnownCreateElementDataIndex, PositionType } from 'roosterjs-editor-types'; -import { - createElement, - createRange, - findClosestElementAncestor, - getBlockElementAtNode, - isNodeEmpty, - Position, - safeInstanceOf, -} from 'roosterjs-editor-dom'; -import type { EnsureTypeInContainer } from '../publicTypes/ContentModelEditorCore'; - -/** - * @internal - * When typing goes directly under content div, many things can go wrong - * We fix it by wrapping it with a div and reposition cursor within the div - */ -export const ensureTypeInContainer: EnsureTypeInContainer = ( - core, - innerCore, - position, - keyboardEvent -) => { - const { contentDiv, api } = innerCore; - const table = findClosestElementAncestor(position.node, contentDiv, 'table'); - let td: HTMLElement | null; - - if (table && (td = table.querySelector('td,th'))) { - position = new Position(td, PositionType.Begin); - } - position = position.normalize(); - - const block = getBlockElementAtNode(contentDiv, position.node); - let formatNode: HTMLElement | null; - - if (block) { - formatNode = block.collapseToSingleElement(); - if (isNodeEmpty(formatNode, false /* trimContent */, true /* shouldCountBrAsVisible */)) { - const brEl = formatNode.ownerDocument.createElement('br'); - formatNode.append(brEl); - } - // if the block is empty, apply default format - // Otherwise, leave it as it is as we don't want to change the style for existing data - // unless the block was just created by the keyboard event (e.g. ctrl+a & start typing) - const shouldSetNodeStyles = - isNodeEmpty(formatNode) || - (keyboardEvent && wasNodeJustCreatedByKeyboardEvent(keyboardEvent, formatNode)); - formatNode = formatNode && shouldSetNodeStyles ? formatNode : null; - } else { - // Only reason we don't get the selection block is that we have an empty content div - // which can happen when users removes everything (i.e. select all and DEL, or backspace from very end to begin) - // The fix is to add a DIV wrapping, apply default format and move cursor over - formatNode = createElement( - KnownCreateElementDataIndex.EmptyLine, - contentDiv.ownerDocument - ) as HTMLElement; - core.api.insertNode(core, innerCore, formatNode, { - position: ContentPosition.End, - updateCursor: false, - replaceSelection: false, - insertOnNewLine: false, - }); - - // element points to a wrapping node we added "

                                                                      ". We should move the selection left to
                                                                      - position = new Position(formatNode, PositionType.Begin); - } - - // If this is triggered by a keyboard event, let's select the new position - if (keyboardEvent) { - api.setDOMSelection(innerCore, { - type: 'range', - range: createRange(new Position(position)), - isReverted: false, - }); - } -}; - -function wasNodeJustCreatedByKeyboardEvent(event: KeyboardEvent, formatNode: HTMLElement) { - return ( - safeInstanceOf(event.target, 'Node') && - event.target.contains(formatNode) && - event.key === formatNode.innerText - ); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts deleted file mode 100644 index 3119abb1310..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { GetContentMode } from 'roosterjs-editor-types'; -import { transformColor } from 'roosterjs-content-model-core'; -import { - createRange, - getHtmlWithSelectionPath, - getSelectionPath, - getTextContent, - safeInstanceOf, -} from 'roosterjs-editor-dom'; -import type { GetContent } from '../publicTypes/ContentModelEditorCore'; - -/** - * @internal - * Get current editor content as HTML string - * @param core The StandaloneEditorCore object - * @param mode specify what kind of HTML content to retrieve - * @returns HTML string representing current editor content - */ -export const getContent: GetContent = (core, innerCore, mode): string => { - let content: string | null = ''; - const triggerExtractContentEvent = mode == GetContentMode.CleanHTML; - const includeSelectionMarker = mode == GetContentMode.RawHTMLWithSelection; - const { lifecycle, contentDiv, api, darkColorHandler } = innerCore; - - // When there is fragment for shadow edit, always use the cached fragment as document since HTML node in editor - // has been changed by uncommitted shadow edit which should be ignored. - const root = lifecycle.shadowEditFragment || contentDiv; - - if (mode == GetContentMode.PlainTextFast) { - content = root.textContent; - } else if (mode == GetContentMode.PlainText) { - content = getTextContent(root); - } else { - const clonedRoot = cloneNode(root); - clonedRoot.normalize(); - - const originalRange = api.getDOMSelection(innerCore); - const path = - !includeSelectionMarker || lifecycle.shadowEditFragment - ? null - : originalRange?.type == 'range' - ? getSelectionPath(contentDiv, originalRange.range) - : null; - const range = path && createRange(clonedRoot, path.start, path.end); - - if (lifecycle.isDarkMode) { - transformColor(clonedRoot, false /*includeSelf*/, 'darkToLight', darkColorHandler); - } - - if (triggerExtractContentEvent) { - api.triggerEvent( - innerCore, - { - eventType: 'extractContentWithDom', - clonedRoot, - }, - true /*broadcast*/ - ); - - content = clonedRoot.innerHTML; - } else if (range) { - // range is not null, which means we want to include a selection path in the content - content = getHtmlWithSelectionPath(clonedRoot, range); - } else { - content = clonedRoot.innerHTML; - } - } - - return content ?? ''; -}; - -function cloneNode(node: HTMLElement | DocumentFragment): HTMLElement { - let clonedNode: HTMLElement; - if (safeInstanceOf(node, 'DocumentFragment')) { - clonedNode = node.ownerDocument.createElement('div'); - clonedNode.appendChild(node.cloneNode(true /*deep*/)); - } else { - clonedNode = node.cloneNode(true /*deep*/) as HTMLElement; - } - - return clonedNode; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts deleted file mode 100644 index 65bf128b95f..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { contains, getComputedStyles } from 'roosterjs-editor-dom'; -import { NodeType } from 'roosterjs-editor-types'; -import type { GetStyleBasedFormatState } from '../publicTypes/ContentModelEditorCore'; - -/** - * @internal - * Get style based format state from current selection, including font name/size and colors - * @param core The StandaloneEditorCore objects - * @param node The node to get style from - */ -export const getStyleBasedFormatState: GetStyleBasedFormatState = (core, innerCore, node) => { - if (!node) { - return {}; - } - - const styles = node - ? getComputedStyles(node, [ - 'font-family', - 'font-size', - 'color', - 'background-color', - 'line-height', - 'margin-top', - 'margin-bottom', - 'text-align', - 'direction', - 'font-weight', - ]) - : []; - const { contentDiv, lifecycle } = innerCore; - const { darkColorHandler } = core; - - let styleTextColor: string | undefined; - let styleBackColor: string | undefined; - - while ( - node && - contains(contentDiv, node, true /*treatSameNodeAsContain*/) && - !(styleTextColor && styleBackColor) - ) { - if (node.nodeType == NodeType.Element) { - const element = node as HTMLElement; - - styleTextColor = styleTextColor || element.style.getPropertyValue('color'); - styleBackColor = styleBackColor || element.style.getPropertyValue('background-color'); - } - node = node.parentNode; - } - - if (!lifecycle.isDarkMode && node == contentDiv) { - styleTextColor = styleTextColor || styles[2]; - styleBackColor = styleBackColor || styles[3]; - } - - const textColor = darkColorHandler.parseColorValue(styleTextColor); - const backColor = darkColorHandler.parseColorValue(styleBackColor); - - return { - fontName: styles[0], - fontSize: styles[1], - textColor: textColor.lightModeColor, - backgroundColor: backColor.lightModeColor, - textColors: textColor.darkModeColor - ? { - lightModeColor: textColor.lightModeColor, - darkModeColor: textColor.darkModeColor, - } - : undefined, - backgroundColors: backColor.darkModeColor - ? { - lightModeColor: backColor.lightModeColor, - darkModeColor: backColor.darkModeColor, - } - : undefined, - lineHeight: styles[4], - marginTop: styles[5], - marginBottom: styles[6], - textAlign: styles[7], - direction: styles[8], - fontWeight: styles[9], - }; -}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts deleted file mode 100644 index 2ea1d68e87a..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { ChangeSource, transformColor } from 'roosterjs-content-model-core'; -import { SelectionRangeTypes } from 'roosterjs-editor-types'; -import { - createRange, - extractContentMetadata, - queryElements, - restoreContentWithEntityPlaceholder, -} from 'roosterjs-editor-dom'; -import type { ContentMetadata } from 'roosterjs-editor-types'; -import type { SetContent } from '../publicTypes/ContentModelEditorCore'; -import type { DOMSelection, StandaloneEditorCore } from 'roosterjs-content-model-types'; - -/** - * @internal - * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered - * if triggerContentChangedEvent is set to true - * @param core The ContentModelEditorCore object - * @param content HTML content to set in - * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true - * @param metadata @optional Metadata of the content that helps editor know the selection and color mode. - * If not passed, we will treat content as in light mode without selection - */ -export const setContent: SetContent = ( - core, - innerCore, - content, - triggerContentChangedEvent, - metadata -) => { - const { contentDiv, api, entity, trustedHTMLHandler, lifecycle, darkColorHandler } = innerCore; - - let contentChanged = false; - - if (innerCore.contentDiv.innerHTML != content) { - api.triggerEvent( - innerCore, - { - eventType: 'beforeSetContent', - newContent: content, - }, - true /*broadcast*/ - ); - - const entities = entity.entityMap; - const html = content || ''; - const body = new DOMParser().parseFromString( - trustedHTMLHandler?.(html) ?? html, - 'text/html' - ).body; - - restoreContentWithEntityPlaceholder(body, contentDiv, entities); - - const metadataFromContent = extractContentMetadata(contentDiv); - metadata = metadata || metadataFromContent; - selectContentMetadata(innerCore, metadata); - contentChanged = true; - } - - const isDarkMode = lifecycle.isDarkMode; - - if ((!metadata && isDarkMode) || (metadata && !!metadata.isDarkMode != !!isDarkMode)) { - transformColor( - contentDiv, - false /*includeSelf*/, - isDarkMode ? 'lightToDark' : 'darkToLight', - darkColorHandler - ); - contentChanged = true; - } - - if (triggerContentChangedEvent && contentChanged) { - api.triggerEvent( - innerCore, - { - eventType: 'contentChanged', - source: ChangeSource.SetContent, - }, - false /*broadcast*/ - ); - } -}; - -function selectContentMetadata(core: StandaloneEditorCore, metadata: ContentMetadata | undefined) { - if (!core.lifecycle.shadowEditFragment && metadata) { - const selection = convertMetadataToDOMSelection(core.contentDiv, metadata); - - if (selection) { - core.api.setDOMSelection(core, selection); - } - } -} - -function convertMetadataToDOMSelection( - contentDiv: HTMLElement, - metadata: ContentMetadata | undefined -): DOMSelection | null { - switch (metadata?.type) { - case SelectionRangeTypes.Normal: - return { - type: 'range', - range: createRange(contentDiv, metadata.start, metadata.end), - isReverted: false, - }; - case SelectionRangeTypes.TableSelection: - const table = queryElements(contentDiv, '#' + metadata.tableId)[0] as HTMLTableElement; - - return table - ? { - type: 'table', - table: table, - firstColumn: metadata.firstCell.x, - firstRow: metadata.firstCell.y, - lastColumn: metadata.lastCell.x, - lastRow: metadata.lastCell.y, - } - : null; - case SelectionRangeTypes.ImageSelection: - const image = queryElements(contentDiv, '#' + metadata.imageId)[0] as HTMLImageElement; - - return image - ? { - type: 'image', - image: image, - } - : null; - - default: - return null; - } -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index b1de6094518..fb44cbcda10 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -2,7 +2,6 @@ import { BridgePlugin } from '../corePlugins/BridgePlugin'; import { buildRangeEx } from './utils/buildRangeEx'; import { createEditorCore } from './createEditorCore'; import { getObjectKeys } from 'roosterjs-content-model-dom'; -import { getPendableFormatState } from './utils/getPendableFormatState'; import { newEventToOldEvent, oldEventToNewEvent, @@ -10,8 +9,10 @@ import { } from './utils/eventConverter'; import { createModelFromHtml, + exportContent, isBold, redo, + retrieveModelFormatState, StandaloneEditor, transformColor, undo, @@ -90,7 +91,20 @@ import type { ContentModelEditorOptions, IContentModelEditor, } from '../publicTypes/IContentModelEditor'; -import type { DOMEventRecord, Rect } from 'roosterjs-content-model-types'; +import type { + ContentModelFormatState, + DOMEventRecord, + ExportContentMode, + Rect, +} from 'roosterjs-content-model-types'; + +const GetContentModeMap: Record = { + [GetContentMode.CleanHTML]: 'HTML', + [GetContentMode.PlainText]: 'PlainText', + [GetContentMode.PlainTextFast]: 'PlainTextFast', + [GetContentMode.RawHTMLOnly]: 'HTML', + [GetContentMode.RawHTMLWithSelection]: 'HTML', +}; /** * Editor for Content Model. @@ -300,10 +314,7 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode * @returns HTML string representing current editor content */ getContent(mode: GetContentMode | CompatibleGetContentMode = GetContentMode.CleanHTML): string { - const core = this.getContentModelEditorCore(); - const innerCore = this.getCore(); - - return core.api.getContent(core, innerCore, mode as GetContentMode); + return exportContent(this, GetContentModeMap[mode]); } /** @@ -312,10 +323,39 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true */ setContent(content: string, triggerContentChangedEvent: boolean = true) { - const core = this.getContentModelEditorCore(); - const innerCore = this.getCore(); + const core = this.getCore(); + const { contentDiv, api, trustedHTMLHandler, lifecycle, darkColorHandler } = core; + + api.triggerEvent( + core, + { + eventType: 'beforeSetContent', + newContent: content, + }, + true /*broadcast*/ + ); - core.api.setContent(core, innerCore, content, triggerContentChangedEvent); + const newModel = createModelFromHtml( + content, + core.domToModelSettings.customized, + trustedHTMLHandler, + core.format.defaultFormat + ); + + api.setContentModel(core, newModel); + + if (triggerContentChangedEvent) { + api.triggerEvent( + core, + { + eventType: 'contentChanged', + source: ChangeSource.SetContent, + }, + false /*broadcast*/ + ); + } else if (lifecycle.isDarkMode) { + transformColor(contentDiv, false /*includeSelf*/, 'lightToDark', darkColorHandler); + } } /** @@ -902,38 +942,52 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode } /** + * @deprecated * Get style based format state from current selection, including font name/size and colors */ - getStyleBasedFormatState(node?: Node): StyleBasedFormatState { - if (!node) { - const range = this.getSelectionRange(); - node = (range && Position.getStart(range).normalize().node) ?? undefined; - } - const core = this.getContentModelEditorCore(); - const innerCore = this.getCore(); + getStyleBasedFormatState(): StyleBasedFormatState { + const format = this.retrieveFormatState(); - return core.api.getStyleBasedFormatState(core, innerCore, node ?? null); + return { + backgroundColor: format.backgroundColor, + direction: format.direction, + fontName: format.fontName, + fontSize: format.fontSize, + fontWeight: format.fontWeight, + lineHeight: format.lineHeight, + marginBottom: format.marginBottom, + marginTop: format.marginTop, + textAlign: format.textAlign, + textColor: format.textColor, + }; } /** + * @deprecated * Get the pendable format such as underline and bold - * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. * @returns The pending format state */ - getPendableFormatState(forceGetStateFromDOM: boolean = false): PendableFormatState { - const core = this.getCore(); - return getPendableFormatState(core); + getPendableFormatState(): PendableFormatState { + const format = this.retrieveFormatState(); + + return { + isBold: format.isBold, + isItalic: format.isItalic, + isStrikeThrough: format.isStrikeThrough, + isSubscript: format.isSubscript, + isSuperscript: format.isSubscript, + isUnderline: format.isUnderline, + }; } /** + * @deprecated * Ensure user will type into a container element rather than into the editor content DIV directly * @param position The position that user is about to type to * @param keyboardEvent Optional keyboard event object */ ensureTypeInContainer(position: NodePosition, keyboardEvent?: KeyboardEvent) { - const core = this.getContentModelEditorCore(); - const innerCore = this.getCore(); - core.api.ensureTypeInContainer(core, innerCore, position, keyboardEvent); + // No OP } //#endregion @@ -1031,6 +1085,16 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode return core.darkColorHandler; } + private retrieveFormatState(): ContentModelFormatState { + const pendingFormat = this.getPendingFormat(); + const result: ContentModelFormatState = {}; + const model = this.getContentModelCopy('reduced'); + + retrieveModelFormatState(model, pendingFormat, result); + + return result; + } + /** * @returns the current ContentModelEditorCore object * @throws a standard Error if there's no core object diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts deleted file mode 100644 index 5a89fdcbc6c..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { contains, getObjectKeys, getTagOfNode, Position } from 'roosterjs-editor-dom'; -import { NodeType } from 'roosterjs-editor-types'; -import type { PendableFormatNames } from 'roosterjs-editor-dom'; -import type { NodePosition, PendableFormatState } from 'roosterjs-editor-types'; -import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; - -/** - * @internal - * @param core The StandaloneEditorCore object - * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. - * @returns The cached format state if it exists. If the cached position do not exist, search for pendable elements in the DOM tree and return the pendable format state. - */ -export function getPendableFormatState(core: StandaloneEditorCore): PendableFormatState { - const selection = core.api.getDOMSelection(core); - const range = selection?.type == 'range' ? selection.range : null; - const currentPosition = range && Position.getStart(range).normalize(); - - return currentPosition ? queryCommandStateFromDOM(core, currentPosition) : {}; -} - -const PendableStyleCheckers: Record< - PendableFormatNames, - (tagName: string, style: CSSStyleDeclaration) => boolean -> = { - isBold: (tag, style) => - tag == 'B' || - tag == 'STRONG' || - tag == 'H1' || - tag == 'H2' || - tag == 'H3' || - tag == 'H4' || - tag == 'H5' || - tag == 'H6' || - parseInt(style.fontWeight) >= 700 || - ['bold', 'bolder'].indexOf(style.fontWeight) >= 0, - isUnderline: (tag, style) => tag == 'U' || style.textDecoration.indexOf('underline') >= 0, - isItalic: (tag, style) => tag == 'I' || tag == 'EM' || style.fontStyle === 'italic', - isSubscript: (tag, style) => tag == 'SUB' || style.verticalAlign === 'sub', - isSuperscript: (tag, style) => tag == 'SUP' || style.verticalAlign === 'super', - isStrikeThrough: (tag, style) => - tag == 'S' || tag == 'STRIKE' || style.textDecoration.indexOf('line-through') >= 0, -}; - -/** - * CssFalsyCheckers checks for non pendable format that might overlay a pendable format, then it can prevent getPendableFormatState return falsy pendable format states. - */ - -const CssFalsyCheckers: Record boolean> = { - isBold: style => - (style.fontWeight !== '' && parseInt(style.fontWeight) < 700) || - style.fontWeight === 'normal', - isUnderline: style => - style.textDecoration !== '' && style.textDecoration.indexOf('underline') < 0, - isItalic: style => style.fontStyle !== '' && style.fontStyle !== 'italic', - isSubscript: style => style.verticalAlign !== '' && style.verticalAlign !== 'sub', - isSuperscript: style => style.verticalAlign !== '' && style.verticalAlign !== 'super', - isStrikeThrough: style => - style.textDecoration !== '' && style.textDecoration.indexOf('line-through') < 0, -}; - -function queryCommandStateFromDOM( - core: StandaloneEditorCore, - currentPosition: NodePosition -): PendableFormatState { - let node: Node | null = currentPosition.node; - const formatState: PendableFormatState = {}; - const pendableKeys: PendableFormatNames[] = []; - while (node && contains(core.contentDiv, node)) { - const tag = getTagOfNode(node); - const style = node.nodeType == NodeType.Element && (node as HTMLElement).style; - if (tag && style) { - getObjectKeys(PendableStyleCheckers).forEach(key => { - if (!(pendableKeys.indexOf(key) >= 0)) { - formatState[key] = formatState[key] || PendableStyleCheckers[key](tag, style); - if (CssFalsyCheckers[key](style)) { - pendableKeys.push(key); - } - } - }); - } - node = node.parentNode; - } - return formatState; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index d0a490ed6e3..0d1fcef72be 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -1,11 +1,7 @@ export { ContentModelEditorCore, ContentModelCoreApiMap, - SetContent, InsertNode, - GetContent, - GetStyleBasedFormatState, - EnsureTypeInContainer, } from './publicTypes/ContentModelEditorCore'; export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; export { ContentModelCorePluginState } from './publicTypes/ContentModelCorePlugins'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts index 806ae0b366c..c646607741c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts @@ -3,44 +3,11 @@ import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; import type { CustomData, ExperimentalFeatures, - ContentMetadata, - GetContentMode, InsertOption, - NodePosition, - StyleBasedFormatState, SizeTransformer, DarkColorHandler, } from 'roosterjs-editor-types'; -/** - * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered - * if triggerContentChangedEvent is set to true - * @param core The ContentModelEditorCore object - * @param innerCore The StandaloneEditorCore object - * @param content HTML content to set in - * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true - */ -export type SetContent = ( - core: ContentModelEditorCore, - innerCore: StandaloneEditorCore, - content: string, - triggerContentChangedEvent: boolean, - metadata?: ContentMetadata -) => void; - -/** - * Get current editor content as HTML string - * @param core The ContentModelEditorCore object - * @param innerCore The StandaloneEditorCore object - * @param mode specify what kind of HTML content to retrieve - * @returns HTML string representing current editor content - */ -export type GetContent = ( - core: ContentModelEditorCore, - innerCore: StandaloneEditorCore, - mode: GetContentMode -) => string; - /** * Insert a DOM node into editor content * @param core The ContentModelEditorCore object. No op if null. @@ -54,48 +21,10 @@ export type InsertNode = ( option: InsertOption | null ) => boolean; -/** - * Get style based format state from current selection, including font name/size and colors - * @param core The ContentModelEditorCore objects - * @param innerCore The StandaloneEditorCore object - * @param node The node to get style from - */ -export type GetStyleBasedFormatState = ( - core: ContentModelEditorCore, - innerCore: StandaloneEditorCore, - node: Node | null -) => StyleBasedFormatState; - -/** - * Ensure user will type into a container element rather than into the editor content DIV directly - * @param core The ContentModelEditorCore object. - * @param innerCore The StandaloneEditorCore object - * @param position The position that user is about to type to - * @param keyboardEvent Optional keyboard event object - * @param deprecated Deprecated parameter, not used - */ -export type EnsureTypeInContainer = ( - core: ContentModelEditorCore, - innerCore: StandaloneEditorCore, - position: NodePosition, - keyboardEvent?: KeyboardEvent, - deprecated?: boolean -) => void; - /** * Core API map for Content Model editor */ export interface ContentModelCoreApiMap { - /** - * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered - * if triggerContentChangedEvent is set to true - * @param core The ContentModelEditorCore object - * @param innerCore The StandaloneEditorCore object - * @param content HTML content to set in - * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true - */ - setContent: SetContent; - /** * Insert a DOM node into editor content * @param core The ContentModelEditorCore object. No op if null. @@ -103,33 +32,6 @@ export interface ContentModelCoreApiMap { * @param option An insert option object to specify how to insert the node */ insertNode: InsertNode; - - /** - * Get current editor content as HTML string - * @param core The ContentModelEditorCore object - * @param innerCore The StandaloneEditorCore object - * @param mode specify what kind of HTML content to retrieve - * @returns HTML string representing current editor content - */ - getContent: GetContent; - - /** - * Get style based format state from current selection, including font name/size and colors - * @param core The ContentModelEditorCore objects - * @param innerCore The StandaloneEditorCore object - * @param node The node to get style from - */ - getStyleBasedFormatState: GetStyleBasedFormatState; - - /** - * Ensure user will type into a container element rather than into the editor content DIV directly - * @param core The EditorCore object. - * @param innerCore The StandaloneEditorCore object - * @param position The position that user is about to type to - * @param keyboardEvent Optional keyboard event object - * @param deprecated Deprecated parameter, not used - */ - ensureTypeInContainer: EnsureTypeInContainer; } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/enum/ExportContentMode.ts b/packages-content-model/roosterjs-content-model-types/lib/enum/ExportContentMode.ts new file mode 100644 index 00000000000..b4ac150929b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/enum/ExportContentMode.ts @@ -0,0 +1,18 @@ +/** + * The mode parameter type for exportContent API + */ +export type ExportContentMode = + /** + * Export to clean HTML in light color mode with dehydrated entities + */ + | 'HTML' + + /** + * Export to plain text + */ + | 'PlainText' + + /** + * Export to plain text via browser's textContent property + */ + | 'PlainTextFast'; 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 41a1a461251..885067819f5 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -86,6 +86,7 @@ export { PasteType } from './enum/PasteType'; export { BorderOperations } from './enum/BorderOperations'; export { DeleteResult } from './enum/DeleteResult'; export { InsertEntityPosition } from './enum/InsertEntityPosition'; +export { ExportContentMode } from './enum/ExportContentMode'; export { ContentModelBlock } from './block/ContentModelBlock'; export { ContentModelParagraph } from './block/ContentModelParagraph'; diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts index e1e0b274f0b..ef07dd2b059 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts @@ -26,6 +26,11 @@ export interface DOMHelper { */ queryElements(selector: string): HTMLElement[]; + /** + * Get plain text content of editor using textContent property + */ + getTextContent(): string; + /** * Calculate current zoom scale of editor */ From e653898a80f2e119943948d806023c49352b34d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 7 Feb 2024 14:18:24 -0300 Subject: [PATCH 088/112] test --- .../roosterjs-content-model-plugins/test/edit/EditPluginTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index d2bef52d85d..44c2a408dfc 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -58,7 +58,7 @@ describe('EditPlugin', () => { }); it('Tab', () => { - const plugin = new ContentModelEditPlugin(); + const plugin = new EditPlugin(); const rawEvent = { key: 'Tab' } as any; plugin.initialize(editor); From 963718ab46eb2bff0777b0700ebf1b18212f62bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 7 Feb 2024 14:28:48 -0300 Subject: [PATCH 089/112] remove code --- .../lib/edit/keyboardTab.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts index abe931d119b..db03e9df607 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts @@ -31,14 +31,7 @@ export function keyboardTab(editor: IStandaloneEditor, rawEvent: KeyboardEvent) } function shouldHandleTab(rawEvent: KeyboardEvent, selection: DOMSelection | null) { - return ( - (rawEvent.key == 'Tab' || - (rawEvent.shiftKey && - (rawEvent.altKey || rawEvent.metaKey) && - (rawEvent.key == 'ArrowRight' || rawEvent.key == 'ArrowLeft'))) && - selection && - selection?.type == 'range' - ); + return rawEvent.key == 'Tab' && selection && selection?.type == 'range'; } function isMarkerAtStartOfBlock(listItem: ContentModelListItem) { From 58671fb7d1656fb2d28eb07b23fe9dd68c1b7ecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 7 Feb 2024 14:29:26 -0300 Subject: [PATCH 090/112] remove code --- .../roosterjs-content-model-plugins/lib/edit/EditPlugin.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index 3c7ad396425..5817243aaf6 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -75,6 +75,7 @@ export class EditPlugin implements EditorPlugin { case 'Tab': keyboardTab(editor, rawEvent); break; + case 'Enter': default: keyboardInput(editor, rawEvent); break; From 62450ab77cfacede2cb2d7b11c165c8438bacf50 Mon Sep 17 00:00:00 2001 From: Andres-CT98 <107568016+Andres-CT98@users.noreply.github.com> Date: Wed, 7 Feb 2024 14:55:17 -0600 Subject: [PATCH 091/112] Restore getScrollContainer, getVisibleViewport, and IsMobileOrTablet (#2400) * add isMobileOrTablet * restore getScrollContainer and getVisibleViewport * add tests --- .../lib/editor/StandaloneEditor.ts | 15 +++++ .../lib/editor/createStandaloneEditorCore.ts | 18 ++++++ .../test/editor/StandaloneEditorTest.ts | 60 +++++++++++++++++++ .../editor/createStandaloneEditorCoreTest.ts | 6 ++ .../lib/editor/ContentModelEditor.ts | 17 ------ .../lib/editor/IStandaloneEditor.ts | 11 ++++ .../lib/parameter/EditorEnvironment.ts | 5 ++ 7 files changed, 115 insertions(+), 17 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts index 463ab92041a..713f99f35ae 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts @@ -26,6 +26,7 @@ import type { StandaloneEditorCore, StandaloneEditorOptions, TrustedHTMLHandler, + Rect, } from 'roosterjs-content-model-types'; /** @@ -365,6 +366,20 @@ export class StandaloneEditor implements IStandaloneEditor { return this.getCore().trustedHTMLHandler; } + /** + * Get the scroll container of the editor + */ + getScrollContainer(): HTMLElement { + return this.getCore().domEvent.scrollContainer; + } + + /** + * Retrieves the rect of the visible viewport of the editor. + */ + getVisibleViewport(): Rect | null { + return this.getCore().api.getVisibleViewport(this.getCore()); + } + /** * @returns the current StandaloneEditorCore object * @throws a standard Error if there's no core object diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts index b4680abdb9e..53e629732dd 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts @@ -69,9 +69,27 @@ function createEditorEnvironment(contentDiv: HTMLElement): EditorEnvironment { userAgent.indexOf('Safari') >= 0 && userAgent.indexOf('Chrome') < 0 && userAgent.indexOf('Android') < 0, + isMobileOrTablet: getIsMobileOrTablet(userAgent), }; } +function getIsMobileOrTablet(userAgent: string) { + // Reference: http://detectmobilebrowsers.com/ + // The default regex on the website doesn't consider tablet. + // To support tablet, add |android|ipad|playbook|silk to the first regex according to the info in /about page + if ( + /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test( + userAgent + ) || + /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( + userAgent.substring(0, 4) + ) + ) { + return true; + } + return false; +} + /** * @internal export for test only */ diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts index 304b3834b68..36bbccc32ee 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts @@ -3,6 +3,7 @@ import * as createEmptyModel from 'roosterjs-content-model-dom/lib/modelApi/crea import * as createStandaloneEditorCore from '../../lib/editor/createStandaloneEditorCore'; import * as transformColor from '../../lib/publicApi/color/transformColor'; import { ChangeSource } from '../../lib/constants/ChangeSource'; +import { Rect, StandaloneEditorCore } from 'roosterjs-content-model-types'; import { reducedModelChildProcessor } from '../../lib/override/reducedModelChildProcessor'; import { StandaloneEditor } from '../../lib/editor/StandaloneEditor'; import { tableProcessor } from 'roosterjs-content-model-dom'; @@ -863,4 +864,63 @@ describe('StandaloneEditor', () => { expect(() => editor.isDarkMode()).toThrow(); expect(() => editor.setDarkModeState()).toThrow(); }); + + it('getScrollContainer', () => { + const div = document.createElement('div'); + const mockedScrollContainer = 'SCROLLCONTAINER' as any; + const resetSpy = jasmine.createSpy('reset'); + const mockedCore = { + plugins: [], + darkColorHandler: { + updateKnownColor: updateKnownColorSpy, + reset: resetSpy, + }, + api: { setContentModel: setContentModelSpy }, + domEvent: { scrollContainer: mockedScrollContainer }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + const result = editor.getScrollContainer(); + + expect(result).toBe(mockedScrollContainer); + + editor.dispose(); + expect(resetSpy).toHaveBeenCalledWith(); + expect(() => editor.getScrollContainer()).toThrow(); + }); + + it('getVisibleViewport', () => { + const div = document.createElement('div'); + const mockedScrollContainer: Rect = { top: 0, bottom: 100, left: 0, right: 100 }; + const resetSpy = jasmine.createSpy('reset'); + const mockedCore = { + plugins: [], + darkColorHandler: { + updateKnownColor: updateKnownColorSpy, + reset: resetSpy, + }, + api: { + setContentModel: setContentModelSpy, + getVisibleViewport: (core: StandaloneEditorCore) => { + return mockedScrollContainer; + }, + }, + domEvent: { scrollContainer: mockedScrollContainer }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new StandaloneEditor(div); + + const result = editor.getVisibleViewport(); + + expect(result).toBe(mockedScrollContainer); + + editor.dispose(); + expect(resetSpy).toHaveBeenCalledWith(); + expect(() => editor.getVisibleViewport()).toThrow(); + }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts index a7e4e383390..3d6a523dd40 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/createStandaloneEditorCoreTest.ts @@ -85,6 +85,7 @@ describe('createEditorCore', () => { isMac: false, isAndroid: false, isSafari: false, + isMobileOrTablet: false, }, darkColorHandler: mockedDarkColorHandler, trustedHTMLHandler: defaultTrustHtmlHandler, @@ -203,6 +204,7 @@ describe('createEditorCore', () => { isMac: false, isAndroid: true, isSafari: false, + isMobileOrTablet: true, }, }); @@ -233,6 +235,7 @@ describe('createEditorCore', () => { isMac: false, isAndroid: true, isSafari: false, + isMobileOrTablet: true, }, }); @@ -263,6 +266,7 @@ describe('createEditorCore', () => { isMac: true, isAndroid: false, isSafari: false, + isMobileOrTablet: false, }, }); @@ -293,6 +297,7 @@ describe('createEditorCore', () => { isMac: false, isAndroid: false, isSafari: true, + isMobileOrTablet: false, }, }); @@ -323,6 +328,7 @@ describe('createEditorCore', () => { isMac: false, isAndroid: false, isSafari: false, + isMobileOrTablet: false, }, }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index fb44cbcda10..22fb6b40e57 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -95,7 +95,6 @@ import type { ContentModelFormatState, DOMEventRecord, ExportContentMode, - Rect, } from 'roosterjs-content-model-types'; const GetContentModeMap: Record = { @@ -754,13 +753,6 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode //#region Misc - /** - * Get the scroll container of the editor - */ - getScrollContainer(): HTMLElement { - return this.getCore().domEvent.scrollContainer; - } - /** * Get custom data related to this editor * @param key Key of the custom data @@ -1068,15 +1060,6 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode return this.getContentModelEditorCore().sizeTransformer; } - /** - * Retrieves the rect of the visible viewport of the editor. - */ - getVisibleViewport(): Rect | null { - const core = this.getCore(); - - return core.api.getVisibleViewport(core); - } - /** * Get a darkColorHandler object for this editor. */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts index d4210aa8389..eef110367d2 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts @@ -16,6 +16,7 @@ import type { } from '../parameter/FormatContentModelOptions'; import type { DarkColorHandler } from '../context/DarkColorHandler'; import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; +import type { Rect } from '../parameter/Rect'; /** * An interface of standalone Content Model editor. @@ -196,4 +197,14 @@ export interface IStandaloneEditor { * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types */ getTrustedHTMLHandler(): TrustedHTMLHandler; + + /** + * Get the scroll container of the editor + */ + getScrollContainer(): HTMLElement; + + /** + * Retrieves the rect of the visible viewport of the editor. + */ + getVisibleViewport(): Rect | null; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts index ec554db2bd8..e5d9966272b 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts @@ -16,4 +16,9 @@ export interface EditorEnvironment { * Whether editor is running on Safari browser */ isSafari?: boolean; + + /** + * Whether current browser is on mobile or a tablet + */ + isMobileOrTablet?: boolean; } From 449ea670a404f8c845308e0e1eb6f36a4d715ddb Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 8 Feb 2024 10:51:15 -0800 Subject: [PATCH 092/112] Code cleanup: Clean up constructor of StandaloneEditor (#2385) * Code cleanup: Remove isContentModelEditor * add buttons * Code cleanup: Replace createContentModel with getContentModelCopy * Remove unnecessary core API from ContentModelEditor * Code cleanup: code rename * add more rename * Support exportContent * add test * Code cleanup: Remove get/setZoomScale * Code cleanup: Move isNodeInEditor into DOMHelper * Add calculateZoomScale function * improve * Remove setContent core API * Code cleanup: Cleanup ctor of StandaloneEditor * improve * improve * improve * change name of plugins * Improve * improve --- .../lib/editor/StandaloneEditor.ts | 8 +- .../lib/corePlugins/BridgePlugin.ts | 82 ++++--- .../lib/editor/ContentModelEditor.ts | 52 ++-- .../lib/editor/DarkColorHandlerImpl.ts | 5 +- .../lib/editor/createEditorCore.ts | 34 --- .../lib/publicTypes/IContentModelEditor.ts | 1 + .../test/corePlugins/BridgePluginTest.ts | 222 ++++++++++++++---- .../test/editor/DarkColorHandlerImplTest.ts | 56 ++--- .../test/editor/createEditorCoreTest.ts | 74 ------ 9 files changed, 280 insertions(+), 254 deletions(-) delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts index 713f99f35ae..d99c8c894cc 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts @@ -40,15 +40,9 @@ export class StandaloneEditor implements IStandaloneEditor { * @param contentDiv The DIV HTML element which will be the container element of editor * @param options An optional options object to customize the editor */ - constructor( - contentDiv: HTMLDivElement, - options: StandaloneEditorOptions = {}, - onBeforeInitializePlugins?: () => void - ) { + constructor(contentDiv: HTMLDivElement, options: StandaloneEditorOptions = {}) { this.core = createStandaloneEditorCore(contentDiv, options); - onBeforeInitializePlugins?.(); - const initialModel = options.initialModel ?? createEmptyModel(options.defaultSegmentFormat); this.core.api.setContentModel(this.core, initialModel, { ignoreSelection: true }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts index 7d38348dba5..3ae0d693d66 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts @@ -1,18 +1,26 @@ +import { coreApiMap } from '../coreApi/coreApiMap'; +import { createDarkColorHandler } from '../editor/DarkColorHandlerImpl'; import { createEditPlugin } from './EditPlugin'; import { createEntityDelimiterPlugin } from './EntityDelimiterPlugin'; import { newEventToOldEvent, oldEventToNewEvent } from '../editor/utils/eventConverter'; -import { PluginEventType } from 'roosterjs-editor-types'; -import type { ContentModelCorePluginState } from '../publicTypes/ContentModelCorePlugins'; import type { - ContentModelEditorOptions, - IContentModelEditor, -} from '../publicTypes/IContentModelEditor'; + ContentModelCoreApiMap, + ContentModelEditorCore, +} from '../publicTypes/ContentModelEditorCore'; +import type { ContentModelCorePluginState } from '../publicTypes/ContentModelCorePlugins'; import type { EditorPlugin as LegacyEditorPlugin, PluginEvent as LegacyPluginEvent, ContextMenuProvider as LegacyContextMenuProvider, + IEditor as ILegacyEditor, + ExperimentalFeatures, + SizeTransformer, } from 'roosterjs-editor-types'; -import type { ContextMenuProvider, PluginEvent } from 'roosterjs-content-model-types'; +import type { + ContextMenuProvider, + IStandaloneEditor, + PluginEvent, +} from 'roosterjs-content-model-types'; const ExclusivelyHandleEventPluginKey = '__ExclusivelyHandleEventPlugin'; @@ -23,18 +31,18 @@ const ExclusivelyHandleEventPluginKey = '__ExclusivelyHandleEventPlugin'; export class BridgePlugin implements ContextMenuProvider { private legacyPlugins: LegacyEditorPlugin[]; private corePluginState: ContentModelCorePluginState; - private outerEditor: IContentModelEditor | null = null; private checkExclusivelyHandling: boolean; - constructor(options: ContentModelEditorOptions) { + constructor( + private onInitialize: (core: ContentModelEditorCore) => ILegacyEditor, + legacyPlugins: LegacyEditorPlugin[] = [], + private legacyCoreApiOverride?: Partial, + private experimentalFeatures: ExperimentalFeatures[] = [] + ) { const editPlugin = createEditPlugin(); const entityDelimiterPlugin = createEntityDelimiterPlugin(); - this.legacyPlugins = [ - editPlugin, - ...(options.legacyPlugins ?? []).filter(x => !!x), - entityDelimiterPlugin, - ]; + this.legacyPlugins = [editPlugin, ...legacyPlugins.filter(x => !!x), entityDelimiterPlugin]; this.corePluginState = { edit: editPlugin.getState(), contextMenuProviders: this.legacyPlugins.filter(isContextMenuProvider), @@ -51,36 +59,14 @@ export class BridgePlugin implements ContextMenuProvider { return 'Bridge'; } - /** - * Get core plugin state - */ - getCorePluginState(): ContentModelCorePluginState { - return this.corePluginState; - } - /** * Initialize this plugin. This should only be called from Editor * @param editor Editor instance */ - initialize() { - if (this.outerEditor) { - const editor = this.outerEditor; - - this.legacyPlugins.forEach(plugin => plugin.initialize(editor)); + initialize(editor: IStandaloneEditor) { + const outerEditor = this.onInitialize(this.createEditorCore(editor)); - this.legacyPlugins.forEach(plugin => - plugin.onPluginEvent?.({ - eventType: PluginEventType.EditorReady, - }) - ); - } - } - - /** - * Initialize all inner plugins with Content Model Editor - */ - setOuterEditor(editor: IContentModelEditor) { - this.outerEditor = editor; + this.legacyPlugins.forEach(plugin => plugin.initialize(outerEditor)); } /** @@ -154,6 +140,26 @@ export class BridgePlugin implements ContextMenuProvider { return allItems; } + + private createEditorCore(editor: IStandaloneEditor): ContentModelEditorCore { + return { + api: { ...coreApiMap, ...this.legacyCoreApiOverride }, + originalApi: coreApiMap, + customData: {}, + experimentalFeatures: this.experimentalFeatures ?? [], + sizeTransformer: createSizeTransformer(editor), + darkColorHandler: createDarkColorHandler(editor.getColorManager()), + ...this.corePluginState, + }; + } +} + +/** + * @internal Export for test only. This function is only used for compatibility from older build + + */ +export function createSizeTransformer(editor: IStandaloneEditor): SizeTransformer { + return size => size / editor.getDOMHelper().calculateZoomScale(); } function isContextMenuProvider( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 22fb6b40e57..1358d7bded9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -1,6 +1,5 @@ import { BridgePlugin } from '../corePlugins/BridgePlugin'; import { buildRangeEx } from './utils/buildRangeEx'; -import { createEditorCore } from './createEditorCore'; import { getObjectKeys } from 'roosterjs-content-model-dom'; import { newEventToOldEvent, @@ -118,7 +117,17 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode * @param options An optional options object to customize the editor */ constructor(contentDiv: HTMLDivElement, options: ContentModelEditorOptions = {}) { - const bridgePlugin = new BridgePlugin(options); + const bridgePlugin = new BridgePlugin( + core => { + this.contentModelEditorCore = core; + + return this; + }, + options.legacyPlugins, + options.legacyCoreApiOverride, + options.experimentalFeatures + ); + const plugins = [bridgePlugin, ...(options.plugins ?? [])]; const initContent = options.initialContent ?? contentDiv.innerHTML; const initialModel = @@ -135,44 +144,31 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode plugins, initialModel, }; - const corePluginState = bridgePlugin.getCorePluginState(); - - super(contentDiv, standaloneEditorOptions, () => { - const core = this.getCore(); - const sizeTransformer: SizeTransformer = size => - size / this.getDOMHelper().calculateZoomScale(); - - // Need to create Content Model Editor Core before initialize plugins since some plugins need this object - this.contentModelEditorCore = createEditorCore( - options, - corePluginState, - core.darkColorHandler, - sizeTransformer - ); - bridgePlugin.setOuterEditor(this); - }); + super(contentDiv, standaloneEditorOptions); } /** * Dispose this editor, dispose all plugins and custom data */ dispose(): void { - super.dispose(); + const core = this.contentModelEditorCore; - const core = this.getContentModelEditorCore(); + if (core) { + getObjectKeys(core.customData).forEach(key => { + const data = core.customData[key]; - getObjectKeys(core.customData).forEach(key => { - const data = core.customData[key]; + if (data && data.disposer) { + data.disposer(data.value); + } - if (data && data.disposer) { - data.disposer(data.value); - } + delete core.customData[key]; + }); - delete core.customData[key]; - }); + this.contentModelEditorCore = undefined; + } - this.contentModelEditorCore = undefined; + super.dispose(); } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts index 591727ba99d..2d012789a3c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts @@ -6,10 +6,7 @@ const VARIABLE_REGEX = /^\s*var\(\s*(\-\-[a-zA-Z0-9\-_]+)\s*(?:,\s*(.*))?\)\s*$/ const VARIABLE_PREFIX = 'var('; const COLOR_VAR_PREFIX = 'darkColor'; -/** - * @internal - */ -export class DarkColorHandlerImpl implements DarkColorHandler { +class DarkColorHandlerImpl implements DarkColorHandler { constructor(private innerHandler: StandaloneDarkColorHandler) {} /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts deleted file mode 100644 index 0e1efc7bd85..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { coreApiMap } from '../coreApi/coreApiMap'; -import { createDarkColorHandler } from './DarkColorHandlerImpl'; -import type { ContentModelCorePluginState } from '../publicTypes/ContentModelCorePlugins'; -import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; -import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; -import type { SizeTransformer } from 'roosterjs-editor-types'; -import type { DarkColorHandler } from 'roosterjs-content-model-types'; - -/** - * @internal - * Create a new instance of Content Model Editor Core - * @param options The editor options - * @param corePluginState Core plugin state for Content Model editor - * @param innerDarkColorHandler Inner dark color handler - * @param sizeTransformer @deprecated A size transformer function to calculate size when editor is zoomed - */ -export function createEditorCore( - options: ContentModelEditorOptions, - corePluginState: ContentModelCorePluginState, - innerDarkColorHandler: DarkColorHandler, - sizeTransformer: SizeTransformer -): ContentModelEditorCore { - const core: ContentModelEditorCore = { - api: { ...coreApiMap, ...options.legacyCoreApiOverride }, - originalApi: coreApiMap, - customData: {}, - experimentalFeatures: options.experimentalFeatures ?? [], - sizeTransformer, - darkColorHandler: createDarkColorHandler(innerDarkColorHandler), - ...corePluginState, - }; - - return core; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index c15e43ac0a1..f7c33c5f184 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -28,6 +28,7 @@ export interface ContentModelEditorOptions extends StandaloneEditorOptions { * Specify the enabled experimental features */ experimentalFeatures?: ExperimentalFeatures[]; + /** * Legacy plugins using IEditor interface */ diff --git a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts index 26484b98713..ef1c5f6e605 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts @@ -1,6 +1,8 @@ +import * as BridgePlugin from '../../lib/corePlugins/BridgePlugin'; +import * as DarkColorHandler from '../../lib/editor/DarkColorHandlerImpl'; import * as EditPlugin from '../../lib/corePlugins/EditPlugin'; import * as eventConverter from '../../lib/editor/utils/eventConverter'; -import { BridgePlugin } from '../../lib/corePlugins/BridgePlugin'; +import { coreApiMap } from '../../lib/coreApi/coreApiMap'; import { PluginEventType } from 'roosterjs-editor-types'; describe('BridgePlugin', () => { @@ -35,40 +37,153 @@ describe('BridgePlugin', () => { const mockedEditor = { queryElements: queryElementsSpy, } as any; - - const plugin = new BridgePlugin({ - legacyPlugins: [mockedPlugin1, mockedPlugin2], - }); + const onInitializeSpy = jasmine.createSpy('onInitialize').and.returnValue(mockedEditor); + const plugin = new BridgePlugin.BridgePlugin(onInitializeSpy, [ + mockedPlugin1, + mockedPlugin2, + ]); expect(initializeSpy).not.toHaveBeenCalled(); expect(onPluginEventSpy1).not.toHaveBeenCalled(); expect(onPluginEventSpy2).not.toHaveBeenCalled(); expect(disposeSpy).not.toHaveBeenCalled(); + expect(onInitializeSpy).not.toHaveBeenCalled(); + + const mockedZoomScale = 'ZOOM' as any; + const calculateZoomScaleSpy = jasmine + .createSpy('calculateZoomScale') + .and.returnValue(mockedZoomScale); + const mockedColorManager = 'COLOR' as any; + const mockedInnerDarkColorHandler = 'INNERCOLOR' as any; + const mockedInnerEditor = { + getDOMHelper: () => ({ + calculateZoomScale: calculateZoomScaleSpy, + }), + getColorManager: () => mockedInnerDarkColorHandler, + } as any; + + const createDarkColorHandlerSpy = spyOn( + DarkColorHandler, + 'createDarkColorHandler' + ).and.returnValue(mockedColorManager); + + plugin.initialize(mockedInnerEditor); - expect(plugin.getCorePluginState()).toEqual({ + expect(onInitializeSpy).toHaveBeenCalledWith({ + api: coreApiMap, + originalApi: coreApiMap, + customData: {}, + experimentalFeatures: [], + sizeTransformer: jasmine.anything(), + darkColorHandler: mockedColorManager, edit: 'edit', contextMenuProviders: [], } as any); - - plugin.setOuterEditor(mockedEditor); - - expect(initializeSpy).toHaveBeenCalledTimes(0); - expect(onPluginEventSpy1).toHaveBeenCalledTimes(0); - expect(onPluginEventSpy2).toHaveBeenCalledTimes(0); + expect(createDarkColorHandlerSpy).toHaveBeenCalledWith(mockedInnerDarkColorHandler); + expect(initializeSpy).toHaveBeenCalledTimes(2); expect(disposeSpy).not.toHaveBeenCalled(); + expect(initializeSpy).toHaveBeenCalledWith(mockedEditor); + expect(onPluginEventSpy1).not.toHaveBeenCalled(); + expect(onPluginEventSpy2).not.toHaveBeenCalled(); - plugin.initialize(); + plugin.onPluginEvent({ eventType: 'editorReady' }); - expect(initializeSpy).toHaveBeenCalledTimes(2); expect(onPluginEventSpy1).toHaveBeenCalledTimes(1); expect(onPluginEventSpy2).toHaveBeenCalledTimes(1); + expect(onPluginEventSpy1).toHaveBeenCalledWith({ + eventType: PluginEventType.EditorReady, + eventDataCache: undefined, + }); + expect(onPluginEventSpy2).toHaveBeenCalledWith({ + eventType: PluginEventType.EditorReady, + eventDataCache: undefined, + }); + + plugin.dispose(); + + expect(disposeSpy).toHaveBeenCalledTimes(2); + }); + + it('Ctor and init with more options', () => { + const initializeSpy = jasmine.createSpy('initialize'); + const onPluginEventSpy1 = jasmine.createSpy('onPluginEvent1'); + const onPluginEventSpy2 = jasmine.createSpy('onPluginEvent2'); + const disposeSpy = jasmine.createSpy('dispose'); + const queryElementsSpy = jasmine.createSpy('queryElement').and.returnValue([]); + + const mockedPlugin1 = { + initialize: initializeSpy, + onPluginEvent: onPluginEventSpy1, + dispose: disposeSpy, + } as any; + const mockedPlugin2 = { + initialize: initializeSpy, + onPluginEvent: onPluginEventSpy2, + dispose: disposeSpy, + } as any; + const mockedEditor = { + queryElements: queryElementsSpy, + } as any; + const onInitializeSpy = jasmine.createSpy('onInitialize').and.returnValue(mockedEditor); + const plugin = new BridgePlugin.BridgePlugin( + onInitializeSpy, + [mockedPlugin1, mockedPlugin2], + { a: 'b' } as any, + ['c' as any] + ); + expect(initializeSpy).not.toHaveBeenCalled(); + expect(onPluginEventSpy1).not.toHaveBeenCalled(); + expect(onPluginEventSpy2).not.toHaveBeenCalled(); expect(disposeSpy).not.toHaveBeenCalled(); + expect(onInitializeSpy).not.toHaveBeenCalled(); + + const mockedZoomScale = 'ZOOM' as any; + const calculateZoomScaleSpy = jasmine + .createSpy('calculateZoomScale') + .and.returnValue(mockedZoomScale); + const mockedColorManager = 'COLOR' as any; + const mockedInnerDarkColorHandler = 'INNERCOLOR' as any; + const mockedInnerEditor = { + getDOMHelper: () => ({ + calculateZoomScale: calculateZoomScaleSpy, + }), + getColorManager: () => mockedInnerDarkColorHandler, + } as any; + + const createDarkColorHandlerSpy = spyOn( + DarkColorHandler, + 'createDarkColorHandler' + ).and.returnValue(mockedColorManager); + + plugin.initialize(mockedInnerEditor); + expect(onInitializeSpy).toHaveBeenCalledWith({ + api: { ...coreApiMap, a: 'b' }, + originalApi: coreApiMap, + customData: {}, + experimentalFeatures: ['c'], + sizeTransformer: jasmine.anything(), + darkColorHandler: mockedColorManager, + edit: 'edit', + contextMenuProviders: [], + } as any); + expect(createDarkColorHandlerSpy).toHaveBeenCalledWith(mockedInnerDarkColorHandler); + expect(initializeSpy).toHaveBeenCalledTimes(2); + expect(disposeSpy).not.toHaveBeenCalled(); expect(initializeSpy).toHaveBeenCalledWith(mockedEditor); + expect(onPluginEventSpy1).not.toHaveBeenCalled(); + expect(onPluginEventSpy2).not.toHaveBeenCalled(); + + plugin.onPluginEvent({ eventType: 'editorReady' }); + + expect(onPluginEventSpy1).toHaveBeenCalledTimes(1); + expect(onPluginEventSpy2).toHaveBeenCalledTimes(1); expect(onPluginEventSpy1).toHaveBeenCalledWith({ eventType: PluginEventType.EditorReady, + eventDataCache: undefined, }); expect(onPluginEventSpy2).toHaveBeenCalledWith({ eventType: PluginEventType.EditorReady, + eventDataCache: undefined, }); plugin.dispose(); @@ -97,16 +212,16 @@ describe('BridgePlugin', () => { dispose: disposeSpy, } as any; const mockedEditor = 'EDITOR' as any; - const plugin = new BridgePlugin({ - legacyPlugins: [mockedPlugin1, mockedPlugin2], - }); + const onInitializeSpy = jasmine.createSpy('onInitialize').and.returnValue(mockedEditor); + const plugin = new BridgePlugin.BridgePlugin(onInitializeSpy, [ + mockedPlugin1, + mockedPlugin2, + ]); spyOn(eventConverter, 'newEventToOldEvent').and.callFake(newEvent => { return ('NEW_' + newEvent) as any; }); - plugin.setOuterEditor(mockedEditor); - const mockedEvent = {} as any; const result = plugin.willHandleEventExclusively(mockedEvent); @@ -144,9 +259,11 @@ describe('BridgePlugin', () => { } as any; const mockedEditor = 'EDITOR' as any; - const plugin = new BridgePlugin({ - legacyPlugins: [mockedPlugin1, mockedPlugin2], - }); + const onInitializeSpy = jasmine.createSpy('onInitialize').and.returnValue(mockedEditor); + const plugin = new BridgePlugin.BridgePlugin(onInitializeSpy, [ + mockedPlugin1, + mockedPlugin2, + ]); spyOn(eventConverter, 'newEventToOldEvent').and.callFake(newEvent => { return { @@ -160,8 +277,6 @@ describe('BridgePlugin', () => { } as any; }); - plugin.setOuterEditor(mockedEditor); - const mockedEvent = { eventType: 'newEvent', } as any; @@ -218,9 +333,11 @@ describe('BridgePlugin', () => { } as any; const mockedEditor = 'EDITOR' as any; - const plugin = new BridgePlugin({ - legacyPlugins: [mockedPlugin1, mockedPlugin2], - }); + const onInitializeSpy = jasmine.createSpy('onInitialize').and.returnValue(mockedEditor); + const plugin = new BridgePlugin.BridgePlugin(onInitializeSpy, [ + mockedPlugin1, + mockedPlugin2, + ]); spyOn(eventConverter, 'newEventToOldEvent').and.callFake(newEvent => { return { @@ -229,8 +346,6 @@ describe('BridgePlugin', () => { } as any; }); - plugin.setOuterEditor(mockedEditor); - const mockedEvent = { eventType: 'newEvent', eventDataCache: { @@ -283,39 +398,64 @@ describe('BridgePlugin', () => { queryElements: queryElementsSpy, } as any; - const plugin = new BridgePlugin({ - legacyPlugins: [mockedPlugin1, mockedPlugin2], - }); + const onInitializeSpy = jasmine.createSpy('onInitialize').and.returnValue(mockedEditor); + const plugin = new BridgePlugin.BridgePlugin(onInitializeSpy, [ + mockedPlugin1, + mockedPlugin2, + ]); expect(initializeSpy).not.toHaveBeenCalled(); expect(onPluginEventSpy1).not.toHaveBeenCalled(); expect(onPluginEventSpy2).not.toHaveBeenCalled(); expect(disposeSpy).not.toHaveBeenCalled(); - expect(plugin.getCorePluginState()).toEqual({ + const mockedZoomScale = 'ZOOM' as any; + const calculateZoomScaleSpy = jasmine + .createSpy('calculateZoomScale') + .and.returnValue(mockedZoomScale); + const mockedColorManager = 'COLOR' as any; + const mockedInnerEditor = { + getDOMHelper: () => ({ + calculateZoomScale: calculateZoomScaleSpy, + }), + getColorManager: () => mockedColorManager, + } as any; + const mockedDarkColorHandler = 'COLOR' as any; + const createDarkColorHandlerSpy = spyOn( + DarkColorHandler, + 'createDarkColorHandler' + ).and.returnValue(mockedDarkColorHandler); + + plugin.initialize(mockedInnerEditor); + + expect(onInitializeSpy).toHaveBeenCalledWith({ + api: coreApiMap, + originalApi: coreApiMap, + customData: {}, + experimentalFeatures: [], + sizeTransformer: jasmine.anything(), + darkColorHandler: mockedDarkColorHandler, edit: 'edit', contextMenuProviders: [mockedPlugin1, mockedPlugin2], } as any); - - plugin.setOuterEditor(mockedEditor); - - expect(initializeSpy).toHaveBeenCalledTimes(0); + expect(createDarkColorHandlerSpy).toHaveBeenCalledWith(mockedColorManager); + expect(initializeSpy).toHaveBeenCalledTimes(2); expect(onPluginEventSpy1).toHaveBeenCalledTimes(0); expect(onPluginEventSpy2).toHaveBeenCalledTimes(0); expect(disposeSpy).not.toHaveBeenCalled(); + expect(initializeSpy).toHaveBeenCalledWith(mockedEditor); - plugin.initialize(); + plugin.onPluginEvent({ eventType: 'editorReady' }); - expect(initializeSpy).toHaveBeenCalledTimes(2); expect(onPluginEventSpy1).toHaveBeenCalledTimes(1); expect(onPluginEventSpy2).toHaveBeenCalledTimes(1); expect(disposeSpy).not.toHaveBeenCalled(); - - expect(initializeSpy).toHaveBeenCalledWith(mockedEditor); expect(onPluginEventSpy1).toHaveBeenCalledWith({ eventType: PluginEventType.EditorReady, + eventDataCache: undefined, }); expect(onPluginEventSpy2).toHaveBeenCalledWith({ eventType: PluginEventType.EditorReady, + eventDataCache: undefined, }); const mockedNode = 'NODE' as any; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/DarkColorHandlerImplTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/DarkColorHandlerImplTest.ts index b7f793b8914..a7c02c0072f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/DarkColorHandlerImplTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/DarkColorHandlerImplTest.ts @@ -1,6 +1,6 @@ -import { ColorKeyAndValue } from 'roosterjs-editor-types'; -import { createDarkColorHandler } from 'roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl'; -import { DarkColorHandlerImpl } from '../../lib/editor/DarkColorHandlerImpl'; +import { ColorKeyAndValue, DarkColorHandler } from 'roosterjs-editor-types'; +import { createDarkColorHandler } from '../../lib/editor/DarkColorHandlerImpl'; +import { createDarkColorHandler as createInnderDarkColorHandler } from 'roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl'; function getDarkColor(color: string) { return 'Dark_' + color; @@ -9,16 +9,16 @@ function getDarkColor(color: string) { describe('DarkColorHandlerImpl.ctor', () => { it('No additional param', () => { const div = document.createElement('div'); - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); expect(handler).toBeDefined(); }); it('Calculate color using customized base color', () => { const div = document.createElement('div'); - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); const darkColor = handler.registerColor('red', true); const parsedColor = handler.parseColorValue(darkColor); @@ -34,12 +34,12 @@ describe('DarkColorHandlerImpl.ctor', () => { describe('DarkColorHandlerImpl.parseColorValue', () => { let div: HTMLElement; - let handler: DarkColorHandlerImpl; + let handler: DarkColorHandler; beforeEach(() => { div = document.createElement('div'); - const innerHandler = createDarkColorHandler(div, getDarkColor); - handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + handler = createDarkColorHandler(innerHandler); }); function runTest(input: string, expectedOutput: ColorKeyAndValue) { @@ -133,7 +133,7 @@ describe('DarkColorHandlerImpl.parseColorValue', () => { describe('DarkColorHandlerImpl.registerColor', () => { let setProperty: jasmine.Spy; - let handler: DarkColorHandlerImpl; + let handler: DarkColorHandler; beforeEach(() => { setProperty = jasmine.createSpy('setProperty'); @@ -142,8 +142,8 @@ describe('DarkColorHandlerImpl.registerColor', () => { setProperty, }, } as any) as HTMLElement; - const innerHandler = createDarkColorHandler(div, getDarkColor); - handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + handler = createDarkColorHandler(innerHandler); }); function runTest( @@ -233,8 +233,8 @@ describe('DarkColorHandlerImpl.reset', () => { removeProperty, }, } as any) as HTMLElement; - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); (handler as any).innerHandler.knownColors = { '--aa': { @@ -258,8 +258,8 @@ describe('DarkColorHandlerImpl.reset', () => { describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { it('Not found', () => { const div = ({} as any) as HTMLElement; - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); const result = handler.findLightColorFromDarkColor('#010203'); @@ -268,8 +268,8 @@ describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { it('Found: HEX to RGB', () => { const div = ({} as any) as HTMLElement; - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); (handler as any).innerHandler.knownColors = { '--bb': { @@ -289,8 +289,8 @@ describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { it('Found: HEX to HEX', () => { const div = ({} as any) as HTMLElement; - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); (handler as any).innerHandler.knownColors = { '--bb': { @@ -310,8 +310,8 @@ describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { it('Found: RGB to HEX', () => { const div = ({} as any) as HTMLElement; - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); (handler as any).innerHandler.knownColors = { '--bb': { @@ -331,8 +331,8 @@ describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { it('Found: RGB to RGB', () => { const div = ({} as any) as HTMLElement; - const innerHandler = createDarkColorHandler(div, getDarkColor); - const handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const handler = createDarkColorHandler(innerHandler); (handler as any).innerHandler.knownColors = { '--bb': { @@ -352,13 +352,13 @@ describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { }); describe('DarkColorHandlerImpl.transformElementColor', () => { - let handler: DarkColorHandlerImpl; + let handler: DarkColorHandler; let contentDiv: HTMLDivElement; beforeEach(() => { contentDiv = document.createElement('div'); - const innerHandler = createDarkColorHandler(contentDiv, getDarkColor); - handler = new DarkColorHandlerImpl(innerHandler); + const innerHandler = createInnderDarkColorHandler(contentDiv, getDarkColor); + handler = createDarkColorHandler(innerHandler); }); it('No color, light to dark', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts deleted file mode 100644 index 7b92620902b..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts +++ /dev/null @@ -1,74 +0,0 @@ -import * as darkColorHandler from '../../lib/editor/DarkColorHandlerImpl'; -import { coreApiMap } from '../../lib/coreApi/coreApiMap'; -import { createEditorCore } from '../../lib/editor/createEditorCore'; - -describe('createEditorCore', () => { - const mockedSizeTransformer = 'TRANSFORMER' as any; - const mockedEditPluginState = 'EDITSTATE' as any; - const mockedInnerHandler = 'INNER' as any; - const mockedDarkHandler = 'DARK' as any; - - beforeEach(() => { - spyOn(darkColorHandler, 'createDarkColorHandler').and.returnValue(mockedDarkHandler); - }); - - it('No additional option', () => { - const core = createEditorCore( - {}, - { - edit: mockedEditPluginState, - contextMenuProviders: [], - }, - mockedInnerHandler, - mockedSizeTransformer - ); - - expect(core).toEqual({ - api: { ...coreApiMap }, - originalApi: { ...coreApiMap }, - customData: {}, - experimentalFeatures: [], - edit: mockedEditPluginState, - contextMenuProviders: [], - sizeTransformer: mockedSizeTransformer, - darkColorHandler: mockedDarkHandler, - }); - expect(darkColorHandler.createDarkColorHandler).toHaveBeenCalledWith(mockedInnerHandler); - }); - - it('With additional plugins', () => { - const mockedPlugin1 = 'P1' as any; - const mockedPlugin2 = 'P2' as any; - const mockedPlugin3 = 'P3' as any; - const mockedFeatures = 'FEATURES' as any; - const mockedCoreApi = { - a: 'b', - } as any; - - const core = createEditorCore( - { - plugins: [mockedPlugin1, mockedPlugin2], - experimentalFeatures: mockedFeatures, - legacyCoreApiOverride: mockedCoreApi, - }, - { - edit: mockedEditPluginState, - contextMenuProviders: [mockedPlugin3], - }, - mockedInnerHandler, - mockedSizeTransformer - ); - - expect(core).toEqual({ - api: { ...coreApiMap, a: 'b' } as any, - originalApi: { ...coreApiMap }, - customData: {}, - contextMenuProviders: [mockedPlugin3], - experimentalFeatures: mockedFeatures, - edit: mockedEditPluginState, - sizeTransformer: mockedSizeTransformer, - darkColorHandler: mockedDarkHandler, - }); - expect(darkColorHandler.createDarkColorHandler).toHaveBeenCalledWith(mockedInnerHandler); - }); -}); From f74b89053a0020d5eb450f90f83bbd5e27b157b4 Mon Sep 17 00:00:00 2001 From: Rain-Zheng <67583056+Rain-Zheng@users.noreply.github.com> Date: Fri, 9 Feb 2024 07:50:09 +0800 Subject: [PATCH 093/112] Content model: fix keyboard delete issue on Android (#2402) * handle input event if need * nit * fix dependency * add & fix test * add check --------- Co-authored-by: Jiuqing Song --- .../eventViewer/ContentModelEventViewPane.tsx | 3 + .../lib/edit/EditPlugin.ts | 61 ++++++++++++ .../lib/edit/keyboardDelete.ts | 9 +- .../test/edit/EditPluginTest.ts | 96 +++++++++++++++++-- .../test/edit/keyboardDeleteTest.ts | 8 +- 5 files changed, 163 insertions(+), 14 deletions(-) diff --git a/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx b/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx index 4fbe6876ec9..06a70871549 100644 --- a/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx +++ b/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx @@ -249,6 +249,9 @@ export default class ContentModelEventViewPane extends React.Component< case PluginEventType.BeforeKeyboardEditing: return Key code={event.rawEvent.which}; + case PluginEventType.Input: + return Input type={event.rawEvent.inputType}; + default: return null; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index b997549a4bb..9d5f16ba4a9 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -7,6 +7,9 @@ import type { PluginEvent, } from 'roosterjs-content-model-types'; +const BACKSPACE_KEY = 8; +const DELETE_KEY = 46; + /** * ContentModel edit plugins helps editor to do editing operation on top of content model. * This includes: @@ -15,6 +18,8 @@ import type { */ export class EditPlugin implements EditorPlugin { private editor: IStandaloneEditor | null = null; + private disposer: (() => void) | null = null; + private shouldHandleNextInputEvent = false; /** * Get name of this plugin @@ -31,6 +36,13 @@ export class EditPlugin implements EditorPlugin { */ initialize(editor: IStandaloneEditor) { this.editor = editor; + if (editor.getEnvironment().isAndroid) { + this.disposer = this.editor.attachDomEvent({ + beforeinput: { + beforeDispatch: e => this.handleBeforeInputEvent(editor, e), + }, + }); + } } /** @@ -40,6 +52,8 @@ export class EditPlugin implements EditorPlugin { */ dispose() { this.editor = null; + this.disposer?.(); + this.disposer = null; } /** @@ -70,6 +84,12 @@ export class EditPlugin implements EditorPlugin { keyboardDelete(editor, rawEvent); break; + case 'Unidentified': + if (editor.getEnvironment().isAndroid) { + this.shouldHandleNextInputEvent = true; + } + break; + case 'Enter': default: keyboardInput(editor, rawEvent); @@ -77,4 +97,45 @@ export class EditPlugin implements EditorPlugin { } } } + + private handleBeforeInputEvent(editor: IStandaloneEditor, rawEvent: Event) { + // Some Android IMEs doesn't fire correct keydown event for BACKSPACE/DELETE key + // Here we translate input event to BACKSPACE/DELETE keydown event to be compatible with existing logic + if ( + !this.shouldHandleNextInputEvent || + !(rawEvent instanceof InputEvent) || + rawEvent.defaultPrevented + ) { + return; + } + this.shouldHandleNextInputEvent = false; + + let handled = false; + switch (rawEvent.inputType) { + case 'deleteContentBackward': + handled = keyboardDelete( + editor, + new KeyboardEvent('keydown', { + key: 'Backspace', + keyCode: BACKSPACE_KEY, + which: BACKSPACE_KEY, + }) + ); + break; + case 'deleteContentForward': + handled = keyboardDelete( + editor, + new KeyboardEvent('keydown', { + key: 'Delete', + keyCode: DELETE_KEY, + which: DELETE_KEY, + }) + ); + break; + } + + if (handled) { + rawEvent.preventDefault(); + } + } } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts index 8e6ac7e22b4..38bd533d2c2 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts @@ -26,8 +26,10 @@ import type { * Do keyboard event handling for DELETE/BACKSPACE key * @param editor The Content Model Editor * @param rawEvent DOM keyboard event + * @returns True if the event is handled by content model, otherwise false */ export function keyboardDelete(editor: IStandaloneEditor, rawEvent: KeyboardEvent) { + let handled = false; const selection = editor.getDOMSelection(); if (shouldDeleteWithContentModel(selection, rawEvent)) { @@ -39,7 +41,8 @@ export function keyboardDelete(editor: IStandaloneEditor, rawEvent: KeyboardEven context ).deleteResult; - return handleKeyboardEventResult(editor, model, rawEvent, result, context); + handled = handleKeyboardEventResult(editor, model, rawEvent, result, context); + return handled; }, { rawEvent, @@ -48,9 +51,9 @@ export function keyboardDelete(editor: IStandaloneEditor, rawEvent: KeyboardEven apiName: rawEvent.key == 'Delete' ? 'handleDeleteKey' : 'handleBackspaceKey', } ); - - return true; } + + return handled; } function getDeleteSteps(rawEvent: KeyboardEvent, isMac: boolean): (DeleteSelectionStep | null)[] { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 2fbc8984507..d2cc3d25742 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -1,13 +1,29 @@ import * as keyboardDelete from '../../lib/edit/keyboardDelete'; import * as keyboardInput from '../../lib/edit/keyboardInput'; import { EditPlugin } from '../../lib/edit/EditPlugin'; -import { IStandaloneEditor } from 'roosterjs-content-model-types'; +import { DOMEventRecord, IStandaloneEditor } from 'roosterjs-content-model-types'; describe('EditPlugin', () => { + let plugin: EditPlugin; let editor: IStandaloneEditor; + let eventMap: Record; + let attachDOMEventSpy: jasmine.Spy; + let getEnvironmentSpy: jasmine.Spy; beforeEach(() => { + attachDOMEventSpy = jasmine + .createSpy('attachDOMEvent') + .and.callFake((handlers: Record) => { + eventMap = handlers; + }); + + getEnvironmentSpy = jasmine.createSpy('getEnvironment').and.returnValue({ + isAndroid: true, + }); + editor = ({ + attachDomEvent: attachDOMEventSpy, + getEnvironment: getEnvironmentSpy, getDOMSelection: () => ({ type: -1, @@ -15,6 +31,10 @@ describe('EditPlugin', () => { } as any) as IStandaloneEditor; }); + afterEach(() => { + plugin.dispose(); + }); + describe('onPluginEvent', () => { let keyboardDeleteSpy: jasmine.Spy; let keyboardInputSpy: jasmine.Spy; @@ -25,7 +45,7 @@ describe('EditPlugin', () => { }); it('Backspace', () => { - const plugin = new EditPlugin(); + plugin = new EditPlugin(); const rawEvent = { key: 'Backspace' } as any; plugin.initialize(editor); @@ -40,7 +60,7 @@ describe('EditPlugin', () => { }); it('Delete', () => { - const plugin = new EditPlugin(); + plugin = new EditPlugin(); const rawEvent = { key: 'Delete' } as any; plugin.initialize(editor); @@ -55,7 +75,7 @@ describe('EditPlugin', () => { }); it('Other key', () => { - const plugin = new EditPlugin(); + plugin = new EditPlugin(); const rawEvent = { which: 41, key: 'A' } as any; const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); @@ -73,7 +93,7 @@ describe('EditPlugin', () => { }); it('Default prevented', () => { - const plugin = new EditPlugin(); + plugin = new EditPlugin(); const rawEvent = { key: 'Delete', defaultPrevented: true } as any; plugin.initialize(editor); @@ -87,7 +107,7 @@ describe('EditPlugin', () => { }); it('Trigger entity event first', () => { - const plugin = new EditPlugin(); + plugin = new EditPlugin(); const wrapper = 'WRAPPER' as any; plugin.initialize(editor); @@ -122,4 +142,68 @@ describe('EditPlugin', () => { expect(keyboardInputSpy).not.toHaveBeenCalled(); }); }); + + describe('onBeforeInputEvent', () => { + let keyboardDeleteSpy: jasmine.Spy; + + beforeEach(() => { + keyboardDeleteSpy = spyOn(keyboardDelete, 'keyboardDelete'); + }); + + it('Handle deleteContentBackward event when key is unidentified', () => { + plugin = new EditPlugin(); + const rawEvent = { key: 'Unidentified' } as any; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + eventMap.beforeinput.beforeDispatch( + new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + }) + ); + + expect(keyboardDeleteSpy).toHaveBeenCalledTimes(1); + expect(keyboardDeleteSpy).toHaveBeenCalledWith( + editor, + new KeyboardEvent('keydown', { + key: 'Backspace', + keyCode: 8, + which: 8, + }) + ); + }); + + it('Handle deleteContentForward event when key is unidentified', () => { + plugin = new EditPlugin(); + const rawEvent = { key: 'Unidentified' } as any; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + eventMap.beforeinput.beforeDispatch( + new InputEvent('beforeinput', { + inputType: 'deleteContentForward', + }) + ); + + expect(keyboardDeleteSpy).toHaveBeenCalledTimes(1); + expect(keyboardDeleteSpy).toHaveBeenCalledWith( + editor, + new KeyboardEvent('keydown', { + key: 'Delete', + keyCode: 46, + which: 46, + }) + ); + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts index 06f1edd5e85..7b544bbff81 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts @@ -65,7 +65,7 @@ describe('keyboardDelete', () => { const result = keyboardDelete(editor, mockedEvent); - expect(result).toBeTrue(); + expect(result).toBe(expectedDelete == 'range' || expectedDelete == 'singleChar'); }, input, expectedResult, @@ -589,9 +589,8 @@ describe('keyboardDelete', () => { getDOMSelection: () => range, } as any; - const result = keyboardDelete(editor, rawEvent); + keyboardDelete(editor, rawEvent); - expect(result).toBeTrue(); expect(formatWithContentModelSpy).toHaveBeenCalledTimes(1); }); @@ -613,9 +612,8 @@ describe('keyboardDelete', () => { getDOMSelection: () => range, } as any; - const result = keyboardDelete(editor, rawEvent); + keyboardDelete(editor, rawEvent); - expect(result).toBeTrue(); expect(formatWithContentModelSpy).toHaveBeenCalledTimes(1); }); }); From 233ea6463855ffe81d1f8002884f6f68400cc0f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 9 Feb 2024 12:56:34 -0300 Subject: [PATCH 094/112] fixes --- .../roosterjs-content-model-api/lib/index.ts | 1 + .../lib/modelApi/block/setModelIndentation.ts | 17 ++++++++--- .../lib/edit/keyboardTab.ts | 29 +++++-------------- .../test/edit/keyboardTabTest.ts | 10 +++---- 4 files changed, 26 insertions(+), 31 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/index.ts b/packages-content-model/roosterjs-content-model-api/lib/index.ts index d2b85850720..2109b8841f9 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/index.ts @@ -43,3 +43,4 @@ export { default as insertEntity } from './publicApi/entity/insertEntity'; export { formatTableWithContentModel } from './publicApi/utils/formatTableWithContentModel'; export { setListType } from './modelApi/list/setListType'; +export { setModelIndentation } from './modelApi/block/setModelIndentation'; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts index 39853cde852..ce7158d17e6 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts @@ -17,7 +17,10 @@ import type { const IndentStepInPixel = 40; /** - * @internal + * @param model The content model to set indentation + * @param indentation The indentation type, 'indent' to indent, 'outdent' to outdent + * @param length The length of indentation in pixel, default value is 40 + * Set indentation for selected list items or paragraphs */ export function setModelIndentation( model: ContentModelDocument, @@ -35,7 +38,7 @@ export function setModelIndentation( if (isBlockGroupOfType(block, 'ListItem')) { const thread = findListItemsInSameThread(model, block); const firstItem = thread[0]; - + //if the first item is selected and has only one level, we should add margin to the whole list if (isSelected(firstItem) && firstItem.levels.length == 1) { const level = block.levels[0]; const { format } = level; @@ -53,7 +56,8 @@ export function setModelIndentation( level.format.marginLeft = newValue + 'px'; } } - } else if (block.levels.length == 1 || !multilevelSelection(model, block, parent)) { + //if block has only one level, there is not need to check if it is multilevel selection + } else if (block.levels.length == 1 || !isMultilevelSelection(model, block, parent)) { if (isIndent) { const lastLevel = block.levels[block.levels.length - 1]; const newLevel: ContentModelListLevel = createListLevel( @@ -99,7 +103,11 @@ function isSelected(listItem: ContentModelListItem) { }); } -function multilevelSelection( +/* + * Check if the selection has list items with different levels and the first item of the list is selected, do not create a sub list. + * Otherwise, the margin of the first item will be changed, and the sub list will be created, creating a unintentional margin difference between the list items. + */ +function isMultilevelSelection( model: ContentModelDocument, listItem: ContentModelListItem, parent: ContentModelBlockGroup @@ -120,6 +128,7 @@ function multilevelSelection( return false; } } + return false; } function calculateMarginValue( diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts index db03e9df607..733dc7ef582 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardTab.ts @@ -1,10 +1,8 @@ import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; -import { setIndentation } from 'roosterjs-content-model-api'; - +import { setModelIndentation } from 'roosterjs-content-model-api'; import type { ContentModelDocument, ContentModelListItem, - DOMSelection, IStandaloneEditor, } from 'roosterjs-content-model-types'; @@ -14,26 +12,17 @@ import type { export function keyboardTab(editor: IStandaloneEditor, rawEvent: KeyboardEvent) { const selection = editor.getDOMSelection(); - if (shouldHandleTab(rawEvent, selection)) { + if (selection?.type == 'range') { editor.takeSnapshot(); - editor.formatContentModel( - (model, _context) => { - return handleTabOnList(editor, model, rawEvent); - }, - { - rawEvent, - } - ); + editor.formatContentModel((model, _context) => { + return handleTabOnList(model, rawEvent); + }); return true; } } -function shouldHandleTab(rawEvent: KeyboardEvent, selection: DOMSelection | null) { - return rawEvent.key == 'Tab' && selection && selection?.type == 'range'; -} - function isMarkerAtStartOfBlock(listItem: ContentModelListItem) { return ( listItem.blocks[0].blockType == 'Paragraph' && @@ -41,11 +30,7 @@ function isMarkerAtStartOfBlock(listItem: ContentModelListItem) { ); } -function handleTabOnList( - editor: IStandaloneEditor, - model: ContentModelDocument, - rawEvent: KeyboardEvent -) { +function handleTabOnList(model: ContentModelDocument, rawEvent: KeyboardEvent) { const blocks = getOperationalBlocks(model, ['ListItem'], ['TableCell']); const listItem = blocks[0].block; @@ -53,7 +38,7 @@ function handleTabOnList( isBlockGroupOfType(listItem, 'ListItem') && isMarkerAtStartOfBlock(listItem) ) { - setIndentation(editor, rawEvent.shiftKey ? 'outdent' : 'indent'); + setModelIndentation(model, rawEvent.shiftKey ? 'outdent' : 'indent'); rawEvent.preventDefault(); return true; } diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts index 620a67bcb1a..1dd114aa903 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts @@ -1,14 +1,14 @@ -import * as setIndentation from '../../../roosterjs-content-model-api/lib/publicApi/block/setIndentation'; +import * as setModelIndentation from '../../../roosterjs-content-model-api/lib/modelApi/block/setModelIndentation'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { keyboardTab } from '../../lib/edit/keyboardTab'; describe('keyboardTab', () => { let takeSnapshotSpy: jasmine.Spy; - let setIndentationSpy: jasmine.Spy; + let setModelIndentationSpy: jasmine.Spy; beforeEach(() => { takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); - setIndentationSpy = spyOn(setIndentation, 'default'); + setModelIndentationSpy = spyOn(setModelIndentation, 'setModelIndentation'); }); function runTest( @@ -50,9 +50,9 @@ describe('keyboardTab', () => { expect(formatWithContentModelSpy).toHaveBeenCalled(); if (indent) { - expect(setIndentationSpy).toHaveBeenCalledWith(editor as any, indent); + expect(setModelIndentationSpy).toHaveBeenCalledWith(editor as any, indent); } else { - expect(setIndentationSpy).not.toHaveBeenCalled(); + expect(setModelIndentationSpy).not.toHaveBeenCalled(); } } From 2935a8eb7667d8b6229c0098dbe61ea3ff9d9d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 9 Feb 2024 13:31:57 -0300 Subject: [PATCH 095/112] fix test --- .../test/edit/keyboardTabTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts index 1dd114aa903..150d9b5c673 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardTabTest.ts @@ -50,7 +50,7 @@ describe('keyboardTab', () => { expect(formatWithContentModelSpy).toHaveBeenCalled(); if (indent) { - expect(setModelIndentationSpy).toHaveBeenCalledWith(editor as any, indent); + expect(setModelIndentationSpy).toHaveBeenCalledWith(input as any, indent); } else { expect(setModelIndentationSpy).not.toHaveBeenCalled(); } From b44b4193a268f58e48e1ce8a2ee08582bb165943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 9 Feb 2024 16:16:14 -0300 Subject: [PATCH 096/112] adjust list for safari --- .../lib/modelApi/list/setListType.ts | 2 +- .../test/modelApi/list/setListTypeTest.ts | 2 -- .../test/autoFormat/keyboardListTriggerTest.ts | 13 ------------- 3 files changed, 1 insertion(+), 16 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts index 4ffacd55d76..c824a5a1bbe 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts @@ -89,7 +89,7 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') ); if (block.blockType == 'Paragraph') { - block.isImplicit = true; + setParagraphNotImplicit(block); } newListItem.blocks.push(block); diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts index 12ab4e5aab5..b457c35abff 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts @@ -99,7 +99,6 @@ describe('indent', () => { expect(para).toEqual({ blockType: 'Paragraph', format: {}, - isImplicit: true, segments: [ { segmentType: 'Text', @@ -392,7 +391,6 @@ describe('indent', () => { expect(para).toEqual({ blockType: 'Paragraph', format: { direction: 'rtl', textAlign: 'start', backgroundColor: 'yellow' }, - isImplicit: true, segments: [ { segmentType: 'Text', diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts index 512e7c50aae..2f302ae4d9a 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/keyboardListTriggerTest.ts @@ -89,7 +89,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -144,7 +143,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -204,7 +202,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -242,7 +239,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -371,7 +367,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -477,7 +472,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -578,7 +572,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -661,7 +654,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -736,7 +728,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -789,7 +780,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -877,7 +867,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -984,7 +973,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ @@ -1069,7 +1057,6 @@ describe('keyboardListTrigger', () => { }, ], format: {}, - isImplicit: true, }, ], levels: [ From 96c08419820f341daf74b94967e939271a707918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 9 Feb 2024 16:37:59 -0300 Subject: [PATCH 097/112] wip --- .../getDefaultContentEditFeatureSettings.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts index 9e9a172339e..0118762dd9e 100644 --- a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts +++ b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts @@ -4,12 +4,28 @@ import { getObjectKeys } from 'roosterjs-editor-dom'; export default function getDefaultContentEditFeatureSettings(): ContentEditFeatureSettings { const allFeatures = getAllFeatures(); + return { ...getObjectKeys(allFeatures).reduce((settings, key) => { settings[key] = !allFeatures[key].defaultDisabled; return settings; }, {}), - indentWhenAltShiftRight: true, - outdentWhenAltShiftLeft: true, + ...listFeatures, }; } + +const listFeatures = { + autoBullet: false, + indentWhenTab: false, + outdentWhenShiftTab: false, + outdentWhenBackspaceOnEmptyFirstLine: false, + outdentWhenEnterOnEmptyLine: false, + mergeInNewLineWhenBackspaceOnFirstChar: false, + maintainListChain: false, + maintainListChainWhenDelete: false, + autoNumberingList: false, + autoBulletList: false, + mergeListOnBackspaceAfterList: false, + outdentWhenAltShiftLeft: false, + indentWhenAltShiftRight: false, +}; From 99e67eedd849a157f1f0300ab88661656ee89700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 9 Feb 2024 16:40:03 -0300 Subject: [PATCH 098/112] disable list features --- .../getDefaultContentEditFeatureSettings.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts index 9e9a172339e..0118762dd9e 100644 --- a/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts +++ b/demo/scripts/controls/sidePane/editorOptions/getDefaultContentEditFeatureSettings.ts @@ -4,12 +4,28 @@ import { getObjectKeys } from 'roosterjs-editor-dom'; export default function getDefaultContentEditFeatureSettings(): ContentEditFeatureSettings { const allFeatures = getAllFeatures(); + return { ...getObjectKeys(allFeatures).reduce((settings, key) => { settings[key] = !allFeatures[key].defaultDisabled; return settings; }, {}), - indentWhenAltShiftRight: true, - outdentWhenAltShiftLeft: true, + ...listFeatures, }; } + +const listFeatures = { + autoBullet: false, + indentWhenTab: false, + outdentWhenShiftTab: false, + outdentWhenBackspaceOnEmptyFirstLine: false, + outdentWhenEnterOnEmptyLine: false, + mergeInNewLineWhenBackspaceOnFirstChar: false, + maintainListChain: false, + maintainListChainWhenDelete: false, + autoNumberingList: false, + autoBulletList: false, + mergeListOnBackspaceAfterList: false, + outdentWhenAltShiftLeft: false, + indentWhenAltShiftRight: false, +}; From 36010934337cfa774e23e1e4cf07723bf9e004ab Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Fri, 9 Feb 2024 13:43:29 -0600 Subject: [PATCH 099/112] Fix REM Unit behavior (#2403) * init * add test * add computed value to context * fix type * Fix tests * address comment * change to documentElement --- .../lib/coreApi/createEditorContext.ts | 17 +++++++++++++- .../test/coreApi/createEditorContextTest.ts | 6 +++++ .../segment/fontSizeFormatHandler.ts | 23 +++++++++++++++---- .../utils/parseValueWithUnit.ts | 6 ++++- .../utils/parseValueWithUnitTest.ts | 15 +++++++++++- .../lib/context/EditorContext.ts | 5 ++++ 6 files changed, 64 insertions(+), 8 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts index 2073a4403f5..34973eb1afb 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts @@ -1,4 +1,11 @@ -import type { EditorContext, CreateEditorContext } from 'roosterjs-content-model-types'; +import { parseValueWithUnit } from 'roosterjs-content-model-dom'; +import type { + EditorContext, + CreateEditorContext, + StandaloneEditorCore, +} from 'roosterjs-content-model-types'; + +const DefaultRootFontSize = 16; /** * @internal @@ -16,6 +23,8 @@ export const createEditorContext: CreateEditorContext = (core, saveIndex) => { allowCacheElement: true, domIndexer: saveIndex ? cache.domIndexer : undefined, zoomScale: domHelper.calculateZoomScale(), + rootFontSize: + parseValueWithUnit(getRootComputedStyle(core)?.fontSize) || DefaultRootFontSize, }; checkRootRtl(contentDiv, context); @@ -30,3 +39,9 @@ function checkRootRtl(element: HTMLElement, context: EditorContext) { context.isRootRtl = true; } } + +function getRootComputedStyle(core: StandaloneEditorCore) { + const document = core.contentDiv.ownerDocument; + const rootComputedStyle = document.defaultView?.getComputedStyle(document.documentElement); + return rootComputedStyle; +} diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts index 7efe27aa876..bdf2f92ba06 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts @@ -46,6 +46,7 @@ describe('createEditorContext', () => { domIndexer: undefined, pendingFormat: undefined, zoomScale: 1, + rootFontSize: 16, }); }); @@ -93,6 +94,7 @@ describe('createEditorContext', () => { domIndexer, pendingFormat: undefined, zoomScale: 1, + rootFontSize: 16, }); }); @@ -139,6 +141,7 @@ describe('createEditorContext', () => { domIndexer: undefined, pendingFormat: mockedPendingFormat, zoomScale: 1, + rootFontSize: 16, }); }); }); @@ -193,6 +196,7 @@ describe('createEditorContext - checkZoomScale', () => { allowCacheElement: true, domIndexer: undefined, pendingFormat: undefined, + rootFontSize: 16, }); }); }); @@ -248,6 +252,7 @@ describe('createEditorContext - checkRootDir', () => { domIndexer: undefined, pendingFormat: undefined, zoomScale: 1, + rootFontSize: 16, }); }); @@ -268,6 +273,7 @@ describe('createEditorContext - checkRootDir', () => { domIndexer: undefined, pendingFormat: undefined, zoomScale: 1, + rootFontSize: 16, }); }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts index 4c4b8bffecc..61434860976 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts @@ -1,6 +1,6 @@ import { isSuperOrSubScript } from './superOrSubScriptFormatHandler'; import { parseValueWithUnit } from '../utils/parseValueWithUnit'; -import type { FontSizeFormat } from 'roosterjs-content-model-types'; +import type { EditorContext, FontSizeFormat } from 'roosterjs-content-model-types'; import type { FormatHandler } from '../FormatHandler'; /** @@ -15,7 +15,11 @@ export const fontSizeFormatHandler: FormatHandler = { // the font size will be handled by superOrSubScript handler if (fontSize && !isSuperOrSubScript(fontSize, verticalAlign) && fontSize != 'inherit') { if (element.style.fontSize) { - format.fontSize = normalizeFontSize(fontSize, context.segmentFormat.fontSize); + format.fontSize = normalizeFontSize( + fontSize, + context.segmentFormat.fontSize, + context + ); } else if (defaultStyle.fontSize) { format.fontSize = fontSize; } @@ -40,7 +44,11 @@ const KnownFontSizes: Record = { 'xxx-large': '36pt', }; -function normalizeFontSize(fontSize: string, contextFont: string | undefined): string | undefined { +function normalizeFontSize( + fontSize: string, + contextFont: string | undefined, + context: EditorContext +): string | undefined { const knownFontSize = KnownFontSizes[fontSize]; if (knownFontSize) { @@ -49,12 +57,17 @@ function normalizeFontSize(fontSize: string, contextFont: string | undefined): s fontSize == 'smaller' || fontSize == 'larger' || fontSize.endsWith('em') || - fontSize.endsWith('%') + fontSize.endsWith('%') || + fontSize.endsWith('rem') ) { if (!contextFont) { return undefined; } else { - const existingFontSize = parseValueWithUnit(contextFont, undefined /*element*/, 'px'); + const existingFontSize = parseValueWithUnit( + contextFont, + fontSize.endsWith('rem') ? context.rootFontSize : undefined /*element*/, + 'px' + ); if (existingFontSize) { switch (fontSize) { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts index f78aaad8ed9..3120b432958 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts @@ -3,6 +3,8 @@ const MarginValueRegex = /(-?\d+(\.\d+)?)([a-z]+|%)/; // According to https://developer.mozilla.org/en-US/docs/Glossary/CSS_pixel, 1in = 96px const PixelPerInch = 96; +const DefaultRootFontSize = 16; + /** * Parse unit value with its unit * @param value The source value to parse @@ -29,7 +31,6 @@ export function parseValueWithUnit( result = ptToPx(num); break; case 'em': - case 'rem': result = getFontSize(currentSizePxOrElement) * num; break; case 'ex': @@ -41,6 +42,9 @@ export function parseValueWithUnit( case 'in': result = num * PixelPerInch; break; + case 'rem': + result = (getFontSize(currentSizePxOrElement) || DefaultRootFontSize) * num; + break; } } diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts index c2e25db3bad..0338573847a 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts @@ -9,6 +9,7 @@ describe('parseValueWithUnit with element', () => { fontSize: '15pt', }), }, + querySelector: () => mockedElement, }, offsetWidth: 1000, } as any) as HTMLElement; @@ -48,7 +49,19 @@ describe('parseValueWithUnit with element', () => { }); it('rem', () => { - runTest('rem', [0, 20, 22, -22]); + const unit = 'rem'; + const results = [0, 16, 17.6, -17.6]; + + ['0', '1', '1.1', '-1.1'].forEach((value, i) => { + const input = value + unit; + const result = parseValueWithUnit(input, 16); + + if (Number.isNaN(results[i])) { + expect(result).toBeNaN(); + } else { + expect(result).toEqual(results[i], input); + } + }); }); it('no unit', () => { diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts b/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts index 2cbc3468272..4ea984e21d6 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts @@ -52,4 +52,9 @@ export interface EditorContext { * @optional Indexer for content model, to help build backward relationship from DOM node to Content Model */ domIndexer?: DomIndexer; + + /** + * Root Font size in Px. + */ + rootFontSize?: number; } From a0120d2198f6f81d2dbee0c8abaf73df72a25599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 9 Feb 2024 18:25:17 -0300 Subject: [PATCH 100/112] add tests --- .../lib/edit/inputSteps/handleEnterOnList.ts | 29 +- .../test/edit/editingTestCommon.ts | 4 +- .../edit/inputSteps/handleEnterOnListTest.ts | 639 ++++++++++++++++++ 3 files changed, 664 insertions(+), 8 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts index 048d24d8a71..64c7f3cfabd 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -24,25 +24,40 @@ export const handleEnterOnList: DeleteSelectionStep = context => { context.deleteResult == 'notDeleted' || context.deleteResult == 'range' ) { - const { insertPoint } = context; + const { insertPoint, formatContext } = context; const { path } = insertPoint; + const rawEvent = formatContext?.rawEvent as KeyboardEvent | undefined; const index = getClosestAncestorBlockGroupIndex(path, ['ListItem'], ['TableCell']); const listItem = path[index]; if (listItem && listItem.blockGroupType === 'ListItem') { const listParent = path[index + 1]; - if (isEmptyListItem(listItem)) { - listItem.levels.pop(); + if (rawEvent?.shiftKey) { + insertParagraphAfterListItem(listParent, listItem, insertPoint); } else { - createNewListItem(context, listItem, listParent); + if (isEmptyListItem(listItem)) { + listItem.levels.pop(); + } else { + createNewListItem(context, listItem, listParent); + } } - context.formatContext?.rawEvent?.preventDefault(); + rawEvent?.preventDefault(); context.deleteResult = 'range'; } } }; +const insertParagraphAfterListItem = ( + listParent: ContentModelBlockGroup, + listItem: ContentModelListItem, + insertPoint: InsertPoint +) => { + const paragraph = createNewParagraph(insertPoint); + const index = listParent.blocks.indexOf(listItem); + listParent.blocks.splice(index + 1, 0, paragraph); +}; + const isEmptyListItem = (listItem: ContentModelListItem) => { return ( listItem.blocks.length === 1 && @@ -94,14 +109,14 @@ const createNewParagraph = (insertPoint: InsertPoint) => { paragraph.segments.length - markerIndex ); + newParagraph.segments.push(...segments); + setParagraphNotImplicit(paragraph); if (paragraph.segments.every(x => x.segmentType == 'SelectionMarker')) { paragraph.segments.push(createBr(marker.format)); } - newParagraph.segments.push(...segments); - normalizeParagraph(newParagraph); return newParagraph; }; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts index 340b927f43a..352a357bf4f 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts @@ -6,7 +6,7 @@ import { } from 'roosterjs-content-model-types'; export function editingTestCommon( - apiName: string, + apiName: string | undefined, executionCallback: (editor: IStandaloneEditor) => void, model: ContentModelDocument, result: ContentModelDocument, @@ -30,6 +30,8 @@ export function editingTestCommon( const editor = ({ triggerEvent, + takeSnapshot: () => {}, + isInIME: () => false, getEnvironment: () => ({}), formatContentModel, } as any) as IStandaloneEditor; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts index 6032669a107..2234e87b918 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -1,6 +1,8 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; import { deleteSelection } from 'roosterjs-content-model-core'; +import { editingTestCommon } from '../editingTestCommon'; import { handleEnterOnList } from '../../../lib/edit/inputSteps/handleEnterOnList'; +import { keyboardInput } from '../../../lib/edit/keyboardInput'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; describe('handleEnterOnList', () => { @@ -1447,3 +1449,640 @@ describe('handleEnterOnList', () => { runTest(model, expectedModel, 'range'); }); }); + +describe('keyboardInput - handleEnterOnList', () => { + function runTest( + input: ContentModelDocument, + isShiftKey: boolean, + expectedResult: ContentModelDocument + ) { + const preventDefault = jasmine.createSpy('preventDefault'); + const mockedEvent = ({ + key: 'Enter', + shiftKey: isShiftKey, + preventDefault, + } as any) as KeyboardEvent; + + let editor: any; + + editingTestCommon( + undefined, + newEditor => { + editor = newEditor; + + editor.getDOMSelection = () => ({ + type: 'range', + range: { + collapsed: true, + }, + }); + + keyboardInput(editor, mockedEvent); + }, + input, + expectedResult, + 1 + ); + } + + it('Enter on list', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + startNumberOverride: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + runTest(input, false, expected); + }); + + it('Enter on empty list item', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, false, expected); + }); + + it('Enter + Shift on list item', () => { + const input: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + format: {}, + }; + const expected: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + runTest(input, true, expected); + }); +}); From 09dda18c2fe4693312f80296e6256aa86534927e Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 12 Feb 2024 09:37:26 -0800 Subject: [PATCH 101/112] Convert inline CSS when create init model (#2405) * Convert inline CSS when create init model * improve * add test * Improve * fix build * fix test * add test --- .../lib/coreApi/paste.ts | 2 +- .../lib/override/pasteEntityProcessor.ts | 4 +- .../lib/override/pasteGeneralProcessor.ts | 4 +- .../publicApi/model/createModelFromHtml.ts | 34 +-- .../lib/utils/{paste => }/convertInlineCss.ts | 36 +++- .../createDomToModelContextForSanitizing.ts | 57 +++++ .../paste/generatePasteOptionFromPlugins.ts | 4 +- .../lib/utils/paste/mergePasteContent.ts | 25 +-- .../lib/utils/paste/retrieveHtmlInfo.ts | 34 +-- .../model/createModelFromHtmlTest.ts | 201 ++++++++++++++++++ .../utils/{paste => }/convertInlineCssTest.ts | 3 +- ...reateDomToModelContextForSanitizingTest.ts | 112 ++++++++++ .../test/utils/paste/mergePasteContentTest.ts | 36 +--- .../ContentModelBeforePasteEvent.ts | 4 +- .../lib/context/DomToModelOption.ts | 26 +++ .../lib/event/BeforePasteEvent.ts | 30 +-- .../lib/index.ts | 8 +- 17 files changed, 471 insertions(+), 149 deletions(-) rename packages-content-model/roosterjs-content-model-core/lib/utils/{paste => }/convertInlineCss.ts (50%) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/utils/createDomToModelContextForSanitizing.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/publicApi/model/createModelFromHtmlTest.ts rename packages-content-model/roosterjs-content-model-core/test/utils/{paste => }/convertInlineCssTest.ts (96%) create mode 100644 packages-content-model/roosterjs-content-model-core/test/utils/createDomToModelContextForSanitizingTest.ts diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts index aee4fbf2dc3..d229c830a10 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/paste.ts @@ -1,5 +1,5 @@ import { cloneModel } from '../publicApi/model/cloneModel'; -import { convertInlineCss } from '../utils/paste/convertInlineCss'; +import { convertInlineCss } from '../utils/convertInlineCss'; import { createPasteFragment } from '../utils/paste/createPasteFragment'; import { generatePasteOptionFromPlugins } from '../utils/paste/generatePasteOptionFromPlugins'; import { mergePasteContent } from '../utils/paste/mergePasteContent'; 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 8424d684dec..433ab5bbc5e 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,6 +1,6 @@ import { AllowedTags, DisallowedTags, sanitizeElement } from '../utils/sanitizeElement'; import type { - DomToModelOptionForPaste, + DomToModelOptionForSanitizing, ElementProcessor, ValueSanitizer, } from 'roosterjs-content-model-types'; @@ -13,7 +13,7 @@ const DefaultStyleSanitizers: Readonly> = { * @internal */ export function createPasteEntityProcessor( - options: DomToModelOptionForPaste + options: DomToModelOptionForSanitizing ): ElementProcessor { const allowedTags = AllowedTags.concat(options.additionalAllowedTags); const disallowedTags = DisallowedTags.concat(options.additionalDisallowedTags); 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 025601b625a..43fce8da116 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,7 +1,7 @@ import { AllowedTags, createSanitizedElement, DisallowedTags } from '../utils/sanitizeElement'; import { moveChildNodes } from 'roosterjs-content-model-dom'; import type { - DomToModelOptionForPaste, + DomToModelOptionForSanitizing, ElementProcessor, ValueSanitizer, } from 'roosterjs-content-model-types'; @@ -22,7 +22,7 @@ const DefaultStyleSanitizers: Readonly> = { * @internal */ export function createPasteGeneralProcessor( - options: DomToModelOptionForPaste + options: DomToModelOptionForSanitizing ): ElementProcessor { const allowedTags = AllowedTags.concat(options.additionalAllowedTags); const disallowedTags = DisallowedTags.concat(options.additionalDisallowedTags); diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts index e8f0c3b8c1e..d9b8bb2f76d 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts @@ -1,8 +1,6 @@ -import { - createDomToModelContext, - createEmptyModel, - domToContentModel, -} from 'roosterjs-content-model-dom'; +import { convertInlineCss, retrieveCssRules } from '../../utils/convertInlineCss'; +import { createDomToModelContextForSanitizing } from '../../utils/createDomToModelContextForSanitizing'; +import { createEmptyModel, domToContentModel, parseFormat } from 'roosterjs-content-model-dom'; import type { ContentModelDocument, ContentModelSegmentFormat, @@ -23,17 +21,19 @@ export function createModelFromHtml( trustedHTMLHandler?: TrustedHTMLHandler, defaultSegmentFormat?: ContentModelSegmentFormat ): ContentModelDocument { - const doc = new DOMParser().parseFromString(trustedHTMLHandler?.(html) ?? html, 'text/html'); + const doc = html + ? new DOMParser().parseFromString(trustedHTMLHandler?.(html) ?? html, 'text/html') + : null; - return doc?.body - ? domToContentModel( - doc.body, - createDomToModelContext( - { - defaultFormat: defaultSegmentFormat, - }, - options - ) - ) - : createEmptyModel(defaultSegmentFormat); + if (doc?.body) { + const context = createDomToModelContextForSanitizing(defaultSegmentFormat, options); + const cssRules = doc ? retrieveCssRules(doc) : []; + + convertInlineCss(doc, cssRules); + parseFormat(doc.body, context.formatParsers.segmentOnBlock, context.segmentFormat, context); + + return domToContentModel(doc.body, context); + } else { + return createEmptyModel(defaultSegmentFormat); + } } diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/convertInlineCss.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/convertInlineCss.ts similarity index 50% rename from packages-content-model/roosterjs-content-model-core/lib/utils/paste/convertInlineCss.ts rename to packages-content-model/roosterjs-content-model-core/lib/utils/convertInlineCss.ts index 1ec7973f0ae..576cf8e563b 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/convertInlineCss.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/convertInlineCss.ts @@ -1,5 +1,39 @@ import { toArray } from 'roosterjs-content-model-dom'; -import type { CssRule } from './retrieveHtmlInfo'; + +/** + * @internal + */ +export interface CssRule { + selectors: string[]; + text: string; +} + +/** + * @internal + */ +export function retrieveCssRules(doc: Document): CssRule[] { + const styles = toArray(doc.querySelectorAll('style')); + const result: CssRule[] = []; + + styles.forEach(styleNode => { + const sheet = styleNode.sheet as CSSStyleSheet; + + for (let ruleIndex = 0; ruleIndex < sheet.cssRules.length; ruleIndex++) { + const rule = sheet.cssRules[ruleIndex] as CSSStyleRule; + + if (rule.type == CSSRule.STYLE_RULE && rule.selectorText) { + result.push({ + selectors: rule.selectorText.split(','), + text: rule.style.cssText, + }); + } + } + + styleNode.parentNode?.removeChild(styleNode); + }); + + return result; +} /** * @internal diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/createDomToModelContextForSanitizing.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/createDomToModelContextForSanitizing.ts new file mode 100644 index 00000000000..2e9f59382da --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/createDomToModelContextForSanitizing.ts @@ -0,0 +1,57 @@ +import { containerSizeFormatParser } from '../override/containerSizeFormatParser'; +import { createDomToModelContext } from 'roosterjs-content-model-dom'; +import { createPasteEntityProcessor } from '../override/pasteEntityProcessor'; +import { createPasteGeneralProcessor } from '../override/pasteGeneralProcessor'; +import { pasteDisplayFormatParser } from '../override/pasteDisplayFormatParser'; +import { pasteTextProcessor } from '../override/pasteTextProcessor'; +import type { + ContentModelSegmentFormat, + DomToModelContext, + DomToModelOption, + DomToModelOptionForSanitizing, +} from 'roosterjs-content-model-types'; + +const DefaultSanitizingOption: DomToModelOptionForSanitizing = { + processorOverride: {}, + formatParserOverride: {}, + additionalFormatParsers: {}, + additionalAllowedTags: [], + additionalDisallowedTags: [], + styleSanitizers: {}, + attributeSanitizers: {}, +}; + +/** + * @internal + */ +export function createDomToModelContextForSanitizing( + defaultFormat?: ContentModelSegmentFormat, + defaultOption?: DomToModelOption, + additionalSanitizingOption?: DomToModelOptionForSanitizing +): DomToModelContext { + const sanitizingOption: DomToModelOptionForSanitizing = { + ...DefaultSanitizingOption, + ...additionalSanitizingOption, + }; + + return createDomToModelContext( + { + defaultFormat, + }, + defaultOption, + { + processorOverride: { + '#text': pasteTextProcessor, + entity: createPasteEntityProcessor(sanitizingOption), + '*': createPasteGeneralProcessor(sanitizingOption), + }, + formatParserOverride: { + display: pasteDisplayFormatParser, + }, + additionalFormatParsers: { + container: [containerSizeFormatParser], + }, + }, + sanitizingOption + ); +} 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 1e230b5da39..0d73aace66e 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 @@ -2,7 +2,7 @@ import type { HtmlFromClipboard } from './retrieveHtmlInfo'; import type { BeforePasteEvent, ClipboardData, - DomToModelOptionForPaste, + DomToModelOptionForSanitizing, PasteType, StandaloneEditorCore, } from 'roosterjs-content-model-types'; @@ -17,7 +17,7 @@ export function generatePasteOptionFromPlugins( htmlFromClipboard: HtmlFromClipboard, pasteType: PasteType ): BeforePasteEvent { - const domToModelOption: DomToModelOptionForPaste = { + const domToModelOption: DomToModelOptionForSanitizing = { additionalAllowedTags: [], additionalDisallowedTags: [], additionalFormatParsers: {}, 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 57796bbb053..30ed6c4803c 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,13 +1,9 @@ import { ChangeSource } from '../../constants/ChangeSource'; -import { containerSizeFormatParser } from '../../override/containerSizeFormatParser'; -import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; -import { createPasteEntityProcessor } from '../../override/pasteEntityProcessor'; -import { createPasteGeneralProcessor } from '../../override/pasteGeneralProcessor'; +import { createDomToModelContextForSanitizing } from '../createDomToModelContextForSanitizing'; +import { domToContentModel } from 'roosterjs-content-model-dom'; import { getSegmentTextFormat } from '../../publicApi/domUtils/getSegmentTextFormat'; import { getSelectedSegments } from '../../publicApi/selection/collectSelections'; import { mergeModel } from '../../publicApi/model/mergeModel'; -import { pasteDisplayFormatParser } from '../../override/pasteDisplayFormatParser'; -import { pasteTextProcessor } from '../../override/pasteTextProcessor'; import type { MergeModelOption } from '../../publicApi/model/mergeModel'; import type { BeforePasteEvent, @@ -45,22 +41,9 @@ export function mergePasteContent( core, (model, context) => { const selectedSegment = getSelectedSegments(model, true /*includeFormatHolder*/)[0]; - const domToModelContext = createDomToModelContext( - undefined /*editorContext*/, + const domToModelContext = createDomToModelContextForSanitizing( + undefined /*defaultFormat*/, core.domToModelSettings.customized, - { - processorOverride: { - '#text': pasteTextProcessor, - entity: createPasteEntityProcessor(domToModelOption), - '*': createPasteGeneralProcessor(domToModelOption), - }, - formatParserOverride: { - display: pasteDisplayFormatParser, - }, - additionalFormatParsers: { - container: [containerSizeFormatParser], - }, - }, domToModelOption ); diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/retrieveHtmlInfo.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/retrieveHtmlInfo.ts index e0a1aefe773..7e49a3f783b 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/paste/retrieveHtmlInfo.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/paste/retrieveHtmlInfo.ts @@ -1,17 +1,11 @@ import { isNodeOfType, toArray } from 'roosterjs-content-model-dom'; +import { retrieveCssRules } from '../convertInlineCss'; +import type { CssRule } from '../convertInlineCss'; import type { ClipboardData } from 'roosterjs-content-model-types'; const START_FRAGMENT = ''; const END_FRAGMENT = ''; -/** - * @internal - */ -export interface CssRule { - selectors: string[]; - text: string; -} - /** * @internal */ @@ -80,30 +74,6 @@ function retrieveMetadata(doc: Document): Record { return result; } -function retrieveCssRules(doc: Document): CssRule[] { - const styles = toArray(doc.querySelectorAll('style')); - const result: CssRule[] = []; - - styles.forEach(styleNode => { - const sheet = styleNode.sheet as CSSStyleSheet; - - for (let ruleIndex = 0; ruleIndex < sheet.cssRules.length; ruleIndex++) { - const rule = sheet.cssRules[ruleIndex] as CSSStyleRule; - - if (rule.type == CSSRule.STYLE_RULE && rule.selectorText) { - result.push({ - selectors: rule.selectorText.split(','), - text: rule.style.cssText, - }); - } - } - - styleNode.parentNode?.removeChild(styleNode); - }); - - return result; -} - function retrieveHtmlStrings( clipboardData: Partial ): { diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/model/createModelFromHtmlTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/createModelFromHtmlTest.ts new file mode 100644 index 00000000000..b700d4de8e4 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/createModelFromHtmlTest.ts @@ -0,0 +1,201 @@ +import * as convertInlineCss from '../../../lib/utils/convertInlineCss'; +import * as createDomToModelContextForSanitizing from '../../../lib/utils/createDomToModelContextForSanitizing'; +import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; +import * as parseFormat from 'roosterjs-content-model-dom/lib/domToModel/utils/parseFormat'; +import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import { createModelFromHtml } from '../../../lib/publicApi/model/createModelFromHtml'; + +describe('createModelFromHtml', () => { + it('Empty html, no options', () => { + const html = ''; + const model = createModelFromHtml(html); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Valid html, no options', () => { + const html = '
                                                                      test
                                                                      '; + const model = createModelFromHtml(html); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { fontSize: '20px' }, + }, + ], + segmentFormat: { fontSize: '20px' }, + format: {}, + }, + ], + }); + }); + + it('Valid html with style on BODY and global CSS, no options', () => { + const html = + '
                                                                      test
                                                                      '; + const model = createModelFromHtml(html); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { fontSize: '20px', textColor: 'red', fontFamily: 'Arial' }, + }, + ], + segmentFormat: { fontSize: '20px', fontFamily: 'Arial' }, + format: {}, + }, + ], + }); + }); + + it('Valid html, with options', () => { + const html = '
                                                                      test
                                                                      '; + const mockedOptions = 'OPTIONS' as any; + const mockedContext = { + formatParsers: { + segmentOnBlock: 'PARSERS', + }, + segmentFormat: 'SEGMENT', + } as any; + const createContextSpy = spyOn( + createDomToModelContextForSanitizing, + 'createDomToModelContextForSanitizing' + ).and.returnValue(mockedContext); + const parseFormatSpy = spyOn(parseFormat, 'parseFormat'); + const mockedDoc = { + body: 'BODY', + } as any; + const mockedModel = 'MODEL' as any; + const domToContentModelSpy = spyOn(domToContentModel, 'domToContentModel').and.returnValue( + mockedModel + ); + const domParserSpy = spyOn(DOMParser.prototype, 'parseFromString').and.returnValue( + mockedDoc + ); + const mockedRules = 'RULES' as any; + const retrieveCssRulesSpy = spyOn(convertInlineCss, 'retrieveCssRules').and.returnValue( + mockedRules + ); + const convertInlineCssSpy = spyOn(convertInlineCss, 'convertInlineCss'); + const mockedTrustedHtmlHandler = jasmine + .createSpy('trustHandler') + .and.returnValue('TRUSTEDHTML'); + const mockedDefaultSegmentFormat = 'FORMAT' as any; + + const model = createModelFromHtml( + html, + mockedOptions, + mockedTrustedHtmlHandler, + mockedDefaultSegmentFormat + ); + + expect(model).toEqual(mockedModel); + expect(mockedTrustedHtmlHandler).toHaveBeenCalledWith(html); + expect(domParserSpy).toHaveBeenCalledWith('TRUSTEDHTML', 'text/html'); + expect(parseFormatSpy).toHaveBeenCalledTimes(1); + expect(parseFormatSpy).toHaveBeenCalledWith( + 'BODY' as any, + 'PARSERS' as any, + 'SEGMENT' as any, + mockedContext + ); + expect(createContextSpy).toHaveBeenCalledTimes(1); + expect(createContextSpy).toHaveBeenCalledWith(mockedDefaultSegmentFormat, mockedOptions); + expect(domToContentModelSpy).toHaveBeenCalledWith('BODY' as any, mockedContext); + expect(retrieveCssRulesSpy).toHaveBeenCalledWith(mockedDoc); + expect(convertInlineCssSpy).toHaveBeenCalledWith(mockedDoc, mockedRules); + }); + + it('Empty html, with options', () => { + const mockedOptions = 'OPTIONS' as any; + const mockedContext = { + formatParsers: { + segmentOnBlock: 'PARSERS', + }, + segmentFormat: 'SEGMENT', + } as any; + const createContextSpy = spyOn( + createDomToModelContextForSanitizing, + 'createDomToModelContextForSanitizing' + ).and.returnValue(mockedContext); + const parseFormatSpy = spyOn(parseFormat, 'parseFormat'); + const mockedDoc = { + body: 'BODY', + } as any; + const mockedModel = 'MODEL' as any; + const domToContentModelSpy = spyOn(domToContentModel, 'domToContentModel').and.returnValue( + mockedModel + ); + const domParserSpy = spyOn(DOMParser.prototype, 'parseFromString').and.returnValue( + mockedDoc + ); + const mockedRules = 'RULES' as any; + const retrieveCssRulesSpy = spyOn(convertInlineCss, 'retrieveCssRules').and.returnValue( + mockedRules + ); + const convertInlineCssSpy = spyOn(convertInlineCss, 'convertInlineCss'); + const segmentFormat: ContentModelSegmentFormat = { fontSize: '10pt' }; + + const model = createModelFromHtml('', mockedOptions, undefined, segmentFormat); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segmentFormat: { fontSize: '10pt' }, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontSize: '10pt' }, + }, + { + segmentType: 'Br', + format: { fontSize: '10pt' }, + }, + ], + }, + ], + format: { fontSize: '10pt' }, + }); + expect(domParserSpy).not.toHaveBeenCalled(); + expect(parseFormatSpy).not.toHaveBeenCalled(); + expect(createContextSpy).not.toHaveBeenCalled(); + expect(domToContentModelSpy).not.toHaveBeenCalled(); + expect(retrieveCssRulesSpy).not.toHaveBeenCalled(); + expect(convertInlineCssSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/paste/convertInlineCssTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/convertInlineCssTest.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-core/test/utils/paste/convertInlineCssTest.ts rename to packages-content-model/roosterjs-content-model-core/test/utils/convertInlineCssTest.ts index 89fba0c41c8..a87322bab5c 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/paste/convertInlineCssTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/convertInlineCssTest.ts @@ -1,5 +1,4 @@ -import { convertInlineCss } from '../../../lib/utils/paste/convertInlineCss'; -import { CssRule } from '../../../lib/utils/paste/retrieveHtmlInfo'; +import { convertInlineCss, CssRule } from '../../lib/utils/convertInlineCss'; describe('convertInlineCss', () => { it('Empty DOM, empty CSS', () => { diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/createDomToModelContextForSanitizingTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/createDomToModelContextForSanitizingTest.ts new file mode 100644 index 00000000000..cb84c8ed392 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/utils/createDomToModelContextForSanitizingTest.ts @@ -0,0 +1,112 @@ +import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; +import * as createPasteEntityProcessor from '../../lib/override/pasteEntityProcessor'; +import * as createPasteGeneralProcessor from '../../lib/override/pasteGeneralProcessor'; +import { containerSizeFormatParser } from '../../lib/override/containerSizeFormatParser'; +import { createDomToModelContextForSanitizing } from '../../lib/utils/createDomToModelContextForSanitizing'; +import { DomToModelOptionForSanitizing } from 'roosterjs-content-model-types'; +import { pasteDisplayFormatParser } from '../../lib/override/pasteDisplayFormatParser'; +import { pasteTextProcessor } from '../../lib/override/pasteTextProcessor'; + +describe('createDomToModelContextForSanitizing', () => { + const mockedPasteGeneralProcessor = 'GENERALPROCESSOR' as any; + const mockedPasteEntityProcessor = 'ENTITYPROCESSOR' as any; + const mockedResult = 'CONTEXT' as any; + const defaultOptions: DomToModelOptionForSanitizing = { + processorOverride: {}, + formatParserOverride: {}, + additionalFormatParsers: {}, + additionalAllowedTags: [], + additionalDisallowedTags: [], + styleSanitizers: {}, + attributeSanitizers: {}, + }; + let createDomToModelContextSpy: jasmine.Spy; + + let createPasteGeneralProcessorSpy: jasmine.Spy; + let createPasteEntityProcessorSpy: jasmine.Spy; + + beforeEach(() => { + createPasteGeneralProcessorSpy = spyOn( + createPasteGeneralProcessor, + 'createPasteGeneralProcessor' + ).and.returnValue(mockedPasteGeneralProcessor); + createPasteEntityProcessorSpy = spyOn( + createPasteEntityProcessor, + 'createPasteEntityProcessor' + ).and.returnValue(mockedPasteEntityProcessor); + + createDomToModelContextSpy = spyOn( + createDomToModelContext, + 'createDomToModelContext' + ).and.returnValue(mockedResult); + }); + + it('no options', () => { + const context = createDomToModelContextForSanitizing(); + + expect(context).toBe(mockedResult); + expect(createDomToModelContextSpy).toHaveBeenCalledWith( + { + defaultFormat: undefined, + }, + undefined, + { + processorOverride: { + '#text': pasteTextProcessor, + entity: mockedPasteEntityProcessor, + '*': mockedPasteGeneralProcessor, + }, + formatParserOverride: { + display: pasteDisplayFormatParser, + }, + additionalFormatParsers: { + container: [containerSizeFormatParser], + }, + }, + defaultOptions + ); + expect(createPasteGeneralProcessorSpy).toHaveBeenCalledWith(defaultOptions); + expect(createPasteEntityProcessorSpy).toHaveBeenCalledWith(defaultOptions); + }); + + it('with options', () => { + const mockedDefaultFormat = 'FORMAT' as any; + const mockedOption = 'OPTION' as any; + const mockedAdditionalOption = { a: 'b' } as any; + + const context = createDomToModelContextForSanitizing( + mockedDefaultFormat, + mockedOption, + mockedAdditionalOption + ); + + const additionalOption = { + ...defaultOptions, + ...mockedAdditionalOption, + }; + + expect(context).toBe(mockedResult); + expect(createDomToModelContextSpy).toHaveBeenCalledWith( + { + defaultFormat: mockedDefaultFormat, + }, + mockedOption, + { + processorOverride: { + '#text': pasteTextProcessor, + entity: mockedPasteEntityProcessor, + '*': mockedPasteGeneralProcessor, + }, + formatParserOverride: { + display: pasteDisplayFormatParser, + }, + additionalFormatParsers: { + container: [containerSizeFormatParser], + }, + }, + additionalOption + ); + expect(createPasteGeneralProcessorSpy).toHaveBeenCalledWith(additionalOption); + expect(createPasteEntityProcessorSpy).toHaveBeenCalledWith(additionalOption); + }); +}); 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 a9f80f52a11..b3b2c51feac 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 @@ -1,13 +1,8 @@ -import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; -import * as createPasteEntityProcessor from '../../../lib/override/pasteEntityProcessor'; -import * as createPasteGeneralProcessor from '../../../lib/override/pasteGeneralProcessor'; +import * as createDomToModelContextForSanitizing from '../../../lib/utils/createDomToModelContextForSanitizing'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as mergeModelFile from '../../../lib/publicApi/model/mergeModel'; -import { containerSizeFormatParser } from '../../../lib/override/containerSizeFormatParser'; 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 { ContentModelDocument, ContentModelFormatter, @@ -348,8 +343,6 @@ describe('mergePasteContent', () => { paragraph: null!, path: [], }; - const mockedPasteGeneralProcessor = 'GENERALPROCESSOR' as any; - const mockedPasteEntityProcessor = 'ENTITYPROCESSOR' as any; const mockedDomToModelContext = { name: 'DOMTOMODELCONTEXT', } as any; @@ -358,17 +351,9 @@ describe('mergePasteContent', () => { pasteModel ); const mergeModelSpy = spyOn(mergeModelFile, 'mergeModel').and.returnValue(insertPoint); - const createPasteGeneralProcessorSpy = spyOn( - createPasteGeneralProcessor, - 'createPasteGeneralProcessor' - ).and.returnValue(mockedPasteGeneralProcessor); - const createPasteEntityProcessorSpy = spyOn( - createPasteEntityProcessor, - 'createPasteEntityProcessor' - ).and.returnValue(mockedPasteEntityProcessor); const createDomToModelContextSpy = spyOn( - createDomToModelContext, - 'createDomToModelContext' + createDomToModelContextForSanitizing, + 'createDomToModelContextForSanitizing' ).and.returnValue(mockedDomToModelContext); const mockedDomToModelOptions = 'OPTION1' as any; @@ -413,24 +398,9 @@ describe('mergePasteContent', () => { mergeFormat: 'none', mergeTable: false, }); - expect(createPasteGeneralProcessorSpy).toHaveBeenCalledWith(mockedDefaultDomToModelOptions); - expect(createPasteEntityProcessorSpy).toHaveBeenCalledWith(mockedDefaultDomToModelOptions); expect(createDomToModelContextSpy).toHaveBeenCalledWith( undefined, mockedDomToModelOptions, - { - processorOverride: { - '#text': pasteTextProcessor, - entity: mockedPasteEntityProcessor, - '*': mockedPasteGeneralProcessor, - }, - formatParserOverride: { - display: pasteDisplayFormatParser, - }, - additionalFormatParsers: { - container: [containerSizeFormatParser], - }, - }, mockedDefaultDomToModelOptions ); expect(mockedDomToModelContext.segmentFormat).toEqual({ lineHeight: '1pt' }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelBeforePasteEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelBeforePasteEvent.ts index d9cb72619f1..b553201fb23 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelBeforePasteEvent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelBeforePasteEvent.ts @@ -1,6 +1,6 @@ import type { BeforePasteEvent } from 'roosterjs-editor-types'; import type { - DomToModelOptionForPaste, + DomToModelOptionForSanitizing, MergePastedContentFunc, } from 'roosterjs-content-model-types'; @@ -11,7 +11,7 @@ export interface ContentModelBeforePasteEvent extends BeforePasteEvent { /** * domToModel Options to use when creating the content model from the paste fragment */ - readonly domToModelOption: DomToModelOptionForPaste; + readonly domToModelOption: DomToModelOptionForSanitizing; /** * customizedMerge Customized merge function to use when merging the paste fragment into the editor diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts index e381cc5b596..48f243ed566 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts @@ -1,3 +1,4 @@ +import type { ValueSanitizer } from '../parameter/ValueSanitizer'; import type { ElementProcessorMap, FormatParsers, @@ -23,3 +24,28 @@ export interface DomToModelOption { */ additionalFormatParsers?: Partial; } + +/** + * Options for DOM to Content Model conversion for paste only + */ +export interface DomToModelOptionForSanitizing extends Required { + /** + * Additional allowed HTML tags in lower case. Element with these tags will be preserved + */ + readonly additionalAllowedTags: Lowercase[]; + + /** + * Additional disallowed HTML tags in lower case. Elements with these tags will be dropped + */ + readonly additionalDisallowedTags: Lowercase[]; + + /** + * Additional sanitizers for CSS styles + */ + readonly styleSanitizers: Record; + + /** + * Additional sanitizers for CSS styles + */ + readonly attributeSanitizers: Record; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts b/packages-content-model/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts index 58d45aa8a9a..ddb1e75d68c 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts @@ -1,35 +1,9 @@ +import type { DomToModelOptionForSanitizing } from '../context/DomToModelOption'; import type { PasteType } from '../enum/PasteType'; import type { ClipboardData } from '../parameter/ClipboardData'; import type { BasePluginEvent } from './BasePluginEvent'; -import type { DomToModelOption } from '../context/DomToModelOption'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { InsertPoint } from '../selection/InsertPoint'; -import type { ValueSanitizer } from '../parameter/ValueSanitizer'; - -/** - * Options for DOM to Content Model conversion for paste only - */ -export interface DomToModelOptionForPaste extends Required { - /** - * Additional allowed HTML tags in lower case. Element with these tags will be preserved - */ - readonly additionalAllowedTags: Lowercase[]; - - /** - * Additional disallowed HTML tags in lower case. Elements with these tags will be dropped - */ - readonly additionalDisallowedTags: Lowercase[]; - - /** - * Additional sanitizers for CSS styles - */ - readonly styleSanitizers: Record; - - /** - * Additional sanitizers for CSS styles - */ - readonly attributeSanitizers: Record; -} /** * A function type used by merging pasted content into current Content Model @@ -79,7 +53,7 @@ export interface BeforePasteEvent extends BasePluginEvent<'beforePaste'> { /** * domToModel Options to use when creating the content model from the paste fragment */ - readonly domToModelOption: DomToModelOptionForPaste; + readonly domToModelOption: DomToModelOptionForSanitizing; /** * customizedMerge Customized merge function to use when merging the paste fragment into the editor 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 885067819f5..69dceefa3dd 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -182,7 +182,7 @@ export { ContentModelSegmentHandler, ContentModelBlockHandler, } from './context/ContentModelHandler'; -export { DomToModelOption } from './context/DomToModelOption'; +export { DomToModelOption, DomToModelOptionForSanitizing } from './context/DomToModelOption'; export { ModelToDomOption } from './context/ModelToDomOption'; export { DomIndexer } from './context/DomIndexer'; export { TextMutationObserver } from './context/TextMutationObserver'; @@ -289,11 +289,7 @@ export { BasePluginEvent, BasePluginDomEvent } from './event/BasePluginEvent'; export { BeforeCutCopyEvent } from './event/BeforeCutCopyEvent'; export { BeforeDisposeEvent } from './event/BeforeDisposeEvent'; export { BeforeKeyboardEditingEvent } from './event/BeforeKeyboardEditingEvent'; -export { - BeforePasteEvent, - DomToModelOptionForPaste, - MergePastedContentFunc, -} from './event/BeforePasteEvent'; +export { BeforePasteEvent, MergePastedContentFunc } from './event/BeforePasteEvent'; export { BeforeSetContentEvent } from './event/BeforeSetContentEvent'; export { ContentChangedEvent, ChangedEntity } from './event/ContentChangedEvent'; export { ContextMenuEvent } from './event/ContextMenuEvent'; From fbb5485773369d9fd719b9b19838315c072b0830 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Mon, 12 Feb 2024 17:45:23 -0600 Subject: [PATCH 102/112] Support Cursor around block entities (#2401) * backup * Revert "Add support to cursor around Block entities. (#2350)" This reverts commit 7998d03a7f225e270d5e061926587bf978a27e98. * Revert "Revert "Add support to cursor around Block entities. (#2350)"" This reverts commit 45bc0d5ac30beb5cd5c7e35b79ffc94b398ab700. * address issue * back up * Support Delimiter with block entity * address comments * Fix Undo issue * fix test * address comment * try fix test build * fix after merge --- .../lib/publicApi/entity/insertEntity.ts | 10 +- .../test/publicApi/entity/insertEntityTest.ts | 13 +- .../lib/corePlugin/CopyPastePlugin.ts | 4 +- .../lib/corePlugin/EntityPlugin.ts | 10 +- .../corePlugin/utils/entityDelimiterUtils.ts | 262 ++++ .../override/pasteCopyBlockEntityParser.ts | 43 + .../createDomToModelContextForSanitizing.ts | 2 + .../lib/utils/paste/mergePasteContent.ts | 1 + .../lib/utils/restoreSnapshotHTML.ts | 37 +- .../test/corePlugin/CopyPastePluginTest.ts | 5 + .../test/corePlugin/EntityPluginTest.ts | 18 + .../corePlugin/utils/delimiterUtilsTest.ts | 1083 +++++++++++++++++ .../pasteCopyBlockEntityParserTest.ts | 85 ++ ...reateDomToModelContextForSanitizingTest.ts | 3 + .../test/utils/restoreSnapshotHTMLTest.ts | 657 ++++++++++ .../domToModel/processors/entityProcessor.ts | 4 +- .../lib/domUtils/entityUtils.ts | 26 +- .../roosterjs-content-model-dom/lib/index.ts | 1 + .../lib/modelToDom/handlers/handleEntity.ts | 34 +- .../processors/entityProcessorTest.ts | 31 + .../modelToDom/handlers/handleEntityTest.ts | 33 + .../lib/corePlugins/BridgePlugin.ts | 4 +- .../lib/corePlugins/EntityDelimiterPlugin.ts | 325 ----- 23 files changed, 2344 insertions(+), 347 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/override/pasteCopyBlockEntityParser.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/overrides/pasteCopyBlockEntityParserTest.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts index 2cefb62eb9b..8282c0fcc9f 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts @@ -58,16 +58,22 @@ export default function insertEntity( options?: InsertEntityOptions ): ContentModelEntity | null { const { contentNode, focusAfterEntity, wrapperDisplay, skipUndoSnapshot } = options || {}; - const wrapper = editor.getDocument().createElement(isBlock ? BlockEntityTag : InlineEntityTag); + const document = editor.getDocument(); + const wrapper = document.createElement(isBlock ? BlockEntityTag : InlineEntityTag); const display = wrapperDisplay ?? (isBlock ? undefined : 'inline-block'); wrapper.style.setProperty('display', display || null); + if (display == undefined && isBlock) { + wrapper.style.setProperty('width', '100%'); + wrapper.style.setProperty('display', 'inline-block'); + } + if (contentNode) { wrapper.appendChild(contentNode); } - const entityModel = createEntity(wrapper, true /*isReadonly*/, undefined /*format*/, type); + const entityModel = createEntity(wrapper, true /* isReadonly */, undefined /*format*/, type); editor.formatContentModel( (model, context) => { diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts index 8ac06260d5b..2753aaaeb2f 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts @@ -1,3 +1,4 @@ +import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; import * as insertEntityModel from '../../../lib/modelApi/entity/insertEntityModel'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import insertEntity from '../../../lib/publicApi/entity/insertEntity'; @@ -44,6 +45,9 @@ describe('insertEntity', () => { setProperty: setPropertySpy, }, appendChild: appendChildSpy, + classList: { + add: () => {}, + }, } as any; formatWithContentModelSpy = jasmine @@ -65,6 +69,8 @@ describe('insertEntity', () => { isDarkMode: isDarkModeSpy, formatContentModel: formatWithContentModelSpy, } as any; + + spyOn(entityUtils, 'addDelimiters').and.returnValue([]); }); it('insert inline entity to top', () => { @@ -115,8 +121,9 @@ describe('insertEntity', () => { const entity = insertEntity(editor, type, true, 'root'); expect(createElementSpy).toHaveBeenCalledWith('div'); - expect(setPropertySpy).toHaveBeenCalledWith('display', null); - expect(appendChildSpy).not.toHaveBeenCalled(); + expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block'); + expect(setPropertySpy).toHaveBeenCalledWith('width', '100%'); + expect(appendChildSpy).toHaveBeenCalledTimes(0); expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName); expect(formatWithContentModelSpy.calls.argsFor(0)[1].changeSource).toEqual( ChangeSource.InsertEntity @@ -167,6 +174,8 @@ describe('insertEntity', () => { expect(createElementSpy).toHaveBeenCalledWith('div'); expect(setPropertySpy).toHaveBeenCalledWith('display', 'none'); + expect(setPropertySpy).not.toHaveBeenCalledWith('display', 'inline-block'); + expect(setPropertySpy).not.toHaveBeenCalledWith('width', '100%'); expect(appendChildSpy).toHaveBeenCalledWith(contentNode); expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName); expect(formatWithContentModelSpy.calls.argsFor(0)[1].changeSource).toEqual( diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts index 7d032880801..45834288230 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts @@ -5,6 +5,7 @@ import { deleteSelection } from '../publicApi/selection/deleteSelection'; import { extractClipboardItems } from '../utils/extractClipboardItems'; import { getSelectedCells } from '../publicApi/table/getSelectedCells'; import { iterateSelections } from '../publicApi/selection/iterateSelections'; +import { onCreateCopyEntityNode } from '../override/pasteCopyBlockEntityParser'; import { contentModelToDom, @@ -299,13 +300,14 @@ function domSelectionToRange(doc: Document, selection: DOMSelection): Range | nu * @internal * Exported only for unit testing */ -export const onNodeCreated: OnNodeCreated = (_, node): void => { +export const onNodeCreated: OnNodeCreated = (modelElement, node): void => { if (isNodeOfType(node, 'ELEMENT_NODE') && isElementOfType(node, 'table')) { wrap(node.ownerDocument, node, 'div'); } if (isNodeOfType(node, 'ELEMENT_NODE') && !node.isContentEditable) { node.removeAttribute('contenteditable'); } + onCreateCopyEntityNode(modelElement, node); }; /** diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts index cdb1545b433..fef628a00d2 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts @@ -1,5 +1,9 @@ import { findAllEntities } from './utils/findAllEntities'; import { transformColor } from '../publicApi/color/transformColor'; +import { + handleDelimiterContentChangedEvent, + handleDelimiterKeyDownEvent, +} from './utils/entityDelimiterUtils'; import { createEntity, generateEntityClassNames, @@ -81,7 +85,9 @@ class EntityPlugin implements PluginWithState { case 'contentChanged': this.handleContentChangedEvent(this.editor, event); break; - + case 'keyDown': + handleDelimiterKeyDownEvent(this.editor, event); + break; case 'editorReady': this.handleContentChangedEvent(this.editor); break; @@ -170,6 +176,8 @@ class EntityPlugin implements PluginWithState { ); } }); + + handleDelimiterContentChangedEvent(editor); } private getChangedEntities(editor: IStandaloneEditor): ChangedEntity[] { diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts new file mode 100644 index 00000000000..77ddefad7e6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts @@ -0,0 +1,262 @@ +import { isCharacterValue } from '../../publicApi/domUtils/eventUtils'; +import { iterateSelections } from '../../publicApi/selection/iterateSelections'; +import type { + ContentModelBlockGroup, + ContentModelFormatter, + ContentModelParagraph, + ContentModelSegmentFormat, + IStandaloneEditor, + KeyDownEvent, + RangeSelection, +} from 'roosterjs-content-model-types'; +import { + addDelimiters, + createBr, + createModelToDomContext, + createParagraph, + isEntityDelimiter, + isEntityElement, + isNodeOfType, +} from 'roosterjs-content-model-dom'; + +const DelimiterBefore = 'entityDelimiterBefore'; +const DelimiterAfter = 'entityDelimiterAfter'; +const DelimiterSelector = '.' + DelimiterAfter + ',.' + DelimiterBefore; +const ZeroWidthSpace = '\u200B'; +const EntityInfoName = '_Entity'; +const InlineEntitySelector = 'span.' + EntityInfoName; +const BlockEntityContainer = '_E_EBlockEntityContainer'; +const BlockEntityContainerSelector = '.' + BlockEntityContainer; + +/** + * @internal exported only for unit test + */ +export function preventTypeInDelimiter(node: HTMLElement, editor: IStandaloneEditor) { + const isAfter = node.classList.contains(DelimiterAfter); + const entitySibling = isAfter ? node.previousElementSibling : node.nextElementSibling; + if (entitySibling && isEntityElement(entitySibling)) { + removeInvalidDelimiters( + [entitySibling.previousElementSibling, entitySibling.nextElementSibling].filter( + element => !!element + ) as HTMLElement[] + ); + editor.formatContentModel(model => { + iterateSelections(model, (_path, _tableContext, block, _segments) => { + if (block?.blockType == 'Paragraph') { + block.segments.forEach(segment => { + if (segment.segmentType == 'Text') { + segment.text = segment.text.replace(ZeroWidthSpace, ''); + } + }); + } + }); + return true; + }); + } +} + +function addDelimitersIfNeeded( + nodes: Element[] | NodeListOf, + format: ContentModelSegmentFormat | null +) { + if (nodes.length > 0) { + const context = createModelToDomContext(); + nodes.forEach(node => { + if ( + isNodeOfType(node, 'ELEMENT_NODE') && + isEntityElement(node) && + !node.isContentEditable + ) { + addDelimiters(node.ownerDocument, node as HTMLElement, format, context); + } + }); + } +} + +function removeNode(el: Node | undefined | null) { + el?.parentElement?.removeChild(el); +} + +function removeInvalidDelimiters(nodes: Element[] | NodeListOf) { + nodes.forEach(node => { + if (!isNodeOfType(node, 'ELEMENT_NODE')) { + return; + } + if (isEntityDelimiter(node)) { + const sibling = node.classList.contains(DelimiterBefore) + ? node.nextElementSibling + : node.previousElementSibling; + if (!(isNodeOfType(sibling, 'ELEMENT_NODE') && isEntityElement(sibling))) { + removeNode(node); + } + } else { + removeDelimiterAttr(node); + } + }); +} + +function removeDelimiterAttr(node: Element | undefined | null, checkEntity: boolean = true) { + if (!node) { + return; + } + + const isAfter = node.classList.contains(DelimiterAfter); + const entitySibling = isAfter ? node.previousElementSibling : node.nextElementSibling; + if (checkEntity && entitySibling && isEntityElement(entitySibling)) { + return; + } + + node.classList.remove(DelimiterAfter, DelimiterBefore); + + node.normalize(); + node.childNodes.forEach(cn => { + const index = cn.textContent?.indexOf(ZeroWidthSpace) ?? -1; + if (index >= 0) { + const range = new Range(); + range.setStart(cn, index); + range.setEnd(cn, index + 1); + range.deleteContents(); + } + }); +} + +function getFocusedElement(selection: RangeSelection): HTMLElement | null { + const { range, isReverted } = selection; + let node: Node | null = isReverted ? range.startContainer : range.endContainer; + const offset = isReverted ? range.startOffset : range.endOffset; + if (!isNodeOfType(node, 'ELEMENT_NODE')) { + if (node.textContent != ZeroWidthSpace && (node.textContent || '').length == offset) { + node = node.nextSibling ?? node.parentElement?.closest(DelimiterSelector) ?? null; + } else { + node = node?.parentElement?.closest(DelimiterSelector) ?? null; + } + } else { + node = node.childNodes.length == offset ? node : node.childNodes.item(offset); + } + if (node && !node.hasChildNodes()) { + node = node.nextSibling; + } + return isNodeOfType(node, 'ELEMENT_NODE') ? node : null; +} + +/** + * @internal + */ +export function handleDelimiterContentChangedEvent(editor: IStandaloneEditor) { + const helper = editor.getDOMHelper(); + removeInvalidDelimiters(helper.queryElements(DelimiterSelector)); + addDelimitersIfNeeded(helper.queryElements(InlineEntitySelector), editor.getPendingFormat()); +} + +/** + * @internal + */ +export function handleDelimiterKeyDownEvent(editor: IStandaloneEditor, event: KeyDownEvent) { + const selection = editor.getDOMSelection(); + + const { rawEvent } = event; + if (!selection || selection.type != 'range') { + return; + } + const isEnter = rawEvent.key === 'Enter'; + if (selection.range.collapsed && (isCharacterValue(rawEvent) || isEnter)) { + const helper = editor.getDOMHelper(); + const node = getFocusedElement(selection); + if (node && isEntityDelimiter(node) && helper.isNodeInEditor(node)) { + const blockEntityContainer = node.closest(BlockEntityContainerSelector); + if (blockEntityContainer && helper.isNodeInEditor(blockEntityContainer)) { + const isAfter = node.classList.contains(DelimiterAfter); + + if (isAfter) { + selection.range.setStartAfter(blockEntityContainer); + } else { + selection.range.setStartBefore(blockEntityContainer); + } + selection.range.collapse(true /* toStart */); + + if (isEnter) { + event.rawEvent.preventDefault(); + } + + editor.formatContentModel(handleKeyDownInBlockDelimiter, { + selectionOverride: { + type: 'range', + isReverted: false, + range: selection.range, + }, + }); + } else { + if (isEnter) { + event.rawEvent.preventDefault(); + editor.formatContentModel(handleEnterInlineEntity); + } else { + editor + .getDocument() + .defaultView?.requestAnimationFrame(() => + preventTypeInDelimiter(node, editor) + ); + } + } + } + } +} + +/** + * @internal Exported Only for unit test + * @returns + */ +export const handleKeyDownInBlockDelimiter: ContentModelFormatter = (model, context) => { + iterateSelections(model, (_path, _tableContext, block) => { + if (block?.blockType == 'Paragraph') { + delete block.isImplicit; + const selectionMarker = block.segments.find(w => w.segmentType == 'SelectionMarker'); + if (selectionMarker?.segmentType == 'SelectionMarker') { + block.segmentFormat = { ...selectionMarker.format }; + context.newPendingFormat = { ...selectionMarker.format }; + } + block.segments.unshift(createBr()); + } + }); + return true; +}; + +/** + * @internal Exported Only for unit test + * @returns + */ +export const handleEnterInlineEntity: ContentModelFormatter = model => { + let selectionBlock: ContentModelParagraph | undefined; + let selectionBlockParent: ContentModelBlockGroup | undefined; + + iterateSelections(model, (path, _tableContext, block) => { + if (block?.blockType == 'Paragraph') { + selectionBlock = block; + selectionBlockParent = path[path.length - 1]; + } + }); + + if (selectionBlock && selectionBlockParent) { + const selectionMarker = selectionBlock.segments.find( + segment => segment.segmentType == 'SelectionMarker' + ); + if (selectionMarker) { + const markerIndex = selectionBlock.segments.indexOf(selectionMarker); + const segmentsAfterMarker = selectionBlock.segments.splice(markerIndex); + + const newPara = createParagraph( + false, + selectionBlock.format, + selectionBlock.segmentFormat, + selectionBlock.decorator + ); + newPara.segments.push(...segmentsAfterMarker); + + const selectionBlockIndex = selectionBlockParent.blocks.indexOf(selectionBlock); + if (selectionBlockIndex >= 0) { + selectionBlockParent.blocks.splice(selectionBlockIndex + 1, 0, newPara); + } + } + } + + return true; +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/override/pasteCopyBlockEntityParser.ts b/packages-content-model/roosterjs-content-model-core/lib/override/pasteCopyBlockEntityParser.ts new file mode 100644 index 00000000000..8a4a567c542 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/override/pasteCopyBlockEntityParser.ts @@ -0,0 +1,43 @@ +import { isBlockElement, isElementOfType, isNodeOfType } from 'roosterjs-content-model-dom'; +import type { + ContentModelEntity, + EntityInfoFormat, + FormatParser, + OnNodeCreated, +} from 'roosterjs-content-model-types'; + +const BlockEntityClass = '_EBlock'; +const OneHundredPercent = '100%'; +const InlineBlock = 'inline-block'; + +/** + * @internal + */ +export const onCreateCopyEntityNode: OnNodeCreated = (model, node) => { + const entityModel = model as ContentModelEntity; + if ( + entityModel && + entityModel.wrapper && + entityModel.blockType == 'Entity' && + isNodeOfType(node, 'ELEMENT_NODE') && + isElementOfType(node, 'div') && + !isBlockElement(entityModel.wrapper) && + entityModel.wrapper.style.display == InlineBlock && + entityModel.wrapper.style.width == OneHundredPercent + ) { + node.classList.add(BlockEntityClass); + node.style.display = 'block'; + node.style.width = ''; + } +}; + +/** + * @internal + */ +export const pasteBlockEntityParser: FormatParser = (_, element) => { + if (element.classList.contains(BlockEntityClass)) { + element.classList.remove(BlockEntityClass); + element.style.display = InlineBlock; + element.style.width = OneHundredPercent; + } +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/createDomToModelContextForSanitizing.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/createDomToModelContextForSanitizing.ts index 2e9f59382da..ec0471f3098 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/createDomToModelContextForSanitizing.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/createDomToModelContextForSanitizing.ts @@ -2,6 +2,7 @@ import { containerSizeFormatParser } from '../override/containerSizeFormatParser import { createDomToModelContext } from 'roosterjs-content-model-dom'; import { createPasteEntityProcessor } from '../override/pasteEntityProcessor'; import { createPasteGeneralProcessor } from '../override/pasteGeneralProcessor'; +import { pasteBlockEntityParser } from '../override/pasteCopyBlockEntityParser'; import { pasteDisplayFormatParser } from '../override/pasteDisplayFormatParser'; import { pasteTextProcessor } from '../override/pasteTextProcessor'; import type { @@ -50,6 +51,7 @@ export function createDomToModelContextForSanitizing( }, additionalFormatParsers: { container: [containerSizeFormatParser], + entity: [pasteBlockEntityParser], }, }, sanitizingOption 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 30ed6c4803c..905ac41b5c4 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 @@ -4,6 +4,7 @@ import { domToContentModel } from 'roosterjs-content-model-dom'; import { getSegmentTextFormat } from '../../publicApi/domUtils/getSegmentTextFormat'; import { getSelectedSegments } from '../../publicApi/selection/collectSelections'; import { mergeModel } from '../../publicApi/model/mergeModel'; + import type { MergeModelOption } from '../../publicApi/model/mergeModel'; import type { BeforePasteEvent, diff --git a/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotHTML.ts b/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotHTML.ts index cdd92a87f4d..3b05a30be00 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotHTML.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotHTML.ts @@ -12,6 +12,8 @@ import type { ContentModelEntityFormat, } from 'roosterjs-content-model-types'; +const BlockEntityContainer = '_E_EBlockEntityContainer'; + /** * @internal */ @@ -75,15 +77,38 @@ function tryGetEntityElement( ): HTMLElement | null { let result: HTMLElement | null = null; - if (isNodeOfType(node, 'ELEMENT_NODE') && isEntityElement(node)) { - const format: ContentModelEntityFormat = {}; + if (isNodeOfType(node, 'ELEMENT_NODE')) { + if (isEntityElement(node)) { + const format: ContentModelEntityFormat = {}; - node.classList.forEach(name => { - parseEntityClassName(name, format); - }); + node.classList.forEach(name => { + parseEntityClassName(name, format); + }); - result = (format.id && entityMap[format.id]?.element) || null; + result = (format.id && entityMap[format.id]?.element) || null; + } else if (isBlockEntityContainer(node)) { + result = tryGetEntityFromContainer(node, entityMap); + } } return result; } +function isBlockEntityContainer(node: HTMLElement) { + return node.classList.contains(BlockEntityContainer); +} + +function tryGetEntityFromContainer( + element: HTMLElement, + entityMap: Record +): HTMLElement | null { + const format: ContentModelEntityFormat = {}; + element.childNodes.forEach(node => { + if (isEntityElement(node) && isNodeOfType(node, 'ELEMENT_NODE')) { + node.classList.forEach(name => parseEntityClassName(name, format)); + } + }); + + const parent = format.id ? entityMap[format.id]?.element.parentElement : null; + + return isNodeOfType(parent, 'ELEMENT_NODE') && isBlockEntityContainer(parent) ? parent : null; +} diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts index 4b55955fa54..cf529029dd4 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts @@ -1,5 +1,6 @@ import * as addRangeToSelection from '../../lib/corePlugin/utils/addRangeToSelection'; import * as contentModelToDomFile from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; +import * as copyPasteEntityOverride from '../../lib/override/pasteCopyBlockEntityParser'; import * as deleteSelectionsFile from '../../lib/publicApi/selection/deleteSelection'; import * as extractClipboardItemsFile from '../../lib/utils/extractClipboardItems'; import * as iterateSelectionsFile from '../../lib/publicApi/selection/iterateSelections'; @@ -622,17 +623,20 @@ describe('CopyPastePlugin |', () => { it('onNodeCreated with table', () => { const div = document.createElement('div'); const table = document.createElement('table'); + spyOn(copyPasteEntityOverride, 'onCreateCopyEntityNode').and.callThrough(); div.appendChild(table); onNodeCreated(null!, table); expect(div.innerHTML).toEqual('
                                                                      '); + expect(copyPasteEntityOverride.onCreateCopyEntityNode).toHaveBeenCalled(); }); it('onNodeCreated with readonly element', () => { const div = document.createElement('div'); div.contentEditable = 'true'; + spyOn(copyPasteEntityOverride, 'onCreateCopyEntityNode').and.callThrough(); const span = document.createElement('span'); div.appendChild(span); @@ -640,6 +644,7 @@ describe('CopyPastePlugin |', () => { onNodeCreated(null!, span); + expect(copyPasteEntityOverride.onCreateCopyEntityNode).toHaveBeenCalled(); expect(div.innerHTML).toBe(''); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts index 2f3b43156df..bb723cfc8d2 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts @@ -1,3 +1,4 @@ +import * as DelimiterUtils from '../../lib/corePlugin/utils/entityDelimiterUtils'; import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; import * as transformColor from '../../lib/publicApi/color/transformColor'; import { createContentModelDocument, createEntity } from '../../../roosterjs-content-model-dom/lib'; @@ -55,6 +56,9 @@ describe('EntityPlugin', () => { }); describe('EditorReady event', () => { + beforeEach(() => { + spyOn(DelimiterUtils, 'handleDelimiterContentChangedEvent').and.callFake(() => {}); + }); it('empty doc', () => { mockedModel = createContentModelDocument(); @@ -67,6 +71,7 @@ describe('EntityPlugin', () => { entityMap: {}, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('Doc with entity', () => { @@ -107,6 +112,7 @@ describe('EntityPlugin', () => { state: undefined, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('Doc with entity, can persist', () => { @@ -150,10 +156,14 @@ describe('EntityPlugin', () => { state: undefined, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); }); describe('ContentChanged event', () => { + beforeEach(() => { + spyOn(DelimiterUtils, 'handleDelimiterContentChangedEvent').and.callFake(() => {}); + }); it('No changedEntity param', () => { const wrapper = document.createElement('div'); const entity = createEntity(wrapper, true, undefined, 'Entity1'); @@ -192,6 +202,7 @@ describe('EntityPlugin', () => { state: undefined, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('New entity in dark mode', () => { @@ -239,6 +250,7 @@ describe('EntityPlugin', () => { 'lightToDark', mockedDarkColorHandler ); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('No changedEntity param, has deleted entity', () => { @@ -301,6 +313,7 @@ describe('EntityPlugin', () => { state: undefined, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('Do not trigger event for already deleted entity', () => { @@ -331,6 +344,7 @@ describe('EntityPlugin', () => { }); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(0); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('Add back a deleted entity', () => { @@ -376,6 +390,7 @@ describe('EntityPlugin', () => { state: undefined, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('Has changedEntities parameter', () => { @@ -452,6 +467,7 @@ describe('EntityPlugin', () => { state: undefined, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('Handle conflict id', () => { @@ -510,6 +526,7 @@ describe('EntityPlugin', () => { state: undefined, }); expect(transformColorSpy).not.toHaveBeenCalled(); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); it('With content state', () => { @@ -555,6 +572,7 @@ describe('EntityPlugin', () => { }, state: entityState, }); + expect(DelimiterUtils.handleDelimiterContentChangedEvent).toHaveBeenCalled(); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts new file mode 100644 index 00000000000..f24a7c13fd6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts @@ -0,0 +1,1083 @@ +import * as DelimiterFile from '../../../lib/corePlugin/utils/entityDelimiterUtils'; +import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; +import { + handleDelimiterContentChangedEvent, + handleDelimiterKeyDownEvent, +} from '../../../lib/corePlugin/utils/entityDelimiterUtils'; +import { + contentModelToDom, + createEntity, + createModelToDomContext, +} from 'roosterjs-content-model-dom'; +import { + ContentModelDocument, + DOMSelection, + IStandaloneEditor, +} from 'roosterjs-content-model-types'; + +const ZeroWidthSpace = '\u200B'; +const BlockEntityContainer = '_E_EBlockEntityContainer'; + +describe('EntityDelimiterUtils |', () => { + let queryElementsSpy: jasmine.Spy; + let formatContentModelSpy: jasmine.Spy; + let mockedEditor: any; + beforeEach(() => { + mockedEditor = ({ + getDOMHelper: () => ({ + queryElements: queryElementsSpy, + isNodeInEditor: () => true, + }), + getPendingFormat: ((): any => null), + }) as Partial; + }); + + describe('contentChanged |', () => { + it('remove invalid delimiters', () => { + const div = document.createElement('div'); + const entityWrapper = document.createElement('span'); + entityWrapper.style.width = '100%'; + entityWrapper.style.display = 'inline-block'; + + contentModelToDom( + document, + div, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Entity', + entityFormat: { + isReadonly: true, + entityType: 'Test', + id: 'Id', + }, + format: {}, + segmentType: 'Entity', + wrapper: entityWrapper, + }, + ], + }, + createModelToDomContext({ + addDelimiterForEntity: true, + }) + ); + queryElementsSpy = jasmine + .createSpy('queryElement') + .and.callFake(sel => div.querySelectorAll(sel)); + + entityWrapper.remove(); + + handleDelimiterContentChangedEvent(mockedEditor); + + expect(queryElementsSpy).toHaveBeenCalledTimes(2); + expect(div.firstElementChild?.childElementCount).toEqual(0); + }); + + it('add delimiters', () => { + const div = document.createElement('div'); + const entityWrapper = document.createElement('span'); + entityWrapper.style.width = '100%'; + entityWrapper.style.display = 'inline-block'; + + contentModelToDom( + document, + div, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Entity', + entityFormat: { + isReadonly: true, + entityType: 'Test', + id: 'Id', + }, + format: {}, + segmentType: 'Entity', + wrapper: entityWrapper, + }, + ], + }, + createModelToDomContext({}) + ); + queryElementsSpy = jasmine + .createSpy('queryElement') + .and.callFake(sel => div.querySelectorAll(sel)); + + handleDelimiterContentChangedEvent(mockedEditor); + + expect(queryElementsSpy).toHaveBeenCalledTimes(2); + expect(entityWrapper.parentElement!.childElementCount).toEqual(3); + }); + + it('Remove delimiter info', () => { + const div = document.createElement('div'); + const entityWrapper = document.createElement('span'); + entityWrapper.style.width = '100%'; + entityWrapper.style.display = 'inline-block'; + + contentModelToDom( + document, + div, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Entity', + entityFormat: { + isReadonly: true, + entityType: 'Test', + id: 'Id', + }, + format: {}, + segmentType: 'Entity', + wrapper: entityWrapper, + }, + ], + }, + createModelToDomContext({ + addDelimiterForEntity: true, + }) + ); + queryElementsSpy = jasmine + .createSpy('queryElement') + .and.callFake(sel => div.querySelectorAll(sel)); + + const invalidDelimiter = entityWrapper.previousElementSibling; + invalidDelimiter?.appendChild(document.createTextNode('_')); + + handleDelimiterContentChangedEvent(mockedEditor); + + expect(queryElementsSpy).toHaveBeenCalledTimes(2); + expect(entityWrapper.parentElement!.childElementCount).toEqual(4); + expect( + invalidDelimiter && entityUtils.isEntityDelimiter(invalidDelimiter) + ).toBeFalsy(); + }); + }); + + describe('onKeyDown |', () => { + let mockedSelection: DOMSelection; + let rafSpy: jasmine.Spy; + beforeEach(() => { + mockedSelection = undefined!; + rafSpy = jasmine.createSpy('requestAnimationFrame'); + formatContentModelSpy = jasmine.createSpy('formatContentModel'); + mockedEditor = ({ + getDOMSelection: () => mockedSelection, + getDocument: () => + { + defaultView: { + requestAnimationFrame: rafSpy, + }, + }, + formatContentModel: formatContentModelSpy, + getDOMHelper: () => ({ + queryElements: queryElementsSpy, + isNodeInEditor: () => true, + }), + }) as Partial; + spyOn(DelimiterFile, 'preventTypeInDelimiter').and.callThrough(); + }); + + it('Dont handle, no selection', () => { + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(DelimiterFile.preventTypeInDelimiter).not.toHaveBeenCalled(); + }); + + it('Dont handle, image selection', () => { + mockedSelection = { + type: 'image', + }; + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(DelimiterFile.preventTypeInDelimiter).not.toHaveBeenCalled(); + }); + + it('Dont handle, table selection', () => { + mockedSelection = { + type: 'table', + }; + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(DelimiterFile.preventTypeInDelimiter).not.toHaveBeenCalled(); + }); + + it('Dont handle, range selection & no delimiter & no entity', () => { + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterBefore'); + mockedSelection = { + type: 'range', + range: { + endContainer: text, + endOffset: 0, + collapsed: true, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityDelimiter').and.returnValue(false); + spyOn(entityUtils, 'isEntityElement').and.returnValue(false); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(DelimiterFile.preventTypeInDelimiter).not.toHaveBeenCalled(); + }); + + it('Handle, range selection & delimiter', () => { + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterBefore'); + mockedSelection = { + type: 'range', + range: { + endContainer: text, + endOffset: 0, + collapsed: true, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityDelimiter').and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(false); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + }, + }); + + expect(rafSpy).toHaveBeenCalled(); + }); + + it('Handle, range selection & delimiter before wrapped in block entity', () => { + const div = document.createElement('div'); + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterBefore'); + div.classList.add(BlockEntityContainer); + div.appendChild(parent); + + const setStartBeforeSpy = jasmine.createSpy('setStartBeforeSpy'); + const setStartAfterSpy = jasmine.createSpy('setStartAfterSpy'); + const collapseSpy = jasmine.createSpy('collapseSpy'); + const preventDefaultSpy = jasmine.createSpy('preventDefaultSpy'); + + mockedSelection = { + type: 'range', + range: { + endContainer: text, + endOffset: 0, + collapsed: true, + setStartAfter: setStartAfterSpy, + setStartBefore: setStartBeforeSpy, + collapse: collapseSpy, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityDelimiter').and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(false); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + preventDefault: preventDefaultSpy, + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setStartBeforeSpy).toHaveBeenCalled(); + expect(collapseSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledWith( + DelimiterFile.handleKeyDownInBlockDelimiter, + { + selectionOverride: mockedSelection, + } + ); + }); + + it('Handle, range selection & delimiter after wrapped in block entity', () => { + const div = document.createElement('div'); + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterAfter'); + div.classList.add(BlockEntityContainer); + div.appendChild(parent); + + const setStartBeforeSpy = jasmine.createSpy('setStartBeforeSpy'); + const setStartAfterSpy = jasmine.createSpy('setStartAfterSpy'); + const collapseSpy = jasmine.createSpy('collapseSpy'); + const preventDefaultSpy = jasmine.createSpy('preventDefaultSpy'); + + mockedSelection = { + type: 'range', + range: { + endContainer: text, + endOffset: 0, + collapsed: true, + setStartAfter: setStartAfterSpy, + setStartBefore: setStartBeforeSpy, + collapse: collapseSpy, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityDelimiter').and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(false); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + preventDefault: preventDefaultSpy, + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).toHaveBeenCalled(); + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(collapseSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledWith( + DelimiterFile.handleKeyDownInBlockDelimiter, + { + selectionOverride: mockedSelection, + } + ); + }); + + it('Handle, range selection & delimiter before wrapped in block entity | Enter Key', () => { + const div = document.createElement('div'); + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterBefore'); + div.classList.add(BlockEntityContainer); + div.appendChild(parent); + + const setStartBeforeSpy = jasmine.createSpy('setStartBeforeSpy'); + const setStartAfterSpy = jasmine.createSpy('setStartAfterSpy'); + const collapseSpy = jasmine.createSpy('collapseSpy'); + const preventDefaultSpy = jasmine.createSpy('preventDefaultSpy'); + + mockedSelection = { + type: 'range', + range: { + endContainer: text, + endOffset: 0, + collapsed: true, + setStartAfter: setStartAfterSpy, + setStartBefore: setStartBeforeSpy, + collapse: collapseSpy, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityDelimiter').and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(false); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'Enter', + preventDefault: preventDefaultSpy, + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setStartBeforeSpy).toHaveBeenCalled(); + expect(collapseSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledWith( + DelimiterFile.handleKeyDownInBlockDelimiter, + { + selectionOverride: mockedSelection, + } + ); + }); + + it('Handle, range selection & delimiter after wrapped in block entity', () => { + const div = document.createElement('div'); + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterAfter'); + div.classList.add(BlockEntityContainer); + div.appendChild(parent); + + const setStartBeforeSpy = jasmine.createSpy('setStartBeforeSpy'); + const setStartAfterSpy = jasmine.createSpy('setStartAfterSpy'); + const collapseSpy = jasmine.createSpy('collapseSpy'); + const preventDefaultSpy = jasmine.createSpy('preventDefaultSpy'); + + mockedSelection = { + type: 'range', + range: { + endContainer: text, + endOffset: 0, + collapsed: true, + setStartAfter: setStartAfterSpy, + setStartBefore: setStartBeforeSpy, + collapse: collapseSpy, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityDelimiter').and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(false); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'Enter', + preventDefault: preventDefaultSpy, + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(setStartAfterSpy).toHaveBeenCalled(); + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(collapseSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledWith( + DelimiterFile.handleKeyDownInBlockDelimiter, + { + selectionOverride: mockedSelection, + } + ); + }); + }); +}); + +describe('preventTypeInDelimiter', () => { + let mockedEditor: any; + let mockedModel: ContentModelDocument; + beforeEach(() => { + mockedModel = { + blockGroupType: 'Document', + blocks: [], + }; + mockedEditor = { + formatContentModel: formatter => { + formatter(mockedModel, {}); + }, + } as Partial; + }); + + it('handle delimiter after entity', () => { + const entityWrapper = document.createElement('span'); + entityWrapper.className = BlockEntityContainer; + + mockedModel = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + createEntity(entityWrapper, true), + { + segmentType: 'Text', + format: {}, + text: 'a' + ZeroWidthSpace, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + const root = document.createElement('div'); + contentModelToDom( + document, + root, + mockedModel, + createModelToDomContext({ + addDelimiterForEntity: true, + }) + ); + + DelimiterFile.preventTypeInDelimiter( + entityWrapper.nextElementSibling as HTMLElement, + mockedEditor + ); + + expect(mockedModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + isReadonly: true, + id: undefined, + entityType: undefined, + }, + wrapper: entityWrapper, + }, + { + segmentType: 'Text', + format: {}, + text: 'a', + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + }); + + it('handle delimiter before entity', () => { + const entityWrapper = document.createElement('span'); + entityWrapper.className = BlockEntityContainer; + + mockedModel = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + format: {}, + text: ZeroWidthSpace + 'Test', + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + createEntity(entityWrapper, true), + ], + format: {}, + }, + ], + format: {}, + }; + + const root = document.createElement('div'); + contentModelToDom( + document, + root, + mockedModel, + createModelToDomContext({ + addDelimiterForEntity: true, + }) + ); + + DelimiterFile.preventTypeInDelimiter( + entityWrapper.previousElementSibling as HTMLElement, + mockedEditor + ); + + expect(mockedModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'Test', + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + isReadonly: true, + id: undefined, + entityType: undefined, + }, + wrapper: entityWrapper, + }, + ], + format: {}, + }, + ], + format: {}, + }); + }); +}); + +describe('handleKeyDownInBlockDelimiter', () => { + it('handle after block entity', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: false, + }, + wrapper: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + segmentFormat: {}, + isImplicit: true, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + DelimiterFile.handleKeyDownInBlockDelimiter(model, {}); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: false, + }, + wrapper: jasmine.anything(), + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + segmentFormat: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + }); + + it('handle before block entity', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: false, + }, + wrapper: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + DelimiterFile.handleKeyDownInBlockDelimiter(model, {}); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + segmentFormat: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: false, + }, + wrapper: jasmine.anything(), + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + }); +}); + +describe('handleEnterInlineEntity', () => { + it('handle after entity', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '_', + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: true, + }, + wrapper: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: '_', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + DelimiterFile.handleEnterInlineEntity(model, {}); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '_', + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: true, + }, + wrapper: jasmine.anything(), + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: '_', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + }); + + it('handle before entity', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '_', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: true, + }, + wrapper: {}, + }, + { + segmentType: 'Text', + text: '_', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + DelimiterFile.handleEnterInlineEntity(model, {}); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '_', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: true, + }, + wrapper: jasmine.anything(), + }, + { + segmentType: 'Text', + text: '_', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/overrides/pasteCopyBlockEntityParserTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteCopyBlockEntityParserTest.ts new file mode 100644 index 00000000000..e1fec607ff1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/pasteCopyBlockEntityParserTest.ts @@ -0,0 +1,85 @@ +import * as entityUtilsFile from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; +import { ContentModelEntity } from 'roosterjs-content-model-types'; +import { + onCreateCopyEntityNode, + pasteBlockEntityParser, +} from '../../lib/override/pasteCopyBlockEntityParser'; + +describe('onCreateCopyEntityNode', () => { + it('handle', () => { + const div = document.createElement('div'); + div.style.width = '100%'; + div.style.display = 'inline-block'; + const modelEntity: ContentModelEntity = { + entityFormat: {}, + format: {}, + wrapper: div, + segmentType: 'Entity', + blockType: 'Entity', + }; + + onCreateCopyEntityNode(modelEntity, div); + + expect(div.style.display).toEqual('block'); + expect(div.style.width).toEqual(''); + expect(div.classList.contains('_EBlock')).toBeTrue(); + }); + + it('Dont handle, no 100% width', () => { + const div = document.createElement('div'); + div.style.display = 'inline-block'; + const modelEntity: ContentModelEntity = { + entityFormat: {}, + format: {}, + wrapper: div, + segmentType: 'Entity', + blockType: 'Entity', + }; + + onCreateCopyEntityNode(modelEntity, div); + + expect(div.style.display).not.toEqual('block'); + expect(div.classList.contains('_EBlock')).not.toBeTrue(); + }); + + it('Dont handle, not inline block', () => { + const div = document.createElement('div'); + div.style.width = '100%'; + const modelEntity: ContentModelEntity = { + entityFormat: {}, + format: {}, + wrapper: div, + segmentType: 'Entity', + blockType: 'Entity', + }; + + onCreateCopyEntityNode(modelEntity, div); + + expect(div.style.display).not.toEqual('block'); + expect(div.classList.contains('_EBlock')).not.toBeTrue(); + }); +}); + +describe('pasteBlockEntityParser', () => { + it('handle', () => { + const div = document.createElement('div'); + div.classList.add('_EBlock'); + spyOn(entityUtilsFile, 'addDelimiters'); + + pasteBlockEntityParser({}, div, {}, {}); + + expect(div.style.width).toEqual('100%'); + expect(div.style.display).toEqual('inline-block'); + expect(div.classList.contains('_EBlock')).toBeFalse(); + }); + + it('Dont handle', () => { + const div = document.createElement('div'); + + pasteBlockEntityParser({}, div, {}, {}); + + expect(div.style.width).not.toEqual('100%'); + expect(div.style.display).not.toEqual('inline-block'); + expect(div.classList.contains('_EBlock')).toBeFalse(); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/createDomToModelContextForSanitizingTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/createDomToModelContextForSanitizingTest.ts index cb84c8ed392..f99a0bc17e3 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/createDomToModelContextForSanitizingTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/createDomToModelContextForSanitizingTest.ts @@ -4,6 +4,7 @@ import * as createPasteGeneralProcessor from '../../lib/override/pasteGeneralPro import { containerSizeFormatParser } from '../../lib/override/containerSizeFormatParser'; import { createDomToModelContextForSanitizing } from '../../lib/utils/createDomToModelContextForSanitizing'; import { DomToModelOptionForSanitizing } from 'roosterjs-content-model-types'; +import { pasteBlockEntityParser } from '../../lib/override/pasteCopyBlockEntityParser'; import { pasteDisplayFormatParser } from '../../lib/override/pasteDisplayFormatParser'; import { pasteTextProcessor } from '../../lib/override/pasteTextProcessor'; @@ -61,6 +62,7 @@ describe('createDomToModelContextForSanitizing', () => { }, additionalFormatParsers: { container: [containerSizeFormatParser], + entity: [pasteBlockEntityParser], }, }, defaultOptions @@ -102,6 +104,7 @@ describe('createDomToModelContextForSanitizing', () => { }, additionalFormatParsers: { container: [containerSizeFormatParser], + entity: [pasteBlockEntityParser], }, }, additionalOption diff --git a/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotHTMLTest.ts b/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotHTMLTest.ts index d673bc8e91b..0fba26ea47e 100644 --- a/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotHTMLTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotHTMLTest.ts @@ -1,5 +1,6 @@ import { restoreSnapshotHTML } from '../../lib/utils/restoreSnapshotHTML'; import { Snapshot, StandaloneEditorCore } from 'roosterjs-content-model-types'; +import { wrap } from 'roosterjs-content-model-dom'; describe('restoreSnapshotHTML', () => { let core: StandaloneEditorCore; @@ -420,4 +421,660 @@ describe('restoreSnapshotHTML', () => { ); expect(div.childNodes[1].firstChild).toBe(entityWrapper); }); + + it('HTML with block entity at root level, cannot match | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: '
                                                                      test1

                                                                      test2
                                                                      ', + } as any; + + const entityWrapper = document.createElement('DIV'); + wrapInContainer(entityWrapper); + + entityWrapper.id = 'div2'; + core.entity.entityMap.C = { + element: entityWrapper, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1

                                                                      test2
                                                                      ' + ); + }); + + it('HTML with block entity at root level, can match | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1

                                                                      test2
                                                                      ', + } as any; + + const entityWrapper = document.createElement('DIV'); + const container = wrapInContainer(entityWrapper); + + entityWrapper.id = 'div2'; + core.entity.entityMap.B = { + element: entityWrapper, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test2
                                                                      ' + ); + expect(div.childNodes[1]).toBe(container); + expect(div.childNodes[1].firstChild).toBe(entityWrapper); + }); + + it('HTML with block entity at root level, entity is already in editor | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1

                                                                      test2
                                                                      ', + } as any; + + const entityWrapper = document.createElement('DIV'); + const container = wrapInContainer(entityWrapper); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container); + div.appendChild(document.createTextNode('test2')); + + entityWrapper.id = 'div2'; + core.entity.entityMap.B = { + element: entityWrapper, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test2
                                                                      ' + ); + expect(div.childNodes[1]).toBe(container); + expect(div.childNodes[1].firstChild).toBe(entityWrapper); + }); + + it('HTML with double block entity at root level, entity is already in editor in the same order | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1

                                                                      test2

                                                                      test3
                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container1); + div.appendChild(document.createTextNode('test2')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test2
                                                                      test3
                                                                      ' + ); + expect(div.childNodes[1]).toBe(container1); + expect(div.childNodes[1].firstChild).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the same order, continuous in original DOM | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1

                                                                      test2

                                                                      test3
                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container1); + div.appendChild(container2); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test2
                                                                      test3
                                                                      ' + ); + expect(div.childNodes[1]).toBe(container1); + expect(div.childNodes[1].firstChild).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the same order, continuous in snapshot | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1


                                                                      test3
                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container1); + div.appendChild(document.createTextNode('test2')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test3
                                                                      ' + ); + expect(div.childNodes[1]).toBe(container1); + expect(div.childNodes[1].firstChild).toBe(entityWrapper1); + expect(div.childNodes[2]).toBe(container2); + expect(div.childNodes[2].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1

                                                                      test2

                                                                      test3
                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test2')); + div.appendChild(container1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test2
                                                                      test3
                                                                      ' + ); + expect(div.childNodes[1]).toBe(container1); + expect(div.childNodes[1].firstChild).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in original DOM | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1

                                                                      test2

                                                                      test3
                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(container1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test2
                                                                      test3
                                                                      ' + ); + expect(div.childNodes[1]).toBe(container1); + expect(div.childNodes[1].firstChild).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in snapshot | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1


                                                                      test3
                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test2')); + div.appendChild(container1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test3
                                                                      ' + ); + expect(div.childNodes[1]).toBe(container1); + expect(div.childNodes[1].firstChild).toBe(entityWrapper1); + expect(div.childNodes[2]).toBe(container2); + expect(div.childNodes[2].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in both | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1


                                                                      test3
                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(container1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test3
                                                                      ' + ); + expect(div.childNodes[1]).toBe(container1); + expect(div.childNodes[2]).toBe(container2); + expect(div.childNodes[1].firstChild).toBe(entityWrapper1); + expect(div.childNodes[2].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in both, no other nodes | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '


                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container1 = wrapInContainer(entityWrapper1); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(container2); + div.appendChild(container1); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      ' + ); + expect(div.childNodes[0]).toBe(container1); + expect(div.childNodes[1]).toBe(container2); + expect(div.childNodes[0].firstChild).toBe(entityWrapper1); + expect(div.childNodes[1].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the same order | blockEntityContainer and non container', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1

                                                                      test2

                                                                      test3
                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(entityWrapper1); + div.appendChild(document.createTextNode('test2')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test2
                                                                      test3
                                                                      ' + ); + expect(div.childNodes[1]).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the same order, continuous in original DOM | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1

                                                                      test2

                                                                      test3
                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(entityWrapper1); + div.appendChild(container2); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test2
                                                                      test3
                                                                      ' + ); + expect(div.childNodes[1]).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the same order, continuous in snapshot | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1


                                                                      test3
                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(entityWrapper1); + div.appendChild(document.createTextNode('test2')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test3
                                                                      ' + ); + expect(div.childNodes[1]).toBe(entityWrapper1); + expect(div.childNodes[2]).toBe(container2); + expect(div.childNodes[2].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1

                                                                      test2

                                                                      test3
                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test2')); + div.appendChild(entityWrapper1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test2
                                                                      test3
                                                                      ' + ); + expect(div.childNodes[1]).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in original DOM | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1

                                                                      test2

                                                                      test3
                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(entityWrapper1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test2
                                                                      test3
                                                                      ' + ); + expect(div.childNodes[1]).toBe(entityWrapper1); + expect(div.childNodes[3]).toBe(container2); + expect(div.childNodes[3].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in snapshot | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1


                                                                      test3
                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(document.createTextNode('test2')); + div.appendChild(entityWrapper1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test3
                                                                      ' + ); + expect(div.childNodes[1]).toBe(entityWrapper1); + expect(div.childNodes[2]).toBe(container2); + expect(div.childNodes[2].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in both | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '
                                                                      test1


                                                                      test3
                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(document.createTextNode('test1')); + div.appendChild(container2); + div.appendChild(entityWrapper1); + div.appendChild(document.createTextNode('test3')); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      test1
                                                                      test3
                                                                      ' + ); + expect(div.childNodes[1]).toBe(entityWrapper1); + expect(div.childNodes[2]).toBe(container2); + expect(div.childNodes[2].firstChild).toBe(entityWrapper2); + }); + + it('HTML with double block entity at root level, entity is already in editor in the reverse order, continuous in both, no other nodes | blockEntityContainer', () => { + const snapshot: Snapshot = { + html: + '


                                                                      ', + } as any; + + const entityWrapper1 = document.createElement('DIV'); + const entityWrapper2 = document.createElement('DIV'); + const container2 = wrapInContainer(entityWrapper2); + + div.appendChild(container2); + div.appendChild(entityWrapper1); + + entityWrapper1.id = 'divA'; + entityWrapper2.id = 'divB'; + + core.entity.entityMap.B1 = { + element: entityWrapper1, + }; + core.entity.entityMap.B2 = { + element: entityWrapper2, + }; + + restoreSnapshotHTML(core, snapshot); + + expect(div.innerHTML).toBe( + '
                                                                      ' + ); + expect(div.childNodes[0]).toBe(entityWrapper1); + expect(div.childNodes[1]).toBe(container2); + expect(div.childNodes[1].firstChild).toBe(entityWrapper2); + }); }); + +function wrapInContainer(entity: HTMLElement) { + const el = wrap(entity.ownerDocument, entity, 'div'); + el.className = '_E_EBlockEntityContainer'; + return el; +} diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts index feff4584d73..a5ee6271385 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts @@ -13,7 +13,9 @@ import type { ElementProcessor } from 'roosterjs-content-model-types'; * @param context DOM to Content Model context */ export const entityProcessor: ElementProcessor = (group, element, context) => { - const isBlockEntity = isBlockElement(element); + const isBlockEntity = + isBlockElement(element) || + (element.style.display == 'inline-block' && element.style.width == '100%'); stackFormat( context, diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts index dc0a493603e..a95979139dd 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts @@ -1,7 +1,12 @@ import toArray from './toArray'; +import { applyFormat } from '../modelToDom/utils/applyFormat'; import { isElementOfType } from './isElementOfType'; import { isNodeOfType } from './isNodeOfType'; -import type { ContentModelEntityFormat } from 'roosterjs-content-model-types'; +import type { + ContentModelEntityFormat, + ContentModelSegmentFormat, + ModelToDomContext, +} from 'roosterjs-content-model-types'; const ENTITY_INFO_NAME = '_Entity'; const ENTITY_TYPE_PREFIX = '_EType_'; @@ -61,7 +66,9 @@ export function generateEntityClassNames(format: ContentModelEntityFormat): stri } /** - * @internal + * Checks whether the node provided is a Entity delimiter + * @param node the node to check + * @return true if it is a delimiter */ export function isEntityDelimiter(element: HTMLElement): boolean { return ( @@ -75,16 +82,29 @@ export function isEntityDelimiter(element: HTMLElement): boolean { /** * Adds delimiters to the element provided. If the delimiters already exists, will not be added * @param element the node to add the delimiters + * @param format format to set to the delimiters, so when typing inside of one the format is not lost + * @param context Model to Dom context to use. */ -export function addDelimiters(doc: Document, element: HTMLElement): HTMLElement[] { +export function addDelimiters( + doc: Document, + element: HTMLElement, + format?: ContentModelSegmentFormat | null, + context?: ModelToDomContext +): HTMLElement[] { let [delimiterAfter, delimiterBefore] = getDelimiters(element); if (!delimiterAfter) { delimiterAfter = insertDelimiter(doc, element, true /*isAfter*/); + if (context && format) { + applyFormat(delimiterAfter, context.formatAppliers.segment, format, context); + } } if (!delimiterBefore) { delimiterBefore = insertDelimiter(doc, element, false /*isAfter*/); + if (context && format) { + applyFormat(delimiterBefore, context.formatAppliers.segment, format, context); + } } return [delimiterAfter, delimiterBefore]; 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 3cbb323ff43..0c2a9867a64 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -27,6 +27,7 @@ export { parseEntityClassName, generateEntityClassNames, addDelimiters, + isEntityDelimiter, } from './domUtils/entityUtils'; export { reuseCachedElement } from './domUtils/reuseCachedElement'; export { isWhiteSpacePreserved } from './domUtils/isWhiteSpacePreserved'; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts index 740118c4e64..eb5f392d8c8 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts @@ -6,14 +6,18 @@ import { wrap } from '../../domUtils/wrap'; import type { ContentModelBlockHandler, ContentModelEntity, + ContentModelSegmentFormat, ContentModelSegmentHandler, + ModelToDomContext, } from 'roosterjs-content-model-types'; +const BlockEntityContainer = '_E_EBlockEntityContainer'; + /** * @internal */ export const handleEntityBlock: ContentModelBlockHandler = ( - _, + doc, parent, entityModel, context, @@ -23,7 +27,23 @@ export const handleEntityBlock: ContentModelBlockHandler = ( applyFormat(wrapper, context.formatAppliers.entity, entityFormat, context); - refNode = reuseCachedElement(parent, wrapper, refNode); + const isCursorAroundEntity = + context.addDelimiterForEntity && + wrapper.style.display == 'inline-block' && + wrapper.style.width == '100%'; + const isContained = wrapper.parentElement?.classList.contains(BlockEntityContainer); + const elementToReuse = isContained && isCursorAroundEntity ? wrapper.parentElement! : wrapper; + + refNode = reuseCachedElement(parent, elementToReuse, refNode); + + if (isCursorAroundEntity) { + if (!isContained) { + const element = wrap(doc, wrapper, 'div'); + element.classList.add(BlockEntityContainer); + } + addDelimiters(doc, wrapper, getSegmentFormat(context), context); + } + context.onNodeCreated?.(entityModel, wrapper); return refNode; @@ -53,7 +73,7 @@ export const handleEntitySegment: ContentModelSegmentHandler applyFormat(wrapper, context.formatAppliers.entity, entityFormat, context); if (context.addDelimiterForEntity && entityFormat.isReadonly) { - const [after, before] = addDelimiters(doc, wrapper); + const [after, before] = addDelimiters(doc, wrapper, getSegmentFormat(context), context); newSegments?.push(after, before); context.regularSelection.current.segment = after; @@ -63,3 +83,11 @@ export const handleEntitySegment: ContentModelSegmentHandler context.onNodeCreated?.(entityModel, wrapper); }; +function getSegmentFormat( + context: ModelToDomContext +): ContentModelSegmentFormat | null | undefined { + return { + ...context.pendingFormat?.format, + ...context.defaultFormat, + }; +} diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts index b2c9cbd524d..06284b767ef 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts @@ -1,3 +1,4 @@ +import * as addBlock from '../../../lib/modelApi/common/addBlock'; import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { entityProcessor } from '../../../lib/domToModel/processors/entityProcessor'; @@ -284,4 +285,34 @@ describe('entityProcessor', () => { }); expect(onSegmentSpy).toHaveBeenCalledWith(span, paragraphModel, [entityModel]); }); + + it('Block element entity with Display: inline-block and width: 100%', () => { + const group = createContentModelDocument(); + const span = document.createElement('span'); + span.style.display = 'inline-block'; + span.style.width = '100%'; + spyOn(addBlock, 'addBlock').and.callThrough(); + + setEntityElementClasses(span, 'entity', true, 'entity_1'); + + entityProcessor(group, span, context); + + expect(addBlock.addBlock).toHaveBeenCalled(); + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Entity', + segmentType: 'Entity', + format: {}, + entityFormat: { + id: 'entity_1', + entityType: 'entity', + isReadonly: true, + }, + wrapper: span, + }, + ], + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts index 71f6472def0..cf98d8daffc 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts @@ -1,6 +1,7 @@ import * as entityUtils from '../../../lib/domUtils/entityUtils'; import { ContentModelEntity, ModelToDomContext } from 'roosterjs-content-model-types'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; +import { itChromeOnly } from '../../testUtils'; import { handleEntityBlock, handleEntitySegment, @@ -44,6 +45,37 @@ describe('handleEntity', () => { expect(entityUtils.addDelimiters).toHaveBeenCalledTimes(0); }); + itChromeOnly('Block entity with display: inline-block & width: 100%', () => { + const div = document.createElement('div'); + div.style.display = 'inline-block'; + div.style.width = '100%'; + + const entityModel: ContentModelEntity = { + blockType: 'Entity', + segmentType: 'Entity', + format: {}, + entityFormat: { + id: 'entity_1', + entityType: 'entity', + isReadonly: true, + }, + wrapper: div, + }; + + const parent = document.createElement('div'); + + context.addDelimiterForEntity = true; + handleEntityBlock(document, parent, entityModel, context, null); + + expect(parent.innerHTML).toBe( + '
                                                                      ' + ); + expect(div.outerHTML).toBe( + '
                                                                      ' + ); + expect(entityUtils.addDelimiters).toHaveBeenCalledTimes(1); + }); + it('Fake entity', () => { const div = document.createElement('div'); const entityModel: ContentModelEntity = { @@ -159,6 +191,7 @@ describe('handleEntity', () => { const entityDiv = ({ nextSibling: br, parentNode: parent, + style: {}, } as any) as HTMLElement; const entityModel: ContentModelEntity = { blockType: 'Entity', diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts index 3ae0d693d66..d4e64743f8c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts @@ -1,7 +1,6 @@ import { coreApiMap } from '../coreApi/coreApiMap'; import { createDarkColorHandler } from '../editor/DarkColorHandlerImpl'; import { createEditPlugin } from './EditPlugin'; -import { createEntityDelimiterPlugin } from './EntityDelimiterPlugin'; import { newEventToOldEvent, oldEventToNewEvent } from '../editor/utils/eventConverter'; import type { ContentModelCoreApiMap, @@ -40,9 +39,8 @@ export class BridgePlugin implements ContextMenuProvider { private experimentalFeatures: ExperimentalFeatures[] = [] ) { const editPlugin = createEditPlugin(); - const entityDelimiterPlugin = createEntityDelimiterPlugin(); - this.legacyPlugins = [editPlugin, ...legacyPlugins.filter(x => !!x), entityDelimiterPlugin]; + this.legacyPlugins = [editPlugin, ...legacyPlugins.filter(x => !!x)]; this.corePluginState = { edit: editPlugin.getState(), contextMenuProviders: this.legacyPlugins.filter(isContextMenuProvider), diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts deleted file mode 100644 index 95e8b9c6ffe..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityDelimiterPlugin.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { isCharacterValue } from 'roosterjs-content-model-core'; -import type { IContentModelEditor } from '../publicTypes/IContentModelEditor'; -import { - addDelimiters, - isBlockElement, - isEntityElement, - isNodeOfType, -} from 'roosterjs-content-model-dom'; -import { - DelimiterClasses, - Keys, - NodeType, - PluginEventType, - PositionType, - SelectionRangeTypes, -} from 'roosterjs-editor-types'; -import { - Position, - createRange, - getDelimiterFromElement, - getEntityFromElement, - getEntitySelector, - matchesSelector, - splitTextNode, -} from 'roosterjs-editor-dom'; -import type { - EditorPlugin, - IEditor, - PluginEvent, - PluginKeyDownEvent, -} from 'roosterjs-editor-types'; - -const DELIMITER_SELECTOR = - '.' + DelimiterClasses.DELIMITER_AFTER + ',.' + DelimiterClasses.DELIMITER_BEFORE; -const ZERO_WIDTH_SPACE = '\u200B'; -const INLINE_ENTITY_SELECTOR = 'span' + getEntitySelector(); - -/** - * @internal - * Entity delimiter plugin helps maintain delimiter elements around an entity so that user can put focus before/after an entity - */ -class EntityDelimiterPlugin implements EditorPlugin { - private editor: IContentModelEditor | null = null; - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'EntityDelimiter'; - } - - /** - * The first method that editor will call to a plugin when editor is initializing. - * It will pass in the editor instance, plugin should take this chance to save the - * editor reference so that it can call to any editor method or format API later. - * @param editor The editor object - */ - initialize(editor: IEditor) { - this.editor = editor as IContentModelEditor; - } - - /** - * The last method that editor will call to a plugin before it is disposed. - * Plugin can take this chance to clear the reference to editor. After this method is - * called, plugin should not call to any editor method since it will result in error. - */ - dispose() { - this.editor = null; - } - - /** - * Core method for a plugin. Once an event happens in editor, editor will call this - * method of each plugin to handle the event as long as the event is not handled - * exclusively by another plugin. - * @param event The event to handle: - */ - onPluginEvent(event: PluginEvent) { - if (this.editor) { - switch (event.eventType) { - case PluginEventType.ContentChanged: - case PluginEventType.EditorReady: - normalizeDelimitersInEditor(this.editor); - break; - - case PluginEventType.BeforePaste: - const { fragment } = event; - addDelimitersIfNeeded(fragment.querySelectorAll(INLINE_ENTITY_SELECTOR)); - - break; - - case PluginEventType.ExtractContentWithDom: - case PluginEventType.BeforeCutCopy: - event.clonedRoot.querySelectorAll(DELIMITER_SELECTOR).forEach(node => { - if (getDelimiterFromElement(node)) { - removeNode(node); - } else { - removeDelimiterAttr(node); - } - }); - break; - - case PluginEventType.KeyDown: - handleKeyDownEvent(this.editor, event); - break; - } - } - } -} - -function preventTypeInDelimiter(delimiter: HTMLElement) { - delimiter.normalize(); - const textNode = delimiter.firstChild as Node; - const index = textNode.nodeValue?.indexOf(ZERO_WIDTH_SPACE) ?? -1; - if (index >= 0) { - splitTextNode(textNode, index == 0 ? 1 : index, false /* returnFirstPart */); - let nodeToMove: Node | undefined; - delimiter.childNodes.forEach(node => { - if (node.nodeValue !== ZERO_WIDTH_SPACE) { - nodeToMove = node; - } - }); - if (nodeToMove) { - delimiter.parentElement?.insertBefore( - nodeToMove, - delimiter.className == DelimiterClasses.DELIMITER_BEFORE - ? delimiter - : delimiter.nextSibling - ); - const selection = nodeToMove.ownerDocument?.getSelection(); - - if (selection) { - selection.setPosition( - nodeToMove, - new Position(nodeToMove, PositionType.End).offset - ); - } - } - } -} - -/** - * @internal - */ -export function normalizeDelimitersInEditor(editor: IEditor) { - removeInvalidDelimiters(editor.queryElements(DELIMITER_SELECTOR)); - addDelimitersIfNeeded(editor.queryElements(INLINE_ENTITY_SELECTOR)); -} - -function addDelimitersIfNeeded(nodes: Element[] | NodeListOf) { - nodes.forEach(node => { - if ( - isNodeOfType(node, 'ELEMENT_NODE') && - isEntityElement(node) && - !node.isContentEditable - ) { - addDelimiters(node.ownerDocument, node as HTMLElement); - } - }); -} - -function removeNode(el: Node | undefined | null) { - el?.parentElement?.removeChild(el); -} - -function removeInvalidDelimiters(nodes: Element[] | NodeListOf) { - nodes.forEach(node => { - if (getDelimiterFromElement(node)) { - const sibling = node.classList.contains(DelimiterClasses.DELIMITER_BEFORE) - ? node.nextElementSibling - : node.previousElementSibling; - if (!(isNodeOfType(sibling, 'ELEMENT_NODE') && getEntityFromElement(sibling))) { - removeNode(node); - } - } else { - removeDelimiterAttr(node); - } - }); -} - -function removeDelimiterAttr(node: Element | undefined | null, checkEntity: boolean = true) { - if (!node) { - return; - } - - const isAfter = node.classList.contains(DelimiterClasses.DELIMITER_AFTER); - const entitySibling = isAfter ? node.previousElementSibling : node.nextElementSibling; - if (checkEntity && entitySibling && isEntityElement(entitySibling)) { - return; - } - - node.classList.remove(DelimiterClasses.DELIMITER_AFTER, DelimiterClasses.DELIMITER_BEFORE); - - node.normalize(); - node.childNodes.forEach(cn => { - const index = cn.textContent?.indexOf(ZERO_WIDTH_SPACE) ?? -1; - if (index >= 0) { - createRange(cn, index, cn, index + 1)?.deleteContents(); - } - }); -} - -function handleCollapsedEnter(editor: IEditor, delimiter: HTMLElement) { - const isAfter = delimiter.classList.contains(DelimiterClasses.DELIMITER_AFTER); - const entity = !isAfter ? delimiter.nextSibling : delimiter.previousSibling; - const block = getBlock(editor, delimiter); - - editor.runAsync(() => { - if (!block) { - return; - } - const blockToCheck = isAfter ? block.nextSibling : block.previousSibling; - if (blockToCheck && isNodeOfType(blockToCheck, 'ELEMENT_NODE')) { - const delimiters = blockToCheck.querySelectorAll(DELIMITER_SELECTOR); - // Check if the last or first delimiter still contain the delimiter class and remove it. - const delimiterToCheck = delimiters.item(isAfter ? 0 : delimiters.length - 1); - removeDelimiterAttr(delimiterToCheck); - } - - if (entity && isEntityElement(entity)) { - const entityElement = entity as HTMLElement; - const { nextElementSibling, previousElementSibling } = entityElement; - [nextElementSibling, previousElementSibling].forEach(el => { - // Check if after Enter the ZWS got removed but we still have a element with the class - // Remove the attributes of the element if it is invalid now. - if (el && matchesSelector(el, DELIMITER_SELECTOR) && !getDelimiterFromElement(el)) { - removeDelimiterAttr(el, false /* checkEntity */); - } - }); - - // Add delimiters to the entity if needed because on Enter we can sometimes lose the ZWS of the element. - addDelimiters(entityElement.ownerDocument, entityElement); - } - }); -} - -const getPosition = (container: HTMLElement | null) => { - if (container && getDelimiterFromElement(container)) { - const isAfter = container.classList.contains(DelimiterClasses.DELIMITER_AFTER); - return new Position(container, isAfter ? PositionType.After : PositionType.Before); - } - return undefined; -}; - -function getBlock(editor: IEditor, element: Node | undefined) { - if (!element) { - return undefined; - } - - let block = editor.getBlockElementAtNode(element)?.getStartNode(); - - while (block && (!isNodeOfType(block, 'ELEMENT_NODE') || !isBlockElement(block))) { - block = editor.contains(block.parentElement) ? block.parentElement! : undefined; - } - - return block; -} - -function handleSelectionNotCollapsed(editor: IEditor, range: Range, event: KeyboardEvent) { - const { startContainer, endContainer, startOffset, endOffset } = range; - - const startElement = editor.getElementAtCursor(DELIMITER_SELECTOR, startContainer); - const endElement = editor.getElementAtCursor(DELIMITER_SELECTOR, endContainer); - - const startUpdate = getPosition(startElement); - const endUpdate = getPosition(endElement); - - if (startUpdate || endUpdate) { - editor.select( - startUpdate ?? new Position(startContainer, startOffset), - endUpdate ?? new Position(endContainer, endOffset) - ); - } - editor.runAsync(aEditor => { - const delimiter = aEditor.getElementAtCursor(DELIMITER_SELECTOR); - if (delimiter) { - preventTypeInDelimiter(delimiter); - if (event.which === Keys.ENTER) { - removeDelimiterAttr(delimiter); - } - } - }); -} - -function handleKeyDownEvent(editor: IEditor, event: PluginKeyDownEvent) { - const range = editor.getSelectionRangeEx(); - const { rawEvent } = event; - if (range.type != SelectionRangeTypes.Normal) { - return; - } - - if (range.areAllCollapsed && (isCharacterValue(rawEvent) || rawEvent.which === Keys.ENTER)) { - const position = editor.getFocusedPosition()?.normalize(); - if (!position) { - return; - } - - const { element, node } = position; - const refNode = element == node ? element.childNodes.item(position.offset) : element; - - const delimiter = editor.getElementAtCursor(DELIMITER_SELECTOR, refNode); - if (!delimiter) { - return; - } - - if (rawEvent.which === Keys.ENTER) { - handleCollapsedEnter(editor, delimiter); - } else if (delimiter.firstChild?.nodeType == NodeType.Text) { - editor.runAsync(() => preventTypeInDelimiter(delimiter)); - } - } else if (!range.areAllCollapsed && !rawEvent.shiftKey && rawEvent.which != Keys.SHIFT) { - const currentRange = range.ranges[0]; - if (!currentRange) { - return; - } - handleSelectionNotCollapsed(editor, currentRange, rawEvent); - } -} - -/** - * @internal - * Create a new instance of EntityDelimiterPlugin. - */ -export function createEntityDelimiterPlugin(): EditorPlugin { - return new EntityDelimiterPlugin(); -} From 38f203d6d40a37c7c2fcdf7bb598dbe3bcd6a1ef Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 13 Feb 2024 09:26:17 -0800 Subject: [PATCH 103/112] Code rename: ContentModelEditor (#2411) * Code rename: ContentModelEditor * improve * fix test --- .../controls/ContentModelEditorMainPane.tsx | 18 +++++----- .../editor/ContentModelRooster.tsx | 8 ++--- .../contentModel/ContentModelPanePlugin.ts | 12 ++++--- .../insertEntity/InsertEntityPane.tsx | 9 +++-- .../codes/ContentModelEditorCode.ts | 2 +- .../ContentModelFormatStatePlugin.ts | 8 +++-- demo/scripts/tsconfig.json | 8 ++--- .../lib/coreApi/coreApiMap.ts | 9 ----- .../lib/index.ts | 10 ------ .../publicTypes/ContentModelCorePlugins.ts | 16 --------- .../lib/publicTypes/IContentModelEditor.ts | 36 ------------------- .../processPastedContentWacComponents.ts | 2 +- .../processPastedContentFromWordDesktop.ts | 2 +- .../test/autoFormat/AutoFormatPluginTest.ts | 7 ++-- .../test/edit/EditPluginTest.ts | 4 +-- .../lib/event/BeforePasteEvent.ts | 2 +- .../lib/createEditor.ts | 8 ++--- .../lib/coreApi/coreApiMap.ts | 9 +++++ .../lib/coreApi/insertNode.ts | 4 +-- .../lib/corePlugins/BridgePlugin.ts | 27 +++++++------- .../lib/corePlugins/EditPlugin.ts | 0 .../lib/editor/DarkColorHandlerImpl.ts | 0 .../lib/editor/EditorAdapter.ts | 24 ++++++------- .../lib/editor/utils/buildRangeEx.ts | 0 .../lib/editor/utils/eventConverter.ts | 6 ++-- .../lib/editor/utils/selectionConverter.ts | 0 .../roosterjs-editor-adapter/lib/index.ts | 9 +++++ .../publicTypes/BeforePasteAdapterEvent.ts | 4 +-- .../lib/publicTypes/EditorAdapterCore.ts | 31 ++++++++++------ .../lib/publicTypes/EditorAdapterOptions.ts | 30 ++++++++++++++++ .../roosterjs-editor-adapter}/package.json | 4 +-- .../test/corePlugins/BridgePluginTest.ts | 0 .../test/editor/DarkColorHandlerImplTest.ts | 0 .../test/editor/EditorAdapterTest.ts | 20 +++++------ .../test/editor/utils/eventConverterTest.ts | 6 ++-- .../editor/utils/selectionConverterTest.ts | 0 tools/tsconfig.doc.json | 2 +- 37 files changed, 161 insertions(+), 176 deletions(-) delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/index.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts create mode 100644 packages/roosterjs-editor-adapter/lib/coreApi/coreApiMap.ts rename {packages-content-model/roosterjs-content-model-editor => packages/roosterjs-editor-adapter}/lib/coreApi/insertNode.ts (98%) rename {packages-content-model/roosterjs-content-model-editor => packages/roosterjs-editor-adapter}/lib/corePlugins/BridgePlugin.ts (86%) rename {packages-content-model/roosterjs-content-model-editor => packages/roosterjs-editor-adapter}/lib/corePlugins/EditPlugin.ts (100%) rename {packages-content-model/roosterjs-content-model-editor => packages/roosterjs-editor-adapter}/lib/editor/DarkColorHandlerImpl.ts (100%) rename packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts => packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts (98%) rename {packages-content-model/roosterjs-content-model-editor => packages/roosterjs-editor-adapter}/lib/editor/utils/buildRangeEx.ts (100%) rename {packages-content-model/roosterjs-content-model-editor => packages/roosterjs-editor-adapter}/lib/editor/utils/eventConverter.ts (98%) rename {packages-content-model/roosterjs-content-model-editor => packages/roosterjs-editor-adapter}/lib/editor/utils/selectionConverter.ts (100%) create mode 100644 packages/roosterjs-editor-adapter/lib/index.ts rename packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelBeforePasteEvent.ts => packages/roosterjs-editor-adapter/lib/publicTypes/BeforePasteAdapterEvent.ts (85%) rename packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts => packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterCore.ts (69%) create mode 100644 packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts rename {packages-content-model/roosterjs-content-model-editor => packages/roosterjs-editor-adapter}/package.json (68%) rename {packages-content-model/roosterjs-content-model-editor => packages/roosterjs-editor-adapter}/test/corePlugins/BridgePluginTest.ts (100%) rename {packages-content-model/roosterjs-content-model-editor => packages/roosterjs-editor-adapter}/test/editor/DarkColorHandlerImplTest.ts (100%) rename packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts => packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts (93%) rename {packages-content-model/roosterjs-content-model-editor => packages/roosterjs-editor-adapter}/test/editor/utils/eventConverterTest.ts (99%) rename {packages-content-model/roosterjs-content-model-editor => packages/roosterjs-editor-adapter}/test/editor/utils/selectionConverterTest.ts (100%) diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 7232d0e5c2f..c7f0fb2bead 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -30,11 +30,11 @@ import { clearFormatButton } from './ribbonButtons/contentModel/clearFormatButto import { codeButton } from './ribbonButtons/contentModel/codeButton'; import { ContentModelRibbon } from './ribbonButtons/contentModel/ContentModelRibbon'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; -import { ContentModelSegmentFormat, Snapshots } from 'roosterjs-content-model-types'; import { createEmojiPlugin, createPasteOptionPlugin } from 'roosterjs-react'; import { darkMode } from './ribbonButtons/contentModel/darkMode'; import { decreaseFontSizeButton } from './ribbonButtons/contentModel/decreaseFontSizeButton'; import { decreaseIndentButton } from './ribbonButtons/contentModel/decreaseIndentButton'; +import { EditorAdapter, EditorAdapterOptions } from 'roosterjs-editor-adapter'; import { EditorPlugin } from 'roosterjs-editor-types'; import { exportContent } from './ribbonButtons/contentModel/export'; import { fontButton } from './ribbonButtons/contentModel/fontButton'; @@ -80,6 +80,11 @@ import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; import { underlineButton } from './ribbonButtons/contentModel/underlineButton'; import { undoButton } from './ribbonButtons/contentModel/undoButton'; import { zoom } from './ribbonButtons/contentModel/zoom'; +import { + ContentModelSegmentFormat, + IStandaloneEditor, + Snapshots, +} from 'roosterjs-content-model-types'; import { spaceAfterButton, spaceBeforeButton, @@ -92,11 +97,6 @@ import { tableMergeButton, tableSplitButton, } from './ribbonButtons/contentModel/tableEditButtons'; -import { - ContentModelEditor, - ContentModelEditorOptions, - IContentModelEditor, -} from 'roosterjs-content-model-editor'; const styles = require('./ContentModelEditorMainPane.scss'); @@ -155,7 +155,7 @@ const DarkTheme: PartialTheme = { }; interface ContentModelMainPaneState extends MainPaneBaseState { - editorCreator: (div: HTMLDivElement, options: ContentModelEditorOptions) => IContentModelEditor; + editorCreator: (div: HTMLDivElement, options: EditorAdapterOptions) => IStandaloneEditor; } class ContentModelEditorMainPane extends MainPaneBase { @@ -339,8 +339,8 @@ class ContentModelEditorMainPane extends MainPaneBase resetEditor() { this.toggleablePlugins = null; this.setState({ - editorCreator: (div: HTMLDivElement, options: ContentModelEditorOptions) => - new ContentModelEditor(div, { + editorCreator: (div: HTMLDivElement, options: EditorAdapterOptions) => + new EditorAdapter(div, { ...options, cacheModel: this.state.initState.cacheModel, }), diff --git a/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx b/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx index 717ec81bcfa..a0d5f6cb501 100644 --- a/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx +++ b/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; -import { ContentModelEditor, ContentModelEditorOptions } from 'roosterjs-content-model-editor'; import { createUIUtilities, ReactEditorPlugin, UIUtilities } from 'roosterjs-react'; import { divProperties, getNativeProps } from '@fluentui/react/lib/Utilities'; +import { EditorAdapter, EditorAdapterOptions } from 'roosterjs-editor-adapter'; import { useTheme } from '@fluentui/react/lib/Theme'; import { EditorPlugin, @@ -14,7 +14,7 @@ import type { EditorPlugin as LegacyEditorPlugin } from 'roosterjs-editor-types' * Properties for Rooster react component */ export interface ContentModelRoosterProps - extends ContentModelEditorOptions, + extends EditorAdapterOptions, React.HTMLAttributes { /** * Creator function used for creating the instance of roosterjs editor. @@ -86,8 +86,8 @@ function setUIUtilities( }); } -function defaultEditorCreator(div: HTMLDivElement, options: ContentModelEditorOptions) { - return new ContentModelEditor(div, options); +function defaultEditorCreator(div: HTMLDivElement, options: EditorAdapterOptions) { + return new EditorAdapter(div, options); } function isReactEditorPlugin( diff --git a/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts b/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts index 7fb8c1ad82b..b392e4c38cd 100644 --- a/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts +++ b/demo/scripts/controls/sidePane/contentModel/ContentModelPanePlugin.ts @@ -1,8 +1,8 @@ import ContentModelPane, { ContentModelPaneProps } from './ContentModelPane'; import SidePanePluginImpl from '../SidePanePluginImpl'; import { ContentModelRibbonPlugin } from '../../ribbonButtons/contentModel/ContentModelRibbonPlugin'; -import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { IEditor, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { setCurrentContentModel } from './currentModel'; import { SidePaneElementProps } from '../SidePaneElement'; @@ -20,7 +20,7 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< initialize(editor: IEditor): void { super.initialize(editor); - this.contentModelRibbon.initialize(editor as IContentModelEditor); // Temporarily use IContentModelEditor here. TODO: Port side pane to use IStandaloneEditor + this.contentModelRibbon.initialize(editor as IEditor & IStandaloneEditor); // TODO: Port side pane to use IStandaloneEditor editor.getDocument().addEventListener('selectionchange', this.onModelChangeFromSelection); } @@ -37,7 +37,9 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< if (e.eventType == PluginEventType.ContentChanged && e.source == 'RefreshModel') { this.getComponent(component => { // TODO: Port to use IStandaloneEditor and remove type cast here - const model = (this.editor as IContentModelEditor).getContentModelCopy('connected'); + const model = (this.editor as IEditor & IStandaloneEditor).getContentModelCopy( + 'connected' + ); component.setContentModel(model); setCurrentContentModel(model); }); @@ -72,7 +74,9 @@ export default class ContentModelPanePlugin extends SidePanePluginImpl< private onModelChange = () => { this.getComponent(component => { // TODO: Port to use IStandaloneEditor and remove type cast here - const model = (this.editor as IContentModelEditor).getContentModelCopy('connected'); + const model = (this.editor as IEditor & IStandaloneEditor).getContentModelCopy( + 'connected' + ); component.setContentModel(model); setCurrentContentModel(model); }); diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx index 47bba88757b..1e84cdeb8b7 100644 --- a/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx @@ -1,10 +1,9 @@ import * as React from 'react'; import ApiPaneProps from '../ApiPaneProps'; -import { Entity } from 'roosterjs-editor-types'; +import { Entity, IEditor } from 'roosterjs-editor-types'; import { getEntityFromElement, getEntitySelector } from 'roosterjs-editor-dom'; -import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { insertEntity } from 'roosterjs-content-model-api'; -import { InsertEntityOptions } from 'roosterjs-content-model-types'; +import { InsertEntityOptions, IStandaloneEditor } from 'roosterjs-content-model-types'; import { trustedHTMLHandler } from '../../../../utils/trustedHTMLHandler'; const styles = require('./InsertEntityPane.scss'); @@ -115,7 +114,7 @@ export default class InsertEntityPane extends React.Component[]; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts deleted file mode 100644 index f7c33c5f184..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ContentModelCoreApiMap } from './ContentModelEditorCore'; -import type { EditorPlugin, ExperimentalFeatures, IEditor } from 'roosterjs-editor-types'; -import type { StandaloneEditorOptions, IStandaloneEditor } from 'roosterjs-content-model-types'; - -/** - * An interface of editor with Content Model support. - * (This interface is still under development, and may still be changed in the future with some breaking changes) - */ -export interface IContentModelEditor extends IEditor, IStandaloneEditor {} - -/** - * Options for Content Model editor - */ -export interface ContentModelEditorOptions extends StandaloneEditorOptions { - /** - * Initial HTML content - * Default value is whatever already inside the editor content DIV - */ - initialContent?: string; - - /** - * A function map to override default core API implementation - * Default value is null - */ - legacyCoreApiOverride?: Partial; - - /** - * Specify the enabled experimental features - */ - experimentalFeatures?: ExperimentalFeatures[]; - - /** - * Legacy plugins using IEditor interface - */ - legacyPlugins?: EditorPlugin[]; -} diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts index 27693949f83..c0402df790a 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts @@ -213,7 +213,7 @@ const wacCommentParser: FormatParser = ( * Convert pasted content from Office Online * Once it is known that the document is from WAC * We need to remove the display property and margin from all the list item - * @param ev ContentModelBeforePasteEvent + * @param ev BeforePasteEvent */ export function processPastedContentWacComponents(ev: BeforePasteEvent) { addParser(ev.domToModelOption, 'segment', wacSubSuperParser); diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts index 3a9a762e60e..daf6497990e 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts @@ -22,7 +22,7 @@ const DEFAULT_BROWSER_LINE_HEIGHT_PERCENTAGE = 120; /** * @internal * Handles Pasted content when source is Word Desktop - * @param ev ContentModelBeforePasteEvent + * @param ev BeforePasteEvent */ export function processPastedContentFromWordDesktop( ev: BeforePasteEvent, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts index fa4309d80dd..1e762489d3c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/autoFormat/AutoFormatPluginTest.ts @@ -1,10 +1,9 @@ import * as keyboardTrigger from '../../lib/autoFormat/keyboardListTrigger'; import { AutoFormatPlugin } from '../../lib/autoFormat/AutoFormatPlugin'; -import { IContentModelEditor } from 'roosterjs-content-model-editor'; -import { KeyDownEvent } from 'roosterjs-content-model-types'; +import { IStandaloneEditor, KeyDownEvent } from 'roosterjs-content-model-types'; describe('Content Model Auto Format Plugin Test', () => { - let editor: IContentModelEditor; + let editor: IStandaloneEditor; beforeEach(() => { editor = ({ @@ -13,7 +12,7 @@ describe('Content Model Auto Format Plugin Test', () => { ({ type: -1, } as any), // Force return invalid range to go through content model code - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; }); describe('onPluginEvent', () => { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 732ba963285..7c0c2c5ac93 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -1,8 +1,8 @@ import * as keyboardDelete from '../../lib/edit/keyboardDelete'; import * as keyboardInput from '../../lib/edit/keyboardInput'; import * as keyboardTab from '../../lib/edit/keyboardTab'; -import { EditPlugin } from '../../lib/edit/EditPlugin'; import { DOMEventRecord, IStandaloneEditor } from 'roosterjs-content-model-types'; +import { EditPlugin } from '../../lib/edit/EditPlugin'; describe('EditPlugin', () => { let plugin: EditPlugin; @@ -78,7 +78,7 @@ describe('EditPlugin', () => { }); it('Tab', () => { - const plugin = new EditPlugin(); + plugin = new EditPlugin(); const rawEvent = { key: 'Tab' } as any; plugin.initialize(editor); diff --git a/packages-content-model/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts b/packages-content-model/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts index ddb1e75d68c..d08e76db901 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/event/BeforePasteEvent.ts @@ -17,7 +17,7 @@ export type MergePastedContentFunc = ( ) => InsertPoint | null; /** - * Data of ContentModelBeforePasteEvent + * Data of BeforePasteEvent */ export interface BeforePasteEvent extends BasePluginEvent<'beforePaste'> { /** diff --git a/packages-content-model/roosterjs-content-model/lib/createEditor.ts b/packages-content-model/roosterjs-content-model/lib/createEditor.ts index 880d3c2aad3..eed17791252 100644 --- a/packages-content-model/roosterjs-content-model/lib/createEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createEditor.ts @@ -8,12 +8,12 @@ import type { } from 'roosterjs-content-model-types'; /** - * Create a Content Model Editor using the given options + * Create a new Editor instance using the given options * @param contentDiv The html div element needed for creating the editor * @param additionalPlugins The additional user defined plugins. Currently the default plugins that are already included are - * ContentEdit, HyperLink and Paste, user don't need to add those. - * @param initialContent The initial content to show in editor. It can't be removed by undo, user need to manually remove it if needed. - * @returns The ContentModelEditor instance + * PastePlugin, EditPlugin, user don't need to add those. + * @param initialModel The initial content model to show in editor. It can't be removed by undo, user need to manually remove it if needed. + * @returns The Editor instance */ export function createEditor( contentDiv: HTMLDivElement, diff --git a/packages/roosterjs-editor-adapter/lib/coreApi/coreApiMap.ts b/packages/roosterjs-editor-adapter/lib/coreApi/coreApiMap.ts new file mode 100644 index 00000000000..edc7232911f --- /dev/null +++ b/packages/roosterjs-editor-adapter/lib/coreApi/coreApiMap.ts @@ -0,0 +1,9 @@ +import { insertNode } from './insertNode'; +import type { EditorAdapterCoreApiMap } from '../publicTypes/EditorAdapterCore'; + +/** + * @internal + */ +export const coreApiMap: EditorAdapterCoreApiMap = { + insertNode, +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts b/packages/roosterjs-editor-adapter/lib/coreApi/insertNode.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts rename to packages/roosterjs-editor-adapter/lib/coreApi/insertNode.ts index c1f40618293..4697f76ef07 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts +++ b/packages/roosterjs-editor-adapter/lib/coreApi/insertNode.ts @@ -17,7 +17,7 @@ import { splitParentNode, } from 'roosterjs-editor-dom'; import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; -import type { InsertNode } from '../publicTypes/ContentModelEditorCore'; +import type { InsertNode } from '../publicTypes/EditorAdapterCore'; function getInitialRange( core: StandaloneEditorCore, @@ -44,7 +44,7 @@ function getInitialRange( /** * @internal * Insert a DOM node into editor content - * @param core The ContentModelEditorCore object. No op if null. + * @param core The EditorAdapterCore object. No op if null. * @param option An insert option object to specify how to insert the node */ export const insertNode: InsertNode = (core, innerCore, node, option) => { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts similarity index 86% rename from packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts rename to packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts index d4e64743f8c..4721c2d0755 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/BridgePlugin.ts +++ b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts @@ -2,11 +2,7 @@ import { coreApiMap } from '../coreApi/coreApiMap'; import { createDarkColorHandler } from '../editor/DarkColorHandlerImpl'; import { createEditPlugin } from './EditPlugin'; import { newEventToOldEvent, oldEventToNewEvent } from '../editor/utils/eventConverter'; -import type { - ContentModelCoreApiMap, - ContentModelEditorCore, -} from '../publicTypes/ContentModelEditorCore'; -import type { ContentModelCorePluginState } from '../publicTypes/ContentModelCorePlugins'; +import type { EditorAdapterCoreApiMap, EditorAdapterCore } from '../publicTypes/EditorAdapterCore'; import type { EditorPlugin as LegacyEditorPlugin, PluginEvent as LegacyPluginEvent, @@ -14,6 +10,7 @@ import type { IEditor as ILegacyEditor, ExperimentalFeatures, SizeTransformer, + EditPluginState, } from 'roosterjs-editor-types'; import type { ContextMenuProvider, @@ -29,22 +26,21 @@ const ExclusivelyHandleEventPluginKey = '__ExclusivelyHandleEventPlugin'; */ export class BridgePlugin implements ContextMenuProvider { private legacyPlugins: LegacyEditorPlugin[]; - private corePluginState: ContentModelCorePluginState; + private edit: EditPluginState; + private contextMenuProviders: LegacyContextMenuProvider[]; private checkExclusivelyHandling: boolean; constructor( - private onInitialize: (core: ContentModelEditorCore) => ILegacyEditor, + private onInitialize: (core: EditorAdapterCore) => ILegacyEditor, legacyPlugins: LegacyEditorPlugin[] = [], - private legacyCoreApiOverride?: Partial, + private legacyCoreApiOverride?: Partial, private experimentalFeatures: ExperimentalFeatures[] = [] ) { const editPlugin = createEditPlugin(); this.legacyPlugins = [editPlugin, ...legacyPlugins.filter(x => !!x)]; - this.corePluginState = { - edit: editPlugin.getState(), - contextMenuProviders: this.legacyPlugins.filter(isContextMenuProvider), - }; + this.edit = editPlugin.getState(); + this.contextMenuProviders = this.legacyPlugins.filter(isContextMenuProvider); this.checkExclusivelyHandling = this.legacyPlugins.some( plugin => plugin.willHandleEventExclusively ); @@ -125,7 +121,7 @@ export class BridgePlugin implements ContextMenuProvider { getContextMenuItems(target: Node): any[] { const allItems: any[] = []; - this.corePluginState.contextMenuProviders.forEach(provider => { + this.contextMenuProviders.forEach(provider => { const items = provider.getContextMenuItems(target) ?? []; if (items?.length > 0) { if (allItems.length > 0) { @@ -139,7 +135,7 @@ export class BridgePlugin implements ContextMenuProvider { return allItems; } - private createEditorCore(editor: IStandaloneEditor): ContentModelEditorCore { + private createEditorCore(editor: IStandaloneEditor): EditorAdapterCore { return { api: { ...coreApiMap, ...this.legacyCoreApiOverride }, originalApi: coreApiMap, @@ -147,7 +143,8 @@ export class BridgePlugin implements ContextMenuProvider { experimentalFeatures: this.experimentalFeatures ?? [], sizeTransformer: createSizeTransformer(editor), darkColorHandler: createDarkColorHandler(editor.getColorManager()), - ...this.corePluginState, + edit: this.edit, + contextMenuProviders: this.contextMenuProviders, }; } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EditPlugin.ts b/packages/roosterjs-editor-adapter/lib/corePlugins/EditPlugin.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EditPlugin.ts rename to packages/roosterjs-editor-adapter/lib/corePlugins/EditPlugin.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts b/packages/roosterjs-editor-adapter/lib/editor/DarkColorHandlerImpl.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts rename to packages/roosterjs-editor-adapter/lib/editor/DarkColorHandlerImpl.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts rename to packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index 1358d7bded9..601cb9e9871 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts @@ -52,6 +52,7 @@ import type { TableSelection, DOMEventHandlerObject, DarkColorHandler, + IEditor, } from 'roosterjs-editor-types'; import { convertDomSelectionToRangeEx, @@ -85,15 +86,14 @@ import { toArray, wrap, } from 'roosterjs-editor-dom'; -import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; -import type { - ContentModelEditorOptions, - IContentModelEditor, -} from '../publicTypes/IContentModelEditor'; +import type { EditorAdapterCore } from '../publicTypes/EditorAdapterCore'; +import type { EditorAdapterOptions } from '../publicTypes/EditorAdapterOptions'; import type { ContentModelFormatState, DOMEventRecord, ExportContentMode, + IStandaloneEditor, + StandaloneEditorOptions, } from 'roosterjs-content-model-types'; const GetContentModeMap: Record = { @@ -108,15 +108,15 @@ const GetContentModeMap: Record = { * Editor for Content Model. * (This class is still under development, and may still be changed in the future with some breaking changes) */ -export class ContentModelEditor extends StandaloneEditor implements IContentModelEditor { - private contentModelEditorCore: ContentModelEditorCore | undefined; +export class EditorAdapter extends StandaloneEditor implements IEditor { + private contentModelEditorCore: EditorAdapterCore | undefined; /** * Creates an instance of Editor * @param contentDiv The DIV HTML element which will be the container element of editor * @param options An optional options object to customize the editor */ - constructor(contentDiv: HTMLDivElement, options: ContentModelEditorOptions = {}) { + constructor(contentDiv: HTMLDivElement, options: EditorAdapterOptions = {}) { const bridgePlugin = new BridgePlugin( core => { this.contentModelEditorCore = core; @@ -139,7 +139,7 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode options.defaultSegmentFormat ) : options.initialModel; - const standaloneEditorOptions: ContentModelEditorOptions = { + const standaloneEditorOptions: StandaloneEditorOptions = { ...options, plugins, initialModel, @@ -837,7 +837,7 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode * @param callback The callback function to run * @returns a function to cancel this async run */ - runAsync(callback: (editor: IContentModelEditor) => void) { + runAsync(callback: (editor: IEditor & IStandaloneEditor) => void) { const win = this.getCore().contentDiv.ownerDocument.defaultView || window; const handle = win.requestAnimationFrame(() => { if (!this.isDisposed() && callback) { @@ -1075,10 +1075,10 @@ export class ContentModelEditor extends StandaloneEditor implements IContentMode } /** - * @returns the current ContentModelEditorCore object + * @returns the current EditorAdapterCore object * @throws a standard Error if there's no core object */ - private getContentModelEditorCore(): ContentModelEditorCore { + private getContentModelEditorCore(): EditorAdapterCore { if (!this.contentModelEditorCore) { throw new Error('Editor is already disposed'); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/buildRangeEx.ts b/packages/roosterjs-editor-adapter/lib/editor/utils/buildRangeEx.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/utils/buildRangeEx.ts rename to packages/roosterjs-editor-adapter/lib/editor/utils/buildRangeEx.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts b/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts rename to packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts index 3fd1b936649..2ec3011c38d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/eventConverter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/utils/eventConverter.ts @@ -1,6 +1,6 @@ import { convertDomSelectionToRangeEx, convertRangeExToDomSelection } from './selectionConverter'; import { createDefaultHtmlSanitizerOptions } from 'roosterjs-editor-dom'; -import type { ContentModelBeforePasteEvent } from '../../publicTypes/ContentModelBeforePasteEvent'; +import type { BeforePasteAdapterEvent } from '../../publicTypes/BeforePasteAdapterEvent'; import { KnownAnnounceStrings as OldKnownAnnounceStrings, PasteType as OldPasteType, @@ -134,7 +134,7 @@ export function oldEventToNewEvent( case PluginEventType.BeforePaste: const refBeforePasteEvent = refEvent?.eventType == 'beforePaste' ? refEvent : undefined; - const cmBeforePasteEvent = input as ContentModelBeforePasteEvent; + const cmBeforePasteEvent = input as BeforePasteAdapterEvent; return { eventType: 'beforePaste', @@ -352,7 +352,7 @@ export function newEventToOldEvent(input: NewEvent, refEvent?: OldEvent): OldEve const refBeforePasteEvent = refEvent?.eventType == PluginEventType.BeforePaste ? refEvent : undefined; - const oldBeforePasteEvent: ContentModelBeforePasteEvent = { + const oldBeforePasteEvent: BeforePasteAdapterEvent = { eventType: PluginEventType.BeforePaste, clipboardData: input.clipboardData, eventDataCache: input.eventDataCache, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts b/packages/roosterjs-editor-adapter/lib/editor/utils/selectionConverter.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts rename to packages/roosterjs-editor-adapter/lib/editor/utils/selectionConverter.ts diff --git a/packages/roosterjs-editor-adapter/lib/index.ts b/packages/roosterjs-editor-adapter/lib/index.ts new file mode 100644 index 00000000000..479e992238c --- /dev/null +++ b/packages/roosterjs-editor-adapter/lib/index.ts @@ -0,0 +1,9 @@ +export { + EditorAdapterCore, + EditorAdapterCoreApiMap, + InsertNode, +} from './publicTypes/EditorAdapterCore'; +export { EditorAdapterOptions } from './publicTypes/EditorAdapterOptions'; +export { BeforePasteAdapterEvent } from './publicTypes/BeforePasteAdapterEvent'; + +export { EditorAdapter } from './editor/EditorAdapter'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelBeforePasteEvent.ts b/packages/roosterjs-editor-adapter/lib/publicTypes/BeforePasteAdapterEvent.ts similarity index 85% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelBeforePasteEvent.ts rename to packages/roosterjs-editor-adapter/lib/publicTypes/BeforePasteAdapterEvent.ts index b553201fb23..c86baee478d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelBeforePasteEvent.ts +++ b/packages/roosterjs-editor-adapter/lib/publicTypes/BeforePasteAdapterEvent.ts @@ -5,9 +5,9 @@ import type { } from 'roosterjs-content-model-types'; /** - * A temporary event type to be compatible with both legacy plugin and content model editor + * A temporary event type to be compatible with both legacy plugin and EditorAdapter */ -export interface ContentModelBeforePasteEvent extends BeforePasteEvent { +export interface BeforePasteAdapterEvent extends BeforePasteEvent { /** * domToModel Options to use when creating the content model from the paste fragment */ diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts b/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterCore.ts similarity index 69% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts rename to packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterCore.ts index c646607741c..b76449b5c63 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts +++ b/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterCore.ts @@ -1,4 +1,3 @@ -import type { ContentModelCorePluginState } from './ContentModelCorePlugins'; import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; import type { CustomData, @@ -6,28 +5,30 @@ import type { InsertOption, SizeTransformer, DarkColorHandler, + EditPluginState, + ContextMenuProvider, } from 'roosterjs-editor-types'; /** * Insert a DOM node into editor content - * @param core The ContentModelEditorCore object. No op if null. + * @param core The EditorAdapterCore object. No op if null. * @param innerCore The StandaloneEditorCore object * @param option An insert option object to specify how to insert the node */ export type InsertNode = ( - core: ContentModelEditorCore, + core: EditorAdapterCore, innerCore: StandaloneEditorCore, node: Node, option: InsertOption | null ) => boolean; /** - * Core API map for Content Model editor + * Core API map for editor adapter */ -export interface ContentModelCoreApiMap { +export interface EditorAdapterCoreApiMap { /** * Insert a DOM node into editor content - * @param core The ContentModelEditorCore object. No op if null. + * @param core The EditorAdapterCore object. No op if null. * @param innerCore The StandaloneEditorCore object * @param option An insert option object to specify how to insert the node */ @@ -35,18 +36,18 @@ export interface ContentModelCoreApiMap { } /** - * Represents the core data structure of a Content Model editor + * Represents the core data structure of a editor adapter */ -export interface ContentModelEditorCore extends ContentModelCorePluginState { +export interface EditorAdapterCore { /** * Core API map of this editor */ - readonly api: ContentModelCoreApiMap; + readonly api: EditorAdapterCoreApiMap; /** * Original API map of this editor. Overridden core API can use API from this map to call the original version of core API. */ - readonly originalApi: ContentModelCoreApiMap; + readonly originalApi: EditorAdapterCoreApiMap; /** * Custom data of this editor @@ -64,6 +65,16 @@ export interface ContentModelEditorCore extends ContentModelCorePluginState { */ readonly darkColorHandler: DarkColorHandler; + /** + * Plugin state of EditPlugin + */ + readonly edit: EditPluginState; + + /** + * Context Menu providers + */ + readonly contextMenuProviders: ContextMenuProvider[]; + /** * @deprecated Use zoomScale instead */ diff --git a/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts b/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts new file mode 100644 index 00000000000..2c1006f8daa --- /dev/null +++ b/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts @@ -0,0 +1,30 @@ +import type { StandaloneEditorOptions } from 'roosterjs-content-model-types'; +import type { EditorAdapterCoreApiMap } from './EditorAdapterCore'; +import type { EditorPlugin, ExperimentalFeatures } from 'roosterjs-editor-types'; + +/** + * Options for editor adapter + */ +export interface EditorAdapterOptions extends StandaloneEditorOptions { + /** + * Initial HTML content + * Default value is whatever already inside the editor content DIV + */ + initialContent?: string; + + /** + * A function map to override default core API implementation + * Default value is null + */ + legacyCoreApiOverride?: Partial; + + /** + * Specify the enabled experimental features + */ + experimentalFeatures?: ExperimentalFeatures[]; + + /** + * Legacy plugins using IEditor interface + */ + legacyPlugins?: EditorPlugin[]; +} diff --git a/packages-content-model/roosterjs-content-model-editor/package.json b/packages/roosterjs-editor-adapter/package.json similarity index 68% rename from packages-content-model/roosterjs-content-model-editor/package.json rename to packages/roosterjs-editor-adapter/package.json index 91e09718abb..9248d2db571 100644 --- a/packages-content-model/roosterjs-content-model-editor/package.json +++ b/packages/roosterjs-editor-adapter/package.json @@ -1,6 +1,6 @@ { - "name": "roosterjs-content-model-editor", - "description": "Content Model for roosterjs (Under development)", + "name": "roosterjs-editor-adapter", + "description": "An adapter on top of Content Model based editor to work with legacy roosterjs editor", "dependencies": { "tslib": "^2.3.1", "roosterjs-editor-types": "", diff --git a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/corePlugins/BridgePluginTest.ts rename to packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/DarkColorHandlerImplTest.ts b/packages/roosterjs-editor-adapter/test/editor/DarkColorHandlerImplTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/editor/DarkColorHandlerImplTest.ts rename to packages/roosterjs-editor-adapter/test/editor/DarkColorHandlerImplTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts similarity index 93% rename from packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts rename to packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts index 55b31bf529c..55bb8dc7381 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts @@ -1,7 +1,7 @@ import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as findAllEntities from 'roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities'; -import { ContentModelEditor } from '../../lib/editor/ContentModelEditor'; +import { EditorAdapter } from '../../lib/editor/EditorAdapter'; import { EditorPlugin, PluginEventType } from 'roosterjs-editor-types'; import { ContentModelDocument, @@ -13,7 +13,7 @@ const editorContext: EditorContext = { isDarkMode: false, }; -describe('ContentModelEditor', () => { +describe('EditorAdapter', () => { it('domToContentModel', () => { const mockedResult = 'Result' as any; const mockedContext = 'MockedContext' as any; @@ -27,7 +27,7 @@ describe('ContentModelEditor', () => { spyOn(findAllEntities, 'findAllEntities'); const div = document.createElement('div'); - const editor = new ContentModelEditor(div, { + const editor = new EditorAdapter(div, { coreApiOverride: { createEditorContext: jasmine .createSpy('createEditorContext') @@ -64,7 +64,7 @@ describe('ContentModelEditor', () => { spyOn(findAllEntities, 'findAllEntities'); const div = document.createElement('div'); - const editor = new ContentModelEditor(div, { + const editor = new EditorAdapter(div, { coreApiOverride: { createEditorContext: jasmine .createSpy('createEditorContext') @@ -107,7 +107,7 @@ describe('ContentModelEditor', () => { } }, }; - const editor = new ContentModelEditor(div, { + const editor = new EditorAdapter(div, { legacyPlugins: [plugin], }); editor.dispose(); @@ -137,7 +137,7 @@ describe('ContentModelEditor', () => { it('get model with cache', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div); + const editor = new EditorAdapter(div); const cachedModel = 'MODEL' as any; (editor as any).core.cache.cachedModel = cachedModel; @@ -152,7 +152,7 @@ describe('ContentModelEditor', () => { it('formatContentModel', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div); + const editor = new EditorAdapter(div); const core = (editor as any).core; const formatContentModelSpy = spyOn(core.api, 'formatContentModel'); const callback = jasmine.createSpy('callback'); @@ -165,7 +165,7 @@ describe('ContentModelEditor', () => { it('default format', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div, { + const editor = new EditorAdapter(div, { defaultSegmentFormat: { fontWeight: 'bold', italic: true, @@ -192,7 +192,7 @@ describe('ContentModelEditor', () => { it('getPendingFormat', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div); + const editor = new EditorAdapter(div); const core: StandaloneEditorCore = (editor as any).core; const mockedFormat = 'FORMAT' as any; @@ -209,7 +209,7 @@ describe('ContentModelEditor', () => { const div = document.createElement('div'); div.style.fontFamily = 'Arial'; - const editor = new ContentModelEditor(div); + const editor = new EditorAdapter(div); expect(div.style.fontFamily).toBe('Arial'); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts b/packages/roosterjs-editor-adapter/test/editor/utils/eventConverterTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts rename to packages/roosterjs-editor-adapter/test/editor/utils/eventConverterTest.ts index 581971e0e6c..0860987e5bd 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/eventConverterTest.ts +++ b/packages/roosterjs-editor-adapter/test/editor/utils/eventConverterTest.ts @@ -8,7 +8,7 @@ import { } from 'roosterjs-editor-types'; import type { ContentChangedEvent, PluginEvent as OldEvent } from 'roosterjs-editor-types'; import type { PluginEvent as NewEvent } from 'roosterjs-content-model-types'; -import type { ContentModelBeforePasteEvent } from '../../../lib/publicTypes/ContentModelBeforePasteEvent'; +import type { BeforePasteAdapterEvent } from '../../../lib/publicTypes/BeforePasteAdapterEvent'; describe('oldEventToNewEvent', () => { function runTest( @@ -746,7 +746,7 @@ describe('newEventToOldEvent', () => { }, customizedMerge: mockedCustomizedMerge, domToModelOption: mockedDomToModelOption, - } as ContentModelBeforePasteEvent + } as BeforePasteAdapterEvent ); }); @@ -796,7 +796,7 @@ describe('newEventToOldEvent', () => { sanitizingOption: mockedSanitizeOption, customizedMerge: mockedCustomizedMerge, domToModelOption: mockedDomToModelOption, - } as ContentModelBeforePasteEvent + } as BeforePasteAdapterEvent ); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts b/packages/roosterjs-editor-adapter/test/editor/utils/selectionConverterTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts rename to packages/roosterjs-editor-adapter/test/editor/utils/selectionConverterTest.ts diff --git a/tools/tsconfig.doc.json b/tools/tsconfig.doc.json index 6c968646613..b5da40f7c30 100644 --- a/tools/tsconfig.doc.json +++ b/tools/tsconfig.doc.json @@ -36,7 +36,7 @@ "../packages-content-model/roosterjs-content-model-core/lib/index.ts", "../packages-content-model/roosterjs-content-model-api/lib/index.ts", "../packages-content-model/roosterjs-content-model-plugins/lib/index.ts", - "../packages-content-model/roosterjs-content-model-editor/lib/index.ts", + "../packages/roosterjs-editor-adapter/lib/index.ts", "../packages-content-model/roosterjs-content-model/lib/index.ts" ], "plugin": ["typedoc-plugin-remove-references", "typedoc-plugin-external-module-map"], From a9b031dd2e897f2a44cf465e68b8305453d61e27 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 13 Feb 2024 12:19:12 -0800 Subject: [PATCH 104/112] Outdent parent format container if necessary (#2410) * Outdent parent format container if necessary * Fix test * add test * improve * fix test --- .../lib/modelApi/block/setModelIndentation.ts | 52 +++++-- .../modelApi/block/toggleModelBlockQuote.ts | 6 +- .../modelApi/block/setModelIndentationTest.ts | 134 ++++++++++++++++++ .../publicApi/selection/collectSelections.ts | 7 + .../selection/collectSelectionsTest.ts | 23 +-- 5 files changed, 199 insertions(+), 23 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts index ce7158d17e6..4d09cfa42a7 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts @@ -7,6 +7,7 @@ import { } from 'roosterjs-content-model-core'; import type { + ContentModelBlock, ContentModelBlockFormat, ContentModelBlockGroup, ContentModelDocument, @@ -33,8 +34,9 @@ export function setModelIndentation( ['TableCell'] ); const isIndent = indentation == 'indent'; + const modifiedBlocks: ContentModelBlock[] = []; - paragraphOrListItem.forEach(({ block, parent }, index) => { + paragraphOrListItem.forEach(({ block, parent, path }) => { if (isBlockGroupOfType(block, 'ListItem')) { const thread = findListItemsInSameThread(model, block); const firstItem = thread[0]; @@ -49,7 +51,7 @@ export function setModelIndentation( if (!isIndent && originalValue == 0) { block.levels.pop(); - } else { + } else if (newValue !== null) { if (isRtl) { level.format.marginRight = newValue + 'px'; } else { @@ -80,14 +82,34 @@ export function setModelIndentation( } } } else if (block) { - const { format } = block; - const newValue = calculateMarginValue(format, isIndent, length); - const isRtl = format.direction == 'rtl'; - - if (isRtl) { - format.marginRight = newValue + 'px'; - } else { - format.marginLeft = newValue + 'px'; + let currentBlock: ContentModelBlock = block; + let currentParent: ContentModelBlockGroup = parent; + + while (currentParent && modifiedBlocks.indexOf(currentBlock) < 0) { + const index = path.indexOf(currentParent); + const { format } = currentBlock; + const newValue = calculateMarginValue(format, isIndent, length); + + if (newValue !== null) { + const isRtl = format.direction == 'rtl'; + + if (isRtl) { + format.marginRight = newValue + 'px'; + } else { + format.marginLeft = newValue + 'px'; + } + + modifiedBlocks.push(currentBlock); + + break; + } else if (currentParent.blockGroupType == 'FormatContainer' && index >= 0) { + delete currentParent.cachedElement; + + currentBlock = currentParent; + currentParent = path[index + 1]; + } else { + break; + } } } }); @@ -135,7 +157,7 @@ function calculateMarginValue( format: ContentModelBlockFormat, isIndent: boolean, length: number = IndentStepInPixel -) { +): number | null { const { marginLeft, marginRight, direction } = format; const isRtl = direction == 'rtl'; const originalValue = parseValueWithUnit(isRtl ? marginRight : marginLeft); @@ -144,5 +166,11 @@ function calculateMarginValue( if (newValue == originalValue) { newValue = Math.max(newValue + length * (isIndent ? 1 : -1), 0); } - return newValue; + + if (newValue == originalValue) { + // Return null to let caller know nothing is changed + return null; + } else { + return newValue; + } } diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts index 6272112a10c..d1cc624b43c 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts @@ -70,6 +70,10 @@ function isQuote(block: ContentModelBlock): block is ContentModelFormatContainer function areAllBlockQuotes( blockAndParents: OperationalBlocks[] -): blockAndParents is { block: ContentModelFormatContainer; parent: ContentModelBlockGroup }[] { +): blockAndParents is { + block: ContentModelFormatContainer; + parent: ContentModelBlockGroup; + path: ContentModelBlockGroup[]; +}[] { return blockAndParents.every(blockAndParent => isQuote(blockAndParent.block)); } diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts index 5c5149f5190..f3b72328752 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts @@ -1,6 +1,7 @@ import { setModelIndentation } from '../../../lib/modelApi/block/setModelIndentation'; import { createContentModelDocument, + createFormatContainer, createListItem, createListLevel, createParagraph, @@ -1161,4 +1162,137 @@ describe('outdent', () => { expect(result).toBeTrue(); }); + + it('Outdent parent format container, ltr', () => { + const group = createContentModelDocument(); + const formatContainer = createFormatContainer('div'); + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + + text2.isSelected = true; + formatContainer.format.marginLeft = '100px'; + formatContainer.format.marginRight = '60px'; + + para1.segments.push(text1); + para2.segments.push(text2); + para3.segments.push(text3); + + formatContainer.blocks.push(para1, para2); + group.blocks.push(formatContainer, para3); + + const result = setModelIndentation(group, 'outdent'); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'div', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test1', format: {} }], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: { marginLeft: '80px', marginRight: '60px' }, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test3', format: {} }], + format: {}, + }, + ], + }); + + expect(result).toBeTrue(); + }); + + it('Outdent parent format container, rtl', () => { + const group = createContentModelDocument(); + const formatContainer = createFormatContainer('div'); + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + + text1.isSelected = true; + text2.isSelected = true; + formatContainer.format.marginLeft = '100px'; + formatContainer.format.marginRight = '60px'; + formatContainer.format.direction = 'rtl'; + + para1.segments.push(text1); + para2.segments.push(text2); + para3.segments.push(text3); + + formatContainer.blocks.push(para1, para2); + group.blocks.push(formatContainer, para3); + + const result = setModelIndentation(group, 'outdent'); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'div', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + format: { marginLeft: '100px', marginRight: '40px', direction: 'rtl' }, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test3', format: {} }], + format: {}, + }, + ], + }); + + expect(result).toBeTrue(); + }); }); diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/collectSelections.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/collectSelections.ts index bf9ce4cd9b8..b70b54c1d08 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/collectSelections.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/collectSelections.ts @@ -28,6 +28,11 @@ export type OperationalBlocks = { * The child block */ block: ContentModelBlock | T; + + /** + * Selection path of this block + */ + path: ContentModelBlockGroup[]; }; /** @@ -119,6 +124,7 @@ export function getOperationalBlocks( result.push({ parent: path[groupIndex + 1], block: path[groupIndex] as T, + path: path.slice(groupIndex + 1), }); } break; @@ -126,6 +132,7 @@ export function getOperationalBlocks( result.push({ parent: path[0], block: block, + path, }); break; } diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/collectSelectionsTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/collectSelectionsTest.ts index e1d56510259..afc3fe35601 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/collectSelectionsTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/collectSelectionsTest.ts @@ -715,7 +715,7 @@ describe('getOperationalBlocks', () => { ['ListItem'], ['TableCell'], false, - [{ block: para, parent: group }] + [{ block: para, parent: group, path: [group] }] ); }); @@ -737,10 +737,12 @@ describe('getOperationalBlocks', () => { { block: listItem, parent: group, + path: [group], }, { block: para2, parent: group, + path: [group], }, ] ); @@ -763,8 +765,8 @@ describe('getOperationalBlocks', () => { ['TableCell'], false, [ - { block: listItem, parent: group }, - { block: para3, parent: group }, + { block: listItem, parent: group, path: [group] }, + { block: para3, parent: group, path: [group] }, ] ); }); @@ -786,8 +788,8 @@ describe('getOperationalBlocks', () => { ['FormatContainer'], false, [ - { block: listItem1, parent: group }, - { block: para2, parent: quote }, + { block: listItem1, parent: group, path: [group] }, + { block: para2, parent: quote, path: [quote, listItem2, group] }, ] ); }); @@ -811,8 +813,9 @@ describe('getOperationalBlocks', () => { { block: listItem, parent: group, + path: [group], }, - { block: quote, parent: group }, + { block: quote, parent: group, path: [group] }, ] ); }); @@ -834,8 +837,8 @@ describe('getOperationalBlocks', () => { ['TableCell'], false, [ - { block: quote1, parent: listItem }, - { block: quote2, parent: group }, + { block: quote1, parent: listItem, path: [listItem, group] }, + { block: quote2, parent: group, path: [group] }, ] ); }); @@ -857,8 +860,8 @@ describe('getOperationalBlocks', () => { ['TableCell'], true, [ - { block: listItem, parent: group }, - { block: quote2, parent: group }, + { block: listItem, parent: group, path: [group] }, + { block: quote2, parent: group, path: [group] }, ] ); }); From 55ed264deea03c7f3fae7a2c34ff26c83144f302 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 13 Feb 2024 13:15:54 -0800 Subject: [PATCH 105/112] Fix demo site (#2413) * fix demo site * improve --------- Co-authored-by: Bryan Valverde U --- tools/buildTools/buildDemo.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/buildTools/buildDemo.js b/tools/buildTools/buildDemo.js index 445fb90b28f..478c9151d33 100644 --- a/tools/buildTools/buildDemo.js +++ b/tools/buildTools/buildDemo.js @@ -79,7 +79,7 @@ async function buildDemoSite() { [/^roosterjs-react$/, 'roosterjsReact'], [/^roosterjs-content-model((?!-editor).)*\/.*$/, 'roosterjsContentModel'], ], - [] + ['roosterjs-editor-adapter'] ), stats: 'minimal', mode: 'production', From 766d2785d4b22d181b8eeb91e6a3bb395af61774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 15 Feb 2024 12:34:08 -0300 Subject: [PATCH 106/112] fix enter + shift --- .../lib/edit/inputSteps/handleEnterOnList.ts | 22 +++----------- .../lib/edit/keyboardInput.ts | 2 +- .../test/edit/editingTestCommon.ts | 12 ++++++-- .../edit/inputSteps/handleEnterOnListTest.ts | 29 +++++++------------ 4 files changed, 25 insertions(+), 40 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts index 64c7f3cfabd..3e5cca47e50 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -26,21 +26,17 @@ export const handleEnterOnList: DeleteSelectionStep = context => { ) { const { insertPoint, formatContext } = context; const { path } = insertPoint; - const rawEvent = formatContext?.rawEvent as KeyboardEvent | undefined; + const rawEvent = formatContext?.rawEvent; const index = getClosestAncestorBlockGroupIndex(path, ['ListItem'], ['TableCell']); const listItem = path[index]; if (listItem && listItem.blockGroupType === 'ListItem') { const listParent = path[index + 1]; - if (rawEvent?.shiftKey) { - insertParagraphAfterListItem(listParent, listItem, insertPoint); + if (isEmptyListItem(listItem)) { + listItem.levels.pop(); } else { - if (isEmptyListItem(listItem)) { - listItem.levels.pop(); - } else { - createNewListItem(context, listItem, listParent); - } + createNewListItem(context, listItem, listParent); } rawEvent?.preventDefault(); context.deleteResult = 'range'; @@ -48,16 +44,6 @@ export const handleEnterOnList: DeleteSelectionStep = context => { } }; -const insertParagraphAfterListItem = ( - listParent: ContentModelBlockGroup, - listItem: ContentModelListItem, - insertPoint: InsertPoint -) => { - const paragraph = createNewParagraph(insertPoint); - const index = listParent.blocks.indexOf(listItem); - listParent.blocks.splice(index + 1, 0, paragraph); -}; - const isEmptyListItem = (listItem: ContentModelListItem) => { return ( listItem.blocks.length === 1 && diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index e04765eed38..547a362f106 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -70,5 +70,5 @@ function shouldInputWithContentModel( } const shouldHandleEnterKey = (selection: DOMSelection | null, rawEvent: KeyboardEvent) => { - return selection && selection.type == 'range' && rawEvent.key == 'Enter'; + return selection && selection.type == 'range' && rawEvent.key == 'Enter' && !rawEvent.shiftKey; }; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts index 352a357bf4f..1ff6f52eb16 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts @@ -10,7 +10,8 @@ export function editingTestCommon( executionCallback: (editor: IStandaloneEditor) => void, model: ContentModelDocument, result: ContentModelDocument, - calledTimes: number + calledTimes: number, + doNotCallDefaultFormat?: boolean ) { const triggerEvent = jasmine.createSpy('triggerEvent'); @@ -39,6 +40,11 @@ export function editingTestCommon( executionCallback(editor); expect(model).toEqual(result); - expect(formatContentModel).toHaveBeenCalledTimes(1); - expect(formatResult).toBe(calledTimes > 0); + if (doNotCallDefaultFormat) { + expect(formatContentModel).not.toHaveBeenCalled(); + } else { + expect(formatContentModel).toHaveBeenCalledTimes(1); + } + + expect(!!formatResult).toBe(calledTimes > 0); } diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts index 2234e87b918..f11408881aa 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -1454,7 +1454,9 @@ describe('keyboardInput - handleEnterOnList', () => { function runTest( input: ContentModelDocument, isShiftKey: boolean, - expectedResult: ContentModelDocument + expectedResult: ContentModelDocument, + doNotCallDefaultFormat: boolean = false, + calledTimes: number = 1 ) { const preventDefault = jasmine.createSpy('preventDefault'); const mockedEvent = ({ @@ -1481,7 +1483,8 @@ describe('keyboardInput - handleEnterOnList', () => { }, input, expectedResult, - 1 + calledTimes, + doNotCallDefaultFormat ); } @@ -2041,6 +2044,11 @@ describe('keyboardInput - handleEnterOnList', () => { text: 'test', format: {}, }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, ], format: {}, }, @@ -2065,24 +2073,9 @@ describe('keyboardInput - handleEnterOnList', () => { }, format: {}, }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - format: {}, - }, ], format: {}, }; - runTest(input, true, expected); + runTest(input, true, expected, true, 0); }); }); From bd5e1b1952ed962204157bc8da837326041138f6 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 15 Feb 2024 08:38:52 -0800 Subject: [PATCH 107/112] Improve default format behavior (#2406) * apply default format * improve * improve * add test * improve --- .../lib/corePlugin/FormatPlugin.ts | 80 +++++++++-- .../corePlugin/utils/applyDefaultFormat.ts | 32 +---- .../test/corePlugin/FormatPluginTest.ts | 135 +++++++++++++++--- .../utils/applyDefaultFormatTest.ts | 50 ++++++- 4 files changed, 236 insertions(+), 61 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts index 7fbacf0b734..6c2dd0d93ed 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts @@ -1,17 +1,30 @@ import { applyDefaultFormat } from './utils/applyDefaultFormat'; import { applyPendingFormat } from './utils/applyPendingFormat'; -import { getObjectKeys } from 'roosterjs-content-model-dom'; +import { getObjectKeys, isBlockElement, isNodeOfType } from 'roosterjs-content-model-dom'; import { isCharacterValue, isCursorMovingKey } from '../publicApi/domUtils/eventUtils'; import type { + BackgroundColorFormat, + FontFamilyFormat, + FontSizeFormat, FormatPluginState, IStandaloneEditor, PluginEvent, PluginWithState, StandaloneEditorOptions, + TextColorFormat, } from 'roosterjs-content-model-types'; // During IME input, KeyDown event will have "Process" as key const ProcessKey = 'Process'; +const DefaultStyleKeyMap: Record< + keyof (FontFamilyFormat & FontSizeFormat & TextColorFormat & BackgroundColorFormat), + keyof CSSStyleDeclaration +> = { + backgroundColor: 'backgroundColor', + textColor: 'color', + fontFamily: 'fontFamily', + fontSize: 'fontSize', +}; /** * FormatPlugin plugins helps editor to do formatting on top of content model. @@ -20,8 +33,9 @@ const ProcessKey = 'Process'; */ class FormatPlugin implements PluginWithState { private editor: IStandaloneEditor | null = null; - private hasDefaultFormat = false; + private defaultFormatKeys: Set; private state: FormatPluginState; + private lastCheckedNode: Node | null = null; /** * Construct a new instance of FormatPlugin class @@ -32,6 +46,14 @@ class FormatPlugin implements PluginWithState { defaultFormat: { ...option.defaultSegmentFormat }, pendingFormat: null, }; + + this.defaultFormatKeys = new Set(); + + getObjectKeys(DefaultStyleKeyMap).forEach(key => { + if (this.state.defaultFormat[key]) { + this.defaultFormatKeys.add(DefaultStyleKeyMap[key]); + } + }); } /** @@ -49,10 +71,6 @@ class FormatPlugin implements PluginWithState { */ initialize(editor: IStandaloneEditor) { this.editor = editor; - this.hasDefaultFormat = - getObjectKeys(this.state.defaultFormat).filter( - x => typeof this.state.defaultFormat[x] !== 'undefined' - ).length > 0; } /** @@ -102,9 +120,11 @@ class FormatPlugin implements PluginWithState { case 'keyDown': if (isCursorMovingKey(event.rawEvent)) { this.clearPendingFormat(); + this.lastCheckedNode = null; } else if ( - this.hasDefaultFormat && - (isCharacterValue(event.rawEvent) || event.rawEvent.key == ProcessKey) + this.defaultFormatKeys.size > 0 && + (isCharacterValue(event.rawEvent) || event.rawEvent.key == ProcessKey) && + this.shouldApplyDefaultFormat(this.editor) ) { applyDefaultFormat(this.editor, this.state.defaultFormat); } @@ -113,6 +133,8 @@ class FormatPlugin implements PluginWithState { case 'mouseUp': case 'contentChanged': + this.lastCheckedNode = null; + if (!this.canApplyPendingFormat()) { this.clearPendingFormat(); } @@ -152,6 +174,48 @@ class FormatPlugin implements PluginWithState { return result; } + + private shouldApplyDefaultFormat(editor: IStandaloneEditor): boolean { + const selection = editor.getDOMSelection(); + const range = selection?.type == 'range' ? selection.range : null; + const posContainer = range?.startContainer ?? null; + + if (posContainer && posContainer != this.lastCheckedNode) { + // Cache last checked parent node so no need to check it again if user is keep typing under the same node + this.lastCheckedNode = posContainer; + + const domHelper = editor.getDOMHelper(); + let element: HTMLElement | null = isNodeOfType(posContainer, 'ELEMENT_NODE') + ? posContainer + : posContainer.parentElement; + const foundFormatKeys = new Set(); + + while (element?.parentElement && domHelper.isNodeInEditor(element.parentElement)) { + if (element.getAttribute?.('style')) { + const style = element.style; + this.defaultFormatKeys.forEach(key => { + if (style[key]) { + foundFormatKeys.add(key); + } + }); + + if (foundFormatKeys.size == this.defaultFormatKeys.size) { + return false; + } + } + + if (isBlockElement(element)) { + break; + } + + element = element.parentElement; + } + + return true; + } else { + return false; + } + } } /** diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts index 3ddd87d15cb..263848843b7 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts @@ -1,5 +1,5 @@ import { deleteSelection } from '../../publicApi/selection/deleteSelection'; -import { isBlockElement, isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; +import { normalizeContentModel } from 'roosterjs-content-model-dom'; import type { ContentModelSegmentFormat, IStandaloneEditor } from 'roosterjs-content-model-types'; /** @@ -12,29 +12,6 @@ export function applyDefaultFormat( editor: IStandaloneEditor, defaultFormat: ContentModelSegmentFormat ) { - const selection = editor.getDOMSelection(); - const range = selection?.type == 'range' ? selection.range : null; - const posContainer = range?.startContainer ?? null; - const posOffset = range?.startOffset ?? null; - - if (posContainer) { - let node: Node | null = posContainer; - - while (node && editor.getDOMHelper().isNodeInEditor(node)) { - if (isNodeOfType(node, 'ELEMENT_NODE')) { - if (node.getAttribute?.('style')) { - return; - } else if (isBlockElement(node)) { - break; - } - } - - node = node.parentNode; - } - } else { - return; - } - editor.formatContentModel((model, context) => { const result = deleteSelection(model, [], context); @@ -44,12 +21,7 @@ export function applyDefaultFormat( editor.takeSnapshot(); return true; - } else if ( - result.deleteResult == 'notDeleted' && - result.insertPoint && - posContainer && - posOffset !== null - ) { + } else if (result.deleteResult == 'notDeleted' && result.insertPoint) { const { paragraph, path, marker } = result.insertPoint; const blocks = path[0].blocks; const blockCount = blocks.length; diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts index e55c3a1eb56..e66e99c5c17 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts @@ -1,3 +1,4 @@ +import * as applyDefaultFormat from '../../lib/corePlugin/utils/applyDefaultFormat'; import * as applyPendingFormat from '../../lib/corePlugin/utils/applyPendingFormat'; import { createFormatPlugin } from '../../lib/corePlugin/FormatPlugin'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; @@ -11,9 +12,10 @@ describe('FormatPlugin', () => { const mockedFormat = { fontSize: '10px', }; + let applyPendingFormatSpy: jasmine.Spy; beforeEach(() => { - spyOn(applyPendingFormat, 'applyPendingFormat'); + applyPendingFormatSpy = spyOn(applyPendingFormat, 'applyPendingFormat'); }); it('no pending format, trigger key down event', () => { @@ -31,7 +33,7 @@ describe('FormatPlugin', () => { plugin.dispose(); - expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(applyPendingFormatSpy).not.toHaveBeenCalled(); expect(plugin.getState().pendingFormat).toBeNull(); }); @@ -60,12 +62,8 @@ describe('FormatPlugin', () => { plugin.dispose(); - expect(applyPendingFormat.applyPendingFormat).toHaveBeenCalledTimes(1); - expect(applyPendingFormat.applyPendingFormat).toHaveBeenCalledWith( - editor, - 'a', - mockedFormat - ); + expect(applyPendingFormatSpy).toHaveBeenCalledTimes(1); + expect(applyPendingFormatSpy).toHaveBeenCalledWith(editor, 'a', mockedFormat); expect(state.pendingFormat).toBeNull(); }); @@ -94,7 +92,7 @@ describe('FormatPlugin', () => { }); plugin.dispose(); - expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(applyPendingFormatSpy).not.toHaveBeenCalled(); expect(state.pendingFormat).toEqual({ format: mockedFormat, } as any); @@ -128,11 +126,7 @@ describe('FormatPlugin', () => { }); plugin.dispose(); - expect(applyPendingFormat.applyPendingFormat).toHaveBeenCalledWith( - editor, - 'test', - mockedFormat - ); + expect(applyPendingFormatSpy).toHaveBeenCalledWith(editor, 'test', mockedFormat); expect(state.pendingFormat).toBeNull(); }); @@ -159,7 +153,7 @@ describe('FormatPlugin', () => { }); plugin.dispose(); - expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(applyPendingFormatSpy).not.toHaveBeenCalled(); expect(state.pendingFormat).toEqual({ format: mockedFormat, } as any); @@ -192,7 +186,7 @@ describe('FormatPlugin', () => { }); plugin.dispose(); - expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(applyPendingFormatSpy).not.toHaveBeenCalled(); expect(state.pendingFormat).toBeNull(); expect((plugin as any).canApplyPendingFormat).toHaveBeenCalledTimes(1); }); @@ -221,7 +215,7 @@ describe('FormatPlugin', () => { }); plugin.dispose(); - expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(applyPendingFormatSpy).not.toHaveBeenCalled(); expect(state.pendingFormat).toBeNull(); expect((plugin as any).canApplyPendingFormat).toHaveBeenCalledTimes(1); }); @@ -250,7 +244,7 @@ describe('FormatPlugin', () => { }); plugin.dispose(); - expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(applyPendingFormatSpy).not.toHaveBeenCalled(); expect(state.pendingFormat).toEqual({ format: mockedFormat, } as any); @@ -273,12 +267,11 @@ describe('FormatPlugin for default format', () => { cacheContentModelSpy = jasmine.createSpy('cacheContentModel'); takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); formatContentModelSpy = jasmine.createSpy('formatContentModelSpy'); - contentDiv = document.createElement('div'); editor = ({ getDOMHelper: () => ({ - isNodeInEditor: (e: Node) => contentDiv != e && contentDiv.contains(e), + isNodeInEditor: (e: Node) => contentDiv.contains(e), }), getDOMSelection, getPendingFormat: getPendingFormatSpy, @@ -621,4 +614,106 @@ describe('FormatPlugin for default format', () => { }, }); }); + + it('Collapsed range, already have style but not enough', () => { + const defaultFormat = { + fontFamily: 'Arial', + fontSize: '20px', + textColor: 'red', + }; + const plugin = createFormatPlugin({ + defaultSegmentFormat: defaultFormat, + }); + const rawEvent = { key: 'a' } as any; + const applyDefaultFormatSpy = spyOn(applyDefaultFormat, 'applyDefaultFormat'); + const div = document.createElement('div'); + + contentDiv.appendChild(div); + div.style.fontFamily = 'Arial'; + div.style.fontSize = '10px'; + + (editor as any).defaultFormatKeys = new Set(['fontFamily', 'fontSize', 'textColor']); + + getDOMSelection.and.returnValue({ + type: 'range', + range: { + collapsed: true, + startContainer: div, + startOffset: 0, + }, + }); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(applyDefaultFormatSpy).toHaveBeenCalledTimes(1); + expect(applyDefaultFormatSpy).toHaveBeenCalledWith(editor, defaultFormat); + + // Trigger event again under the same node, no need to apply again + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(applyDefaultFormatSpy).toHaveBeenCalledTimes(1); + + // Trigger event again under the same node, no need to apply again + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { key: 'ArrowUp' } as any, + }); + + expect(applyDefaultFormatSpy).toHaveBeenCalledTimes(1); + + // Trigger event again under after moving cursor, should check again + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(applyDefaultFormatSpy).toHaveBeenCalledTimes(2); + }); + + it('Collapsed range, already have style and is enough', () => { + const defaultFormat = { + fontFamily: 'Arial', + fontSize: '20px', + textColor: 'red', + }; + const plugin = createFormatPlugin({ + defaultSegmentFormat: defaultFormat, + }); + const rawEvent = { key: 'a' } as any; + const applyDefaultFormatSpy = spyOn(applyDefaultFormat, 'applyDefaultFormat'); + const div = document.createElement('div'); + + contentDiv.appendChild(div); + div.style.fontFamily = 'Arial'; + div.style.fontSize = '10px'; + div.style.color = 'green'; + + (editor as any).defaultFormatKeys = new Set(['fontFamily', 'fontSize', 'textColor']); + + getDOMSelection.and.returnValue({ + type: 'range', + range: { + collapsed: true, + startContainer: div, + startOffset: 0, + }, + }); + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(applyDefaultFormatSpy).not.toHaveBeenCalled(); + }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts index 75d7329794a..c6f5823f1e1 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts @@ -27,6 +27,7 @@ describe('applyDefaultFormat', () => { let normalizeContentModelSpy: jasmine.Spy; let takeSnapshotSpy: jasmine.Spy; let getPendingFormatSpy: jasmine.Spy; + let isNodeInEditorSpy: jasmine.Spy; let context: FormatContentModelContext | undefined; let model: ContentModelDocument; @@ -48,6 +49,7 @@ describe('applyDefaultFormat', () => { normalizeContentModelSpy = spyOn(normalizeContentModel, 'normalizeContentModel'); takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); getPendingFormatSpy = jasmine.createSpy('getPendingFormat'); + isNodeInEditorSpy = jasmine.createSpy('isNodeInEditor'); formatContentModelSpy = jasmine .createSpy('formatContentModelSpy') @@ -65,7 +67,7 @@ describe('applyDefaultFormat', () => { editor = { getDOMHelper: () => ({ - isNodeInEditor: () => true, + isNodeInEditor: isNodeInEditorSpy, }), getDOMSelection: getDOMSelectionSpy, formatContentModel: formatContentModelSpy, @@ -76,16 +78,22 @@ describe('applyDefaultFormat', () => { it('No selection', () => { getDOMSelectionSpy.and.returnValue(null); + deleteSelectionSpy.and.returnValue({}); applyDefaultFormat(editor, defaultFormat); - expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); }); it('Selection already has style', () => { + const contentDiv = document.createElement('div'); const node = document.createElement('div'); node.style.fontFamily = 'Tahoma'; + contentDiv.appendChild(node); + + isNodeInEditorSpy.and.callFake(node => contentDiv.contains(node)); + getDOMSelectionSpy.and.returnValue({ type: 'range', range: { @@ -93,10 +101,38 @@ describe('applyDefaultFormat', () => { startOffset: 0, }, }); + deleteSelectionSpy.and.returnValue({ + deleteResult: '', + }); applyDefaultFormat(editor, defaultFormat); - expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + }); + + it('text under content div directly', () => { + const contentDiv = document.createElement('div'); + const text = document.createTextNode('test'); + + contentDiv.style.fontFamily = 'Tahoma'; + contentDiv.appendChild(text); + + isNodeInEditorSpy.and.callFake(node => contentDiv.contains(node)); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: text, + startOffset: 0, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: '', + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalled(); }); it('Good selection, delete range ', () => { @@ -131,6 +167,8 @@ describe('applyDefaultFormat', () => { it('Good selection, NothingToDelete ', () => { const node = document.createElement('div'); + isNodeInEditorSpy.and.returnValue(true); + getDOMSelectionSpy.and.returnValue({ type: 'range', range: { @@ -159,6 +197,7 @@ describe('applyDefaultFormat', () => { it('Good selection, SingleChar ', () => { const node = document.createElement('div'); + isNodeInEditorSpy.and.returnValue(true); getDOMSelectionSpy.and.returnValue({ type: 'range', @@ -192,6 +231,7 @@ describe('applyDefaultFormat', () => { const text = createText('test'); const para = createParagraph(); + isNodeInEditorSpy.and.returnValue(true); para.segments.push(text, marker); model.blocks.push(para); @@ -233,6 +273,7 @@ describe('applyDefaultFormat', () => { const img = createImage('test'); const para = createParagraph(); + isNodeInEditorSpy.and.returnValue(true); para.segments.push(img, marker); model.blocks.push(para); @@ -275,6 +316,7 @@ describe('applyDefaultFormat', () => { const paraPrev = createParagraph(); const para = createParagraph(true /*isImplicit*/); + isNodeInEditorSpy.and.returnValue(true); para.segments.push(marker); model.blocks.push(paraPrev, para); @@ -316,6 +358,7 @@ describe('applyDefaultFormat', () => { const divider = createDivider('hr'); const para = createParagraph(true /*isImplicit*/); + isNodeInEditorSpy.and.returnValue(true); para.segments.push(marker); model.blocks.push(divider, para); @@ -361,6 +404,7 @@ describe('applyDefaultFormat', () => { const img = createImage('test'); const para = createParagraph(); + isNodeInEditorSpy.and.returnValue(true); para.segments.push(img, marker); model.blocks.push(para); From af23f2ad37a688afcb02533615eadf6d9528f05c Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 15 Feb 2024 09:36:23 -0800 Subject: [PATCH 108/112] Remove legacy core API (#2414) * Remove legacy core API * improve * improve --- .../lib/coreApi/coreApiMap.ts | 9 -- .../lib/corePlugins/BridgePlugin.ts | 44 ++++++++-- .../lib/editor/EditorAdapter.ts | 46 +++++++++-- .../lib/editor/utils/buildRangeEx.ts | 5 +- .../{coreApi => editor/utils}/insertNode.ts | 72 ++++++---------- .../roosterjs-editor-adapter/lib/index.ts | 5 -- .../lib/publicTypes/EditorAdapterCore.ts | 82 ------------------- .../lib/publicTypes/EditorAdapterOptions.ts | 7 -- .../test/corePlugins/BridgePluginTest.ts | 8 -- 9 files changed, 104 insertions(+), 174 deletions(-) delete mode 100644 packages/roosterjs-editor-adapter/lib/coreApi/coreApiMap.ts rename packages/roosterjs-editor-adapter/lib/{coreApi => editor/utils}/insertNode.ts (79%) delete mode 100644 packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterCore.ts diff --git a/packages/roosterjs-editor-adapter/lib/coreApi/coreApiMap.ts b/packages/roosterjs-editor-adapter/lib/coreApi/coreApiMap.ts deleted file mode 100644 index edc7232911f..00000000000 --- a/packages/roosterjs-editor-adapter/lib/coreApi/coreApiMap.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { insertNode } from './insertNode'; -import type { EditorAdapterCoreApiMap } from '../publicTypes/EditorAdapterCore'; - -/** - * @internal - */ -export const coreApiMap: EditorAdapterCoreApiMap = { - insertNode, -}; diff --git a/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts index 4721c2d0755..a91e0fb2a5f 100644 --- a/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts +++ b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts @@ -1,8 +1,6 @@ -import { coreApiMap } from '../coreApi/coreApiMap'; import { createDarkColorHandler } from '../editor/DarkColorHandlerImpl'; import { createEditPlugin } from './EditPlugin'; import { newEventToOldEvent, oldEventToNewEvent } from '../editor/utils/eventConverter'; -import type { EditorAdapterCoreApiMap, EditorAdapterCore } from '../publicTypes/EditorAdapterCore'; import type { EditorPlugin as LegacyEditorPlugin, PluginEvent as LegacyPluginEvent, @@ -11,6 +9,8 @@ import type { ExperimentalFeatures, SizeTransformer, EditPluginState, + CustomData, + DarkColorHandler, } from 'roosterjs-editor-types'; import type { ContextMenuProvider, @@ -20,6 +20,43 @@ import type { const ExclusivelyHandleEventPluginKey = '__ExclusivelyHandleEventPlugin'; +/** + * @internal + * Represents the core data structure of a editor adapter + */ +export interface EditorAdapterCore { + /** + * Custom data of this editor + */ + readonly customData: Record; + + /** + * Enabled experimental features + */ + readonly experimentalFeatures: ExperimentalFeatures[]; + + /** + * Dark model handler for the editor, used for variable-based solution. + * If keep it null, editor will still use original dataset-based dark mode solution. + */ + readonly darkColorHandler: DarkColorHandler; + + /** + * Plugin state of EditPlugin + */ + readonly edit: EditPluginState; + + /** + * Context Menu providers + */ + readonly contextMenuProviders: LegacyContextMenuProvider[]; + + /** + * @deprecated Use zoomScale instead + */ + readonly sizeTransformer: SizeTransformer; +} + /** * @internal * Act as a bridge between Standalone editor and Content Model editor, translate Standalone editor event type to legacy event type @@ -33,7 +70,6 @@ export class BridgePlugin implements ContextMenuProvider { constructor( private onInitialize: (core: EditorAdapterCore) => ILegacyEditor, legacyPlugins: LegacyEditorPlugin[] = [], - private legacyCoreApiOverride?: Partial, private experimentalFeatures: ExperimentalFeatures[] = [] ) { const editPlugin = createEditPlugin(); @@ -137,8 +173,6 @@ export class BridgePlugin implements ContextMenuProvider { private createEditorCore(editor: IStandaloneEditor): EditorAdapterCore { return { - api: { ...coreApiMap, ...this.legacyCoreApiOverride }, - originalApi: coreApiMap, customData: {}, experimentalFeatures: this.experimentalFeatures ?? [], sizeTransformer: createSizeTransformer(editor), diff --git a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index 601cb9e9871..76f03bb9d1b 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts @@ -1,6 +1,8 @@ import { BridgePlugin } from '../corePlugins/BridgePlugin'; import { buildRangeEx } from './utils/buildRangeEx'; import { getObjectKeys } from 'roosterjs-content-model-dom'; +import { insertNode } from './utils/insertNode'; +import type { EditorAdapterCore } from '../corePlugins/BridgePlugin'; import { newEventToOldEvent, oldEventToNewEvent, @@ -86,7 +88,6 @@ import { toArray, wrap, } from 'roosterjs-editor-dom'; -import type { EditorAdapterCore } from '../publicTypes/EditorAdapterCore'; import type { EditorAdapterOptions } from '../publicTypes/EditorAdapterOptions'; import type { ContentModelFormatState, @@ -124,7 +125,6 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { return this; }, options.legacyPlugins, - options.legacyCoreApiOverride, options.experimentalFeatures ); @@ -190,10 +190,44 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { * @returns true if node is inserted. Otherwise false */ insertNode(node: Node, option?: InsertOption): boolean { - const core = this.getContentModelEditorCore(); - const innerCore = this.getCore(); + if (node) { + option = option || { + position: ContentPosition.SelectionStart, + insertOnNewLine: false, + updateCursor: true, + replaceSelection: true, + insertToRegionRoot: false, + }; + + const { contentDiv } = this.getCore(); + + if (option.updateCursor) { + this.focus(); + } + + if (option.position == ContentPosition.Outside) { + contentDiv.parentNode?.insertBefore(node, contentDiv.nextSibling); + } else { + if (this.isDarkMode()) { + transformColor( + node, + true /*includeSelf*/, + 'lightToDark', + this.getColorManager() + ); + } + + const selection = insertNode(contentDiv, this.getDOMSelection(), node, option); - return node ? core.api.insertNode(core, innerCore, node, option ?? null) : false; + if (selection) { + this.setDOMSelection(selection); + } + } + + return true; + } else { + return false; + } } /** @@ -469,7 +503,7 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { arg4?: number | PositionType ): boolean { const core = this.getCore(); - const rangeEx = buildRangeEx(core, arg1, arg2, arg3, arg4); + const rangeEx = buildRangeEx(core.contentDiv, arg1, arg2, arg3, arg4); const selection = convertRangeExToDomSelection(rangeEx); this.setDOMSelection(selection); diff --git a/packages/roosterjs-editor-adapter/lib/editor/utils/buildRangeEx.ts b/packages/roosterjs-editor-adapter/lib/editor/utils/buildRangeEx.ts index 72f6affc879..13d1f0448a8 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/utils/buildRangeEx.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/utils/buildRangeEx.ts @@ -1,6 +1,5 @@ import { createRange, safeInstanceOf } from 'roosterjs-editor-dom'; import { SelectionRangeTypes } from 'roosterjs-editor-types'; -import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; import type { NodePosition, PositionType, @@ -13,7 +12,7 @@ import type { * @internal */ export function buildRangeEx( - core: StandaloneEditorCore, + root: HTMLElement, arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, arg2?: NodePosition | number | PositionType | TableSelection | null, arg3?: Node, @@ -44,7 +43,7 @@ export function buildRangeEx( : safeInstanceOf(arg1, 'Range') ? arg1 : isSelectionPath(arg1) - ? createRange(core.contentDiv, arg1.start, arg1.end) + ? createRange(root, arg1.start, arg1.end) : isNodePosition(arg1) || safeInstanceOf(arg1, 'Node') ? createRange( arg1, diff --git a/packages/roosterjs-editor-adapter/lib/coreApi/insertNode.ts b/packages/roosterjs-editor-adapter/lib/editor/utils/insertNode.ts similarity index 79% rename from packages/roosterjs-editor-adapter/lib/coreApi/insertNode.ts rename to packages/roosterjs-editor-adapter/lib/editor/utils/insertNode.ts index 4697f76ef07..37e3e48b384 100644 --- a/packages/roosterjs-editor-adapter/lib/coreApi/insertNode.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/utils/insertNode.ts @@ -1,5 +1,4 @@ import { ContentPosition, NodeType, PositionType, RegionType } from 'roosterjs-editor-types'; -import { transformColor } from 'roosterjs-content-model-core'; import type { BlockElement, InsertOption, NodePosition } from 'roosterjs-editor-types'; import { createRange, @@ -16,18 +15,16 @@ import { splitTextNode, splitParentNode, } from 'roosterjs-editor-dom'; -import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; -import type { InsertNode } from '../publicTypes/EditorAdapterCore'; +import type { DOMSelection } from 'roosterjs-content-model-types'; function getInitialRange( - core: StandaloneEditorCore, + selection: DOMSelection | null, option: InsertOption ): { range: Range | null; rangeToRestore: Range | null } { // Selection start replaces based on the current selection. // Range inserts based on a provided range. // Both have the potential to use the current selection to restore cursor position // So in both cases we need to store the selection state. - const selection = core.api.getDOMSelection(core); let range = selection?.type == 'range' ? selection.range : null; let rangeToRestore = null; @@ -44,32 +41,13 @@ function getInitialRange( /** * @internal * Insert a DOM node into editor content - * @param core The EditorAdapterCore object. No op if null. - * @param option An insert option object to specify how to insert the node */ -export const insertNode: InsertNode = (core, innerCore, node, option) => { - option = option || { - position: ContentPosition.SelectionStart, - insertOnNewLine: false, - updateCursor: true, - replaceSelection: true, - insertToRegionRoot: false, - }; - const { contentDiv, api, lifecycle, darkColorHandler } = innerCore; - - if (option.updateCursor) { - api.focus(innerCore); - } - - if (option.position == ContentPosition.Outside) { - contentDiv.parentNode?.insertBefore(node, contentDiv.nextSibling); - return true; - } - - if (lifecycle.isDarkMode) { - transformColor(node, true /*includeSelf*/, 'lightToDark', darkColorHandler); - } - +export function insertNode( + contentDiv: HTMLDivElement, + selection: DOMSelection | null, + node: Node, + option: InsertOption +): DOMSelection | undefined { switch (option.position) { case ContentPosition.Begin: case ContentPosition.End: { @@ -132,7 +110,7 @@ export const insertNode: InsertNode = (core, innerCore, node, option) => { break; case ContentPosition.Range: case ContentPosition.SelectionStart: - let { range, rangeToRestore } = getInitialRange(innerCore, option); + let { range, rangeToRestore } = getInitialRange(selection, option); if (!range) { break; } @@ -146,12 +124,12 @@ export const insertNode: InsertNode = (core, innerCore, node, option) => { let blockElement: BlockElement | null; if (option.insertOnNewLine && option.insertToRegionRoot) { - pos = adjustInsertPositionRegionRoot(innerCore, range, pos); + pos = adjustInsertPositionRegionRoot(contentDiv, range, pos); } else if ( option.insertOnNewLine && (blockElement = getBlockElementAtNode(contentDiv, pos.normalize().node)) ) { - pos = adjustInsertPositionNewLine(blockElement, innerCore, pos); + pos = adjustInsertPositionNewLine(blockElement, contentDiv, pos); } else { pos = adjustInsertPosition(contentDiv, node, pos, range); } @@ -168,26 +146,22 @@ export const insertNode: InsertNode = (core, innerCore, node, option) => { ); } - if (rangeToRestore) { - api.setDOMSelection(innerCore, { - type: 'range', - range: rangeToRestore, - isReverted: false, - }); - } - - break; + return rangeToRestore + ? { + type: 'range', + range: rangeToRestore, + isReverted: false, + } + : undefined; } - - return true; -}; +} function adjustInsertPositionRegionRoot( - core: StandaloneEditorCore, + contentDiv: HTMLDivElement, range: Range, position: NodePosition ) { - const region = getRegionsFromRange(core.contentDiv, range, RegionType.Table)[0]; + const region = getRegionsFromRange(contentDiv, range, RegionType.Table)[0]; let node: Node | null = position.node; if (region) { @@ -212,12 +186,12 @@ function adjustInsertPositionRegionRoot( function adjustInsertPositionNewLine( blockElement: BlockElement, - core: StandaloneEditorCore, + contentDiv: HTMLDivElement, pos: Position ) { let tempPos = new Position(blockElement.getEndNode(), PositionType.After); if (safeInstanceOf(tempPos.node, 'HTMLTableRowElement')) { - const div = core.contentDiv.ownerDocument.createElement('div'); + const div = contentDiv.ownerDocument.createElement('div'); const range = createRange(pos); range.insertNode(div); tempPos = new Position(div, PositionType.Begin); diff --git a/packages/roosterjs-editor-adapter/lib/index.ts b/packages/roosterjs-editor-adapter/lib/index.ts index 479e992238c..89c5b8e99bc 100644 --- a/packages/roosterjs-editor-adapter/lib/index.ts +++ b/packages/roosterjs-editor-adapter/lib/index.ts @@ -1,8 +1,3 @@ -export { - EditorAdapterCore, - EditorAdapterCoreApiMap, - InsertNode, -} from './publicTypes/EditorAdapterCore'; export { EditorAdapterOptions } from './publicTypes/EditorAdapterOptions'; export { BeforePasteAdapterEvent } from './publicTypes/BeforePasteAdapterEvent'; diff --git a/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterCore.ts b/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterCore.ts deleted file mode 100644 index b76449b5c63..00000000000 --- a/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterCore.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; -import type { - CustomData, - ExperimentalFeatures, - InsertOption, - SizeTransformer, - DarkColorHandler, - EditPluginState, - ContextMenuProvider, -} from 'roosterjs-editor-types'; - -/** - * Insert a DOM node into editor content - * @param core The EditorAdapterCore object. No op if null. - * @param innerCore The StandaloneEditorCore object - * @param option An insert option object to specify how to insert the node - */ -export type InsertNode = ( - core: EditorAdapterCore, - innerCore: StandaloneEditorCore, - node: Node, - option: InsertOption | null -) => boolean; - -/** - * Core API map for editor adapter - */ -export interface EditorAdapterCoreApiMap { - /** - * Insert a DOM node into editor content - * @param core The EditorAdapterCore object. No op if null. - * @param innerCore The StandaloneEditorCore object - * @param option An insert option object to specify how to insert the node - */ - insertNode: InsertNode; -} - -/** - * Represents the core data structure of a editor adapter - */ -export interface EditorAdapterCore { - /** - * Core API map of this editor - */ - readonly api: EditorAdapterCoreApiMap; - - /** - * Original API map of this editor. Overridden core API can use API from this map to call the original version of core API. - */ - readonly originalApi: EditorAdapterCoreApiMap; - - /** - * Custom data of this editor - */ - readonly customData: Record; - - /** - * Enabled experimental features - */ - readonly experimentalFeatures: ExperimentalFeatures[]; - - /** - * Dark model handler for the editor, used for variable-based solution. - * If keep it null, editor will still use original dataset-based dark mode solution. - */ - readonly darkColorHandler: DarkColorHandler; - - /** - * Plugin state of EditPlugin - */ - readonly edit: EditPluginState; - - /** - * Context Menu providers - */ - readonly contextMenuProviders: ContextMenuProvider[]; - - /** - * @deprecated Use zoomScale instead - */ - readonly sizeTransformer: SizeTransformer; -} diff --git a/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts b/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts index 2c1006f8daa..b5ed78fe6fe 100644 --- a/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts +++ b/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts @@ -1,5 +1,4 @@ import type { StandaloneEditorOptions } from 'roosterjs-content-model-types'; -import type { EditorAdapterCoreApiMap } from './EditorAdapterCore'; import type { EditorPlugin, ExperimentalFeatures } from 'roosterjs-editor-types'; /** @@ -12,12 +11,6 @@ export interface EditorAdapterOptions extends StandaloneEditorOptions { */ initialContent?: string; - /** - * A function map to override default core API implementation - * Default value is null - */ - legacyCoreApiOverride?: Partial; - /** * Specify the enabled experimental features */ diff --git a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts index ef1c5f6e605..ec3a93d133e 100644 --- a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts +++ b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts @@ -2,7 +2,6 @@ import * as BridgePlugin from '../../lib/corePlugins/BridgePlugin'; import * as DarkColorHandler from '../../lib/editor/DarkColorHandlerImpl'; import * as EditPlugin from '../../lib/corePlugins/EditPlugin'; import * as eventConverter from '../../lib/editor/utils/eventConverter'; -import { coreApiMap } from '../../lib/coreApi/coreApiMap'; import { PluginEventType } from 'roosterjs-editor-types'; describe('BridgePlugin', () => { @@ -69,8 +68,6 @@ describe('BridgePlugin', () => { plugin.initialize(mockedInnerEditor); expect(onInitializeSpy).toHaveBeenCalledWith({ - api: coreApiMap, - originalApi: coreApiMap, customData: {}, experimentalFeatures: [], sizeTransformer: jasmine.anything(), @@ -127,7 +124,6 @@ describe('BridgePlugin', () => { const plugin = new BridgePlugin.BridgePlugin( onInitializeSpy, [mockedPlugin1, mockedPlugin2], - { a: 'b' } as any, ['c' as any] ); expect(initializeSpy).not.toHaveBeenCalled(); @@ -157,8 +153,6 @@ describe('BridgePlugin', () => { plugin.initialize(mockedInnerEditor); expect(onInitializeSpy).toHaveBeenCalledWith({ - api: { ...coreApiMap, a: 'b' }, - originalApi: coreApiMap, customData: {}, experimentalFeatures: ['c'], sizeTransformer: jasmine.anything(), @@ -428,8 +422,6 @@ describe('BridgePlugin', () => { plugin.initialize(mockedInnerEditor); expect(onInitializeSpy).toHaveBeenCalledWith({ - api: coreApiMap, - originalApi: coreApiMap, customData: {}, experimentalFeatures: [], sizeTransformer: jasmine.anything(), From 0f334bb0235f4c4f548b6b88e9b7a06dc2ee6a70 Mon Sep 17 00:00:00 2001 From: Andres-CT98 <107568016+Andres-CT98@users.noreply.github.com> Date: Thu, 15 Feb 2024 11:49:30 -0600 Subject: [PATCH 109/112] Select newly inserted row/column (#2412) * create clear selection * reorder selection hierarchy * fix insertRow/Column and export * revert selection hierarchy * add comments * simplify clear selection * fix tests * move clearSelectedCells * add tests and valid range check * remove unused import --- .../roosterjs-content-model-api/lib/index.ts | 2 + .../lib/modelApi/table/clearSelectedCells.ts | 26 ++ .../lib/modelApi/table/insertTableColumn.ts | 25 +- .../lib/modelApi/table/insertTableRow.ts | 16 +- .../modelApi/table/clearSelectedCellsTest.ts | 207 ++++++++++++++++ .../modelApi/table/insertTableColumnTest.ts | 232 ++++++++++++------ .../test/modelApi/table/insertTableRowTest.ts | 178 +++++++++----- 7 files changed, 526 insertions(+), 160 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-api/lib/modelApi/table/clearSelectedCells.ts create mode 100644 packages-content-model/roosterjs-content-model-api/test/modelApi/table/clearSelectedCellsTest.ts diff --git a/packages-content-model/roosterjs-content-model-api/lib/index.ts b/packages-content-model/roosterjs-content-model-api/lib/index.ts index 2109b8841f9..66f9dc5effe 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/index.ts @@ -40,6 +40,8 @@ export { default as adjustImageSelection } from './publicApi/image/adjustImageSe export { default as setParagraphMargin } from './publicApi/block/setParagraphMargin'; export { default as toggleCode } from './publicApi/segment/toggleCode'; export { default as insertEntity } from './publicApi/entity/insertEntity'; +export { insertTableRow } from './modelApi/table/insertTableRow'; +export { insertTableColumn } from './modelApi/table/insertTableColumn'; export { formatTableWithContentModel } from './publicApi/utils/formatTableWithContentModel'; export { setListType } from './modelApi/list/setListType'; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/clearSelectedCells.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/clearSelectedCells.ts new file mode 100644 index 00000000000..8cab099e9a5 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/clearSelectedCells.ts @@ -0,0 +1,26 @@ +import { setSelection } from 'roosterjs-content-model-core'; +import type { ContentModelTable, TableSelectionCoordinates } from 'roosterjs-content-model-types'; + +/** + * Clear selection of a table. + * @internal + * @param table The table model where the selection is to be cleared + * @param sel The selection coordinates to be cleared + */ +export function clearSelectedCells(table: ContentModelTable, sel: TableSelectionCoordinates) { + if ( + sel.firstColumn >= 0 && + sel.firstRow >= 0 && + sel.lastColumn < table.widths.length && + sel.lastRow < table.rows.length + ) { + for (let i = sel.firstRow; i <= sel.lastRow; i++) { + const row = table.rows[i]; + for (let j = sel.firstColumn; j <= sel.lastColumn; j++) { + const cell = row.cells[j]; + cell.isSelected = false; + setSelection(cell); + } + } + } +} diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableColumn.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableColumn.ts index b736eac36ac..40ef3f854d4 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableColumn.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableColumn.ts @@ -1,3 +1,4 @@ +import { clearSelectedCells } from './clearSelectedCells'; import { createTableCell } from 'roosterjs-content-model-dom'; import { getSelectedCells } from 'roosterjs-content-model-core'; import type { @@ -6,7 +7,9 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * Insert a column to the table + * @param table The table model where the column is to be inserted + * @param operation The operation to be performed */ export function insertTableColumn( table: ContentModelTable, @@ -16,21 +19,21 @@ export function insertTableColumn( const insertLeft = operation == 'insertLeft'; if (sel) { + clearSelectedCells(table, sel); for (let i = sel?.firstColumn; i <= sel.lastColumn; i++) { table.rows.forEach(row => { const cell = row.cells[insertLeft ? sel.firstColumn : sel.lastColumn]; - row.cells.splice( - insertLeft ? sel.firstColumn : sel.lastColumn + 1, - 0, - createTableCell( - cell.spanLeft, - cell.spanAbove, - cell.isHeader, - cell.format, - cell.dataset - ) + const newCell = createTableCell( + cell.spanLeft, + cell.spanAbove, + cell.isHeader, + cell.format, + cell.dataset ); + newCell.isSelected = true; + + row.cells.splice(insertLeft ? sel.firstColumn : sel.lastColumn + 1, 0, newCell); }); table.widths.splice( insertLeft ? sel.firstColumn : sel.lastColumn + 1, diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableRow.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableRow.ts index 02227e80656..9a09226a11a 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableRow.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableRow.ts @@ -1,3 +1,4 @@ +import { clearSelectedCells } from './clearSelectedCells'; import { createTableCell } from 'roosterjs-content-model-dom'; import { getSelectedCells } from 'roosterjs-content-model-core'; import type { @@ -6,27 +7,32 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * Insert a row to the table + * @param table The table model where the row is to be inserted + * @param operation The operation to be performed */ export function insertTableRow(table: ContentModelTable, operation: TableVerticalInsertOperation) { const sel = getSelectedCells(table); const insertAbove = operation == 'insertAbove'; if (sel) { + clearSelectedCells(table, sel); for (let i = sel.firstRow; i <= sel.lastRow; i++) { const sourceRow = table.rows[insertAbove ? sel.firstRow : sel.lastRow]; table.rows.splice(insertAbove ? sel.firstRow : sel.lastRow + 1, 0, { format: { ...sourceRow.format }, - cells: sourceRow.cells.map(cell => - createTableCell( + cells: sourceRow.cells.map(cell => { + const newCell = createTableCell( cell.spanLeft, cell.spanAbove, cell.isHeader, cell.format, cell.dataset - ) - ), + ); + newCell.isSelected = true; + return newCell; + }), height: sourceRow.height, }); } diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/table/clearSelectedCellsTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/clearSelectedCellsTest.ts new file mode 100644 index 00000000000..5ceb418b561 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/clearSelectedCellsTest.ts @@ -0,0 +1,207 @@ +import { clearSelectedCells } from '../../../lib/modelApi/table/clearSelectedCells'; +import { createSelectionMarker, createTable, createTableCell } from 'roosterjs-content-model-dom'; + +describe('clearSelectedCells', () => { + it('invalid selection to clear', () => { + const table = createTable(2); + const selectedCell = createTableCell(); + selectedCell.isSelected = true; + table.rows.forEach(row => { + row.cells.push(selectedCell, selectedCell), (row.height = 200); + }); + table.widths = [100, 100]; + + clearSelectedCells(table, { firstRow: 0, lastRow: 2, firstColumn: 0, lastColumn: 2 }); + expect(table).toEqual({ + blockType: 'Table', + format: {}, + rows: [ + { + format: {}, + height: 200, + cells: [selectedCell, selectedCell], + }, + { + format: {}, + height: 200, + cells: [selectedCell, selectedCell], + }, + ], + widths: [100, 100], + dataset: {}, + }); + }); + + it('no cells selected - clear all', () => { + const table = createTable(2); + const unselectedCell = createTableCell(); + unselectedCell.isSelected = false; + table.rows.forEach(row => { + row.cells.push(unselectedCell, unselectedCell), (row.height = 200); + }); + table.widths = [100, 100]; + + clearSelectedCells(table, { firstRow: 0, lastRow: 1, firstColumn: 0, lastColumn: 1 }); + expect(table).toEqual({ + blockType: 'Table', + format: {}, + rows: [ + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell], + }, + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell], + }, + ], + widths: [100, 100], + dataset: {}, + }); + }); + + it('all cells selected - clear all', () => { + const table = createTable(4); + const selectedCell = createTableCell(); + selectedCell.isSelected = true; + table.rows.forEach(row => { + row.cells.push(selectedCell, selectedCell, selectedCell, selectedCell), + (row.height = 200); + }); + table.widths = [100, 100, 100, 100]; + + const unselectedCell = { ...selectedCell, isSelected: false }; + + clearSelectedCells(table, { firstRow: 0, lastRow: 3, firstColumn: 0, lastColumn: 3 }); + expect(table).toEqual({ + blockType: 'Table', + format: {}, + rows: [ + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell, unselectedCell, unselectedCell], + }, + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell, unselectedCell, unselectedCell], + }, + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell, unselectedCell, unselectedCell], + }, + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell, unselectedCell, unselectedCell], + }, + ], + widths: [100, 100, 100, 100], + dataset: {}, + }); + }); + + it('all cells selected - clear centre', () => { + const table = createTable(4); + const selectedCell = createTableCell(); + selectedCell.isSelected = true; + table.rows.forEach(row => { + row.cells.push(selectedCell, selectedCell, selectedCell, selectedCell), + (row.height = 200); + }); + table.widths = [100, 100, 100, 100]; + + const unselectedCell = { ...selectedCell, isSelected: false }; + + clearSelectedCells(table, { firstRow: 1, lastRow: 2, firstColumn: 1, lastColumn: 2 }); + expect(table).toEqual({ + blockType: 'Table', + format: {}, + rows: [ + { + format: {}, + height: 200, + cells: [selectedCell, selectedCell, selectedCell, selectedCell], + }, + { + format: {}, + height: 200, + cells: [selectedCell, unselectedCell, unselectedCell, selectedCell], + }, + { + format: {}, + height: 200, + cells: [selectedCell, unselectedCell, unselectedCell, selectedCell], + }, + { + format: {}, + height: 200, + cells: [selectedCell, selectedCell, selectedCell, selectedCell], + }, + ], + widths: [100, 100, 100, 100], + dataset: {}, + }); + }); + + it('clear selection marker', () => { + const table = createTable(3); + const unselectedCell = createTableCell(); + unselectedCell.isSelected = false; + table.rows.forEach(row => { + row.cells.push({ ...unselectedCell }, { ...unselectedCell }, { ...unselectedCell }), + (row.height = 200); + }); + table.widths = [100, 100, 100]; + + table.rows[1].cells[1].blocks = [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + createSelectionMarker(), + { segmentType: 'Text', format: {}, text: 'Text' }, + ], + }, + ]; + + const centreWithoutMarker = createTableCell(); + centreWithoutMarker.isSelected = false; + centreWithoutMarker.blocks = [ + { + blockType: 'Paragraph', + format: {}, + segments: [{ segmentType: 'Text', format: {}, text: 'Text' }], + }, + ]; + + clearSelectedCells(table, { firstRow: 1, lastRow: 1, firstColumn: 1, lastColumn: 1 }); + expect(table).toEqual({ + blockType: 'Table', + format: {}, + rows: [ + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell, unselectedCell], + }, + { + format: {}, + height: 200, + cells: [unselectedCell, centreWithoutMarker, unselectedCell], + }, + { + format: {}, + height: 200, + cells: [unselectedCell, unselectedCell, unselectedCell], + }, + ], + widths: [100, 100, 100], + dataset: {}, + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableColumnTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableColumnTest.ts index 814ab74aa62..57bb4ebbc8f 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableColumnTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableColumnTest.ts @@ -49,20 +49,20 @@ describe('insertTableColumn', () => { it('table with single selection', () => { const table = createTable(1); - const cell1 = createTableCell(); - cell1.isSelected = true; - table.rows[0].cells.push(cell1); + const selectedCell = createTableCell(); + selectedCell.isSelected = true; + table.rows[0].cells.push({ ...selectedCell }); table.widths = [100]; table.rows[0].height = 200; - const cell2 = { ...cell1 }; - delete cell2.isSelected; + const unselectedCell = { ...selectedCell }; + unselectedCell.isSelected = false; insertTableColumn(table, 'insertLeft'); expect(table).toEqual({ blockType: 'Table', format: {}, - rows: [{ format: {}, height: 200, cells: [cell2, cell1] }], + rows: [{ format: {}, height: 200, cells: [selectedCell, unselectedCell] }], widths: [100, 100], dataset: {}, }); @@ -71,73 +71,95 @@ describe('insertTableColumn', () => { expect(table).toEqual({ blockType: 'Table', format: {}, - rows: [{ format: {}, height: 200, cells: [cell2, cell1, cell2] }], + rows: [ + { format: {}, height: 200, cells: [unselectedCell, selectedCell, unselectedCell] }, + ], widths: [100, 100, 100], dataset: {}, }); }); - it('table with multi selection', () => { + it('table with multi selection - insertLeft', () => { const table = createTable(1); - const cell1 = createTableCell(); - const cell2 = createTableCell(false, false, true); - cell1.isSelected = true; - cell2.isSelected = true; - table.rows[0].cells.push(cell1, cell2); + const selectedCell = createTableCell(); + const selectedHeader = createTableCell(false, false, true); + selectedCell.isSelected = true; + selectedHeader.isSelected = true; + table.rows[0].cells.push({ ...selectedCell }, { ...selectedHeader }); table.widths = [100, 200]; table.rows[0].height = 300; - const cell3 = { ...cell1 }; - delete cell3.isSelected; - - const cell4 = { ...cell2 }; - delete cell4.isSelected; + const unselectedCell = { ...selectedCell, isSelected: false }; + const unselectedHeader = { ...selectedHeader, isSelected: false }; insertTableColumn(table, 'insertLeft'); expect(table).toEqual({ blockType: 'Table', format: {}, - rows: [{ format: {}, height: 300, cells: [cell3, cell3, cell1, cell2] }], + rows: [ + { + format: {}, + height: 300, + cells: [selectedCell, selectedCell, unselectedCell, unselectedHeader], + }, + ], widths: [100, 100, 100, 200], dataset: {}, }); + }); + + it('table with multi selection - insertRight', () => { + const table = createTable(1); + const selectedCell = createTableCell(); + const selectedHeader = createTableCell(false, false, true); + selectedCell.isSelected = true; + selectedHeader.isSelected = true; + table.rows[0].cells.push({ ...selectedCell }, { ...selectedHeader }); + table.widths = [100, 200]; + table.rows[0].height = 300; + + const unselectedCell = { ...selectedCell, isSelected: false }; + const unselectedHeader = { ...selectedHeader, isSelected: false }; insertTableColumn(table, 'insertRight'); expect(table).toEqual({ blockType: 'Table', format: {}, - rows: [{ format: {}, height: 300, cells: [cell3, cell3, cell1, cell2, cell4, cell4] }], - widths: [100, 100, 100, 200, 200, 200], + rows: [ + { + format: {}, + height: 300, + cells: [unselectedCell, unselectedHeader, selectedHeader, selectedHeader], + }, + ], + widths: [100, 200, 200, 200], dataset: {}, }); }); it('table with multi selection in multi row', () => { const table = createTable(2); - const cell1 = createTableCell(false, false, true); - const cell2 = createTableCell(false, true); + const selectedHeader = createTableCell(false, false, true); + const selectedSpanAbove = createTableCell(false, true); - cell1.isSelected = true; - cell2.isSelected = true; - table.rows[0].cells.push(cell1); - table.rows[1].cells.push(cell2); + selectedHeader.isSelected = true; + selectedSpanAbove.isSelected = true; + table.rows[0].cells.push({ ...selectedHeader }); + table.rows[1].cells.push({ ...selectedSpanAbove }); table.widths = [100]; table.rows[0].height = 200; table.rows[1].height = 300; - const cell3 = { ...cell1 }; - delete cell3.isSelected; - - const cell4 = { ...cell2 }; - delete cell4.isSelected; + const unselectedHeader = { ...selectedHeader, isSelected: false }; + const unselectedSpanAbove = { ...selectedSpanAbove, isSelected: false }; insertTableColumn(table, 'insertLeft'); expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ - { format: {}, height: 200, cells: [cell3, cell1] }, - { format: {}, height: 300, cells: [cell4, cell2] }, + { format: {}, height: 200, cells: [selectedHeader, unselectedHeader] }, + { format: {}, height: 300, cells: [selectedSpanAbove, unselectedSpanAbove] }, ], widths: [100, 100], dataset: {}, @@ -148,92 +170,150 @@ describe('insertTableColumn', () => { blockType: 'Table', format: {}, rows: [ - { format: {}, height: 200, cells: [cell3, cell1, cell3] }, - { format: {}, height: 300, cells: [cell4, cell2, cell4] }, + { + format: {}, + height: 200, + cells: [unselectedHeader, selectedHeader, unselectedHeader], + }, + { + format: {}, + height: 300, + cells: [unselectedSpanAbove, selectedSpanAbove, unselectedSpanAbove], + }, ], widths: [100, 100, 100], dataset: {}, }); }); - it('table with complex scenario', () => { - const table = createTable(3); + it('table with complex scenario - insertLeft', () => { + const table = createTable(4); const cell1 = createTableCell(false, false, false, { backgroundColor: '1' }); const cell2 = createTableCell(false, false, false, { backgroundColor: '2' }); const cell3 = createTableCell(false, false, false, { backgroundColor: '3' }); const cell4 = createTableCell(false, false, false, { backgroundColor: '4' }); - const cell5 = createTableCell(false, false, false, { backgroundColor: '5' }); - const cell6 = createTableCell(false, false, false, { backgroundColor: '6' }); - const cell7 = createTableCell(true, false, false, { backgroundColor: '7' }); - const cell8 = createTableCell(false, false, false, { backgroundColor: '8' }); - const cell9 = createTableCell(false, false, false, { backgroundColor: '9' }); - const cell10 = createTableCell(false, true, false, { backgroundColor: '10' }); - const cell11 = createTableCell(true, true, false, { backgroundColor: '11' }); + const selectedCell5 = createTableCell(false, false, false, { backgroundColor: '5' }); + const cell6 = createTableCell(true, false, false, { backgroundColor: '6' }); + const cell7 = createTableCell(false, false, false, { backgroundColor: '7' }); + const cell8 = createTableCell(false, true, false, { backgroundColor: '8' }); + const selectedCell9 = createTableCell(true, true, false, { backgroundColor: '9' }); + const cell10 = createTableCell(false, false, false, { backgroundColor: '10' }); + const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); const cell12 = createTableCell(false, false, false, { backgroundColor: '12' }); - cell6.isSelected = true; - cell11.isSelected = true; - table.rows[0].cells.push(cell1, cell2, cell3, cell4); - table.rows[1].cells.push(cell5, cell6, cell7, cell8); - table.rows[2].cells.push(cell9, cell10, cell11, cell12); - table.widths = [100, 200, 300, 400]; - table.rows[0].height = 500; - table.rows[1].height = 600; - table.rows[2].height = 700; - - const cell6Clone = { ...cell6 }; - const cell11Clone = { ...cell11 }; - delete cell6Clone.isSelected; - delete cell11Clone.isSelected; + selectedCell5.isSelected = true; + selectedCell9.isSelected = true; + table.rows[0].cells.push(cell1, cell2, cell3); + table.rows[1].cells.push(cell4, { ...selectedCell5 }, cell6); + table.rows[2].cells.push(cell7, cell8, { ...selectedCell9 }); + table.rows[3].cells.push(cell10, cell11, cell12); + table.widths = [100, 200, 300]; + table.rows[0].height = 400; + table.rows[1].height = 500; + table.rows[2].height = 600; + table.rows[3].height = 700; + + const unselectedCell5 = { ...selectedCell5, isSelected: false }; + const unselectedCell9 = { ...selectedCell9, isSelected: false }; insertTableColumn(table, 'insertLeft'); + + const selectedCell2 = { ...cell2, isSelected: true }; + const selectedCell8 = { ...cell8, isSelected: true }; + const selectedCell11 = { ...cell11, isSelected: true }; + expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ - { format: {}, height: 500, cells: [cell1, cell2, cell2, cell2, cell3, cell4] }, + { + format: {}, + height: 400, + cells: [cell1, selectedCell2, selectedCell2, cell2, cell3], + }, + { + format: {}, + height: 500, + cells: [cell4, selectedCell5, selectedCell5, unselectedCell5, cell6], + }, { format: {}, height: 600, - cells: [cell5, cell6Clone, cell6Clone, cell6, cell7, cell8], + cells: [cell7, selectedCell8, selectedCell8, cell8, unselectedCell9], + }, + { + format: {}, + height: 700, + cells: [cell10, selectedCell11, selectedCell11, cell11, cell12], }, - { format: {}, height: 700, cells: [cell9, cell10, cell10, cell10, cell11, cell12] }, ], - widths: [100, 200, 200, 200, 300, 400], + widths: [100, 200, 200, 200, 300], dataset: {}, }); + }); + + it('table with complex scenario - insertRight', () => { + const table = createTable(4); + const cell1 = createTableCell(false, false, false, { backgroundColor: '1' }); + const cell2 = createTableCell(false, false, false, { backgroundColor: '2' }); + const cell3 = createTableCell(false, false, false, { backgroundColor: '3' }); + const cell4 = createTableCell(false, false, false, { backgroundColor: '4' }); + const selectedCell5 = createTableCell(false, false, false, { backgroundColor: '5' }); + const cell6 = createTableCell(true, false, false, { backgroundColor: '6' }); + const cell7 = createTableCell(false, false, false, { backgroundColor: '7' }); + const cell8 = createTableCell(false, true, false, { backgroundColor: '8' }); + const selectedCell9 = createTableCell(true, true, false, { backgroundColor: '9' }); + const cell10 = createTableCell(false, false, false, { backgroundColor: '10' }); + const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); + const cell12 = createTableCell(false, false, false, { backgroundColor: '12' }); + + selectedCell5.isSelected = true; + selectedCell9.isSelected = true; + table.rows[0].cells.push(cell1, cell2, cell3); + table.rows[1].cells.push(cell4, { ...selectedCell5 }, cell6); + table.rows[2].cells.push(cell7, cell8, { ...selectedCell9 }); + table.rows[3].cells.push(cell10, cell11, cell12); + table.widths = [100, 200, 300]; + table.rows[0].height = 400; + table.rows[1].height = 500; + table.rows[2].height = 600; + table.rows[3].height = 700; + + const unselectedCell5 = { ...selectedCell5, isSelected: false }; + const unselectedCell9 = { ...selectedCell9, isSelected: false }; insertTableColumn(table, 'insertRight'); + + const selectedCell3 = { ...cell3, isSelected: true }; + const selectedCell6 = { ...cell6, isSelected: true }; + const selectedCell12 = { ...cell12, isSelected: true }; + expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ + { + format: {}, + height: 400, + cells: [cell1, cell2, cell3, selectedCell3, selectedCell3], + }, { format: {}, height: 500, - cells: [cell1, cell2, cell2, cell2, cell3, cell3, cell3, cell4], + cells: [cell4, unselectedCell5, cell6, selectedCell6, selectedCell6], }, { format: {}, height: 600, - cells: [cell5, cell6Clone, cell6Clone, cell6, cell7, cell7, cell7, cell8], + cells: [cell7, cell8, unselectedCell9, selectedCell9, selectedCell9], }, { format: {}, height: 700, - cells: [ - cell9, - cell10, - cell10, - cell10, - cell11, - cell11Clone, - cell11Clone, - cell12, - ], + cells: [cell10, cell11, cell12, selectedCell12, selectedCell12], }, ], - widths: [100, 200, 200, 200, 300, 300, 300, 400], + widths: [100, 200, 300, 300, 300], dataset: {}, }); }); diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableRowTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableRowTest.ts index 35c49e4d7ea..8da63101b8f 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableRowTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableRowTest.ts @@ -49,22 +49,21 @@ describe('insertTableRow', () => { it('table with single selection', () => { const table = createTable(1); - const cell1 = createTableCell(); - cell1.isSelected = true; - table.rows[0].cells.push(cell1); + const selectedCell = createTableCell(); + selectedCell.isSelected = true; + table.rows[0].cells.push({ ...selectedCell }); table.widths = [100]; table.rows[0].height = 200; - const cell2 = { ...cell1 }; - delete cell2.isSelected; + const unselectedCell = { ...selectedCell, isSelected: false }; insertTableRow(table, 'insertAbove'); expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ - { format: {}, height: 200, cells: [cell2] }, - { format: {}, height: 200, cells: [cell1] }, + { format: {}, height: 200, cells: [selectedCell] }, + { format: {}, height: 200, cells: [unselectedCell] }, ], widths: [100], dataset: {}, @@ -75,58 +74,69 @@ describe('insertTableRow', () => { blockType: 'Table', format: {}, rows: [ - { format: {}, height: 200, cells: [cell2] }, - { format: {}, height: 200, cells: [cell1] }, - { format: {}, height: 200, cells: [cell2] }, + { format: {}, height: 200, cells: [unselectedCell] }, + { format: {}, height: 200, cells: [selectedCell] }, + { format: {}, height: 200, cells: [unselectedCell] }, ], widths: [100], dataset: {}, }); }); - it('table with multi selection', () => { + it('table with multi selection - insertAbove', () => { const table = createTable(2); - const cell1 = createTableCell(); - const cell2 = createTableCell(false, false, true); - cell1.isSelected = true; - cell2.isSelected = true; - table.rows[0].cells.push(cell1); - table.rows[1].cells.push(cell2); + const selectedCell = createTableCell(); + const selectedHeader = createTableCell(false, false, true); + selectedCell.isSelected = true; + selectedHeader.isSelected = true; + table.rows[0].cells.push({ ...selectedCell }); + table.rows[1].cells.push({ ...selectedHeader }); table.widths = [100]; table.rows[0].height = 200; table.rows[1].height = 300; - const cell3 = { ...cell1 }; - delete cell3.isSelected; - - const cell4 = { ...cell2 }; - delete cell4.isSelected; + const unselectedCell = { ...selectedCell, isSelected: false }; + const unselectedHeader = { ...selectedHeader, isSelected: false }; insertTableRow(table, 'insertAbove'); expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ - { format: {}, height: 200, cells: [cell3] }, - { format: {}, height: 200, cells: [cell3] }, - { format: {}, height: 200, cells: [cell1] }, - { format: {}, height: 300, cells: [cell2] }, + { format: {}, height: 200, cells: [selectedCell] }, + { format: {}, height: 200, cells: [selectedCell] }, + { format: {}, height: 200, cells: [unselectedCell] }, + { format: {}, height: 300, cells: [unselectedHeader] }, ], widths: [100], dataset: {}, }); + }); + + it('table with multi selection - insertBelow', () => { + const table = createTable(2); + const selectedCell = createTableCell(); + const selectedHeader = createTableCell(false, false, true); + selectedCell.isSelected = true; + selectedHeader.isSelected = true; + table.rows[0].cells.push({ ...selectedCell }); + table.rows[1].cells.push({ ...selectedHeader }); + table.widths = [100]; + table.rows[0].height = 200; + table.rows[1].height = 300; + + const unselectedCell = { ...selectedCell, isSelected: false }; + const unselectedHeader = { ...selectedHeader, isSelected: false }; insertTableRow(table, 'insertBelow'); expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ - { format: {}, height: 200, cells: [cell3] }, - { format: {}, height: 200, cells: [cell3] }, - { format: {}, height: 200, cells: [cell1] }, - { format: {}, height: 300, cells: [cell2] }, - { format: {}, height: 300, cells: [cell4] }, - { format: {}, height: 300, cells: [cell4] }, + { format: {}, height: 200, cells: [unselectedCell] }, + { format: {}, height: 300, cells: [unselectedHeader] }, + { format: {}, height: 300, cells: [selectedHeader] }, + { format: {}, height: 300, cells: [selectedHeader] }, ], widths: [100], dataset: {}, @@ -135,28 +145,25 @@ describe('insertTableRow', () => { it('table with multi selection in multi column', () => { const table = createTable(1); - const cell1 = createTableCell(false, false, true); - const cell2 = createTableCell(false, true); + const selectedHeader = createTableCell(false, false, true); + const selectedSpanAbove = createTableCell(false, true); - cell1.isSelected = true; - cell2.isSelected = true; - table.rows[0].cells.push(cell1, cell2); + selectedHeader.isSelected = true; + selectedSpanAbove.isSelected = true; + table.rows[0].cells.push({ ...selectedHeader }, { ...selectedSpanAbove }); table.widths = [100, 200]; table.rows[0].height = 300; - const cell3 = { ...cell1 }; - delete cell3.isSelected; - - const cell4 = { ...cell2 }; - delete cell4.isSelected; + const unselectedHeader = { ...selectedHeader, isSelected: false }; + const unselectedSpanAbove = { ...selectedSpanAbove, isSelected: false }; insertTableRow(table, 'insertAbove'); expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ - { format: {}, height: 300, cells: [cell3, cell4] }, - { format: {}, height: 300, cells: [cell1, cell2] }, + { format: {}, height: 300, cells: [selectedHeader, selectedSpanAbove] }, + { format: {}, height: 300, cells: [unselectedHeader, unselectedSpanAbove] }, ], widths: [100, 200], dataset: {}, @@ -167,35 +174,35 @@ describe('insertTableRow', () => { blockType: 'Table', format: {}, rows: [ - { format: {}, height: 300, cells: [cell3, cell4] }, - { format: {}, height: 300, cells: [cell1, cell2] }, - { format: {}, height: 300, cells: [cell3, cell4] }, + { format: {}, height: 300, cells: [unselectedHeader, unselectedSpanAbove] }, + { format: {}, height: 300, cells: [selectedHeader, selectedSpanAbove] }, + { format: {}, height: 300, cells: [unselectedHeader, unselectedSpanAbove] }, ], widths: [100, 200], dataset: {}, }); }); - it('table with complex scenario', () => { + it('table with complex scenario - insertAbove', () => { const table = createTable(4); const cell1 = createTableCell(false, false, false, { backgroundColor: '1' }); const cell2 = createTableCell(false, false, false, { backgroundColor: '2' }); const cell3 = createTableCell(false, false, false, { backgroundColor: '3' }); const cell4 = createTableCell(false, false, false, { backgroundColor: '4' }); - const cell5 = createTableCell(false, false, false, { backgroundColor: '5' }); + const selectedCell5 = createTableCell(false, false, false, { backgroundColor: '5' }); const cell6 = createTableCell(true, false, false, { backgroundColor: '6' }); const cell7 = createTableCell(false, false, false, { backgroundColor: '7' }); const cell8 = createTableCell(false, true, false, { backgroundColor: '8' }); - const cell9 = createTableCell(true, true, false, { backgroundColor: '9' }); + const selectedCell9 = createTableCell(true, true, false, { backgroundColor: '9' }); const cell10 = createTableCell(false, false, false, { backgroundColor: '10' }); const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); const cell12 = createTableCell(false, false, false, { backgroundColor: '12' }); - cell5.isSelected = true; - cell9.isSelected = true; + selectedCell5.isSelected = true; + selectedCell9.isSelected = true; table.rows[0].cells.push(cell1, cell2, cell3); - table.rows[1].cells.push(cell4, cell5, cell6); - table.rows[2].cells.push(cell7, cell8, cell9); + table.rows[1].cells.push(cell4, { ...selectedCell5 }, cell6); + table.rows[2].cells.push(cell7, cell8, { ...selectedCell9 }); table.rows[3].cells.push(cell10, cell11, cell12); table.widths = [100, 200, 300]; table.rows[0].height = 400; @@ -203,39 +210,74 @@ describe('insertTableRow', () => { table.rows[2].height = 600; table.rows[3].height = 700; - const cell5Clone = { ...cell5 }; - const cell9Clone = { ...cell9 }; - delete cell5Clone.isSelected; - delete cell9Clone.isSelected; + const unselectedCell5 = { ...selectedCell5, isSelected: false }; + const unselectedCell9 = { ...selectedCell9, isSelected: false }; insertTableRow(table, 'insertAbove'); + + const selectedCell4 = { ...cell4, isSelected: true }; + const selectedCell6 = { ...cell6, isSelected: true }; + expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ { format: {}, height: 400, cells: [cell1, cell2, cell3] }, - { format: {}, height: 500, cells: [cell4, cell5Clone, cell6] }, - { format: {}, height: 500, cells: [cell4, cell5Clone, cell6] }, - { format: {}, height: 500, cells: [cell4, cell5, cell6] }, - { format: {}, height: 600, cells: [cell7, cell8, cell9] }, + { format: {}, height: 500, cells: [selectedCell4, selectedCell5, selectedCell6] }, + { format: {}, height: 500, cells: [selectedCell4, selectedCell5, selectedCell6] }, + { format: {}, height: 500, cells: [cell4, unselectedCell5, cell6] }, + { format: {}, height: 600, cells: [cell7, cell8, unselectedCell9] }, { format: {}, height: 700, cells: [cell10, cell11, cell12] }, ], widths: [100, 200, 300], dataset: {}, }); + }); + + it('table with complex scenario - insertBelow', () => { + const table = createTable(4); + const cell1 = createTableCell(false, false, false, { backgroundColor: '1' }); + const cell2 = createTableCell(false, false, false, { backgroundColor: '2' }); + const cell3 = createTableCell(false, false, false, { backgroundColor: '3' }); + const cell4 = createTableCell(false, false, false, { backgroundColor: '4' }); + const selectedCell5 = createTableCell(false, false, false, { backgroundColor: '5' }); + const cell6 = createTableCell(true, false, false, { backgroundColor: '6' }); + const cell7 = createTableCell(false, false, false, { backgroundColor: '7' }); + const cell8 = createTableCell(false, true, false, { backgroundColor: '8' }); + const selectedCell9 = createTableCell(true, true, false, { backgroundColor: '9' }); + const cell10 = createTableCell(false, false, false, { backgroundColor: '10' }); + const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); + const cell12 = createTableCell(false, false, false, { backgroundColor: '12' }); + + selectedCell5.isSelected = true; + selectedCell9.isSelected = true; + table.rows[0].cells.push(cell1, cell2, cell3); + table.rows[1].cells.push(cell4, { ...selectedCell5 }, cell6); + table.rows[2].cells.push(cell7, cell8, { ...selectedCell9 }); + table.rows[3].cells.push(cell10, cell11, cell12); + table.widths = [100, 200, 300]; + table.rows[0].height = 400; + table.rows[1].height = 500; + table.rows[2].height = 600; + table.rows[3].height = 700; + + const unselectedCell5 = { ...selectedCell5, isSelected: false }; + const unselectedCell9 = { ...selectedCell9, isSelected: false }; insertTableRow(table, 'insertBelow'); + + const selectedCell7 = { ...cell7, isSelected: true }; + const selectedCell8 = { ...cell8, isSelected: true }; + expect(table).toEqual({ blockType: 'Table', format: {}, rows: [ { format: {}, height: 400, cells: [cell1, cell2, cell3] }, - { format: {}, height: 500, cells: [cell4, cell5Clone, cell6] }, - { format: {}, height: 500, cells: [cell4, cell5Clone, cell6] }, - { format: {}, height: 500, cells: [cell4, cell5, cell6] }, - { format: {}, height: 600, cells: [cell7, cell8, cell9] }, - { format: {}, height: 600, cells: [cell7, cell8, cell9Clone] }, - { format: {}, height: 600, cells: [cell7, cell8, cell9Clone] }, + { format: {}, height: 500, cells: [cell4, unselectedCell5, cell6] }, + { format: {}, height: 600, cells: [cell7, cell8, unselectedCell9] }, + { format: {}, height: 600, cells: [selectedCell7, selectedCell8, selectedCell9] }, + { format: {}, height: 600, cells: [selectedCell7, selectedCell8, selectedCell9] }, { format: {}, height: 700, cells: [cell10, cell11, cell12] }, ], widths: [100, 200, 300], From 20e6e3594f47da251e7d91c68cc3d8c3ab559d89 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 15 Feb 2024 12:22:59 -0800 Subject: [PATCH 110/112] Fix undo snapshot for entity, fix #2310, and IME after entity (#2409) * Fix #2310, and IME after entity * fix test * fix test * improve * improve * improve * Fix undo snapshot for entity * fix test --- .../insertEntity/InsertEntityPane.tsx | 57 +- .../lib/modelApi/entity/insertEntityModel.ts | 6 + .../modelApi/entity/insertEntityModelTest.ts | 552 ++++++++++++++---- .../lib/coreApi/formatContentModel.ts | 37 +- .../lib/corePlugin/DOMEventPlugin.ts | 59 +- .../lib/corePlugin/EntityPlugin.ts | 4 + .../lib/corePlugin/FormatPlugin.ts | 9 +- .../lib/corePlugin/UndoPlugin.ts | 3 +- .../corePlugin/utils/entityDelimiterUtils.ts | 51 +- .../lib/editor/StandaloneEditor.ts | 8 - .../test/coreApi/formatContentModelTest.ts | 8 +- .../test/corePlugin/DomEventPluginTest.ts | 93 ++- .../test/corePlugin/FormatPluginTest.ts | 37 +- .../corePlugin/utils/delimiterUtilsTest.ts | 51 +- .../test/editor/StandaloneEditorTest.ts | 2 - .../lib/edit/keyboardInput.ts | 10 +- .../test/edit/keyboardInputTest.ts | 75 --- .../lib/editor/IStandaloneEditor.ts | 6 - .../lib/editor/EditorAdapter.ts | 8 + 19 files changed, 735 insertions(+), 341 deletions(-) diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx index 1e84cdeb8b7..e380bafbb40 100644 --- a/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx @@ -105,37 +105,36 @@ export default class InsertEntityPane extends React.Component { - const options: InsertEntityOptions = { - contentNode: node, - focusAfterEntity: focusAfterEntity, - }; + editor.focus(); - if (isBlock) { - insertEntity( - editor as IStandaloneEditor & IEditor, - entityType, - true, - insertAtRoot - ? 'root' - : insertAtTop - ? 'begin' - : insertAtBottom - ? 'end' - : 'focus', - options - ); - } else { - insertEntity( - editor as IStandaloneEditor & IEditor, - entityType, - isBlock, - insertAtTop ? 'begin' : insertAtBottom ? 'end' : 'focus', - options - ); - } - }); + if (isBlock) { + insertEntity( + editor as IStandaloneEditor & IEditor, + entityType, + true, + insertAtRoot + ? 'root' + : insertAtTop + ? 'begin' + : insertAtBottom + ? 'end' + : 'focus', + options + ); + } else { + insertEntity( + editor as IStandaloneEditor & IEditor, + entityType, + isBlock, + insertAtTop ? 'begin' : insertAtBottom ? 'end' : 'focus', + options + ); + } } }; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts index aa5ccdaff80..cf3f41e9df8 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts @@ -38,6 +38,10 @@ export function insertEntityModel( if (position == 'begin' || position == 'end') { blockParent = model; blockIndex = position == 'begin' ? 0 : model.blocks.length; + + if (!isBlock) { + Object.assign(entityModel.format, model.format); + } } else if ((deleteResult = deleteSelection(model, [], context)).insertPoint) { const { marker, paragraph, path } = deleteResult.insertPoint; @@ -48,6 +52,8 @@ export function insertEntityModel( if (!isBlock) { const index = paragraph.segments.indexOf(marker); + Object.assign(entityModel.format, marker.format); + if (index >= 0) { paragraph.segments.splice(focusAfterEntity ? index : index + 1, 0, entityModel); } diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts index 49cf8bb1a6b..612895b5b50 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts @@ -10,8 +10,6 @@ import { createText, } from 'roosterjs-content-model-dom'; -const Entity = 'Entity' as any; - function runTestGlobal( model: ContentModelDocument, pos: InsertEntityPosition, @@ -19,6 +17,10 @@ function runTestGlobal( isBlock: boolean, focusAfterEntity: boolean ) { + const Entity = { + format: {}, + } as any; + insertEntityModel(model, Entity, pos, isBlock, focusAfterEntity); expect(model).toEqual(expectedResult, pos); @@ -55,7 +57,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [], @@ -71,7 +75,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -120,7 +126,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [txt1, marker, txt2], @@ -136,7 +144,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -152,7 +162,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -168,7 +180,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -199,7 +213,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [txt1, txt2], @@ -215,7 +231,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -231,7 +249,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -247,7 +267,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -275,7 +297,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -301,7 +325,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -317,7 +343,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [marker], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [], @@ -333,7 +361,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [marker], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [], @@ -361,7 +391,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -379,7 +411,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { format: {}, }, divider, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -395,7 +429,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [marker], format: {}, }, - Entity, + { + format: {}, + } as any, divider, ], }, @@ -407,7 +443,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [marker], format: {}, }, - Entity, + { + format: {}, + } as any, divider, ], } @@ -431,7 +469,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -449,7 +489,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { format: {}, }, entity2, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -465,7 +507,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [marker], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -482,7 +526,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [marker], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -515,7 +561,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [txt1, marker, txt2], @@ -532,7 +580,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -550,7 +600,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -568,7 +620,9 @@ describe('insertEntityModel, block element, not focus after entity', () => { segments: [txt1, marker, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [br], @@ -612,7 +666,9 @@ describe('insertEntityModel, block element, focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -628,7 +684,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -676,7 +734,9 @@ describe('insertEntityModel, block element, focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, txt1, txt2], @@ -692,7 +752,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -708,7 +770,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -724,7 +788,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -754,7 +820,9 @@ describe('insertEntityModel, block element, focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, txt1, txt2], @@ -770,7 +838,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -786,7 +856,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -802,7 +874,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -828,7 +902,9 @@ describe('insertEntityModel, block element, focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -854,7 +930,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -870,7 +948,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -886,7 +966,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -913,7 +995,9 @@ describe('insertEntityModel, block element, focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -931,7 +1015,9 @@ describe('insertEntityModel, block element, focus after entity', () => { format: {}, }, divider, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -947,7 +1033,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -964,7 +1052,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -992,7 +1082,9 @@ describe('insertEntityModel, block element, focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker], @@ -1010,7 +1102,9 @@ describe('insertEntityModel, block element, focus after entity', () => { format: {}, }, entity2, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -1026,7 +1120,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -1043,7 +1139,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, br], @@ -1077,7 +1175,9 @@ describe('insertEntityModel, block element, focus after entity', () => { { blockGroupType: 'Document', blocks: [ - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker, txt1, txt2], @@ -1094,7 +1194,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker2, br], @@ -1112,7 +1214,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker2, br], @@ -1130,7 +1234,9 @@ describe('insertEntityModel, block element, focus after entity', () => { segments: [txt1, txt2], format: {}, }, - Entity, + { + format: {}, + } as any, { blockType: 'Paragraph', segments: [marker2, br], @@ -1175,7 +1281,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, { @@ -1195,7 +1305,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1242,7 +1356,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, { @@ -1262,7 +1380,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1272,7 +1394,14 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, marker, Entity, txt2], + segments: [ + txt1, + marker, + { + format: {}, + } as any, + txt2, + ], format: {}, }, ], @@ -1282,7 +1411,14 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, marker, Entity, txt2], + segments: [ + txt1, + marker, + { + format: {}, + } as any, + txt2, + ], format: {}, }, ], @@ -1311,7 +1447,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, { @@ -1331,7 +1471,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1341,7 +1485,13 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, marker, Entity], + segments: [ + txt1, + marker, + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1351,7 +1501,13 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, marker, Entity], + segments: [ + txt1, + marker, + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1376,7 +1532,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, { @@ -1406,7 +1566,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1416,7 +1580,12 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [marker, Entity], + segments: [ + marker, + { + format: {}, + } as any, + ], format: {}, }, { @@ -1431,7 +1600,12 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [marker, Entity], + segments: [ + marker, + { + format: {}, + } as any, + ], format: {}, }, { @@ -1462,7 +1636,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, { @@ -1484,7 +1662,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { divider, { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1494,7 +1676,12 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [marker, Entity], + segments: [ + marker, + { + format: {}, + } as any, + ], format: {}, }, divider, @@ -1505,7 +1692,12 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [marker, Entity], + segments: [ + marker, + { + format: {}, + } as any, + ], format: {}, }, divider, @@ -1532,7 +1724,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, { @@ -1554,7 +1750,11 @@ describe('insertEntityModel, inline element, not focus after entity', () => { entity2, { blockType: 'Paragraph', - segments: [Entity], + segments: [ + { + format: {}, + } as any, + ], format: {}, }, ], @@ -1564,7 +1764,12 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [marker, Entity], + segments: [ + marker, + { + format: {}, + } as any, + ], format: {}, }, entity2, @@ -1575,7 +1780,12 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [marker, Entity], + segments: [ + marker, + { + format: {}, + } as any, + ], format: {}, }, entity2, @@ -1606,7 +1816,7 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity], + segments: [{ format } as any], format: {}, segmentFormat: format, }, @@ -1628,7 +1838,7 @@ describe('insertEntityModel, inline element, not focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity], + segments: [{ format } as any], format: {}, segmentFormat: format, }, @@ -1640,7 +1850,7 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, marker, Entity, txt2], + segments: [txt1, marker, { format: {} } as any, txt2], format: {}, }, ], @@ -1651,7 +1861,7 @@ describe('insertEntityModel, inline element, not focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, marker, Entity, txt2], + segments: [txt1, marker, { format: {} } as any, txt2], format: {}, }, ], @@ -1692,7 +1902,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -1712,7 +1927,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -1759,7 +1979,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -1779,7 +2004,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -1789,7 +2019,14 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, Entity, marker, txt2], + segments: [ + txt1, + { + format: {}, + } as any, + marker, + txt2, + ], format: {}, }, ], @@ -1799,7 +2036,14 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, Entity, marker, txt2], + segments: [ + txt1, + { + format: {}, + } as any, + marker, + txt2, + ], format: {}, }, ], @@ -1828,7 +2072,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -1848,7 +2097,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -1858,7 +2112,13 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, Entity, marker], + segments: [ + txt1, + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -1868,7 +2128,13 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, Entity, marker], + segments: [ + txt1, + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -1893,7 +2159,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -1923,7 +2194,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -1933,7 +2209,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -1948,7 +2229,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -1979,7 +2265,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -2001,7 +2292,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { divider, { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -2011,7 +2307,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, divider, @@ -2022,7 +2323,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, divider, @@ -2049,7 +2355,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, { @@ -2071,7 +2382,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { entity2, { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, ], @@ -2081,7 +2397,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, entity2, @@ -2092,7 +2413,12 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker], + segments: [ + { + format: {}, + } as any, + marker, + ], format: {}, }, entity2, @@ -2124,7 +2450,7 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [Entity, marker2], + segments: [{ format } as any, marker2], format: {}, segmentFormat: format, }, @@ -2146,7 +2472,7 @@ describe('insertEntityModel, inline element, focus after entity', () => { }, { blockType: 'Paragraph', - segments: [Entity, marker2], + segments: [{ format } as any, marker2], format: {}, segmentFormat: format, }, @@ -2158,7 +2484,14 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, Entity, marker, txt2], + segments: [ + txt1, + { + format: {}, + } as any, + marker, + txt2, + ], format: {}, }, ], @@ -2169,7 +2502,14 @@ describe('insertEntityModel, inline element, focus after entity', () => { blocks: [ { blockType: 'Paragraph', - segments: [txt1, Entity, marker, txt2], + segments: [ + txt1, + { + format: {}, + } as any, + marker, + txt2, + ], format: {}, }, ], diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts index 2d21506fcff..a75c9ab498c 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts @@ -60,32 +60,33 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) handlePendingFormat(core, context, selection); + const eventData: ContentChangedEvent = { + eventType: 'contentChanged', + contentModel: clearModelCache ? undefined : model, + selection: clearModelCache ? undefined : selection, + source: changeSource || ChangeSource.Format, + data: getChangeData?.(), + formatApiName: apiName, + changedEntities: getChangedEntities(context, rawEvent), + }; + + core.api.triggerEvent(core, eventData, true /*broadcast*/); + + if (canUndoByBackspace && selection?.type == 'range') { + core.undo.posContainer = selection.range.startContainer; + core.undo.posOffset = selection.range.startOffset; + } + if (shouldAddSnapshot) { core.api.addUndoSnapshot(core, !!canUndoByBackspace, entityStates); + } else { + core.undo.snapshotsManager.hasNewContent = true; } } finally { if (!isNested) { core.undo.isNested = false; } } - - const eventData: ContentChangedEvent = { - eventType: 'contentChanged', - contentModel: clearModelCache ? undefined : model, - selection: clearModelCache ? undefined : selection, - source: changeSource || ChangeSource.Format, - data: getChangeData?.(), - formatApiName: apiName, - changedEntities: getChangedEntities(context, rawEvent), - }; - - core.api.triggerEvent(core, eventData, true /*broadcast*/); - - if (canUndoByBackspace && selection?.type == 'range') { - core.undo.snapshotsManager.hasNewContent = false; - core.undo.posContainer = selection.range.startContainer; - core.undo.posOffset = selection.range.startOffset; - } } else { if (clearModelCache) { core.cache.cachedModel = undefined; diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts index fc2757d21e2..f3f08524a5f 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts @@ -7,9 +7,14 @@ import type { DOMEventRecord, StandaloneEditorOptions, PluginWithState, - PluginEventType, } from 'roosterjs-content-model-types'; +const EventTypeMap: Record = { + keydown: 'keyDown', + keyup: 'keyUp', + keypress: 'keyPress', +}; + /** * DOMEventPlugin handles customized DOM events, including: * 1. Keyboard event @@ -60,9 +65,10 @@ class DOMEventPlugin implements PluginWithState { { [P in keyof HTMLElementEventMap]: DOMEventRecord } > = { // 1. Keyboard event - keypress: this.getEventHandler('keyPress'), - keydown: this.getEventHandler('keyDown'), - keyup: this.getEventHandler('keyUp'), + keypress: this.keyboardEventHandler, + keydown: this.keyboardEventHandler, + keyup: this.keyboardEventHandler, + input: this.inputEventHandler, // 2. Mouse event mousedown: { beforeDispatch: this.onMouseDown }, @@ -74,9 +80,6 @@ class DOMEventPlugin implements PluginWithState { // 4. Drag and Drop event dragstart: { beforeDispatch: this.onDragStart }, drop: { beforeDispatch: this.onDrop }, - - // 5. Input event - input: this.getEventHandler('input'), }; this.disposer = this.editor.attachDomEvent(>eventHandlers); @@ -140,28 +143,34 @@ class DOMEventPlugin implements PluginWithState { }); }; - private getEventHandler(eventType: PluginEventType): DOMEventRecord { - const beforeDispatch = (event: Event) => - eventType == 'input' - ? this.onInputEvent(event) - : this.onKeyboardEvent(event); + private keyboardEventHandler: DOMEventRecord = { + beforeDispatch: event => { + const eventType = EventTypeMap[event.type]; - return { - pluginEventType: eventType, - beforeDispatch, - }; - } + if (isCharacterValue(event) || isCursorMovingKey(event)) { + // Stop propagation for Character keys and Up/Down/Left/Right/Home/End/PageUp/PageDown + // since editor already handles these keys and no need to propagate to parents + event.stopPropagation(); + } - private onKeyboardEvent = (event: KeyboardEvent) => { - if (isCharacterValue(event) || isCursorMovingKey(event)) { - // Stop propagation for Character keys and Up/Down/Left/Right/Home/End/PageUp/PageDown - // since editor already handles these keys and no need to propagate to parents - event.stopPropagation(); - } + if (this.editor && eventType && !event.isComposing && !this.state.isInIME) { + this.editor.triggerEvent(eventType, { + rawEvent: event, + }); + } + }, }; - private onInputEvent = (event: InputEvent) => { - event.stopPropagation(); + private inputEventHandler: DOMEventRecord = { + beforeDispatch: event => { + event.stopPropagation(); + + if (this.editor && !(event as InputEvent).isComposing && !this.state.isInIME) { + this.editor.triggerEvent('input', { + rawEvent: event as InputEvent, + }); + } + }, }; private onMouseDown = (event: MouseEvent) => { diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts index fef628a00d2..b49687c2f07 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts @@ -1,6 +1,7 @@ import { findAllEntities } from './utils/findAllEntities'; import { transformColor } from '../publicApi/color/transformColor'; import { + handleCompositionEndEvent, handleDelimiterContentChangedEvent, handleDelimiterKeyDownEvent, } from './utils/entityDelimiterUtils'; @@ -88,6 +89,9 @@ class EntityPlugin implements PluginWithState { case 'keyDown': handleDelimiterKeyDownEvent(this.editor, event); break; + case 'compositionEnd': + handleCompositionEndEvent(this.editor, event); + break; case 'editorReady': this.handleContentChangedEvent(this.editor); break; diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts index 6c2dd0d93ed..1fd6c7164a1 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts @@ -102,14 +102,7 @@ class FormatPlugin implements PluginWithState { switch (event.eventType) { case 'input': - const env = this.editor.getEnvironment(); - - // In Safari, isComposing will be undefined but isInIME() works - // For Android, we can skip checking isComposing since this property is not always reliable in all IME, - // and we have tested without this check it can still work correctly - if (env.isAndroid || (!event.rawEvent.isComposing && !this.editor.isInIME())) { - this.checkAndApplyPendingFormat(event.rawEvent.data); - } + this.checkAndApplyPendingFormat(event.rawEvent.data); break; diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts index 1a12973cd42..7e35ff5c0f1 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts @@ -85,8 +85,7 @@ class UndoPlugin implements PluginWithState { * @param event PluginEvent object */ onPluginEvent(event: PluginEvent): void { - // if editor is in IME, don't do anything - if (!this.editor || this.editor.isInIME()) { + if (!this.editor) { return; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts index 77ddefad7e6..e2f8f1953c1 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts @@ -1,6 +1,7 @@ import { isCharacterValue } from '../../publicApi/domUtils/eventUtils'; import { iterateSelections } from '../../publicApi/selection/iterateSelections'; import type { + CompositionEndEvent, ContentModelBlockGroup, ContentModelFormatter, ContentModelParagraph, @@ -40,7 +41,7 @@ export function preventTypeInDelimiter(node: HTMLElement, editor: IStandaloneEdi element => !!element ) as HTMLElement[] ); - editor.formatContentModel(model => { + editor.formatContentModel((model, context) => { iterateSelections(model, (_path, _tableContext, block, _segments) => { if (block?.blockType == 'Paragraph') { block.segments.forEach(segment => { @@ -50,6 +51,9 @@ export function preventTypeInDelimiter(node: HTMLElement, editor: IStandaloneEdi }); } }); + + context.skipUndoSnapshot = true; + return true; }); } @@ -120,12 +124,30 @@ function removeDelimiterAttr(node: Element | undefined | null, checkEntity: bool }); } -function getFocusedElement(selection: RangeSelection): HTMLElement | null { +function getFocusedElement( + selection: RangeSelection, + existingTextInDelimiter?: string +): HTMLElement | null { const { range, isReverted } = selection; let node: Node | null = isReverted ? range.startContainer : range.endContainer; - const offset = isReverted ? range.startOffset : range.endOffset; + let offset = isReverted ? range.startOffset : range.endOffset; + + while (node?.lastChild) { + if (offset == node.childNodes.length) { + node = node.lastChild; + offset = node.childNodes.length; + } else { + node = node.childNodes[offset]; + offset = 0; + } + } + if (!isNodeOfType(node, 'ELEMENT_NODE')) { - if (node.textContent != ZeroWidthSpace && (node.textContent || '').length == offset) { + const textToCheck = existingTextInDelimiter + ? ZeroWidthSpace + existingTextInDelimiter + : ZeroWidthSpace; + + if (node.textContent != textToCheck && (node.textContent || '').length == offset) { node = node.nextSibling ?? node.parentElement?.closest(DelimiterSelector) ?? null; } else { node = node?.parentElement?.closest(DelimiterSelector) ?? null; @@ -148,6 +170,26 @@ export function handleDelimiterContentChangedEvent(editor: IStandaloneEditor) { addDelimitersIfNeeded(helper.queryElements(InlineEntitySelector), editor.getPendingFormat()); } +/** + * @internal + */ +export function handleCompositionEndEvent(editor: IStandaloneEditor, event: CompositionEndEvent) { + const selection = editor.getDOMSelection(); + + if (selection?.type == 'range' && selection.range.collapsed) { + const node = getFocusedElement(selection, event.rawEvent.data); + + if ( + node?.firstChild && + isNodeOfType(node.firstChild, 'TEXT_NODE') && + node.matches(DelimiterSelector) && + node.textContent == ZeroWidthSpace + event.rawEvent.data + ) { + preventTypeInDelimiter(node, editor); + } + } +} + /** * @internal */ @@ -190,6 +232,7 @@ export function handleDelimiterKeyDownEvent(editor: IStandaloneEditor, event: Ke event.rawEvent.preventDefault(); editor.formatContentModel(handleEnterInlineEntity); } else { + editor.takeSnapshot(); editor .getDocument() .defaultView?.requestAnimationFrame(() => diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts index d99c8c894cc..37b6ba45a90 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/StandaloneEditor.ts @@ -296,14 +296,6 @@ export class StandaloneEditor implements IStandaloneEditor { } } - /** - * Check if editor is in IME input sequence - * @returns True if editor is in IME input sequence, otherwise false - */ - isInIME(): boolean { - return this.getCore().domEvent.isInIME; - } - /** * Check if editor is in Shadow Edit mode */ diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts index daeb9897c7b..96aaf2b74c3 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts @@ -806,9 +806,7 @@ describe('formatContentModel', () => { expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); expect(core.undo).toEqual({ isNested: false, - snapshotsManager: { - hasNewContent: false, - }, + snapshotsManager: {}, posContainer: mockedContainer, posOffset: mockedOffset, } as any); @@ -827,7 +825,9 @@ describe('formatContentModel', () => { expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); expect(core.undo).toEqual({ isNested: true, - snapshotsManager: {}, + snapshotsManager: { + hasNewContent: true, + }, } as any); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts index ea5590641fd..e65bbddcf5c 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts @@ -97,6 +97,7 @@ describe('DOMEventPlugin', () => { describe('DOMEventPlugin verify event handlers while disallow keyboard event propagation', () => { let eventMap: Record; let plugin: PluginWithState; + let triggerEventSpy: jasmine.Spy; beforeEach(() => { const div = { @@ -104,6 +105,8 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro removeEventListener: jasmine.createSpy('removeEventListener'), }; + triggerEventSpy = jasmine.createSpy('triggerEvent'); + plugin = createDOMEventPlugin({}, div); plugin.initialize(({ getDocument, @@ -112,6 +115,7 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro return jasmine.createSpy('disposer'); }, getEnvironment: () => ({}), + triggerEvent: triggerEventSpy, })); }); @@ -122,10 +126,6 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro it('check events are mapped', () => { expect(eventMap).toBeDefined(); - expect(eventMap.keypress.pluginEventType).toBe('keyPress'); - expect(eventMap.keydown.pluginEventType).toBe('keyDown'); - expect(eventMap.keyup.pluginEventType).toBe('keyUp'); - expect(eventMap.input.pluginEventType).toBe('input'); expect(eventMap.keypress.beforeDispatch).toBeDefined(); expect(eventMap.keydown.beforeDispatch).toBeDefined(); expect(eventMap.keyup.beforeDispatch).toBeDefined(); @@ -150,28 +150,101 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro it('verify keydown event for character value', () => { spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); const stopPropagation = jasmine.createSpy(); - eventMap.keydown.beforeDispatch(({ + const mockedEvent = { stopPropagation, - })); + type: 'keydown', + } as any; + + eventMap.keydown.beforeDispatch(mockedEvent); + + expect(stopPropagation).toHaveBeenCalled(); + expect(triggerEventSpy).toHaveBeenCalledWith('keyDown', { rawEvent: mockedEvent }); + }); + + it('verify keydown event within IME 1', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); + const stopPropagation = jasmine.createSpy(); + const mockedEvent = { + stopPropagation, + type: 'keydown', + } as any; + + plugin.getState().isInIME = true; + + eventMap.keydown.beforeDispatch(mockedEvent); + expect(stopPropagation).toHaveBeenCalled(); + expect(triggerEventSpy).not.toHaveBeenCalled(); + }); + + it('verify keydown event within IME 2', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); + const stopPropagation = jasmine.createSpy(); + const mockedEvent = { + stopPropagation, + isComposing: true, + type: 'keydown', + } as any; + + eventMap.keydown.beforeDispatch(mockedEvent); + + expect(stopPropagation).toHaveBeenCalled(); + expect(triggerEventSpy).not.toHaveBeenCalled(); }); it('verify input event for non-character value', () => { spyOn(eventUtils, 'isCharacterValue').and.returnValue(false); const stopPropagation = jasmine.createSpy(); - eventMap.input.beforeDispatch(({ + const mockedEvent = { stopPropagation, - })); + } as any; + + eventMap.input.beforeDispatch(mockedEvent); + expect(stopPropagation).toHaveBeenCalled(); + expect(triggerEventSpy).toHaveBeenCalledWith('input', { rawEvent: mockedEvent }); }); it('verify input event for character value', () => { spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); const stopPropagation = jasmine.createSpy(); - eventMap.input.beforeDispatch(({ + const mockedEvent = { stopPropagation, - })); + } as any; + + eventMap.input.beforeDispatch(mockedEvent); + + expect(stopPropagation).toHaveBeenCalled(); + expect(triggerEventSpy).toHaveBeenCalledWith('input', { rawEvent: mockedEvent }); + }); + + it('verify input event for character value in IME 1', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); + const stopPropagation = jasmine.createSpy(); + const mockedEvent = { + stopPropagation, + } as any; + + plugin.getState().isInIME = true; + + eventMap.input.beforeDispatch(mockedEvent); + + expect(stopPropagation).toHaveBeenCalled(); + expect(triggerEventSpy).not.toHaveBeenCalled(); + }); + + it('verify input event for character value in IME 2', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); + const stopPropagation = jasmine.createSpy(); + const mockedEvent = { + stopPropagation, + isComposing: true, + } as any; + + eventMap.input.beforeDispatch(mockedEvent); + expect(stopPropagation).toHaveBeenCalled(); + expect(triggerEventSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts index e66e99c5c17..dd5a92fc80b 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts @@ -1,12 +1,8 @@ import * as applyDefaultFormat from '../../lib/corePlugin/utils/applyDefaultFormat'; import * as applyPendingFormat from '../../lib/corePlugin/utils/applyPendingFormat'; +import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { createFormatPlugin } from '../../lib/corePlugin/FormatPlugin'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; -import { - addSegment, - createContentModelDocument, - createSelectionMarker, -} from 'roosterjs-content-model-dom'; describe('FormatPlugin', () => { const mockedFormat = { @@ -67,37 +63,6 @@ describe('FormatPlugin', () => { expect(state.pendingFormat).toBeNull(); }); - it('with pending format and selection, trigger input event with isComposing = true', () => { - const model = createContentModelDocument(); - const marker = createSelectionMarker(); - - addSegment(model, marker); - - const editor = ({ - createContentModel: () => model, - cacheContentModel: () => {}, - getEnvironment: () => ({}), - } as any) as IStandaloneEditor; - const plugin = createFormatPlugin({}); - plugin.initialize(editor); - - const state = plugin.getState(); - - state.pendingFormat = { - format: mockedFormat, - } as any; - plugin.onPluginEvent({ - eventType: 'input', - rawEvent: ({ data: 'a', isComposing: true } as any) as InputEvent, - }); - plugin.dispose(); - - expect(applyPendingFormatSpy).not.toHaveBeenCalled(); - expect(state.pendingFormat).toEqual({ - format: mockedFormat, - } as any); - }); - it('with pending format and selection, trigger CompositionEnd event', () => { const triggerEvent = jasmine.createSpy('triggerEvent'); const getVisibleViewport = jasmine.createSpy('getVisibleViewport'); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts index f24a7c13fd6..8b2a2fb17da 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts @@ -160,10 +160,14 @@ describe('EntityDelimiterUtils |', () => { describe('onKeyDown |', () => { let mockedSelection: DOMSelection; let rafSpy: jasmine.Spy; + let takeSnapshotSpy: jasmine.Spy; + beforeEach(() => { mockedSelection = undefined!; rafSpy = jasmine.createSpy('requestAnimationFrame'); formatContentModelSpy = jasmine.createSpy('formatContentModel'); + takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); + mockedEditor = ({ getDOMSelection: () => mockedSelection, getDocument: () => @@ -177,6 +181,7 @@ describe('EntityDelimiterUtils |', () => { queryElements: queryElementsSpy, isNodeInEditor: () => true, }), + takeSnapshot: takeSnapshotSpy, }) as Partial; spyOn(DelimiterFile, 'preventTypeInDelimiter').and.callThrough(); }); @@ -297,6 +302,40 @@ describe('EntityDelimiterUtils |', () => { }); expect(rafSpy).toHaveBeenCalled(); + expect(takeSnapshotSpy).toHaveBeenCalled(); + }); + + it('Handle, range selection on upper container & delimiter', () => { + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterBefore'); + mockedSelection = { + type: 'range', + range: { + endContainer: parent, + endOffset: 1, + collapsed: true, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityDelimiter').and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(false); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + }, + }); + + expect(rafSpy).toHaveBeenCalled(); + expect(takeSnapshotSpy).toHaveBeenCalled(); }); it('Handle, range selection & delimiter before wrapped in block entity', () => { @@ -524,14 +563,18 @@ describe('EntityDelimiterUtils |', () => { describe('preventTypeInDelimiter', () => { let mockedEditor: any; let mockedModel: ContentModelDocument; + let context: any; + beforeEach(() => { + context = {}; + mockedModel = { blockGroupType: 'Document', blocks: [], }; mockedEditor = { formatContentModel: formatter => { - formatter(mockedModel, {}); + formatter(mockedModel, context); }, } as Partial; }); @@ -612,6 +655,9 @@ describe('preventTypeInDelimiter', () => { ], format: {}, }); + expect(context).toEqual({ + skipUndoSnapshot: true, + }); }); it('handle delimiter before entity', () => { @@ -690,6 +736,9 @@ describe('preventTypeInDelimiter', () => { ], format: {}, }); + expect(context).toEqual({ + skipUndoSnapshot: true, + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts index 36bbccc32ee..21f333d1427 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/StandaloneEditorTest.ts @@ -35,7 +35,6 @@ describe('StandaloneEditor', () => { expect(editor.isDisposed()).toBeFalse(); expect(editor.getDocument()).toBe(document); expect(editor.isDarkMode()).toBeFalse(); - expect(editor.isInIME()).toBeFalse(); expect(editor.isInShadowEdit()).toBeFalse(); expect(createEmptyModelSpy).toHaveBeenCalledWith(undefined); @@ -82,7 +81,6 @@ describe('StandaloneEditor', () => { expect(editor.isDisposed()).toBeFalse(); expect(editor.getDocument()).toBe(document); expect(editor.isDarkMode()).toBeTrue(); - expect(editor.isInIME()).toBeFalse(); expect(editor.isInShadowEdit()).toBeFalse(); expect(createEmptyModelSpy).not.toHaveBeenCalled(); expect(setContentModelSpy).toHaveBeenCalledWith( diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 547a362f106..b4404bc8db1 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -9,7 +9,7 @@ import type { DOMSelection, IStandaloneEditor } from 'roosterjs-content-model-ty export function keyboardInput(editor: IStandaloneEditor, rawEvent: KeyboardEvent) { const selection = editor.getDOMSelection(); - if (shouldInputWithContentModel(selection, rawEvent, editor.isInIME())) { + if (shouldInputWithContentModel(selection, rawEvent)) { editor.takeSnapshot(); editor.formatContentModel( @@ -48,12 +48,8 @@ function getInputSteps(selection: DOMSelection | null, rawEvent: KeyboardEvent) return shouldHandleEnterKey(selection, rawEvent) ? [handleEnterOnList] : []; } -function shouldInputWithContentModel( - selection: DOMSelection | null, - rawEvent: KeyboardEvent, - isInIME: boolean -) { - if (!selection || isInIME || rawEvent.isComposing) { +function shouldInputWithContentModel(selection: DOMSelection | null, rawEvent: KeyboardEvent) { + if (!selection) { return false; // Nothing to delete } else if ( !isModifierKey(rawEvent) && diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts index 1ea3063cea2..7db310df950 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts @@ -429,79 +429,4 @@ describe('keyboardInput', () => { }); expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); }); - - it('Enter key input with IME', () => { - const mockedFormat = 'FORMAT' as any; - getDOMSelectionSpy.and.returnValue({ - type: 'range', - range: { - collapsed: true, - }, - }); - deleteSelectionSpy.and.returnValue({ - deleteResult: 'range', - insertPoint: { - marker: { - format: mockedFormat, - }, - }, - }); - isInIMESpy.and.returnValue(true); - - const rawEvent = { - key: 'Enter', - isComposing: false, - } as any; - - keyboardInput(editor, rawEvent); - - expect(getDOMSelectionSpy).toHaveBeenCalled(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); - expect(formatContentModelSpy).not.toHaveBeenCalled(); - expect(deleteSelectionSpy).not.toHaveBeenCalled(); - expect(formatResult).toBeUndefined(); - expect(mockedContext).toEqual({ - deletedEntities: [], - newEntities: [], - newImages: [], - }); - expect(normalizeContentModelSpy).not.toHaveBeenCalled(); - }); - - it('Enter key input with isComposing', () => { - const mockedFormat = 'FORMAT' as any; - getDOMSelectionSpy.and.returnValue({ - type: 'range', - range: { - collapsed: true, - }, - }); - deleteSelectionSpy.and.returnValue({ - deleteResult: 'range', - insertPoint: { - marker: { - format: mockedFormat, - }, - }, - }); - - const rawEvent = { - key: 'Enter', - isComposing: true, - } as any; - - keyboardInput(editor, rawEvent); - - expect(getDOMSelectionSpy).toHaveBeenCalled(); - expect(takeSnapshotSpy).not.toHaveBeenCalled(); - expect(formatContentModelSpy).not.toHaveBeenCalled(); - expect(deleteSelectionSpy).not.toHaveBeenCalled(); - expect(formatResult).toBeUndefined(); - expect(mockedContext).toEqual({ - deletedEntities: [], - newEntities: [], - newImages: [], - }); - expect(normalizeContentModelSpy).not.toHaveBeenCalled(); - }); }); diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts index eef110367d2..87c657be187 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts @@ -135,12 +135,6 @@ export interface IStandaloneEditor { */ restoreSnapshot(snapshot: Snapshot): void; - /** - * Check if editor is in IME input sequence - * @returns True if editor is in IME input sequence, otherwise false - */ - isInIME(): boolean; - /** * Attach a DOM event to the editor content DIV * @param eventMap A map from event name to its handler diff --git a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index 76f03bb9d1b..89d4c0edeea 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts @@ -1098,6 +1098,14 @@ export class EditorAdapter extends StandaloneEditor implements IEditor { return core.darkColorHandler; } + /** + * Check if editor is in IME input sequence + * @returns True if editor is in IME input sequence, otherwise false + */ + isInIME(): boolean { + return this.getCore().domEvent.isInIME; + } + private retrieveFormatState(): ContentModelFormatState { const pendingFormat = this.getPendingFormat(); const result: ContentModelFormatState = {}; From 609a61e6b1a9ed591901fdc3b55c1934e4911a99 Mon Sep 17 00:00:00 2001 From: Andres-CT98 <107568016+Andres-CT98@users.noreply.github.com> Date: Thu, 15 Feb 2024 16:08:29 -0600 Subject: [PATCH 111/112] Add getDomStyle to DOMHelper (#2419) * add getDomStyle * test and strong typing --- .../lib/editor/DOMHelperImpl.ts | 4 ++++ .../test/editor/DOMHelperImplTest.ts | 15 +++++++++++++++ .../lib/parameter/DOMHelper.ts | 6 ++++++ 3 files changed, 25 insertions(+) diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts index ef6ac71492e..998887d351c 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts @@ -36,6 +36,10 @@ class DOMHelperImpl implements DOMHelper { getDomAttribute(name: string): string | null { return this.contentDiv.getAttribute(name); } + + getDomStyle(style: T): CSSStyleDeclaration[T] { + return this.contentDiv.style[style]; + } } /** diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts index 31e8892c686..0774387e1e3 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts @@ -108,4 +108,19 @@ describe('DOMHelperImpl', () => { domHelper.setDomAttribute(mockedAttr2, null); expect(removeAttributeSpy).toHaveBeenCalledWith(mockedAttr2); }); + + it('getDomStyle', () => { + const mockedValue = 'COLOR' as any; + const styleName: keyof CSSStyleDeclaration = 'backgroundColor'; + const styleSpy = jasmine.createSpyObj('style', [styleName]); + styleSpy[styleName] = mockedValue; + const mockedDiv = { + style: styleSpy, + } as any; + + const domHelper = createDOMHelper(mockedDiv); + const result = domHelper.getDomStyle(styleName); + + expect(result).toBe(mockedValue); + }); }); diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts index ef07dd2b059..65b2927178f 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/DOMHelper.ts @@ -48,4 +48,10 @@ export interface DOMHelper { * @param name Name of the attribute */ getDomAttribute(name: string): string | null; + + /** + * Get DOM style of editor content DIV + * @param style Name of the style + */ + getDomStyle(style: T): CSSStyleDeclaration[T]; } From f66dbf146aabb8a744895f42774d9c7445536d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 15 Feb 2024 20:07:44 -0300 Subject: [PATCH 112/112] bump --- versions.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/versions.json b/versions.json index a8b82e0c795..98fc7538df4 100644 --- a/versions.json +++ b/versions.json @@ -1,8 +1,9 @@ { "packages": "8.60.0", "packages-ui": "8.55.0", - "packages-content-model": "0.25.0", + "packages-content-model": "0.26.0", "overrides": { - "roosterjs-editor-plugins": "8.60.2" + "roosterjs-editor-plugins": "8.60.2", + "roosterjs-editor-adapter": "0.26.0" } }