From b818591e82697edabfd1d5e8b3a4ccc27bb9586a Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 22 Nov 2022 09:52:43 -0800 Subject: [PATCH 1/5] Fix a list color issue in dark mode (#1428) (#1429) * Fix a list color issue in dark mode * fix test * Add test --- .../lib/list/VListItem.ts | 15 ++++- .../lib/list/setListItemStyle.ts | 27 +++++--- .../test/list/VListItemTest.ts | 2 +- .../test/list/setListItemStyleTest.ts | 64 ++++++++++++++----- 4 files changed, 80 insertions(+), 28 deletions(-) diff --git a/packages/roosterjs-editor-dom/lib/list/VListItem.ts b/packages/roosterjs-editor-dom/lib/list/VListItem.ts index 7a9df3a3cc5..38bd1b241f4 100644 --- a/packages/roosterjs-editor-dom/lib/list/VListItem.ts +++ b/packages/roosterjs-editor-dom/lib/list/VListItem.ts @@ -32,6 +32,9 @@ const unorderedListStyles = ['disc', 'circle', 'square']; const MARGIN_BASE = '0in 0in 0in 0.5in'; const NEGATIVE_MARGIN = '-.25in'; +const stylesToInherit = ['font-size', 'font-family', 'color']; +const attrsToInherit = ['data-ogsc', 'data-ogsb', 'data-ogac', 'data-ogab']; + /** * @internal * The definition for the number of BulletListType or NumberingListType @@ -362,8 +365,8 @@ export default class VListItem { // 4. Inherit styles of the child element to the li, so we are able to apply the styles to the ::marker if (this.listTypes.length > 1) { - const stylesToInherit = ['font-size', 'font-family', 'color']; - setListItemStyle(this.node, stylesToInherit); + setListItemStyle(this.node, stylesToInherit, true /*isCssStyle*/); + setListItemStyle(this.node, attrsToInherit, false /*isCssStyle*/); } // 5. If this is not a list item now, need to unwrap the LI node and do proper handling @@ -394,6 +397,14 @@ export default class VListItem { ...getStyles(node), }; setStyles(node, styles); + + attrsToInherit.forEach(attr => { + const attrValue = this.node.getAttribute(attr); + + if (attrValue) { + node.setAttribute(attr, attrValue); + } + }); } } } diff --git a/packages/roosterjs-editor-dom/lib/list/setListItemStyle.ts b/packages/roosterjs-editor-dom/lib/list/setListItemStyle.ts index 12d1e125ffc..699ea1d6c45 100644 --- a/packages/roosterjs-editor-dom/lib/list/setListItemStyle.ts +++ b/packages/roosterjs-editor-dom/lib/list/setListItemStyle.ts @@ -1,8 +1,6 @@ import ContentTraverser from '../contentTraverser/ContentTraverser'; import findClosestElementAncestor from '../utils/findClosestElementAncestor'; -import getStyles from '../style/getStyles'; import safeInstanceOf from '../utils/safeInstanceOf'; -import setStyles from '../style/setStyles'; import { InlineElement } from 'roosterjs-editor-types'; /** @@ -10,10 +8,14 @@ import { InlineElement } from 'roosterjs-editor-types'; * If the child inline elements have different styles, it will not modify the styles of the list item * @param element the LI Element to set the styles * @param styles The styles that should be applied to the element. + * @param isCssStyle True means the given styles are CSS style names, false means they are HTML attributes @default true */ -export default function setListItemStyle(element: HTMLLIElement, styles: string[]) { - const elementsStyles = getInlineChildElementsStyle(element, styles); - let stylesToApply: Record = getStyles(element); +export default function setListItemStyle( + element: HTMLLIElement, + styles: string[], + isCssStyle: boolean = true +) { + const elementsStyles = getInlineChildElementsStyle(element, styles, isCssStyle); styles.forEach(styleName => { const styleValues = elementsStyles.map(style => @@ -25,13 +27,16 @@ export default function setListItemStyle(element: HTMLLIElement, styles: string[ (styleValues.length == 1 || new Set(styleValues).size == 1) && styleValues[0] ) { - stylesToApply[styleName] = styleValues[0]; + if (isCssStyle) { + element.style.setProperty(styleName, styleValues[0]); + } else { + element.setAttribute(styleName, styleValues[0]); + } } }); - setStyles(element, stylesToApply); } -function getInlineChildElementsStyle(element: HTMLElement, styles: string[]) { +function getInlineChildElementsStyle(element: HTMLElement, styles: string[], isCssStyle: boolean) { const result: Record[] = []; const contentTraverser = ContentTraverser.createBodyTraverser(element); let currentInlineElement: InlineElement | null = null; @@ -51,8 +56,12 @@ function getInlineChildElementsStyle(element: HTMLElement, styles: string[]) { safeInstanceOf(currentNode, 'HTMLElement') && (result.length == 0 || (currentNode.textContent?.trim().length || 0) > 0) ) { + const element: HTMLElement = currentNode; + styles.forEach(styleName => { - const styleValue = (currentNode as HTMLElement).style.getPropertyValue(styleName); + const styleValue = isCssStyle + ? element.style.getPropertyValue(styleName) + : element.getAttribute(styleName); if (!currentStyle) { currentStyle = {}; diff --git a/packages/roosterjs-editor-dom/test/list/VListItemTest.ts b/packages/roosterjs-editor-dom/test/list/VListItemTest.ts index e44aa273898..adec4750514 100644 --- a/packages/roosterjs-editor-dom/test/list/VListItemTest.ts +++ b/packages/roosterjs-editor-dom/test/list/VListItemTest.ts @@ -405,7 +405,7 @@ describe('VListItem.writeBack', () => { // Assert expect(listStack[0].innerHTML).toBe( - '
  1. test
' + '
  1. test
' ); }); diff --git a/packages/roosterjs-editor-dom/test/list/setListItemStyleTest.ts b/packages/roosterjs-editor-dom/test/list/setListItemStyleTest.ts index 144d87cf511..e626f20a9b5 100644 --- a/packages/roosterjs-editor-dom/test/list/setListItemStyleTest.ts +++ b/packages/roosterjs-editor-dom/test/list/setListItemStyleTest.ts @@ -18,7 +18,7 @@ describe('setListItemStyle', () => { textContent: 'test', }, ], - 'font-size:72pt;color:blue' + 'font-size: 72pt; color: blue;' ); }); @@ -36,7 +36,7 @@ describe('setListItemStyle', () => { textContent: 'test', }, ], - 'font-size:72pt;color:blue' + 'font-size: 72pt; color: blue;' ); }); @@ -59,7 +59,7 @@ describe('setListItemStyle', () => { textContent: 'test', }, ], - 'font-size:72pt;font-family:Tahoma;color:blue' + 'font-size: 72pt; font-family: Tahoma; color: blue;' ); }); @@ -77,7 +77,7 @@ describe('setListItemStyle', () => { textContent: 'test', }, ], - 'font-size:72pt' + 'font-size: 72pt;' ); }); @@ -100,7 +100,7 @@ describe('setListItemStyle', () => { textContent: 'test', }, ], - 'font-size:72pt' + 'font-size: 72pt;' ); }); @@ -169,7 +169,7 @@ describe('setListItemStyle', () => { textContent: 'test', }, ], - 'font-size:72pt;font-family:Tahoma;color:blue' + 'font-size: 72pt; font-family: Tahoma; color: blue;' ); }); @@ -225,11 +225,11 @@ describe('setListItemStyle', () => { listItemElement.appendChild(divElement); // Act - setListItemStyle(listItemElement, stylesToInherit); + setListItemStyle(listItemElement, stylesToInherit, true /*isCssStyle*/); // Assert expect(listItemElement.getAttribute('style')).toBe( - 'font-size:72pt;font-family:Tahoma;color:blue' + 'font-size: 72pt; font-family: Tahoma; color: blue;' ); }); @@ -269,11 +269,11 @@ describe('setListItemStyle', () => { listItemElement.appendChild(spanElement); // Act - setListItemStyle(listItemElement, stylesToInherit); + setListItemStyle(listItemElement, stylesToInherit, true /*isCssStyle*/); // Assert expect(listItemElement.getAttribute('style')).toBe( - 'font-size:72pt;font-family:Tahoma;color:blue' + 'font-size: 72pt; font-family: Tahoma; color: blue;' ); }); @@ -293,11 +293,11 @@ describe('setListItemStyle', () => { listItemElement.appendChild(spanElement); // Act; - setListItemStyle(listItemElement, stylesToInherit); + setListItemStyle(listItemElement, stylesToInherit, true /*isCssStyle*/); // Assert; expect(listItemElement.getAttribute('style')).toBe( - 'font-size:72pt;font-family:Tahoma;color:blue' + 'font-size: 72pt; font-family: Tahoma; color: blue;' ); }); @@ -318,11 +318,39 @@ describe('setListItemStyle', () => { listItemElement.appendChild(divElement); // Act; - setListItemStyle(listItemElement, stylesToInherit); + setListItemStyle(listItemElement, stylesToInherit, true /*isCssStyle*/); // Assert; expect(listItemElement.getAttribute('style')).toBe( - 'font-size:72pt;font-family:Tahoma;color:blue' + 'font-size: 72pt; font-family: Tahoma; color: blue;' + ); + }); + + it('Set HTML attribute', () => { + // Arrange; + const listItemElement = document.createElement('li'); + const divElement = document.createElement('div'); + + const spanElement = createElement({ + elementTag: 'span', + styles: '', + textContent: 'test', + }); + + spanElement.dataset.ogsc = 'red'; + spanElement.dataset.ogsb = 'blue'; + + const b = document.createElement('b'); + b.appendChild(spanElement); + divElement.appendChild(b); + listItemElement.appendChild(divElement); + + // Act; + setListItemStyle(listItemElement, ['data-ogsb', 'data-ogsc'], false /*isCssStyle*/); + + // Assert; + expect(listItemElement.outerHTML).toBe( + '
  • test
  • ' ); }); @@ -335,7 +363,7 @@ describe('setListItemStyle', () => { }); // Act - setListItemStyle(listItemElement, stylesToInherit); + setListItemStyle(listItemElement, stylesToInherit, true /*isCssStyle*/); // Assert expect(listItemElement.getAttribute('style')).toBe(result); @@ -344,7 +372,11 @@ describe('setListItemStyle', () => { function createElement(input: TestChildElement): HTMLElement { const { elementTag, styles, textContent } = input; const element = document.createElement(elementTag); - element.setAttribute('style', styles); + + if (styles) { + element.setAttribute('style', styles); + } + element.textContent = textContent; return element; } From 33a728be932f25f19047845b78cb313b14e7df82 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 15 Nov 2022 20:58:19 -0800 Subject: [PATCH 2/5] Improve shadow edit behavior when there is entity in editor (#1408) * Move mergeFragmentWithEntity to roosterjs * Shadow edit with entity --- .../editor/ExperimentalContentModelEditor.ts | 9 +- packages/roosterjs-content-model/lib/index.ts | 9 +- .../context/createModelToDomContext.ts | 3 +- .../lib/modelToDom/handlers/handleEntity.ts | 25 +- .../lib/publicApi/contentModelToDom.ts | 5 +- .../lib/publicApi/mergeFragmentWithEntity.ts | 79 --- .../lib/publicApi/table/editTable.ts | 5 +- .../lib/publicApi/table/formatTable.ts | 5 +- .../lib/publicApi/table/insertTable.ts | 5 +- .../lib/publicApi/table/setTableCellShade.ts | 5 +- .../IExperimentalContentModelEditor.ts | 10 +- .../context/ModelToDomEntityContext.ts | 19 +- .../context/createModelToDomContextTest.ts | 5 +- .../modelToDom/handlers/handleEntityTest.ts | 11 +- .../publicApi/mergeFragmentWithEntityTest.ts | 507 ------------------ .../lib/coreApi/addUndoSnapshot.ts | 2 +- .../lib/coreApi/switchShadowEdit.ts | 31 +- .../lib/corePlugins/LifecyclePlugin.ts | 1 + .../test/corePlugins/lifecyclePluginTest.ts | 2 + .../lib/entity/entityPlaceholderUtils.ts | 137 +++++ packages/roosterjs-editor-dom/lib/index.ts | 5 + .../test/entity/entityPlaceholderUtilsTest.ts | 447 +++++++++++++++ .../corePluginState/LifecyclePluginState.ts | 5 + 23 files changed, 672 insertions(+), 660 deletions(-) delete mode 100644 packages/roosterjs-content-model/lib/publicApi/mergeFragmentWithEntity.ts delete mode 100644 packages/roosterjs-content-model/test/publicApi/mergeFragmentWithEntityTest.ts create mode 100644 packages/roosterjs-editor-dom/lib/entity/entityPlaceholderUtils.ts create mode 100644 packages/roosterjs-editor-dom/test/entity/entityPlaceholderUtilsTest.ts diff --git a/demo/scripts/controls/editor/ExperimentalContentModelEditor.ts b/demo/scripts/controls/editor/ExperimentalContentModelEditor.ts index 009378cbe08..6207fb75004 100644 --- a/demo/scripts/controls/editor/ExperimentalContentModelEditor.ts +++ b/demo/scripts/controls/editor/ExperimentalContentModelEditor.ts @@ -1,6 +1,10 @@ import { Editor } from 'roosterjs-editor-core'; import { EditorOptions, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { getComputedStyles, Position } from 'roosterjs-editor-dom'; +import { + getComputedStyles, + Position, + restoreContentWithEntityPlaceholder, +} from 'roosterjs-editor-dom'; import { EditorContext, ContentModelDocument, @@ -9,7 +13,6 @@ import { DomToModelOption, IExperimentalContentModelEditor, ModelToDomOption, - mergeFragmentWithEntity, } from 'roosterjs-content-model'; /** @@ -70,7 +73,7 @@ export default class ExperimentalContentModelEditor extends Editor this.createEditorContext(), option ); - const mergingCallback = option?.mergingCallback || mergeFragmentWithEntity; + const mergingCallback = option?.mergingCallback || restoreContentWithEntityPlaceholder; if (range) { if (range.type == SelectionRangeTypes.Normal) { diff --git a/packages/roosterjs-content-model/lib/index.ts b/packages/roosterjs-content-model/lib/index.ts index 0d526a9b4dc..1a1e02a08fc 100644 --- a/packages/roosterjs-content-model/lib/index.ts +++ b/packages/roosterjs-content-model/lib/index.ts @@ -1,9 +1,5 @@ export { default as domToContentModel } from './publicApi/domToContentModel'; export { default as contentModelToDom } from './publicApi/contentModelToDom'; -export { - default as mergeFragmentWithEntity, - preprocessEntitiesFromContentModel, -} from './publicApi/mergeFragmentWithEntity'; export { default as insertTable } from './publicApi/table/insertTable'; export { default as formatTable } from './publicApi/table/formatTable'; export { default as setTableCellShade } from './publicApi/table/setTableCellShade'; @@ -134,10 +130,7 @@ export { ContentModelHandlerTypeMap, DefaultImplicitSegmentFormatMap, } from './publicTypes/context/ModelToDomSettings'; -export { - ModelToDomEntityContext, - EntityPlaceholderPair, -} from './publicTypes/context/ModelToDomEntityContext'; +export { ModelToDomEntityContext } from './publicTypes/context/ModelToDomEntityContext'; export { ElementProcessor } from './publicTypes/context/ElementProcessor'; export { ContentModelHandler } from './publicTypes/context/ContentModelHandler'; diff --git a/packages/roosterjs-content-model/lib/modelToDom/context/createModelToDomContext.ts b/packages/roosterjs-content-model/lib/modelToDom/context/createModelToDomContext.ts index b6b77822123..6fc876eab9d 100644 --- a/packages/roosterjs-content-model/lib/modelToDom/context/createModelToDomContext.ts +++ b/packages/roosterjs-content-model/lib/modelToDom/context/createModelToDomContext.ts @@ -47,9 +47,10 @@ export function createModelToDomContext( ...defaultImplicitSegmentFormatMap, ...(options?.defaultImplicitSegmentFormatOverride || {}), }, - entityPairs: [], + entities: {}, defaultModelHandlers: defaultContentModelHandlers, defaultFormatAppliers: defaultFormatAppliers, + doNotReuseEntityDom: !!options?.doNotReuseEntityDom, }; } diff --git a/packages/roosterjs-content-model/lib/modelToDom/handlers/handleEntity.ts b/packages/roosterjs-content-model/lib/modelToDom/handlers/handleEntity.ts index 596e1701dd4..18b202be1fb 100644 --- a/packages/roosterjs-content-model/lib/modelToDom/handlers/handleEntity.ts +++ b/packages/roosterjs-content-model/lib/modelToDom/handlers/handleEntity.ts @@ -1,5 +1,5 @@ import { applyFormat } from '../utils/applyFormat'; -import { commitEntity, getObjectKeys } from 'roosterjs-editor-dom'; +import { commitEntity, createEntityPlaceholder, getObjectKeys } from 'roosterjs-editor-dom'; import { ContentModelEntity } from '../../publicTypes/entity/ContentModelEntity'; import { ContentModelHandler } from '../../publicTypes/context/ContentModelHandler'; import { ModelToDomContext } from '../../publicTypes/context/ModelToDomContext'; @@ -18,12 +18,6 @@ export const handleEntity: ContentModelHandler = ( // Commit the entity attributes in case there is any change commitEntity(wrapper, type, isReadonly, id); - // Create a comment as placeholder and insert into DOM tree. - // If the entity DOM can be reused, the original DOM node will be preserved without any change - // so that in case there is something that is sensitive to its DOM path (e.g. IFRAME), no need to cause it reloaded. - // For entity that is not directly under root, later we will replace the comment with its original DOM node - const placeholder = doc.createComment('Entity:' + id); - if (getObjectKeys(format).length > 0) { const span = doc.createElement('span'); @@ -32,11 +26,16 @@ export const handleEntity: ContentModelHandler = ( parent = span; } - parent.appendChild(placeholder); + if (context.doNotReuseEntityDom) { + parent.appendChild(wrapper); + } else { + // Create a comment as placeholder and insert into DOM tree. + // If the entity DOM can be reused, the original DOM node will be preserved without any change + // so that in case there is something that is sensitive to its DOM path (e.g. IFRAME), no need to cause it reloaded. + // For entity that is not directly under root, later we will replace the comment with its original DOM node + parent.appendChild(createEntityPlaceholder(entityModel)); - // Save the entity DOM wrapper node and its placeholder into context so that later we know how to handle it - context.entityPairs.push({ - entityWrapper: wrapper, - placeholder: placeholder, - }); + // Save the entity DOM wrapper node and its placeholder into context so that later we know how to handle it + context.entities[id] = wrapper; + } }; diff --git a/packages/roosterjs-content-model/lib/publicApi/contentModelToDom.ts b/packages/roosterjs-content-model/lib/publicApi/contentModelToDom.ts index f689a9b5ef2..e903972bf49 100644 --- a/packages/roosterjs-content-model/lib/publicApi/contentModelToDom.ts +++ b/packages/roosterjs-content-model/lib/publicApi/contentModelToDom.ts @@ -2,7 +2,6 @@ import { ContentModelDocument } from '../publicTypes/group/ContentModelDocument' import { createModelToDomContext } from '../modelToDom/context/createModelToDomContext'; import { createRange, Position, toArray } from 'roosterjs-editor-dom'; import { EditorContext } from '../publicTypes/context/EditorContext'; -import { EntityPlaceholderPair } from '../publicTypes/context/ModelToDomEntityContext'; import { isNodeOfType } from '../domUtils/isNodeOfType'; import { ModelToDomBlockAndSegmentNode } from '../publicTypes/context/ModelToDomSelectionContext'; import { ModelToDomContext } from '../publicTypes/context/ModelToDomContext'; @@ -29,7 +28,7 @@ export default function contentModelToDom( model: ContentModelDocument, editorContext: EditorContext, option?: ModelToDomOption -): [DocumentFragment, SelectionRangeEx | null, EntityPlaceholderPair[]] { +): [DocumentFragment, SelectionRangeEx | null, Record] { const fragment = model.document.createDocumentFragment(); const modelToDomContext = createModelToDomContext(editorContext, option); @@ -40,7 +39,7 @@ export default function contentModelToDom( fragment.normalize(); - return [fragment, range, modelToDomContext.entityPairs]; + return [fragment, range, modelToDomContext.entities]; } function extractSelectionRange(context: ModelToDomContext): SelectionRangeEx | null { diff --git a/packages/roosterjs-content-model/lib/publicApi/mergeFragmentWithEntity.ts b/packages/roosterjs-content-model/lib/publicApi/mergeFragmentWithEntity.ts deleted file mode 100644 index e30dbfe4f6b..00000000000 --- a/packages/roosterjs-content-model/lib/publicApi/mergeFragmentWithEntity.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { EntityPlaceholderPair } from '../publicTypes/context/ModelToDomEntityContext'; -import { isNodeAfter, moveChildNodes } from 'roosterjs-editor-dom'; - -/** - * Default implementation of merging DOM tree generated from Content Model in to existing container - * @param source Source document fragment that is generated from Content Model - * @param target Target container, usually to be editor root container - * @param entityPairs An array of entity wrapper - placeholder pairs, used for reuse existing DOM structure for entity - */ -export default function mergeFragmentWithEntity( - source: DocumentFragment, - target: HTMLElement, - entityPairs: EntityPlaceholderPair[] -) { - const { reusableWrappers, placeholders } = preprocessEntitiesFromContentModel( - entityPairs, - source, - target - ); - - if (reusableWrappers.length == 0) { - moveChildNodes(target); - target.appendChild(source); - } else { - const nodesToRemove: Node[] = []; - - for (let child = target.firstChild; child; child = child.nextSibling) { - if (reusableWrappers.indexOf(child) < 0) { - nodesToRemove.push(child); - } - } - - nodesToRemove.forEach(node => target.removeChild(node)); - - for (let i = 0; i <= reusableWrappers.length; i++) { - while (source.firstChild && source.firstChild != placeholders[i]) { - target.insertBefore(source.firstChild, reusableWrappers[i] || null); - } - - if (source.firstChild && source.firstChild == placeholders[i]) { - source.removeChild(placeholders[i]); - } - } - } -} - -/** - * @internal - */ -export function preprocessEntitiesFromContentModel( - entityPairs: EntityPlaceholderPair[], - source?: DocumentFragment, - target?: HTMLElement -): { reusableWrappers: Node[]; placeholders: Node[] } { - const reusableWrappers: Node[] = []; - const placeholders: Node[] = []; - - entityPairs.forEach(pair => { - const { entityWrapper, placeholder } = pair; - const parent = placeholder.parentNode; - const lastWrapper = reusableWrappers[reusableWrappers.length - 1]; - const lastPlaceholder = placeholders[placeholders.length - 1]; - - if ( - source && - target && - parent == source && - entityWrapper.parentNode == target && - (!lastWrapper || isNodeAfter(entityWrapper, lastWrapper)) && - (!lastPlaceholder || isNodeAfter(placeholder, lastPlaceholder)) - ) { - reusableWrappers.push(entityWrapper); - placeholders.push(placeholder); - } else if (parent) { - parent.replaceChild(pair.entityWrapper, pair.placeholder); - } - }); - return { reusableWrappers, placeholders }; -} diff --git a/packages/roosterjs-content-model/lib/publicApi/table/editTable.ts b/packages/roosterjs-content-model/lib/publicApi/table/editTable.ts index be36093e138..5ee4392f97c 100644 --- a/packages/roosterjs-content-model/lib/publicApi/table/editTable.ts +++ b/packages/roosterjs-content-model/lib/publicApi/table/editTable.ts @@ -13,7 +13,6 @@ import { mergeTableCells } from '../../modelApi/table/mergeTableCells'; import { mergeTableColumn } from '../../modelApi/table/mergeTableColumn'; import { mergeTableRow } from '../../modelApi/table/mergeTableRow'; import { normalizeTable } from '../../modelApi/table/normalizeTable'; -import { preprocessEntitiesFromContentModel } from '../mergeFragmentWithEntity'; import { splitTableCellHorizontally } from '../../modelApi/table/splitTableCellHorizontally'; import { splitTableCellVertically } from '../../modelApi/table/splitTableCellVertically'; @@ -103,8 +102,8 @@ export default function editTable( editor.focus(); if (model && table) { editor.setContentModel(model, { - mergingCallback: (fragment, _, entityPairs) => { - preprocessEntitiesFromContentModel(entityPairs); + doNotReuseEntityDom: true, + mergingCallback: fragment => { editor.replaceNode(table, fragment); }, }); diff --git a/packages/roosterjs-content-model/lib/publicApi/table/formatTable.ts b/packages/roosterjs-content-model/lib/publicApi/table/formatTable.ts index 5ed2f7874ed..650aab5bd1b 100644 --- a/packages/roosterjs-content-model/lib/publicApi/table/formatTable.ts +++ b/packages/roosterjs-content-model/lib/publicApi/table/formatTable.ts @@ -1,7 +1,6 @@ import { applyTableFormat } from '../../modelApi/table/applyTableFormat'; import { ChangeSource } from 'roosterjs-editor-types'; import { IExperimentalContentModelEditor } from '../../publicTypes/IExperimentalContentModelEditor'; -import { preprocessEntitiesFromContentModel } from '../mergeFragmentWithEntity'; import { TableMetadataFormat } from '../../publicTypes/format/formatParts/TableMetadataFormat'; /** @@ -27,8 +26,8 @@ export default function formatTable( editor.focus(); if (model && table) { editor.setContentModel(model, { - mergingCallback: (fragment, _, entityPairs) => { - preprocessEntitiesFromContentModel(entityPairs); + doNotReuseEntityDom: true, + mergingCallback: fragment => { editor.replaceNode(table, fragment); }, }); diff --git a/packages/roosterjs-content-model/lib/publicApi/table/insertTable.ts b/packages/roosterjs-content-model/lib/publicApi/table/insertTable.ts index 42cad8fa57a..bf4cf0852af 100644 --- a/packages/roosterjs-content-model/lib/publicApi/table/insertTable.ts +++ b/packages/roosterjs-content-model/lib/publicApi/table/insertTable.ts @@ -5,7 +5,6 @@ import { createSelectionMarker } from '../../modelApi/creators/createSelectionMa import { createTableStructure } from '../../modelApi/table/createTableStructure'; import { IExperimentalContentModelEditor } from '../../publicTypes/IExperimentalContentModelEditor'; import { normalizeTable } from '../../modelApi/table/normalizeTable'; -import { preprocessEntitiesFromContentModel } from '../mergeFragmentWithEntity'; import { TableMetadataFormat } from '../../publicTypes/format/formatParts/TableMetadataFormat'; /** @@ -38,8 +37,8 @@ export default function insertTable( editor.addUndoSnapshot( () => { editor.setContentModel(doc, { - mergingCallback: (fragment, _, entityPairs) => { - preprocessEntitiesFromContentModel(entityPairs); + doNotReuseEntityDom: true, + mergingCallback: fragment => { editor.insertNode(fragment); }, }); diff --git a/packages/roosterjs-content-model/lib/publicApi/table/setTableCellShade.ts b/packages/roosterjs-content-model/lib/publicApi/table/setTableCellShade.ts index 8c3a2fefd00..871b6bfc137 100644 --- a/packages/roosterjs-content-model/lib/publicApi/table/setTableCellShade.ts +++ b/packages/roosterjs-content-model/lib/publicApi/table/setTableCellShade.ts @@ -1,7 +1,6 @@ import { ChangeSource } from 'roosterjs-editor-types'; import { IExperimentalContentModelEditor } from '../../publicTypes/IExperimentalContentModelEditor'; import { normalizeTable } from '../../modelApi/table/normalizeTable'; -import { preprocessEntitiesFromContentModel } from '../mergeFragmentWithEntity'; import { setTableCellBackgroundColor } from '../../modelApi/table/setTableCellBackgroundColor'; /** @@ -22,8 +21,8 @@ export default function setTableCellShade(editor: IExperimentalContentModelEdito editor.focus(); if (model && table) { editor.setContentModel(model, { - mergingCallback: (fragment, _, entityPairs) => { - preprocessEntitiesFromContentModel(entityPairs); + doNotReuseEntityDom: true, + mergingCallback: fragment => { editor.replaceNode(table, fragment); }, }); diff --git a/packages/roosterjs-content-model/lib/publicTypes/IExperimentalContentModelEditor.ts b/packages/roosterjs-content-model/lib/publicTypes/IExperimentalContentModelEditor.ts index aba494a2542..5f9ddab458d 100644 --- a/packages/roosterjs-content-model/lib/publicTypes/IExperimentalContentModelEditor.ts +++ b/packages/roosterjs-content-model/lib/publicTypes/IExperimentalContentModelEditor.ts @@ -1,6 +1,5 @@ import { ContentModelDocument } from './group/ContentModelDocument'; import { EditorContext } from './context/EditorContext'; -import { EntityPlaceholderPair } from './context/ModelToDomEntityContext'; import { IEditor, SelectionRangeEx } from 'roosterjs-editor-types'; import { ContentModelHandlerMap, @@ -65,14 +64,19 @@ export interface ModelToDomOption { * A callback to specify how to merge DOM tree generated from Content Model in to existing container * @param source Source document fragment that is generated from Content Model * @param target Target container, usually to be editor root container - * @param entityPairs An array of entity wrapper - placeholder pairs, used for reuse existing DOM structure for entity + * @param entities An array of entity wrapper - placeholder pairs, used for reuse existing DOM structure for entity */ mergingCallback?: ( source: DocumentFragment, target: HTMLElement, - entityPairs: EntityPlaceholderPair[] + entities: Record ) => void; + /** + * When set to true, directly put entity DOM nodes into the result DOM tree when doing Content Model to DOM conversion and do not use placeholder + */ + doNotReuseEntityDom?: boolean; + /** * Overrides default format appliers */ diff --git a/packages/roosterjs-content-model/lib/publicTypes/context/ModelToDomEntityContext.ts b/packages/roosterjs-content-model/lib/publicTypes/context/ModelToDomEntityContext.ts index c7c4a4bc196..d8e564d52cf 100644 --- a/packages/roosterjs-content-model/lib/publicTypes/context/ModelToDomEntityContext.ts +++ b/packages/roosterjs-content-model/lib/publicTypes/context/ModelToDomEntityContext.ts @@ -1,21 +1,14 @@ /** - * Represent an object pair of Entity DOM node and placeholder comment node + * Represents context for entity */ -export interface EntityPlaceholderPair { +export interface ModelToDomEntityContext { /** - * Wrapper element of element + * When set to true, directly put entity DOM nodes into the result DOM tree when doing Content Model to DOM conversion and do not use placeholder */ - entityWrapper: HTMLElement; + doNotReuseEntityDom: boolean; /** - * Placeholder comment node + * Entities collected during DOM tree generation, used for reusing existing DOM structure of entities */ - placeholder: Comment; -} - -/** - * Represents context for entity - */ -export interface ModelToDomEntityContext { - entityPairs: EntityPlaceholderPair[]; + entities: Record; } diff --git a/packages/roosterjs-content-model/test/domToModel/context/createModelToDomContextTest.ts b/packages/roosterjs-content-model/test/domToModel/context/createModelToDomContextTest.ts index 96409f7ae71..fa9f279a240 100644 --- a/packages/roosterjs-content-model/test/domToModel/context/createModelToDomContextTest.ts +++ b/packages/roosterjs-content-model/test/domToModel/context/createModelToDomContextTest.ts @@ -31,9 +31,10 @@ describe('createModelToDomContext', () => { formatAppliers: getFormatAppliers(), modelHandlers: defaultContentModelHandlers, defaultImplicitSegmentFormatMap: defaultImplicitSegmentFormatMap, - entityPairs: [], + entities: {}, defaultModelHandlers: defaultContentModelHandlers, defaultFormatAppliers: defaultFormatAppliers, + doNotReuseEntityDom: false, }; it('no param', () => { const context = createModelToDomContext(); @@ -96,7 +97,7 @@ describe('createModelToDomContext', () => { ]); expect(context.modelHandlers.br).toBe(mockedBrHandler); expect(context.defaultImplicitSegmentFormatMap.a).toEqual(mockedAStyle); - expect(context.entityPairs).toEqual([]); + expect(context.entities).toEqual({}); expect(context.defaultModelHandlers).toEqual(defaultContentModelHandlers); expect(context.defaultFormatAppliers).toEqual(defaultFormatAppliers); }); diff --git a/packages/roosterjs-content-model/test/modelToDom/handlers/handleEntityTest.ts b/packages/roosterjs-content-model/test/modelToDom/handlers/handleEntityTest.ts index 6390989868a..07dd9b40fb6 100644 --- a/packages/roosterjs-content-model/test/modelToDom/handlers/handleEntityTest.ts +++ b/packages/roosterjs-content-model/test/modelToDom/handlers/handleEntityTest.ts @@ -26,13 +26,10 @@ describe('handleEntity', () => { handleEntity(document, parent, entityModel, context); - expect(parent.innerHTML).toBe(''); - expect(context.entityPairs).toEqual([ - { - entityWrapper: div, - placeholder: parent.firstChild as Comment, - }, - ]); + expect(parent.innerHTML).toBe(''); + expect(context.entities).toEqual({ + entity_1: div, + }); expect(div.outerHTML).toBe( '
    ' ); diff --git a/packages/roosterjs-content-model/test/publicApi/mergeFragmentWithEntityTest.ts b/packages/roosterjs-content-model/test/publicApi/mergeFragmentWithEntityTest.ts deleted file mode 100644 index 0e5d40ce981..00000000000 --- a/packages/roosterjs-content-model/test/publicApi/mergeFragmentWithEntityTest.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { - default as mergeFragmentWithEntity, - preprocessEntitiesFromContentModel, -} from '../../lib/publicApi/mergeFragmentWithEntity'; - -describe('preprocessEntitiesFromContentModel', () => { - it('empty array', () => { - const { reusableWrappers, placeholders } = preprocessEntitiesFromContentModel([]); - - expect(reusableWrappers).toEqual([]); - expect(placeholders).toEqual([]); - }); - - it('single entry without parent', () => { - const wrapper = document.createElement('div'); - const placeholder = document.createComment('test'); - - const { reusableWrappers, placeholders } = preprocessEntitiesFromContentModel([ - { - entityWrapper: wrapper, - placeholder: placeholder, - }, - ]); - - expect(reusableWrappers).toEqual([]); - expect(placeholders).toEqual([]); - expect(wrapper.parentNode).toBeNull(); - expect(placeholder.parentNode).toBeNull(); - }); - - it('single entry with parent, no reuse', () => { - const target = document.createElement('div'); - const wrapper = document.createElement('div'); - target.appendChild(wrapper); - - const source = document.createDocumentFragment(); - const placeholder = document.createComment('test'); - source.appendChild(placeholder); - - const { reusableWrappers, placeholders } = preprocessEntitiesFromContentModel([ - { - entityWrapper: wrapper, - placeholder: placeholder, - }, - ]); - - expect(reusableWrappers).toEqual([]); - expect(placeholders).toEqual([]); - expect(wrapper.parentNode).toBe(source); - expect(placeholder.parentNode).toBeNull(); - expect(target.outerHTML).toBe('
    '); - }); - - it('two entry with parent, reuse one', () => { - const target = document.createElement('div'); - - const wrapper1 = document.createElement('div'); - wrapper1.id = 'id1'; - target.appendChild(wrapper1); - - const entityParent = document.createElement('div'); - const wrapper2 = document.createElement('div'); - wrapper2.id = 'id2'; - entityParent.appendChild(wrapper2); - target.appendChild(entityParent); - - const source = document.createDocumentFragment(); - const placeholder1 = document.createComment('test1'); - const placeholder2 = document.createComment('test2'); - source.appendChild(placeholder1); - source.appendChild(placeholder2); - - const { reusableWrappers, placeholders } = preprocessEntitiesFromContentModel( - [ - { - entityWrapper: wrapper1, - placeholder: placeholder1, - }, - { - entityWrapper: wrapper2, - placeholder: placeholder2, - }, - ], - source, - target - ); - - expect(reusableWrappers).toEqual([wrapper1]); - expect(placeholders).toEqual([placeholder1]); - expect(wrapper1.parentNode).toBe(target); - expect(wrapper2.parentNode).toBe(source); - expect(placeholder1.parentNode).toBe(source); - expect(placeholder2.parentNode).toBeNull(); - expect(target.outerHTML).toBe('
    '); - expect(source.firstChild).toBe(placeholder1); - expect(source.lastChild).toBe(wrapper2); - }); - - it('two entry, reuse both', () => { - const target = document.createElement('div'); - - const wrapper1 = document.createElement('div'); - wrapper1.id = 'id1'; - target.appendChild(wrapper1); - - const wrapper2 = document.createElement('div'); - wrapper2.id = 'id2'; - target.appendChild(wrapper2); - - const source = document.createDocumentFragment(); - const placeholder1 = document.createComment('test1'); - const placeholder2 = document.createComment('test2'); - source.appendChild(placeholder1); - source.appendChild(placeholder2); - - const { reusableWrappers, placeholders } = preprocessEntitiesFromContentModel( - [ - { - entityWrapper: wrapper1, - placeholder: placeholder1, - }, - { - entityWrapper: wrapper2, - placeholder: placeholder2, - }, - ], - source, - target - ); - - expect(reusableWrappers).toEqual([wrapper1, wrapper2]); - expect(placeholders).toEqual([placeholder1, placeholder2]); - expect(wrapper1.parentNode).toBe(target); - expect(wrapper2.parentNode).toBe(target); - expect(placeholder1.parentNode).toBe(source); - expect(placeholder2.parentNode).toBe(source); - expect(target.outerHTML).toBe('
    '); - expect(source.firstChild).toBe(placeholder1); - expect(source.lastChild).toBe(placeholder2); - }); - - it('two entry in wrong order', () => { - const target = document.createElement('div'); - - const wrapper1 = document.createElement('div'); - wrapper1.id = 'id1'; - target.appendChild(wrapper1); - - const wrapper2 = document.createElement('div'); - wrapper2.id = 'id2'; - target.appendChild(wrapper2); - - const source = document.createDocumentFragment(); - const placeholder1 = document.createComment('test1'); - const placeholder2 = document.createComment('test2'); - source.appendChild(placeholder2); - source.appendChild(placeholder1); - - const { reusableWrappers, placeholders } = preprocessEntitiesFromContentModel( - [ - { - entityWrapper: wrapper1, - placeholder: placeholder1, - }, - { - entityWrapper: wrapper2, - placeholder: placeholder2, - }, - ], - source, - target - ); - - expect(reusableWrappers).toEqual([wrapper1]); - expect(placeholders).toEqual([placeholder1]); - expect(wrapper1.parentNode).toBe(target); - expect(wrapper2.parentNode).toBe(source); - expect(placeholder1.parentNode).toBe(source); - expect(placeholder2.parentNode).toBeNull(); - expect(target.outerHTML).toBe('
    '); - expect(source.firstChild).toBe(wrapper2); - expect(source.lastChild).toBe(placeholder1); - }); -}); - -describe('mergeFragmentWithEntity', () => { - it('empty fragment', () => { - const target = document.createElement('div'); - const source = document.createDocumentFragment(); - - target.innerHTML = 'test'; - - mergeFragmentWithEntity(source, target, []); - - expect(target.innerHTML).toBe(''); - expect(source.firstChild).toBeNull(); - expect(source.lastChild).toBeNull(); - }); - - it('fragment without entity', () => { - const target = document.createElement('div'); - const source = document.createDocumentFragment(); - - const div1 = document.createElement('div'); - const div2 = document.createElement('div'); - - div1.id = 'id1'; - div2.id = 'id2'; - - source.appendChild(div1); - source.appendChild(div2); - - target.innerHTML = 'test'; - - mergeFragmentWithEntity(source, target, []); - - expect(target.innerHTML).toBe('
    '); - expect(source.firstChild).toBeNull(); - expect(source.lastChild).toBeNull(); - }); - - it('fragment with entity, no reuse', () => { - const target = document.createElement('div'); - const source = document.createDocumentFragment(); - - const div1 = document.createElement('div'); - const div2 = document.createElement('div'); - const placeholder = document.createComment('test'); - - div1.id = 'id1'; - div2.id = 'id2'; - div1.appendChild(placeholder); - - source.appendChild(div1); - source.appendChild(div2); - - const wrapper = document.createElement('div'); - wrapper.id = 'entity1'; - - target.innerHTML = 'test'; - - mergeFragmentWithEntity(source, target, [ - { - entityWrapper: wrapper, - placeholder: placeholder, - }, - ]); - - expect(target.innerHTML).toBe( - '
    ' - ); - expect(source.firstChild).toBeNull(); - expect(source.lastChild).toBeNull(); - }); - - it('1 reusable entity', () => { - const target = document.createElement('div'); - const source = document.createDocumentFragment(); - - const div1 = document.createElement('div'); - const div2 = document.createElement('div'); - const div3 = document.createElement('div'); - const div4 = document.createElement('div'); - const placeholder = document.createComment('test'); - - div1.id = 'id1'; - div2.id = 'id2'; - div3.id = 'id3'; - div4.id = 'id4'; - - source.appendChild(div1); - source.appendChild(placeholder); - source.appendChild(div2); - - const wrapper = document.createElement('div'); - wrapper.id = 'entity1'; - - target.appendChild(div3); - target.appendChild(wrapper); - target.appendChild(div4); - - mergeFragmentWithEntity(source, target, [ - { - entityWrapper: wrapper, - placeholder: placeholder, - }, - ]); - - expect(target.innerHTML).toBe( - '
    ' - ); - expect(source.firstChild).toBeNull(); - expect(source.lastChild).toBeNull(); - }); - - it('2 reusable entity side by side in source', () => { - const target = document.createElement('div'); - const source = document.createDocumentFragment(); - - const div1 = document.createElement('div'); - const div3 = document.createElement('div'); - const div4 = document.createElement('div'); - const div5 = document.createElement('div'); - const div6 = document.createElement('div'); - const placeholder1 = document.createComment('test1'); - const placeholder2 = document.createComment('test2'); - - div1.id = 'id1'; - div3.id = 'id3'; - div4.id = 'id4'; - div5.id = 'id5'; - div6.id = 'id6'; - - source.appendChild(div1); - source.appendChild(placeholder1); - source.appendChild(placeholder2); - source.appendChild(div3); - - const wrapper1 = document.createElement('div'); - const wrapper2 = document.createElement('div'); - wrapper1.id = 'entity1'; - wrapper2.id = 'entity2'; - - target.appendChild(div4); - target.appendChild(wrapper1); - target.appendChild(div5); - target.appendChild(wrapper2); - target.appendChild(div6); - - mergeFragmentWithEntity(source, target, [ - { - entityWrapper: wrapper1, - placeholder: placeholder1, - }, - { - entityWrapper: wrapper2, - placeholder: placeholder2, - }, - ]); - - expect(target.innerHTML).toBe( - '
    ' - ); - }); - - it('2 reusable entity side by side in target', () => { - const target = document.createElement('div'); - const source = document.createDocumentFragment(); - - const div1 = document.createElement('div'); - const div2 = document.createElement('div'); - const div3 = document.createElement('div'); - const div4 = document.createElement('div'); - const div6 = document.createElement('div'); - const placeholder1 = document.createComment('test1'); - const placeholder2 = document.createComment('test2'); - - div1.id = 'id1'; - div2.id = 'id2'; - div3.id = 'id3'; - div4.id = 'id4'; - div6.id = 'id6'; - - source.appendChild(div1); - source.appendChild(placeholder1); - source.appendChild(div2); - source.appendChild(placeholder2); - source.appendChild(div3); - - const wrapper1 = document.createElement('div'); - const wrapper2 = document.createElement('div'); - wrapper1.id = 'entity1'; - wrapper2.id = 'entity2'; - - target.appendChild(div4); - target.appendChild(wrapper1); - target.appendChild(wrapper2); - target.appendChild(div6); - - mergeFragmentWithEntity(source, target, [ - { - entityWrapper: wrapper1, - placeholder: placeholder1, - }, - { - entityWrapper: wrapper2, - placeholder: placeholder2, - }, - ]); - - expect(target.innerHTML).toBe( - '
    ' - ); - expect(source.firstChild).toBeNull(); - expect(source.lastChild).toBeNull(); - }); - - it('2 reusable entity in right order', () => { - const target = document.createElement('div'); - const source = document.createDocumentFragment(); - - const div1 = document.createElement('div'); - const div2 = document.createElement('div'); - const div3 = document.createElement('div'); - const div4 = document.createElement('div'); - const div5 = document.createElement('div'); - const div6 = document.createElement('div'); - const placeholder1 = document.createComment('test1'); - const placeholder2 = document.createComment('test2'); - - div1.id = 'id1'; - div2.id = 'id2'; - div3.id = 'id3'; - div4.id = 'id4'; - div5.id = 'id5'; - div6.id = 'id6'; - - source.appendChild(div1); - source.appendChild(placeholder1); - source.appendChild(div2); - source.appendChild(placeholder2); - source.appendChild(div3); - - const wrapper1 = document.createElement('div'); - const wrapper2 = document.createElement('div'); - wrapper1.id = 'entity1'; - wrapper2.id = 'entity2'; - - target.appendChild(div4); - target.appendChild(wrapper1); - target.appendChild(div5); - target.appendChild(wrapper2); - target.appendChild(div6); - - mergeFragmentWithEntity(source, target, [ - { - entityWrapper: wrapper1, - placeholder: placeholder1, - }, - { - entityWrapper: wrapper2, - placeholder: placeholder2, - }, - ]); - - expect(target.innerHTML).toBe( - '
    ' - ); - expect(source.firstChild).toBeNull(); - expect(source.lastChild).toBeNull(); - }); - - it('2 reusable entity in wrong order', () => { - const target = document.createElement('div'); - const source = document.createDocumentFragment(); - - const div1 = document.createElement('div'); - const div2 = document.createElement('div'); - const div3 = document.createElement('div'); - const div4 = document.createElement('div'); - const div5 = document.createElement('div'); - const div6 = document.createElement('div'); - const placeholder1 = document.createComment('test1'); - const placeholder2 = document.createComment('test2'); - - div1.id = 'id1'; - div2.id = 'id2'; - div3.id = 'id3'; - div4.id = 'id4'; - div5.id = 'id5'; - div6.id = 'id6'; - - source.appendChild(div1); - source.appendChild(placeholder2); - source.appendChild(div2); - source.appendChild(placeholder1); - source.appendChild(div3); - - const wrapper1 = document.createElement('div'); - const wrapper2 = document.createElement('div'); - wrapper1.id = 'entity1'; - wrapper2.id = 'entity2'; - - target.appendChild(div4); - target.appendChild(wrapper1); - target.appendChild(div5); - target.appendChild(wrapper2); - target.appendChild(div6); - - mergeFragmentWithEntity(source, target, [ - { - entityWrapper: wrapper1, - placeholder: placeholder1, - }, - { - entityWrapper: wrapper2, - placeholder: placeholder2, - }, - ]); - - expect(target.innerHTML).toBe( - '
    ' - ); - expect(source.firstChild).toBeNull(); - expect(source.lastChild).toBeNull(); - }); -}); diff --git a/packages/roosterjs-editor-core/lib/coreApi/addUndoSnapshot.ts b/packages/roosterjs-editor-core/lib/coreApi/addUndoSnapshot.ts index 2174d0ac1c1..4e8e66ac822 100644 --- a/packages/roosterjs-editor-core/lib/coreApi/addUndoSnapshot.ts +++ b/packages/roosterjs-editor-core/lib/coreApi/addUndoSnapshot.ts @@ -1,3 +1,4 @@ +import { getSelectionPath, Position } from 'roosterjs-editor-dom'; import { AddUndoSnapshot, ChangeSource, @@ -10,7 +11,6 @@ import { SelectionRangeEx, SelectionRangeTypes, } from 'roosterjs-editor-types'; -import { getSelectionPath, Position } from 'roosterjs-editor-dom'; import type { CompatibleChangeSource } from 'roosterjs-editor-types/lib/compatibleTypes'; /** diff --git a/packages/roosterjs-editor-core/lib/coreApi/switchShadowEdit.ts b/packages/roosterjs-editor-core/lib/coreApi/switchShadowEdit.ts index 23a3ee808b0..7be80974110 100644 --- a/packages/roosterjs-editor-core/lib/coreApi/switchShadowEdit.ts +++ b/packages/roosterjs-editor-core/lib/coreApi/switchShadowEdit.ts @@ -1,4 +1,9 @@ -import { createRange, getSelectionPath, moveChildNodes } from 'roosterjs-editor-dom'; +import { + createRange, + getSelectionPath, + moveContentWithEntityPlaceholders, + restoreContentWithEntityPlaceholder, +} from 'roosterjs-editor-dom'; import { EditorCore, PluginEventType, @@ -13,6 +18,7 @@ import { export const switchShadowEdit: SwitchShadowEdit = (core: EditorCore, isOn: boolean): void => { const { lifecycle, contentDiv } = core; let { + shadowEditEntities, shadowEditFragment, shadowEditSelectionPath, shadowEditTableSelectionPath, @@ -43,14 +49,14 @@ export const switchShadowEdit: SwitchShadowEdit = (core: EditorCore, isOn: boole SelectionRangeTypes.TableSelection, selection ); - shadowEditFragment = core.contentDiv.ownerDocument.createDocumentFragment(); shadowEditImageSelectionPath = getShadowEditSelectionPath( SelectionRangeTypes.ImageSelection, selection ); - moveChildNodes(shadowEditFragment, contentDiv); - shadowEditFragment.normalize(); + shadowEditEntities = {}; + shadowEditFragment = moveContentWithEntityPlaceholders(contentDiv, shadowEditEntities); + core.api.triggerEvent( core, { @@ -65,15 +71,21 @@ export const switchShadowEdit: SwitchShadowEdit = (core: EditorCore, isOn: boole lifecycle.shadowEditSelectionPath = shadowEditSelectionPath; lifecycle.shadowEditTableSelectionPath = shadowEditTableSelectionPath; lifecycle.shadowEditImageSelectionPath = shadowEditImageSelectionPath; + lifecycle.shadowEditEntities = shadowEditEntities; } - moveChildNodes(contentDiv); if (lifecycle.shadowEditFragment) { - contentDiv.appendChild(lifecycle.shadowEditFragment.cloneNode(true /*deep*/)); + restoreContentWithEntityPlaceholder( + lifecycle.shadowEditFragment, + contentDiv, + lifecycle.shadowEditEntities, + true /*insertClonedNode*/ + ); } } else { lifecycle.shadowEditFragment = null; lifecycle.shadowEditSelectionPath = null; + lifecycle.shadowEditEntities = null; if (wasInShadowEdit) { core.api.triggerEvent( @@ -84,9 +96,12 @@ export const switchShadowEdit: SwitchShadowEdit = (core: EditorCore, isOn: boole false /*broadcast*/ ); - moveChildNodes(contentDiv); if (shadowEditFragment) { - contentDiv.appendChild(shadowEditFragment); + restoreContentWithEntityPlaceholder( + shadowEditFragment, + contentDiv, + shadowEditEntities + ); } core.api.focus(core); diff --git a/packages/roosterjs-editor-core/lib/corePlugins/LifecyclePlugin.ts b/packages/roosterjs-editor-core/lib/corePlugins/LifecyclePlugin.ts index ff284129a6f..7864530c9bd 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/LifecyclePlugin.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/LifecyclePlugin.ts @@ -97,6 +97,7 @@ export default class LifecyclePlugin implements PluginWithState { shadowEditFragment: null, shadowEditTableSelectionPath: null, shadowEditImageSelectionPath: null, + shadowEditEntities: null, getDarkColor, }); @@ -94,6 +95,7 @@ describe('LifecyclePlugin', () => { shadowEditSelectionPath: null, shadowEditTableSelectionPath: null, shadowEditImageSelectionPath: null, + shadowEditEntities: null, getDarkColor, }); diff --git a/packages/roosterjs-editor-dom/lib/entity/entityPlaceholderUtils.ts b/packages/roosterjs-editor-dom/lib/entity/entityPlaceholderUtils.ts new file mode 100644 index 00000000000..a435fd1abf5 --- /dev/null +++ b/packages/roosterjs-editor-dom/lib/entity/entityPlaceholderUtils.ts @@ -0,0 +1,137 @@ +import getEntityFromElement from './getEntityFromElement'; +import getEntitySelector from './getEntitySelector'; +import getTagOfNode from '../utils/getTagOfNode'; +import safeInstanceOf from '../utils/safeInstanceOf'; +import { Entity } from 'roosterjs-editor-types'; + +const EntityPlaceHolderTagName = 'ENTITY-PLACEHOLDER'; + +/** + * Create a placeholder comment node for entity + * @param entity The entity to create placeholder from + * @returns A placeholder comment node as + */ +export function createEntityPlaceholder(entity: Entity): HTMLElement { + const placeholder = entity.wrapper.ownerDocument.createElement(EntityPlaceHolderTagName); + placeholder.id = entity.id; + + return placeholder; +} + +/** + * Move content from a container into a new Document fragment, and try keep entities to be reusable by creating placeholder + * for them in the document fragment. + * If an entity is directly under root container, the whole entity can be reused and no need to move it at all. + * If an entity is not directly under root container, it is still reusable, but it may need some movement. + * In any case, entities will be replaced with a placeholder in the target document fragment. + * We will use an entity map (the "entities" parameter) to save the map from entity id to its wrapper element. + * @param root The root element + * @param entities A map from entity id to entity wrapper element + * @returns A new document fragment contains all the content and entity placeholders + */ +export function moveContentWithEntityPlaceholders( + root: HTMLDivElement, + entities: Record +) { + const entitySelector = getEntitySelector(); + const fragment = root.ownerDocument.createDocumentFragment(); + let next: Node | null = null; + + for (let child: Node | null = root.firstChild; child; child = next) { + let entity: Entity | null; + let nodeToAppend = child; + + next = child.nextSibling; + + if (safeInstanceOf(child, 'HTMLElement')) { + if ((entity = getEntityFromElement(child))) { + nodeToAppend = getPlaceholder(entity, entities); + } else { + child.querySelectorAll(entitySelector).forEach(wrapper => { + if ((entity = getEntityFromElement(wrapper))) { + const placeholder = getPlaceholder(entity, entities); + + wrapper.parentNode?.replaceChild(placeholder, wrapper); + } + }); + } + } + + fragment.appendChild(nodeToAppend); + } + + fragment.normalize(); + + return fragment; +} + +/** + * Restore HTML content from a document fragment that may contain entity placeholders. + * @param source Source document fragment that contains HTML content and entity placeholders + * @param target Target container, usually to be editor root container + * @param entities A map from entity id to entity wrapper, used for reusing existing DOM structure for entity + * @param insertClonedNode When pass true, merge with a cloned copy of the nodes from source fragment rather than the nodes themselves @default false + */ +export function restoreContentWithEntityPlaceholder( + source: DocumentFragment, + target: HTMLElement, + entities: Record | null, + insertClonedNode?: boolean +) { + let anchor = target.firstChild; + entities = entities || {}; + + for (let current = source.firstChild; current; ) { + let wrapper: HTMLElement | null = null; + const next = current.nextSibling; + const id = tryGetIdFromEntityPlaceholder(current); + + if (id && (wrapper = entities[(current).id])) { + anchor = removeUntil(anchor, wrapper); + + if (anchor) { + anchor = anchor.nextSibling; + } else { + target.appendChild(wrapper); + } + } else { + const nodeToInsert = insertClonedNode ? current.cloneNode(true /*deep*/) : current; + target.insertBefore(nodeToInsert, anchor); + + if (safeInstanceOf(nodeToInsert, 'HTMLElement')) { + nodeToInsert.querySelectorAll(EntityPlaceHolderTagName).forEach(placeholder => { + wrapper = entities![placeholder.id]; + + if (wrapper) { + placeholder.parentNode?.replaceChild(wrapper, placeholder); + } + }); + } + } + + current = next; + } + + removeUntil(anchor); +} + +function removeUntil(anchor: ChildNode | null, nodeToStop?: HTMLElement) { + while (anchor && (!nodeToStop || anchor != nodeToStop)) { + const nodeToRemove = anchor; + anchor = anchor.nextSibling; + nodeToRemove.parentNode?.removeChild(nodeToRemove); + } + return anchor; +} + +function tryGetIdFromEntityPlaceholder(node: Node): string | null { + return getTagOfNode(node) == EntityPlaceHolderTagName ? (node).id : null; +} + +function getPlaceholder(entity: Entity, entities: Record) { + const placeholder = createEntityPlaceholder(entity); + + entities[entity.id] = entity.wrapper; + + return placeholder; +} diff --git a/packages/roosterjs-editor-dom/lib/index.ts b/packages/roosterjs-editor-dom/lib/index.ts index 8967a6e9b1d..d8026248846 100644 --- a/packages/roosterjs-editor-dom/lib/index.ts +++ b/packages/roosterjs-editor-dom/lib/index.ts @@ -102,6 +102,11 @@ export { default as chainSanitizerCallback } from './htmlSanitizer/chainSanitize export { default as commitEntity } from './entity/commitEntity'; export { default as getEntityFromElement } from './entity/getEntityFromElement'; export { default as getEntitySelector } from './entity/getEntitySelector'; +export { + createEntityPlaceholder, + moveContentWithEntityPlaceholders, + restoreContentWithEntityPlaceholder, +} from './entity/entityPlaceholderUtils'; export { default as cacheGetEventData } from './event/cacheGetEventData'; export { default as clearEventDataCache } from './event/clearEventDataCache'; diff --git a/packages/roosterjs-editor-dom/test/entity/entityPlaceholderUtilsTest.ts b/packages/roosterjs-editor-dom/test/entity/entityPlaceholderUtilsTest.ts new file mode 100644 index 00000000000..9c699e4910d --- /dev/null +++ b/packages/roosterjs-editor-dom/test/entity/entityPlaceholderUtilsTest.ts @@ -0,0 +1,447 @@ +import { Entity } from 'roosterjs-editor-types'; +import { + createEntityPlaceholder, + moveContentWithEntityPlaceholders, + restoreContentWithEntityPlaceholder, +} from '../../lib/entity/entityPlaceholderUtils'; + +describe('createEntityPlaceholder', () => { + it('', () => { + const div = document.createElement('div'); + const entity: Entity = { + type: 'a', + id: 'b', + wrapper: div, + isReadonly: false, + }; + const placeholder = createEntityPlaceholder(entity); + + expect(placeholder.outerHTML).toBe(''); + }); +}); + +describe('moveContentWithEntityPlaceholders', () => { + it('empty dom', () => { + const div = document.createElement('div'); + const entities: Record = {}; + + const fragment = moveContentWithEntityPlaceholders(div, entities); + + const resultDiv = document.createElement('div'); + resultDiv.appendChild(fragment); + + expect(div.innerHTML).toBe(''); + expect(resultDiv.innerHTML).toBe(''); + expect(entities).toEqual({}); + }); + + it('no entity', () => { + const div = document.createElement('div'); + const entities: Record = {}; + + div.innerHTML = 'test1test2test3'; + + const fragment = moveContentWithEntityPlaceholders(div, entities); + + const resultDiv = document.createElement('div'); + resultDiv.appendChild(fragment); + + expect(div.innerHTML).toBe(''); + expect(resultDiv.innerHTML).toBe('test1test2test3'); + expect(entities).toEqual({}); + }); + + it('single entity', () => { + const div = document.createElement('div'); + const entities: Record = {}; + + div.innerHTML = '
    '; + + const fragment = moveContentWithEntityPlaceholders(div, entities); + + const resultDiv = document.createElement('div'); + resultDiv.appendChild(fragment); + + expect(div.innerHTML).toBe('
    '); + expect(resultDiv.innerHTML).toBe(''); + expect(entities).toEqual({ + a: div.firstChild as HTMLElement, + }); + }); + + it('two entities with other nodes', () => { + const div = document.createElement('div'); + const entities: Record = {}; + + const node1 = document.createTextNode('test1'); + const node2 = document.createElement('div'); + const node3 = document.createElement('div'); + const node4 = document.createElement('div'); + const node5 = document.createElement('div'); + + node2.className = '_Entity _EType_a _EId_a'; + node3.id = 'node3'; + node4.className = '_Entity _EType_b _EId_b'; + node5.textContent = 'test5'; + + div.appendChild(node1); + div.appendChild(node2); + div.appendChild(node3); + div.appendChild(node4); + div.appendChild(node5); + + const fragment = moveContentWithEntityPlaceholders(div, entities); + + const resultDiv = document.createElement('div'); + resultDiv.appendChild(fragment); + + expect(div.innerHTML).toBe( + '
    ' + ); + expect(resultDiv.innerHTML).toBe( + 'test1
    test5
    ' + ); + expect(entities).toEqual({ + a: node2, + b: node4, + }); + }); + + it('with inner entities', () => { + const div = document.createElement('div'); + const entities: Record = {}; + + const node1 = document.createTextNode('test1'); + const node2 = document.createElement('div'); + const node3 = document.createElement('div'); + const node4 = document.createElement('div'); + const node5 = document.createElement('div'); + + node2.className = '_Entity _EType_a _EId_a'; + node3.id = 'node3'; + node4.className = '_Entity _EType_b _EId_b'; + node5.textContent = 'test5'; + + node3.appendChild(node4); + + div.appendChild(node1); + div.appendChild(node2); + div.appendChild(node3); + div.appendChild(node5); + + const fragment = moveContentWithEntityPlaceholders(div, entities); + + const resultDiv = document.createElement('div'); + resultDiv.appendChild(fragment); + + expect(div.innerHTML).toBe('
    '); + expect(resultDiv.innerHTML).toBe( + 'test1
    test5
    ' + ); + expect(entities).toEqual({ + a: node2, + b: node4, + }); + }); +}); + +describe('restoreContentWithEntityPlaceholder', () => { + it('empty fragment', () => { + const target = document.createElement('div'); + const source = document.createDocumentFragment(); + + target.innerHTML = 'test'; + + restoreContentWithEntityPlaceholder(source, target, {}); + + expect(target.innerHTML).toBe(''); + expect(source.firstChild).toBeNull(); + expect(source.lastChild).toBeNull(); + }); + + it('fragment without entity', () => { + const target = document.createElement('div'); + const source = document.createDocumentFragment(); + + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + + div1.id = 'id1'; + div2.id = 'id2'; + + source.appendChild(div1); + source.appendChild(div2); + + target.innerHTML = 'test'; + + restoreContentWithEntityPlaceholder(source, target, {}); + + expect(target.innerHTML).toBe('
    '); + expect(source.firstChild).toBeNull(); + expect(source.lastChild).toBeNull(); + }); + + it('fragment with entity, no reuse', () => { + const target = document.createElement('div'); + const source = document.createDocumentFragment(); + + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + const placeholder = document.createElement('ENTITY-PLACEHOLDER'); + + placeholder.id = 'entity1'; + + div1.id = 'id1'; + div2.id = 'id2'; + div1.appendChild(placeholder); + + source.appendChild(div1); + source.appendChild(div2); + + const wrapper = document.createElement('div'); + wrapper.id = 'entity1'; + + target.innerHTML = 'test'; + + restoreContentWithEntityPlaceholder(source, target, { + entity1: wrapper, + }); + + expect(target.innerHTML).toBe( + '
    ' + ); + expect(source.firstChild).toBeNull(); + expect(source.lastChild).toBeNull(); + }); + + it('1 reusable entity', () => { + const target = document.createElement('div'); + const source = document.createDocumentFragment(); + + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + const div3 = document.createElement('div'); + const div4 = document.createElement('div'); + const placeholder = document.createElement('ENTITY-PLACEHOLDER'); + + placeholder.id = 'entity1'; + + div1.id = 'id1'; + div2.id = 'id2'; + div3.id = 'id3'; + div4.id = 'id4'; + + source.appendChild(div1); + source.appendChild(placeholder); + source.appendChild(div2); + + const wrapper = document.createElement('div'); + wrapper.id = 'entity1'; + + target.appendChild(div3); + target.appendChild(wrapper); + target.appendChild(div4); + + restoreContentWithEntityPlaceholder(source, target, { + entity1: wrapper, + }); + + expect(target.innerHTML).toBe( + '
    ' + ); + }); + + it('2 reusable entity side by side in source', () => { + const target = document.createElement('div'); + const source = document.createDocumentFragment(); + + const div1 = document.createElement('div'); + const div3 = document.createElement('div'); + const div4 = document.createElement('div'); + const div5 = document.createElement('div'); + const div6 = document.createElement('div'); + const placeholder1 = document.createElement('ENTITY-PLACEHOLDER'); + const placeholder2 = document.createElement('ENTITY-PLACEHOLDER'); + + placeholder1.id = 'entity1'; + placeholder2.id = 'entity2'; + + div1.id = 'id1'; + div3.id = 'id3'; + div4.id = 'id4'; + div5.id = 'id5'; + div6.id = 'id6'; + + source.appendChild(div1); + source.appendChild(placeholder1); + source.appendChild(placeholder2); + source.appendChild(div3); + + const wrapper1 = document.createElement('div'); + const wrapper2 = document.createElement('div'); + wrapper1.id = 'entity1'; + wrapper2.id = 'entity2'; + + target.appendChild(div4); + target.appendChild(wrapper1); + target.appendChild(div5); + target.appendChild(wrapper2); + target.appendChild(div6); + + restoreContentWithEntityPlaceholder(source, target, { + entity1: wrapper1, + entity2: wrapper2, + }); + + expect(target.innerHTML).toBe( + '
    ' + ); + }); + + it('2 reusable entity side by side in target', () => { + const target = document.createElement('div'); + const source = document.createDocumentFragment(); + + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + const div3 = document.createElement('div'); + const div4 = document.createElement('div'); + const div6 = document.createElement('div'); + const placeholder1 = document.createElement('ENTITY-PLACEHOLDER'); + const placeholder2 = document.createElement('ENTITY-PLACEHOLDER'); + + placeholder1.id = 'entity1'; + placeholder2.id = 'entity2'; + + div1.id = 'id1'; + div2.id = 'id2'; + div3.id = 'id3'; + div4.id = 'id4'; + div6.id = 'id6'; + + source.appendChild(div1); + source.appendChild(placeholder1); + source.appendChild(div2); + source.appendChild(placeholder2); + source.appendChild(div3); + + const wrapper1 = document.createElement('div'); + const wrapper2 = document.createElement('div'); + wrapper1.id = 'entity1'; + wrapper2.id = 'entity2'; + + target.appendChild(div4); + target.appendChild(wrapper1); + target.appendChild(wrapper2); + target.appendChild(div6); + + restoreContentWithEntityPlaceholder(source, target, { + entity1: wrapper1, + entity2: wrapper2, + }); + + expect(target.innerHTML).toBe( + '
    ' + ); + }); + + it('2 reusable entity in right order', () => { + const target = document.createElement('div'); + const source = document.createDocumentFragment(); + + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + const div3 = document.createElement('div'); + const div4 = document.createElement('div'); + const div5 = document.createElement('div'); + const div6 = document.createElement('div'); + const placeholder1 = document.createElement('ENTITY-PLACEHOLDER'); + const placeholder2 = document.createElement('ENTITY-PLACEHOLDER'); + + placeholder1.id = 'entity1'; + placeholder2.id = 'entity2'; + + div1.id = 'id1'; + div2.id = 'id2'; + div3.id = 'id3'; + div4.id = 'id4'; + div5.id = 'id5'; + div6.id = 'id6'; + + source.appendChild(div1); + source.appendChild(placeholder1); + source.appendChild(div2); + source.appendChild(placeholder2); + source.appendChild(div3); + + const wrapper1 = document.createElement('div'); + const wrapper2 = document.createElement('div'); + wrapper1.id = 'entity1'; + wrapper2.id = 'entity2'; + + target.appendChild(div4); + target.appendChild(wrapper1); + target.appendChild(div5); + target.appendChild(wrapper2); + target.appendChild(div6); + + restoreContentWithEntityPlaceholder(source, target, { + entity1: wrapper1, + entity2: wrapper2, + }); + + expect(target.innerHTML).toBe( + '
    ' + ); + }); + + it('2 reusable entity in wrong order', () => { + const target = document.createElement('div'); + const source = document.createDocumentFragment(); + + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + const div3 = document.createElement('div'); + const div4 = document.createElement('div'); + const div5 = document.createElement('div'); + const div6 = document.createElement('div'); + const placeholder1 = document.createElement('ENTITY-PLACEHOLDER'); + const placeholder2 = document.createElement('ENTITY-PLACEHOLDER'); + + placeholder1.id = 'entity1'; + placeholder2.id = 'entity2'; + + div1.id = 'id1'; + div2.id = 'id2'; + div3.id = 'id3'; + div4.id = 'id4'; + div5.id = 'id5'; + div6.id = 'id6'; + + source.appendChild(div1); + source.appendChild(placeholder2); + source.appendChild(div2); + source.appendChild(placeholder1); + source.appendChild(div3); + + const wrapper1 = document.createElement('div'); + const wrapper2 = document.createElement('div'); + wrapper1.id = 'entity1'; + wrapper2.id = 'entity2'; + + target.appendChild(div4); + target.appendChild(wrapper1); + target.appendChild(div5); + target.appendChild(wrapper2); + target.appendChild(div6); + + restoreContentWithEntityPlaceholder(source, target, { + entity1: wrapper1, + entity2: wrapper2, + }); + + expect(target.innerHTML).toBe( + '
    ' + ); + }); +}); diff --git a/packages/roosterjs-editor-types/lib/corePluginState/LifecyclePluginState.ts b/packages/roosterjs-editor-types/lib/corePluginState/LifecyclePluginState.ts index 4785e0ab3ee..b5e3da07936 100644 --- a/packages/roosterjs-editor-types/lib/corePluginState/LifecyclePluginState.ts +++ b/packages/roosterjs-editor-types/lib/corePluginState/LifecyclePluginState.ts @@ -43,6 +43,11 @@ export default interface LifecyclePluginState { */ shadowEditFragment: DocumentFragment | null; + /** + * Cached entity pairs for original content + */ + shadowEditEntities: Record | null; + /** * Cached selection path for original content */ From 7e387eb644ab7d318e79d3433c359505204b0bf3 Mon Sep 17 00:00:00 2001 From: JiuqingSong Date: Tue, 22 Nov 2022 10:00:57 -0800 Subject: [PATCH 3/5] 8.37.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a6319aa8b3d..e6deda65d46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roosterjs", - "version": "8.36.0", + "version": "8.37.0", "description": "Framework-independent javascript editor", "repository": { "type": "git", From cc82ee881c78e04721309cea3d6e942ec6391d57 Mon Sep 17 00:00:00 2001 From: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Date: Tue, 22 Nov 2022 15:10:20 -0300 Subject: [PATCH 4/5] Merge pull request #1425 from microsoft/u/juliaroldi/auto-format-list-special-char Valid numbering --- .../utils/getAutoNumberingListStyle.ts | 21 ++++++++++++++----- .../utils/getAutoNumberingListStyleTest.ts | 16 ++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/utils/getAutoNumberingListStyle.ts b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/utils/getAutoNumberingListStyle.ts index 734f8ffdd7e..103534363fd 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/utils/getAutoNumberingListStyle.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/utils/getAutoNumberingListStyle.ts @@ -153,10 +153,21 @@ export default function getAutoNumberingListStyle( } const isDoubleParenthesis = trigger[0] === '(' && trigger[trigger.length - 1] === ')'; - const numberingType = identifyNumberingListType( - trigger, - isDoubleParenthesis, - previousListStyle - ); + const numberingType = isValidNumbering(trigger, isDoubleParenthesis) + ? identifyNumberingListType(trigger, isDoubleParenthesis, previousListStyle) + : null; return numberingType; } + +/** + * Check if index has only numbers or only letters to avoid sequence of character such 1:1. trigger a list. + * @param textBeforeCursor + * @param isDoubleParenthesis + * @returns + */ +function isValidNumbering(textBeforeCursor: string, isDoubleParenthesis: boolean) { + const index = isDoubleParenthesis + ? textBeforeCursor.slice(1, -1) + : textBeforeCursor.slice(0, -1); + return Number(index) || /^[A-Za-z\s]*$/.test(index); +} diff --git a/packages/roosterjs-editor-plugins/test/ContentEdit/features/utils/getAutoNumberingListStyleTest.ts b/packages/roosterjs-editor-plugins/test/ContentEdit/features/utils/getAutoNumberingListStyleTest.ts index 018c70feae3..ff15d37050a 100644 --- a/packages/roosterjs-editor-plugins/test/ContentEdit/features/utils/getAutoNumberingListStyleTest.ts +++ b/packages/roosterjs-editor-plugins/test/ContentEdit/features/utils/getAutoNumberingListStyleTest.ts @@ -86,4 +86,20 @@ describe('getAutoListStyle ', () => { it('(I) ', () => { runTest('(I) ', NumberingListType.UpperRomanDoubleParenthesis); }); + + it('1:1. ', () => { + runTest('1:1. ', null); + }); + + it('30%). ', () => { + runTest('30%). ', null); + }); + + it('4th. ', () => { + runTest('4th. ', null); + }); + + it('30%) ', () => { + runTest('30%) ', null); + }); }); From 9a7cc4ec12610839fe86950915ca8f397b280aab Mon Sep 17 00:00:00 2001 From: Julia Roldi <87443959+juliaroldi@users.noreply.github.com> Date: Tue, 22 Nov 2022 09:42:55 -0300 Subject: [PATCH 5/5] Merge pull request #1426 from microsoft/u/juliaroldi/previous-line-bug Fix cursor jump bug. --- .../controls/sidePane/editorOptions/EditorOptionsPlugin.ts | 1 + .../lib/plugins/ContentEdit/features/listFeatures.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts index 6a6a7ff6482..907789ff49f 100644 --- a/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controls/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -32,6 +32,7 @@ const initialState: BuildInPluginState = { ExperimentalFeatures.ListItemAlignment, ExperimentalFeatures.PendingStyleBasedFormat, ExperimentalFeatures.DefaultFormatInSpan, + ExperimentalFeatures.AutoFormatList, ], isRtl: false, }; diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts index 2e75079636f..4e00cc3d8e4 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/listFeatures.ts @@ -330,10 +330,10 @@ const AutoNumberingList: BuildInEditFeature = { }; const getPreviousListItem = (editor: IEditor, textRange: Range) => { - const previousNode = editor + const blockElement = editor .getBodyTraverser(textRange?.startContainer) - .getPreviousBlockElement() - ?.collapseToSingleElement(); + .getPreviousBlockElement(); + const previousNode = blockElement?.getEndNode(); return getTagOfNode(previousNode) === 'LI' ? previousNode : undefined; };