From 293f512c2472cae5f9907d814fbed92178b9551f Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Fri, 31 May 2024 17:33:49 -0600 Subject: [PATCH 1/6] Word Desktop LineHeight fix on paste (#2677) * Revert "Remove LineSpacing limitation (#2483)" This reverts commit 3b2f7154ce53d0c097d438e424daa2a263b434b7. * address bug * rename function --- .../test/command/paste/pasteTest.ts | 2 +- .../processPastedContentFromWordDesktop.ts | 20 +++++++++++++++++++ .../test/paste/ContentModelPastePluginTest.ts | 2 +- ...processPastedContentFromWordDesktopTest.ts | 8 ++++---- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts b/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts index 2446e2ee8c4..7a0f0cbe3dc 100644 --- a/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts +++ b/packages/roosterjs-content-model-core/test/command/paste/pasteTest.ts @@ -136,7 +136,7 @@ describe('paste with content model & paste plugin', () => { paste(editor!, clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); - expect(addParserF.addParser).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 4); + expect(addParserF.addParser).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 5); expect(WordDesktopFile.processPastedContentFromWordDesktop).toHaveBeenCalledTimes(1); }); diff --git a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts index a3840002df5..403127175cf 100644 --- a/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts +++ b/packages/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts @@ -8,6 +8,7 @@ import { setProcessor } from '../utils/setProcessor'; import type { WordMetadata } from './WordMetadata'; import type { BeforePasteEvent, + ContentModelBlockFormat, ContentModelListItemLevelFormat, ContentModelTableFormat, DomToModelContext, @@ -15,6 +16,10 @@ import type { FormatParser, } from 'roosterjs-content-model-types'; +const PERCENTAGE_REGEX = /%/; +// Default line height in browsers according to https://developer.mozilla.org/en-US/docs/Web/CSS/line-height#normal +const DEFAULT_BROWSER_LINE_HEIGHT_PERCENTAGE = 1.2; + /** * @internal * Handles Pasted content when source is Word Desktop @@ -27,6 +32,7 @@ export function processPastedContentFromWordDesktop( const metadataMap: Map = getStyleMetadata(ev, trustedHTMLHandler); setProcessor(ev.domToModelOption, 'element', wordDesktopElementProcessor(metadataMap)); + addParser(ev.domToModelOption, 'block', adjustPercentileLineHeight); addParser(ev.domToModelOption, 'block', removeNegativeTextIndentParser); addParser(ev.domToModelOption, 'listLevel', listLevelParser); addParser(ev.domToModelOption, 'container', wordTableParser); @@ -50,6 +56,20 @@ const wordDesktopElementProcessor = ( }; }; +function adjustPercentileLineHeight(format: ContentModelBlockFormat, element: HTMLElement): void { + //If the line height is less than the browser default line height, line between the text is going to be too narrow + let parsedLineHeight: number; + if ( + PERCENTAGE_REGEX.test(element.style.lineHeight) && + !isNaN((parsedLineHeight = parseInt(element.style.lineHeight))) + ) { + format.lineHeight = ( + DEFAULT_BROWSER_LINE_HEIGHT_PERCENTAGE * + (parsedLineHeight / 100) + ).toString(); + } +} + function listLevelParser( format: ContentModelListItemLevelFormat, element: HTMLElement, diff --git a/packages/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts b/packages/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts index e6db9e6eca4..c7a175d4093 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts @@ -58,7 +58,7 @@ describe('Content Model Paste Plugin Test', () => { plugin.initialize(editor); plugin.onPluginEvent(event); - expect(addParser.addParser).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 4); + expect(addParser.addParser).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 5); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(1); }); diff --git a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts index f14038afee5..07f17b4039d 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts @@ -144,7 +144,7 @@ describe('processPastedContentFromWordDesktopTest', () => { runTest(source, { blockGroupType: 'Document', blocks: [] }, true); }); - it('Dont remove Line height less than default', () => { + it('adjust Line height less than default', () => { let source = '

Test

'; runTest( source, @@ -154,7 +154,7 @@ describe('processPastedContentFromWordDesktopTest', () => { { segments: [{ text: 'Test', segmentType: 'Text', format: {} }], blockType: 'Paragraph', - format: { marginTop: '1em', marginBottom: '1em', lineHeight: '102%' }, + format: { marginTop: '1em', marginBottom: '1em', lineHeight: '1.224' }, decorator: { tagName: 'p', format: {} }, }, ], @@ -211,7 +211,7 @@ describe('processPastedContentFromWordDesktopTest', () => { }); }); - it('Remove Line height, percentage greater than default', () => { + it('Adjust Line height, percentage greater than default 2', () => { let source = '

Test

'; runTest(source, { blockGroupType: 'Document', @@ -222,7 +222,7 @@ describe('processPastedContentFromWordDesktopTest', () => { format: {}, tagName: 'p', }, - format: { marginTop: '1em', marginBottom: '1em', lineHeight: '122%' }, + format: { marginTop: '1em', marginBottom: '1em', lineHeight: '1.464' }, segments: [ { segmentType: 'Text', From db8e2da5ce9002688a4c70bb8c3a863d07522d57 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 3 Jun 2024 11:37:50 -0700 Subject: [PATCH 2/6] Content Model Cache improvement - Step 8: Finally enable readonly types in all format API (#2651) * Readonly types (3rd try * Improve * fix build * Improve * improve * Improve * Add shallow mutable type * improve * Improve * improve * improve * add test * Readonly types step 2 * Readonly types step 3 * Readonly type step 4 * add test * Improve * improve * improve * Readonly types step 5: dom package * add change * improve * Readonly types step 6 * fix build * improve * Improve * improve * fix test * Improve * Improve * fix build * improve * improve * Readonly types step 7: Port all other files * improve * Readonly type steps 8: Finally enable readonly type * fix test * improve * improve * fix build * fix build * fix build * Add experimental features * improve * improve, add test case --- demo/scripts/controlsV2/mainPane/MainPane.tsx | 3 + .../editorOptions/EditorOptionsPlugin.ts | 2 + .../editorOptions/ExperimentalFeatures.tsx | 42 ++ .../sidePane/editorOptions/OptionState.ts | 3 +- .../sidePane/editorOptions/OptionsPane.tsx | 8 + .../createDomToModelContextForSanitizing.ts | 1 + .../createEditorContext.ts | 1 + .../lib/corePlugin/cache/CachePlugin.ts | 7 +- .../lib/corePlugin/cache/domIndexerImpl.ts | 409 +++++++++--------- .../lib/editor/core/createEditorCore.ts | 1 + ...reateDomToModelContextForSanitizingTest.ts | 2 + .../createEditorContextTest.ts | 7 + .../test/corePlugin/cache/CachePluginTest.ts | 4 +- .../corePlugin/cache/domIndexerImplTest.ts | 18 +- .../test/editor/core/createEditorCoreTest.ts | 1 + .../lib/domToModel/domToContentModel.ts | 6 + .../lib/modelApi/common/normalizeParagraph.ts | 3 +- .../modelApi/selection/iterateSelections.ts | 40 +- .../lib/modelToDom/contentModelToDom.ts | 5 + .../modelApi/common/normalizeParagraphTest.ts | 301 +++++++++++++ .../selection/iterateSelectionsTest.ts | 67 --- .../lib/context/EditorContext.ts | 5 + .../lib/editor/EditorCore.ts | 5 + .../lib/editor/EditorOptions.ts | 10 +- .../lib/editor/ExperimentalFeature.ts | 11 + .../lib/index.ts | 1 + .../parameter/FormatContentModelOptions.ts | 4 +- .../lib/corePlugins/BridgePlugin.ts | 4 +- .../lib/publicTypes/EditorAdapterOptions.ts | 4 +- 29 files changed, 674 insertions(+), 301 deletions(-) create mode 100644 demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx create mode 100644 packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index 9f3ec7f2316..a6923efd2be 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -370,6 +370,9 @@ export class MainPane extends React.Component<{}, MainPaneState> { knownColors={this.knownColors} disableCache={this.state.initState.disableCache} announcerStringGetter={getAnnouncingString} + experimentalFeatures={Array.from( + this.state.initState.experimentalFeatures + )} /> )} diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 1bd0329e394..321ac281c8e 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -1,4 +1,5 @@ import { emojiReplacements } from './getReplacements'; +import { ExperimentalFeature } from 'roosterjs-content-model-types'; import { OptionPaneProps, OptionState, UrlPlaceholder } from './OptionState'; import { OptionsPane } from './OptionsPane'; import { SidePaneElementProps } from '../SidePaneElement'; @@ -55,6 +56,7 @@ const initialState: OptionState = { codeFormat: {}, }, customReplacements: emojiReplacements, + experimentalFeatures: new Set(['PersistCache']), }; export class EditorOptionsPlugin extends SidePanePluginImpl { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx new file mode 100644 index 00000000000..da543da70c9 --- /dev/null +++ b/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { ExperimentalFeature } from 'roosterjs-content-model-types'; +import { OptionState } from './OptionState'; + +export interface DefaultFormatProps { + state: OptionState; + resetState: (callback: (state: OptionState) => void, resetEditor: boolean) => void; +} + +export class ExperimentalFeatures extends React.Component { + render() { + return this.renderFeature('PersistCache'); + } + + private renderFeature(featureName: ExperimentalFeature): JSX.Element { + let checked = this.props.state.experimentalFeatures.has(featureName); + + return ( +
+ this.onFeatureClick(featureName)} + /> + +
+ ); + } + + private onFeatureClick = (featureName: ExperimentalFeature) => { + this.props.resetState(state => { + let checkbox = document.getElementById(featureName) as HTMLInputElement; + + if (checkbox.checked) { + state.experimentalFeatures.add(featureName); + } else { + state.experimentalFeatures.delete(featureName); + } + }, true); + }; +} diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index 57bbd973492..d2eb00a73e7 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -1,6 +1,6 @@ import { AutoFormatOptions, CustomReplace, MarkdownOptions } from 'roosterjs-content-model-plugins'; import type { SidePaneElementProps } from '../SidePaneElement'; -import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import type { ContentModelSegmentFormat, ExperimentalFeature } from 'roosterjs-content-model-types'; export interface LegacyPluginList { imageEdit: boolean; @@ -47,6 +47,7 @@ export interface OptionState { isRtl: boolean; disableCache: boolean; applyChangesOnMouseUp: boolean; + experimentalFeatures: Set; } export interface OptionPaneProps extends OptionState, SidePaneElementProps {} diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx index c9cc0e0e7aa..369bce6fb77 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Code } from './Code'; import { DefaultFormatPane } from './DefaultFormatPane'; import { EditorCode } from './codes/EditorCode'; +import { ExperimentalFeatures } from './ExperimentalFeatures'; import { LegacyPlugins, Plugins } from './Plugins'; import { MainPane } from '../../mainPane/MainPane'; import { OptionPaneProps, OptionState } from './OptionState'; @@ -63,6 +64,12 @@ export class OptionsPane extends React.Component { +
+ + Experimental features + + +

@@ -140,6 +147,7 @@ export class OptionsPane extends React.Component { autoFormatOptions: { ...this.state.autoFormatOptions }, markdownOptions: { ...this.state.markdownOptions }, customReplacements: this.state.customReplacements, + experimentalFeatures: this.state.experimentalFeatures, }; if (callback) { diff --git a/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createDomToModelContextForSanitizing.ts b/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createDomToModelContextForSanitizing.ts index 439bb3466e4..b88c24ae8af 100644 --- a/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createDomToModelContextForSanitizing.ts +++ b/packages/roosterjs-content-model-core/lib/command/createModelFromHtml/createDomToModelContextForSanitizing.ts @@ -41,6 +41,7 @@ export function createDomToModelContextForSanitizing( { defaultFormat, ...getRootComputedStyleForContext(document), + experimentalFeatures: [], }, defaultOption, { diff --git a/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts b/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts index 720c0645a71..ff671ba1259 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts @@ -19,6 +19,7 @@ export const createEditorContext: CreateEditorContext = (core, saveIndex) => { allowCacheElement: true, domIndexer: saveIndex ? cache.domIndexer : undefined, zoomScale: domHelper.calculateZoomScale(), + experimentalFeatures: core.experimentalFeatures ?? [], ...getRootComputedStyleForContext(logicalRoot.ownerDocument), }; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts index 199209cf817..9bac8782375 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts @@ -1,6 +1,6 @@ import { areSameSelection } from './areSameSelection'; import { createTextMutationObserver } from './textMutationObserver'; -import { domIndexerImpl } from './domIndexerImpl'; +import { DomIndexerImpl } from './domIndexerImpl'; import { updateCachedSelection } from './updateCachedSelection'; import type { CachePluginState, @@ -26,7 +26,10 @@ class CachePlugin implements PluginWithState { this.state = option.disableCache ? {} : { - domIndexer: domIndexerImpl, + domIndexer: new DomIndexerImpl( + option.experimentalFeatures && + option.experimentalFeatures.indexOf('PersistCache') >= 0 + ), textMutationObserver: createTextMutationObserver(contentDiv, this.onMutation), }; } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts index 5c42d7460ee..1e0b09ad283 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts @@ -47,254 +47,251 @@ function isIndexedSegment(node: Node): node is IndexedSegmentNode { ); } -function onSegment( - segmentNode: Node, - paragraph: ContentModelParagraph, - segment: ContentModelSegment[] -) { - const indexedText = segmentNode as IndexedSegmentNode; - indexedText.__roosterjsContentModel = { - paragraph, - segments: segment, - }; -} - -function onParagraph(paragraphElement: HTMLElement) { - let previousText: Text | null = null; +/** + * @internal + * Implementation of DomIndexer + */ +export class DomIndexerImpl implements DomIndexer { + constructor(public readonly persistCache?: boolean) {} + + onSegment(segmentNode: Node, paragraph: ContentModelParagraph, segment: ContentModelSegment[]) { + const indexedText = segmentNode as IndexedSegmentNode; + indexedText.__roosterjsContentModel = { + paragraph, + segments: segment, + }; + } - for (let child = paragraphElement.firstChild; child; child = child.nextSibling) { - if (isNodeOfType(child, 'TEXT_NODE')) { - if (!previousText) { - previousText = child; - } else { - const item = isIndexedSegment(previousText) - ? previousText.__roosterjsContentModel - : undefined; + onParagraph(paragraphElement: HTMLElement) { + let previousText: Text | null = null; - if (item && isIndexedSegment(child)) { - item.segments = item.segments.concat(child.__roosterjsContentModel.segments); - child.__roosterjsContentModel.segments = []; + for (let child = paragraphElement.firstChild; child; child = child.nextSibling) { + if (isNodeOfType(child, 'TEXT_NODE')) { + if (!previousText) { + previousText = child; + } else { + const item = isIndexedSegment(previousText) + ? previousText.__roosterjsContentModel + : undefined; + + if (item && isIndexedSegment(child)) { + item.segments = item.segments.concat( + child.__roosterjsContentModel.segments + ); + child.__roosterjsContentModel.segments = []; + } } - } - } else if (isNodeOfType(child, 'ELEMENT_NODE')) { - previousText = null; + } else if (isNodeOfType(child, 'ELEMENT_NODE')) { + previousText = null; - onParagraph(child); - } else { - previousText = null; + this.onParagraph(child); + } else { + previousText = null; + } } } -} -function onTable(tableElement: HTMLTableElement, table: ContentModelTable) { - const indexedTable = tableElement as IndexedTableElement; - indexedTable.__roosterjsContentModel = { tableRows: table.rows }; -} + onTable(tableElement: HTMLTableElement, table: ContentModelTable) { + const indexedTable = tableElement as IndexedTableElement; + indexedTable.__roosterjsContentModel = { tableRows: table.rows }; + } -function reconcileSelection( - model: ContentModelDocument, - newSelection: DOMSelection, - oldSelection?: CacheSelection -): boolean { - if (oldSelection) { - if ( - oldSelection.type == 'range' && - isCollapsed(oldSelection) && - isNodeOfType(oldSelection.start.node, 'TEXT_NODE') - ) { - if (isIndexedSegment(oldSelection.start.node)) { - reconcileTextSelection(oldSelection.start.node); + reconcileSelection( + model: ContentModelDocument, + newSelection: DOMSelection, + oldSelection?: CacheSelection + ): boolean { + if (oldSelection) { + if ( + oldSelection.type == 'range' && + this.isCollapsed(oldSelection) && + isNodeOfType(oldSelection.start.node, 'TEXT_NODE') + ) { + if (isIndexedSegment(oldSelection.start.node)) { + this.reconcileTextSelection(oldSelection.start.node); + } + } else { + setSelection(model); } - } else { - setSelection(model); } - } - - switch (newSelection.type) { - case 'image': - case 'table': - // For image and table selection, we just clear the cached model since during selecting the element id might be changed - return false; - - case 'range': - const newRange = newSelection.range; - if (newRange) { - const { - startContainer, - startOffset, - endContainer, - endOffset, - collapsed, - } = newRange; - - delete model.hasRevertedRangeSelection; - - if (collapsed) { - return !!reconcileNodeSelection(startContainer, startOffset); - } else if ( - startContainer == endContainer && - isNodeOfType(startContainer, 'TEXT_NODE') - ) { - if (newSelection.isReverted) { - model.hasRevertedRangeSelection = true; - } - - return ( - isIndexedSegment(startContainer) && - !!reconcileTextSelection(startContainer, startOffset, endOffset) - ); - } else { - const marker1 = reconcileNodeSelection(startContainer, startOffset); - const marker2 = reconcileNodeSelection(endContainer, endOffset); - if (marker1 && marker2) { + switch (newSelection.type) { + case 'image': + case 'table': + // For image and table selection, we just clear the cached model since during selecting the element id might be changed + return false; + + case 'range': + const newRange = newSelection.range; + if (newRange) { + const { + startContainer, + startOffset, + endContainer, + endOffset, + collapsed, + } = newRange; + + delete model.hasRevertedRangeSelection; + + if (collapsed) { + return !!this.reconcileNodeSelection(startContainer, startOffset); + } else if ( + startContainer == endContainer && + isNodeOfType(startContainer, 'TEXT_NODE') + ) { if (newSelection.isReverted) { model.hasRevertedRangeSelection = true; } - setSelection(model, marker1, marker2); - return true; + return ( + isIndexedSegment(startContainer) && + !!this.reconcileTextSelection(startContainer, startOffset, endOffset) + ); } else { - return false; + const marker1 = this.reconcileNodeSelection(startContainer, startOffset); + const marker2 = this.reconcileNodeSelection(endContainer, endOffset); + + if (marker1 && marker2) { + if (newSelection.isReverted) { + model.hasRevertedRangeSelection = true; + } + + setSelection(model, marker1, marker2); + return true; + } else { + return false; + } } } - } - break; - } + break; + } - return false; -} + return false; + } -function isCollapsed(selection: RangeSelectionForCache): boolean { - const { start, end } = selection; + private isCollapsed(selection: RangeSelectionForCache): boolean { + const { start, end } = selection; - return start.node == end.node && start.offset == end.offset; -} + return start.node == end.node && start.offset == end.offset; + } -function reconcileNodeSelection(node: Node, offset: number): Selectable | undefined { - if (isNodeOfType(node, 'TEXT_NODE')) { - return isIndexedSegment(node) ? reconcileTextSelection(node, offset) : undefined; - } else if (offset >= node.childNodes.length) { - return insertMarker(node.lastChild, true /*isAfter*/); - } else { - return insertMarker(node.childNodes[offset], false /*isAfter*/); + private reconcileNodeSelection(node: Node, offset: number): Selectable | undefined { + if (isNodeOfType(node, 'TEXT_NODE')) { + return isIndexedSegment(node) ? this.reconcileTextSelection(node, offset) : undefined; + } else if (offset >= node.childNodes.length) { + return this.insertMarker(node.lastChild, true /*isAfter*/); + } else { + return this.insertMarker(node.childNodes[offset], false /*isAfter*/); + } } -} -function insertMarker(node: Node | null, isAfter: boolean): Selectable | undefined { - let marker: ContentModelSelectionMarker | undefined; + private insertMarker(node: Node | null, isAfter: boolean): Selectable | undefined { + let marker: ContentModelSelectionMarker | undefined; - if (node && isIndexedSegment(node)) { - const { paragraph, segments } = node.__roosterjsContentModel; - const index = paragraph.segments.indexOf(segments[0]); + if (node && isIndexedSegment(node)) { + const { paragraph, segments } = node.__roosterjsContentModel; + const index = paragraph.segments.indexOf(segments[0]); - if (index >= 0) { - const formatSegment = - (!isAfter && paragraph.segments[index - 1]) || paragraph.segments[index]; - marker = createSelectionMarker(formatSegment.format); + if (index >= 0) { + const formatSegment = + (!isAfter && paragraph.segments[index - 1]) || paragraph.segments[index]; + marker = createSelectionMarker(formatSegment.format); - paragraph.segments.splice(isAfter ? index + 1 : index, 0, marker); + paragraph.segments.splice(isAfter ? index + 1 : index, 0, marker); + } } - } - return marker; -} + return marker; + } -function reconcileTextSelection( - textNode: IndexedSegmentNode, - startOffset?: number, - endOffset?: number -) { - const { paragraph, segments } = textNode.__roosterjsContentModel; - const first = segments[0]; - const last = segments[segments.length - 1]; - let selectable: Selectable | undefined; - - if (first?.segmentType == 'Text' && last?.segmentType == 'Text') { - const newSegments: ContentModelSegment[] = []; - const txt = textNode.nodeValue || ''; - const textSegments: ContentModelText[] = []; - - if (startOffset === undefined) { - first.text = txt; - newSegments.push(first); - textSegments.push(first); - } else { - if (startOffset > 0) { - first.text = txt.substring(0, startOffset); + private reconcileTextSelection( + textNode: IndexedSegmentNode, + startOffset?: number, + endOffset?: number + ) { + const { paragraph, segments } = textNode.__roosterjsContentModel; + const first = segments[0]; + const last = segments[segments.length - 1]; + let selectable: Selectable | undefined; + + if (first?.segmentType == 'Text' && last?.segmentType == 'Text') { + const newSegments: ContentModelSegment[] = []; + const txt = textNode.nodeValue || ''; + const textSegments: ContentModelText[] = []; + + if (startOffset === undefined) { + first.text = txt; newSegments.push(first); textSegments.push(first); - } + } else { + if (startOffset > 0) { + first.text = txt.substring(0, startOffset); + newSegments.push(first); + textSegments.push(first); + } - if (endOffset === undefined) { - const marker = createSelectionMarker(first.format); - newSegments.push(marker); - - selectable = marker; - endOffset = startOffset; - } else if (endOffset > startOffset) { - const middle = createText( - txt.substring(startOffset, endOffset), - first.format, - first.link, - first.code - ); - - middle.isSelected = true; - newSegments.push(middle); - textSegments.push(middle); - selectable = middle; - } + if (endOffset === undefined) { + const marker = createSelectionMarker(first.format); + newSegments.push(marker); + + selectable = marker; + endOffset = startOffset; + } else if (endOffset > startOffset) { + const middle = createText( + txt.substring(startOffset, endOffset), + first.format, + first.link, + first.code + ); - if (endOffset < txt.length) { - const newLast = createText( - txt.substring(endOffset), - first.format, - first.link, - first.code - ); - newSegments.push(newLast); - textSegments.push(newLast); + middle.isSelected = true; + newSegments.push(middle); + textSegments.push(middle); + selectable = middle; + } + + if (endOffset < txt.length) { + const newLast = createText( + txt.substring(endOffset), + first.format, + first.link, + first.code + ); + newSegments.push(newLast); + textSegments.push(newLast); + } } - } - let firstIndex = paragraph.segments.indexOf(first); - let lastIndex = paragraph.segments.indexOf(last); + let firstIndex = paragraph.segments.indexOf(first); + let lastIndex = paragraph.segments.indexOf(last); - if (firstIndex >= 0 && lastIndex >= 0) { - while ( - firstIndex > 0 && - paragraph.segments[firstIndex - 1].segmentType == 'SelectionMarker' - ) { - firstIndex--; - } + if (firstIndex >= 0 && lastIndex >= 0) { + while ( + firstIndex > 0 && + paragraph.segments[firstIndex - 1].segmentType == 'SelectionMarker' + ) { + firstIndex--; + } - while ( - lastIndex < paragraph.segments.length - 1 && - paragraph.segments[lastIndex + 1].segmentType == 'SelectionMarker' - ) { - lastIndex++; + while ( + lastIndex < paragraph.segments.length - 1 && + paragraph.segments[lastIndex + 1].segmentType == 'SelectionMarker' + ) { + lastIndex++; + } + + paragraph.segments.splice(firstIndex, lastIndex - firstIndex + 1, ...newSegments); } - paragraph.segments.splice(firstIndex, lastIndex - firstIndex + 1, ...newSegments); - } + this.onSegment(textNode, paragraph, textSegments); - onSegment(textNode, paragraph, textSegments); + if (!this.persistCache) { + delete paragraph.cachedElement; + } + } - delete paragraph.cachedElement; + return selectable; } - - return selectable; } - -/** - * @internal - * Implementation of DomIndexer - */ -export const domIndexerImpl: DomIndexer = { - onSegment, - onParagraph, - onTable, - reconcileSelection, -}; diff --git a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts index 4d1d0001ef1..b1e05569c4c 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts @@ -46,6 +46,7 @@ export function createEditorCore(contentDiv: HTMLDivElement, options: EditorOpti domHelper: createDOMHelper(contentDiv), ...getPluginState(corePlugins), disposeErrorHandler: options.disposeErrorHandler, + experimentalFeatures: options.experimentalFeatures ? [...options.experimentalFeatures] : [], }; } diff --git a/packages/roosterjs-content-model-core/test/command/createModelFromHtml/createDomToModelContextForSanitizingTest.ts b/packages/roosterjs-content-model-core/test/command/createModelFromHtml/createDomToModelContextForSanitizingTest.ts index d12ce404354..c98a5d0422e 100644 --- a/packages/roosterjs-content-model-core/test/command/createModelFromHtml/createDomToModelContextForSanitizingTest.ts +++ b/packages/roosterjs-content-model-core/test/command/createModelFromHtml/createDomToModelContextForSanitizingTest.ts @@ -50,6 +50,7 @@ describe('createDomToModelContextForSanitizing', () => { { defaultFormat: undefined, rootFontSize: 16, + experimentalFeatures: [], }, undefined, { @@ -94,6 +95,7 @@ describe('createDomToModelContextForSanitizing', () => { { defaultFormat: mockedDefaultFormat, rootFontSize: 16, + experimentalFeatures: [], }, mockedOption, { diff --git a/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts b/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts index 595081af099..acab20a1071 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts @@ -45,6 +45,7 @@ describe('createEditorContext', () => { pendingFormat: undefined, zoomScale: 1, rootFontSize: 16, + experimentalFeatures: [], }); }); @@ -91,6 +92,7 @@ describe('createEditorContext', () => { pendingFormat: undefined, zoomScale: 1, rootFontSize: 16, + experimentalFeatures: [], }); }); @@ -135,6 +137,7 @@ describe('createEditorContext', () => { pendingFormat: mockedPendingFormat, zoomScale: 1, rootFontSize: 16, + experimentalFeatures: [], }); }); @@ -181,6 +184,7 @@ describe('createEditorContext', () => { pendingFormat: mockedPendingFormat, zoomScale: 1, rootFontSize: 16, + experimentalFeatures: [], }); }); }); @@ -234,6 +238,7 @@ describe('createEditorContext - checkZoomScale', () => { domIndexer: undefined, pendingFormat: undefined, rootFontSize: 16, + experimentalFeatures: [], }); }); }); @@ -285,6 +290,7 @@ describe('createEditorContext - checkRootDir', () => { pendingFormat: undefined, zoomScale: 1, rootFontSize: 16, + experimentalFeatures: [], }); }); @@ -303,6 +309,7 @@ describe('createEditorContext - checkRootDir', () => { pendingFormat: undefined, zoomScale: 1, rootFontSize: 16, + experimentalFeatures: [], }); }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/CachePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/CachePluginTest.ts index 7cc0c643624..f924e9eb31d 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/CachePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/CachePluginTest.ts @@ -1,6 +1,6 @@ import * as textMutationObserver from '../../../lib/corePlugin/cache/textMutationObserver'; import { createCachePlugin } from '../../../lib/corePlugin/cache/CachePlugin'; -import { domIndexerImpl } from '../../../lib/corePlugin/cache/domIndexerImpl'; +import { DomIndexerImpl } from '../../../lib/corePlugin/cache/domIndexerImpl'; import { CachePluginState, DomIndexer, @@ -75,7 +75,7 @@ describe('CachePlugin', () => { }); expect(addEventListenerSpy).toHaveBeenCalledWith('selectionchange', jasmine.anything()); expect(plugin.getState()).toEqual({ - domIndexer: domIndexerImpl, + domIndexer: new DomIndexerImpl(), textMutationObserver: mockedObserver, }); expect(startObservingSpy).toHaveBeenCalledTimes(1); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts index efe35870517..5905e3a26bb 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts @@ -1,6 +1,6 @@ import * as setSelection from 'roosterjs-content-model-dom/lib/modelApi/selection/setSelection'; import { createRange } from 'roosterjs-content-model-dom/test/testUtils'; -import { domIndexerImpl } from '../../../lib/corePlugin/cache/domIndexerImpl'; +import { DomIndexerImpl } from '../../../lib/corePlugin/cache/domIndexerImpl'; import { CacheSelection, ContentModelDocument, @@ -24,7 +24,7 @@ describe('domIndexerImpl.onSegment', () => { const paragraph = 'Paragraph' as any; const segment = 'Segment' as any; - domIndexerImpl.onSegment(node, paragraph, [segment]); + new DomIndexerImpl().onSegment(node, paragraph, [segment]); expect(node).toEqual({ __roosterjsContentModel: { paragraph: 'Paragraph', segments: ['Segment'] }, @@ -33,6 +33,12 @@ describe('domIndexerImpl.onSegment', () => { }); describe('domIndexerImpl.onParagraph', () => { + let domIndexerImpl: DomIndexerImpl; + + beforeEach(() => { + domIndexerImpl = new DomIndexerImpl(); + }); + it('Paragraph, no child', () => { const node = document.createElement('div'); @@ -163,6 +169,12 @@ describe('domIndexerImpl.onParagraph', () => { }); describe('domIndexerImpl.onTable', () => { + let domIndexerImpl: DomIndexerImpl; + + beforeEach(() => { + domIndexerImpl = new DomIndexerImpl(); + }); + it('onTable', () => { const node = {} as any; const rows = 'ROWS' as any; @@ -181,10 +193,12 @@ describe('domIndexerImpl.onTable', () => { describe('domIndexerImpl.reconcileSelection', () => { let setSelectionSpy: jasmine.Spy; let model: ContentModelDocument; + let domIndexerImpl: DomIndexerImpl; beforeEach(() => { model = createContentModelDocument(); setSelectionSpy = spyOn(setSelection, 'setSelection').and.callThrough(); + domIndexerImpl = new DomIndexerImpl(); }); it('no old range, fake range', () => { diff --git a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts index 361612fe2aa..24a82bd0f19 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts @@ -100,6 +100,7 @@ describe('createEditorCore', () => { contextMenu: 'contextMenu' as any, domHelper: mockedDOMHelper, disposeErrorHandler: undefined, + experimentalFeatures: [], ...additionalResult, }); diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts b/packages/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts index eacb390f507..45929ed6360 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts @@ -1,5 +1,6 @@ import { createContentModelDocument } from '../modelApi/creators/createContentModelDocument'; import { normalizeContentModel } from '../modelApi/common/normalizeContentModel'; +import type { ContentModelDocumentWithPersistedCache } from '../modelApi/selection/iterateSelections'; import type { ContentModelDocument, DomToModelContext } from 'roosterjs-content-model-types'; /** @@ -18,6 +19,11 @@ export function domToContentModel( model.hasRevertedRangeSelection = true; } + // When allowed, persist cached element and do not clear it if not changed + if (context.domIndexer && context.allowCacheElement) { + (model as ContentModelDocumentWithPersistedCache).persistCache = true; + } + context.elementProcessors.child(model, root, context); normalizeContentModel(model); diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts index b9c120c5a83..c970442bb8e 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts @@ -114,7 +114,8 @@ function internalMoveUpSegmentFormat( if ( firstFormat?.[formatKey] && - segments.every(segment => segment.format[formatKey] == firstFormat[formatKey]) + segments.every(segment => segment.format[formatKey] == firstFormat[formatKey]) && + target[formatKey] != firstFormat[formatKey] ) { target[formatKey] = firstFormat[formatKey]; return true; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/selection/iterateSelections.ts b/packages/roosterjs-content-model-dom/lib/modelApi/selection/iterateSelections.ts index 05db43d0348..5e86025cca6 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/selection/iterateSelections.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/selection/iterateSelections.ts @@ -1,6 +1,7 @@ import type { ContentModelBlockGroup, ContentModelBlockWithCache, + ContentModelDocument, IterateSelectionsCallback, IterateSelectionsOption, ReadonlyContentModelBlockGroup, @@ -9,6 +10,17 @@ import type { ReadonlyTableSelectionContext, } from 'roosterjs-content-model-types'; +/** + * @internal + * This is a temporary type to pass the information of whether element cache should be persisted when possible + */ +export interface ContentModelDocumentWithPersistedCache extends ContentModelDocument { + /** + * When set to + */ + persistCache?: boolean; +} + /** * Iterate all selected elements in a given model * @param group The given Content Model to iterate selection from @@ -38,18 +50,24 @@ export function iterateSelections( callback: ReadonlyIterateSelectionsCallback | IterateSelectionsCallback, option?: IterateSelectionsOption ): void { - const internalCallback: ReadonlyIterateSelectionsCallback = ( - path, - tableContext, - block, - segments - ) => { - if (!!(block as ContentModelBlockWithCache)?.cachedElement) { - delete (block as ContentModelBlockWithCache).cachedElement; - } + const persistCache = + group.blockGroupType == 'Document' + ? (group as ContentModelDocumentWithPersistedCache).persistCache + : false; + const internalCallback: ReadonlyIterateSelectionsCallback = persistCache + ? (callback as ReadonlyIterateSelectionsCallback) + : (path, tableContext, block, segments) => { + if (!!(block as ContentModelBlockWithCache)?.cachedElement) { + delete (block as ContentModelBlockWithCache).cachedElement; + } - return (callback as ReadonlyIterateSelectionsCallback)(path, tableContext, block, segments); - }; + return (callback as ReadonlyIterateSelectionsCallback)( + path, + tableContext, + block, + segments + ); + }; internalIterateSelections([group], internalCallback, option); } diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts index 54bb520d0c7..131e7d68730 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts @@ -1,5 +1,6 @@ import { isNodeOfType } from '../domUtils/isNodeOfType'; import { toArray } from '../domUtils/toArray'; +import type { ContentModelDocumentWithPersistedCache } from '../modelApi/selection/iterateSelections'; import type { ContentModelDocument, DOMSelection, @@ -31,6 +32,10 @@ export function contentModelToDom( range.isReverted = true; } + if (context.domIndexer && context.allowCacheElement) { + (model as ContentModelDocumentWithPersistedCache).persistCache = true; + } + root.normalize(); return range; diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts index a28e3aa5a38..ca033384432 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts @@ -509,3 +509,304 @@ describe('Normalize paragraph with segmentFormat', () => { }); }); }); + +describe('Move up format', () => { + const mockedCache = {} as any; + + it('No format', () => { + const para = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para.segments.push(text1, text2); + para.cachedElement = mockedCache; + + normalizeParagraph(para); + + expect(para).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + format: {}, + cachedElement: mockedCache, + }); + }); + + it('All segments have the same format', () => { + const para = createParagraph(); + const text1 = createText('test1', { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: false, + }); + const text2 = createText('test2', { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: true, + }); + + para.segments.push(text1, text2); + para.cachedElement = mockedCache; + + normalizeParagraph(para); + + expect(para).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: false, + }, + }, + { + segmentType: 'Text', + text: 'test2', + format: { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: true, + }, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + }, + }); + }); + + it('All segments have the same format, paragraph has different format', () => { + const para = createParagraph(false, undefined, { + fontFamily: 'Arial', + fontSize: '12pt', + textColor: 'green', + }); + const text1 = createText('test1', { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: false, + }); + const text2 = createText('test2', { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: true, + }); + + para.segments.push(text1, text2); + para.cachedElement = mockedCache; + + normalizeParagraph(para); + + expect(para).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: false, + }, + }, + { + segmentType: 'Text', + text: 'test2', + format: { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: true, + }, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + }, + }); + }); + + it('Some format are different', () => { + const para = createParagraph(); + const text1 = createText('test1', { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: false, + }); + const text2 = createText('test2', { + fontFamily: 'Calibri', + fontSize: '12pt', + textColor: 'red', + italic: true, + }); + + para.segments.push(text1, text2); + para.cachedElement = mockedCache; + + normalizeParagraph(para); + + expect(para).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: false, + }, + }, + { + segmentType: 'Text', + text: 'test2', + format: { + fontFamily: 'Calibri', + fontSize: '12pt', + textColor: 'red', + italic: true, + }, + }, + ], + format: {}, + segmentFormat: { fontFamily: 'Calibri', textColor: 'red' }, + }); + }); + + it('All formats are different', () => { + const para = createParagraph(); + const text1 = createText('test1', { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: false, + }); + const text2 = createText('test2', { + fontFamily: 'Arial', + fontSize: '12pt', + textColor: 'green', + italic: true, + }); + + para.segments.push(text1, text2); + para.cachedElement = mockedCache; + + normalizeParagraph(para); + + expect(para).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: false, + }, + }, + { + segmentType: 'Text', + text: 'test2', + format: { + fontFamily: 'Arial', + fontSize: '12pt', + textColor: 'green', + italic: true, + }, + }, + ], + format: {}, + cachedElement: mockedCache, + }); + }); + + it('Already has same format in paragraph', () => { + const para = createParagraph(false, undefined, { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: false, + }); + const text1 = createText('test1', { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: false, + }); + const text2 = createText('test2', { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: true, + }); + + para.segments.push(text1, text2); + para.cachedElement = mockedCache; + + normalizeParagraph(para); + + expect(para).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: false, + }, + }, + { + segmentType: 'Text', + text: 'test2', + format: { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: true, + }, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '10pt', + textColor: 'red', + italic: false, + }, + cachedElement: mockedCache, + }); + }); +}); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts index f1761b764a3..762ab0eb22a 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/selection/iterateSelectionsTest.ts @@ -1,7 +1,6 @@ import { iterateSelections } from '../../../lib/modelApi/selection/iterateSelections'; import { IterateSelectionsCallback } from 'roosterjs-content-model-types'; import { - addSegment, createContentModelDocument, createDivider, createEntity, @@ -1207,70 +1206,4 @@ describe('iterateSelections', () => { expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([doc], undefined, para, [entity]); }); - - it('Check cachedElement is cleared', () => { - const quote1 = createFormatContainer('blockquote'); - const para1 = createParagraph(); - const divider1 = createDivider('hr'); - const quote2 = createFormatContainer('blockquote'); - const para2 = createParagraph(); - const divider2 = createDivider('hr'); - const marker1 = createSelectionMarker(); - const marker2 = createSelectionMarker(); - const cache = 'CACHE' as any; - - addSegment(quote1, marker1); - para1.segments.push(marker2); - divider1.isSelected = true; - - quote1.cachedElement = cache; - para1.cachedElement = cache; - divider1.cachedElement = cache; - quote2.cachedElement = cache; - para2.cachedElement = cache; - divider2.cachedElement = cache; - - const doc = createContentModelDocument(); - - doc.blocks.push(quote1, quote2, para1, para2, divider1, divider2); - - iterateSelections(doc, callback); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'BlockGroup', - blockGroupType: 'FormatContainer', - tagName: 'blockquote', - blocks: [ - { - blockType: 'Paragraph', - segments: [marker1], - format: {}, - isImplicit: true, - }, - ], - format: {}, - cachedElement: cache, - }, - { - blockType: 'BlockGroup', - blockGroupType: 'FormatContainer', - tagName: 'blockquote', - blocks: [], - format: {}, - cachedElement: cache, - }, - { - blockType: 'Paragraph', - segments: [marker2], - format: {}, - }, - { blockType: 'Paragraph', segments: [], format: {}, cachedElement: cache }, - { blockType: 'Divider', tagName: 'hr', format: {}, isSelected: true }, - { blockType: 'Divider', tagName: 'hr', format: {}, cachedElement: cache }, - ], - }); - }); }); diff --git a/packages/roosterjs-content-model-types/lib/context/EditorContext.ts b/packages/roosterjs-content-model-types/lib/context/EditorContext.ts index 27ff10f399e..937a72788f6 100644 --- a/packages/roosterjs-content-model-types/lib/context/EditorContext.ts +++ b/packages/roosterjs-content-model-types/lib/context/EditorContext.ts @@ -57,4 +57,9 @@ export interface EditorContext { * Root Font size in Px. */ rootFontSize?: number; + + /** + * Enabled experimental features + */ + experimentalFeatures?: ReadonlyArray; } diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts index 59f0600764c..46d00bd3733 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts @@ -369,4 +369,9 @@ export interface EditorCore extends PluginState { * @param error The error object we got */ readonly disposeErrorHandler?: (plugin: EditorPlugin, error: Error) => void; + + /** + * Enabled experimental features + */ + readonly experimentalFeatures: ReadonlyArray; } diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts b/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts index 3288eb3c88f..6e421f6f8ee 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts @@ -1,3 +1,4 @@ +import type { ExperimentalFeature } from './ExperimentalFeature'; import type { KnownAnnounceStrings } from '../parameter/AnnounceData'; import type { PasteType } from '../enum/PasteType'; import type { Colors, ColorTransformFunction } from '../context/DarkColorHandler'; @@ -25,12 +26,15 @@ export interface EditorOptions { defaultModelToDomOptions?: ModelToDomOption; /** - * Whether content model should be cached in order to improve editing performance. - * Pass true to disable the cache. - * @default false + * @deprecated */ disableCache?: boolean; + /** + * Enabled experimental features + */ + experimentalFeatures?: (ExperimentalFeature | string)[]; + /** * List of plugins. * The order of plugins here determines in what order each event will be dispatched. diff --git a/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts b/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts new file mode 100644 index 00000000000..bdd12a56d22 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts @@ -0,0 +1,11 @@ +/** + * Predefined experiment features + * By default these features are not enabled. To enable them, pass the feature name into EditorOptions.experimentalFeatures + * when create editor + */ +export type ExperimentalFeature = + /** + * When this feature is enabled, we will persist a content model in memory as long as we can, + * and use cached element when write back if it is not changed. + */ + 'PersistCache'; diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index e20fc283675..f236306d258 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -326,6 +326,7 @@ export { export { DarkColorHandler, Colors, ColorTransformFunction } from './context/DarkColorHandler'; export { IEditor } from './editor/IEditor'; +export { ExperimentalFeature } from './editor/ExperimentalFeature'; export { EditorOptions } from './editor/EditorOptions'; export { CreateContentModel, diff --git a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts index ec43eae1ed1..c269e483078 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts @@ -1,4 +1,4 @@ -import type { ContentModelDocument } from '../contentModel/blockGroup/ContentModelDocument'; +import type { ShallowMutableContentModelDocument } from '../contentModel/blockGroup/ContentModelDocument'; import type { DOMSelection } from '../selection/DOMSelection'; import type { FormatContentModelContext } from './FormatContentModelContext'; import type { OnNodeCreated } from '../context/ModelToDomSettings'; @@ -52,6 +52,6 @@ export interface FormatContentModelOptions { * @returns True means the model is changed and need to write back to editor, otherwise false */ export type ContentModelFormatter = ( - model: ContentModelDocument, + model: ShallowMutableContentModelDocument, context: FormatContentModelContext ) => boolean; diff --git a/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts index a4da134f5b0..0eb1d7a106a 100644 --- a/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts +++ b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts @@ -68,7 +68,7 @@ export class BridgePlugin implements ContextMenuProvider { constructor( private onInitialize: (core: EditorAdapterCore) => ILegacyEditor, legacyPlugins: LegacyEditorPlugin[] = [], - private experimentalFeatures: ExperimentalFeatures[] = [] + private experimentalFeatures: string[] = [] ) { const editPlugin = createEditPlugin(); @@ -178,7 +178,7 @@ export class BridgePlugin implements ContextMenuProvider { private createEditorCore(editor: IEditor): EditorAdapterCore { return { customData: {}, - experimentalFeatures: this.experimentalFeatures ?? [], + experimentalFeatures: (this.experimentalFeatures as ExperimentalFeatures[]) ?? [], sizeTransformer: createSizeTransformer(editor), darkColorHandler: createDarkColorHandler(editor.getColorManager()), edit: this.edit, diff --git a/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts b/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts index 69621390e57..6386f05dbab 100644 --- a/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts +++ b/packages/roosterjs-editor-adapter/lib/publicTypes/EditorAdapterOptions.ts @@ -1,5 +1,5 @@ import type { EditorOptions } from 'roosterjs-content-model-types'; -import type { EditorPlugin, ExperimentalFeatures } from 'roosterjs-editor-types'; +import type { EditorPlugin } from 'roosterjs-editor-types'; /** * Options for editor adapter @@ -14,7 +14,7 @@ export interface EditorAdapterOptions extends EditorOptions { /** * Specify the enabled experimental features */ - experimentalFeatures?: ExperimentalFeatures[]; + experimentalFeatures?: string[]; /** * Legacy plugins using IEditor interface From 0698c39f1b6a32692f8cdd444251a89ca1a16cfc Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 3 Jun 2024 11:44:58 -0700 Subject: [PATCH 3/6] Improve --- .../roosterjs-content-model-core/lib/editor/Editor.ts | 9 +++++++++ .../lib/edit/EditPlugin.ts | 7 ++++++- .../roosterjs-content-model-types/lib/editor/IEditor.ts | 7 +++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 3301ff252ee..6bad5976a24 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -32,6 +32,7 @@ import type { CachedElementHandler, DomToModelOptionForCreateModel, AnnounceData, + ExperimentalFeature, } from 'roosterjs-content-model-types'; /** @@ -406,6 +407,14 @@ export class Editor implements IEditor { core.api.announce(core, announceData); } + /** + * Check if a given feature is enabled + * @param featureName The name of feature to check + */ + isExperimentalFeatureEnabled(featureName: ExperimentalFeature | string): boolean { + return this.getCore().experimentalFeatures.indexOf(featureName) >= 0; + } + /** * @returns the current EditorCore object * @throws a standard Error if there's no core object diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index 91c23a861e6..d358fd9bec5 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -26,6 +26,7 @@ export class EditPlugin implements EditorPlugin { private disposer: (() => void) | null = null; private shouldHandleNextInputEvent = false; private selectionAfterDelete: DOMSelection | null = null; + private handleEnterKey = false; /** * Get name of this plugin @@ -42,6 +43,8 @@ export class EditPlugin implements EditorPlugin { */ initialize(editor: IEditor) { this.editor = editor; + this.handleEnterKey = this.editor.isExperimentalFeatureEnabled('PersistCache'); + if (editor.getEnvironment().isAndroid) { this.disposer = this.editor.attachDomEvent({ beforeinput: { @@ -154,7 +157,9 @@ export class EditPlugin implements EditorPlugin { break; case 'Enter': - keyboardEnter(editor, rawEvent); + if (this.handleEnterKey) { + keyboardEnter(editor, rawEvent); + } break; default: diff --git a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts index 2ff5e28bad0..39c5e08e7ae 100644 --- a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts +++ b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts @@ -18,6 +18,7 @@ import type { DarkColorHandler } from '../context/DarkColorHandler'; import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; import type { Rect } from '../parameter/Rect'; import type { EntityState } from '../parameter/FormatContentModelContext'; +import type { ExperimentalFeature } from './ExperimentalFeature'; /** * An interface of Editor, built on top of Content Model @@ -227,4 +228,10 @@ export interface IEditor { * @param announceData Data to announce */ announce(announceData: AnnounceData): void; + + /** + * Check if a given feature is enabled + * @param featureName The name of feature to check + */ + isExperimentalFeatureEnabled(featureName: ExperimentalFeature | string): boolean; } From f1dfe8014e62361a7cef475b69fb5b5156a9cd24 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 3 Jun 2024 11:52:20 -0700 Subject: [PATCH 4/6] fix test --- .../roosterjs-content-model-plugins/test/edit/EditPluginTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 4ca88a12128..016b1b1bf68 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -29,6 +29,7 @@ describe('EditPlugin', () => { ({ type: -1, } as any), // Force return invalid range to go through content model code + isExperimentalFeatureEnabled: () => true, } as any) as IEditor; }); From 37f6b4b3ec1ed70746859db464e97cd7faf57e19 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 3 Jun 2024 16:48:46 -0700 Subject: [PATCH 5/6] add test --- .../test/editor/EditorTest.ts | 32 +++ .../lib/edit/EditPlugin.ts | 2 + .../lib/edit/deleteSteps/deleteEmptyQuote.ts | 58 ++--- .../test/edit/EditPluginTest.ts | 65 +++++- .../edit/deleteSteps/deleteEmptyQuoteTest.ts | 214 +++++++++++++++++- .../inputSteps/handleEnterOnParagraphTest.ts | 102 +++++++++ 6 files changed, 435 insertions(+), 38 deletions(-) create mode 100644 packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnParagraphTest.ts diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index cb415755e7c..3db0c62136f 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -1116,4 +1116,36 @@ describe('Editor', () => { expect(resetSpy).toHaveBeenCalledWith(); expect(() => editor.announce(mockedData)).toThrow(); }); + + it('isExperimentalFeatureEnabled', () => { + const div = document.createElement('div'); + const resetSpy = jasmine.createSpy('reset'); + const mockedCore = { + plugins: [], + darkColorHandler: { + updateKnownColor: updateKnownColorSpy, + reset: resetSpy, + }, + api: { + setContentModel: setContentModelSpy, + }, + experimentalFeatures: ['Feature1', 'Feature2'], + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new Editor(div); + + const result1 = editor.isExperimentalFeatureEnabled('Feature1'); + const result2 = editor.isExperimentalFeatureEnabled('Feature2'); + const result3 = editor.isExperimentalFeatureEnabled('Feature3'); + + expect(result1).toBeTrue(); + expect(result2).toBeTrue(); + expect(result3).toBeFalse(); + + editor.dispose(); + expect(resetSpy).toHaveBeenCalledWith(); + expect(() => editor.isExperimentalFeatureEnabled('Feature4')).toThrow(); + }); }); diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index d358fd9bec5..5ee5b9232c6 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -159,6 +159,8 @@ export class EditPlugin implements EditorPlugin { case 'Enter': if (this.handleEnterKey) { keyboardEnter(editor, rawEvent); + } else { + keyboardInput(editor, rawEvent); } break; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts index 57abbb39218..bb8ed065cc5 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts @@ -1,12 +1,10 @@ import { unwrapBlock, getClosestAncestorBlockGroupIndex, - isBlockGroupOfType, createFormatContainer, mutateBlock, } from 'roosterjs-content-model-dom'; import type { - ContentModelFormatContainer, DeleteSelectionStep, ReadonlyContentModelBlockGroup, ReadonlyContentModelFormatContainer, @@ -21,7 +19,11 @@ import type { export const deleteEmptyQuote: DeleteSelectionStep = context => { const { deleteResult } = context; - if (deleteResult == 'nothingToDelete' || deleteResult == 'notDeleted') { + if ( + deleteResult == 'nothingToDelete' || + deleteResult == 'notDeleted' || + deleteResult == 'range' + ) { const { insertPoint, formatContext } = context; const { path, paragraph } = insertPoint; const rawEvent = formatContext?.rawEvent as KeyboardEvent; @@ -35,52 +37,36 @@ export const deleteEmptyQuote: DeleteSelectionStep = context => { if (quote && quote.blockGroupType === 'FormatContainer' && quote.tagName == 'blockquote') { const parent = path[index + 1]; const quoteBlockIndex = parent.blocks.indexOf(quote); - const blockQuote = parent.blocks[quoteBlockIndex]; - if ( - isBlockGroupOfType(blockQuote, 'FormatContainer') && - blockQuote.tagName === 'blockquote' + if (isEmptyQuote(quote)) { + unwrapBlock(parent, quote); + rawEvent?.preventDefault(); + context.deleteResult = 'range'; + } else if ( + rawEvent?.key === 'Enter' && + quote.blocks.indexOf(paragraph) >= 0 && + isEmptyParagraph(paragraph) ) { - if (isEmptyQuote(blockQuote)) { - unwrapBlock(parent, blockQuote); - rawEvent?.preventDefault(); - context.deleteResult = 'range'; - } else if ( - isSelectionOnEmptyLine(blockQuote, paragraph) && - rawEvent?.key === 'Enter' - ) { - insertNewLine(blockQuote, parent, quoteBlockIndex, paragraph); - rawEvent?.preventDefault(); - context.deleteResult = 'range'; - } + insertNewLine(mutateBlock(quote), parent, quoteBlockIndex, paragraph); + rawEvent?.preventDefault(); + context.deleteResult = 'range'; } } } }; -const isEmptyQuote = (quote: ContentModelFormatContainer) => { +const isEmptyQuote = (quote: ReadonlyContentModelFormatContainer) => { return ( quote.blocks.length === 1 && quote.blocks[0].blockType === 'Paragraph' && - quote.blocks[0].segments.every( - s => s.segmentType === 'SelectionMarker' || s.segmentType === 'Br' - ) + isEmptyParagraph(quote.blocks[0]) ); }; -const isSelectionOnEmptyLine = ( - quote: ReadonlyContentModelFormatContainer, - paragraph: ReadonlyContentModelParagraph -) => { - const paraIndex = quote.blocks.indexOf(paragraph); - - if (paraIndex >= 0) { - return paragraph.segments.every( - s => s.segmentType === 'SelectionMarker' || s.segmentType === 'Br' - ); - } else { - return false; - } +const isEmptyParagraph = (paragraph: ReadonlyContentModelParagraph) => { + return paragraph.segments.every( + s => s.segmentType === 'SelectionMarker' || s.segmentType === 'Br' + ); }; const insertNewLine = ( diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 016b1b1bf68..0c895000cbb 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -1,4 +1,5 @@ import * as keyboardDelete from '../../lib/edit/keyboardDelete'; +import * as keyboardEnter from '../../lib/edit/keyboardEnter'; import * as keyboardInput from '../../lib/edit/keyboardInput'; import * as keyboardTab from '../../lib/edit/keyboardTab'; import { DOMEventRecord, IEditor } from 'roosterjs-content-model-types'; @@ -10,6 +11,7 @@ describe('EditPlugin', () => { let eventMap: Record; let attachDOMEventSpy: jasmine.Spy; let getEnvironmentSpy: jasmine.Spy; + let isExperimentalFeatureEnabledSpy: jasmine.Spy; beforeEach(() => { attachDOMEventSpy = jasmine @@ -21,6 +23,9 @@ describe('EditPlugin', () => { getEnvironmentSpy = jasmine.createSpy('getEnvironment').and.returnValue({ isAndroid: true, }); + isExperimentalFeatureEnabledSpy = jasmine + .createSpy('isExperimentalFeatureEnabled') + .and.returnValue(false); editor = ({ attachDomEvent: attachDOMEventSpy, @@ -29,7 +34,7 @@ describe('EditPlugin', () => { ({ type: -1, } as any), // Force return invalid range to go through content model code - isExperimentalFeatureEnabled: () => true, + isExperimentalFeatureEnabled: isExperimentalFeatureEnabledSpy, } as any) as IEditor; }); @@ -41,11 +46,13 @@ describe('EditPlugin', () => { let keyboardDeleteSpy: jasmine.Spy; let keyboardInputSpy: jasmine.Spy; let keyboardTabSpy: jasmine.Spy; + let keyboardEnterSpy: jasmine.Spy; beforeEach(() => { keyboardDeleteSpy = spyOn(keyboardDelete, 'keyboardDelete'); keyboardInputSpy = spyOn(keyboardInput, 'keyboardInput'); keyboardTabSpy = spyOn(keyboardTab, 'keyboardTab'); + keyboardEnterSpy = spyOn(keyboardEnter, 'keyboardEnter'); }); it('Backspace', () => { @@ -61,6 +68,8 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Delete', () => { @@ -76,6 +85,8 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Shift+Delete', () => { @@ -91,6 +102,8 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).not.toHaveBeenCalled(); expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Tab', () => { @@ -107,6 +120,50 @@ describe('EditPlugin', () => { expect(keyboardTabSpy).toHaveBeenCalledWith(editor, rawEvent); expect(keyboardInputSpy).not.toHaveBeenCalled(); expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + }); + + it('Enter, keyboardEnter not enabled', () => { + plugin = new EditPlugin(); + const rawEvent = { which: 13, key: 'Enter' } as any; + const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + + editor.takeSnapshot = addUndoSnapshotSpy; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardInputSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); + }); + + it('Enter, keyboardEnter enabled', () => { + isExperimentalFeatureEnabledSpy.and.callFake( + (featureName: string) => featureName == 'PersistCache' + ); + plugin = new EditPlugin(); + const rawEvent = { which: 13, key: 'Enter' } as any; + const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + + editor.takeSnapshot = addUndoSnapshotSpy; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Other key', () => { @@ -125,6 +182,8 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).not.toHaveBeenCalled(); expect(keyboardInputSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Default prevented', () => { @@ -139,6 +198,8 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).not.toHaveBeenCalled(); expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Trigger entity event first', () => { @@ -175,6 +236,8 @@ describe('EditPlugin', () => { key: 'Delete', } as any); expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteEmptyQuoteTest.ts b/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteEmptyQuoteTest.ts index 3494db5d813..ab1f69fe948 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteEmptyQuoteTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteEmptyQuoteTest.ts @@ -126,6 +126,218 @@ describe('deleteEmptyQuote', () => { ], format: {}, }; - runTest(model, model, 'notDeleted'); + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + ], + format: {}, + segmentFormat: { textColor: 'rgb(102, 102, 102)' }, + }, + ], + format: { + marginTop: '1em', + marginRight: '40px', + marginBottom: '1em', + marginLeft: '40px', + paddingLeft: '10px', + borderLeft: '3px solid rgb(200, 200, 200)', + }, + }, + ], + format: {}, + }; + runTest(model, expectedModel, 'notDeleted'); + }); +}); + +describe('delete with Enter', () => { + it('Enter in empty paragraph in middle of quote', () => { + function runTest( + model: ContentModelDocument, + expectedModel: ContentModelDocument, + deleteResult: string + ) { + const result = deleteSelection(model, [deleteEmptyQuote], { + rawEvent: { + key: 'Enter', + preventDefault: () => {}, + } as any, + newEntities: [], + deletedEntities: [], + newImages: [], + }); + normalizeContentModel(model); + expect(result.deleteResult).toEqual(deleteResult); + expect(model).toEqual(expectedModel); + } + + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + ], + format: {}, + }, + ], + format: { + marginTop: '1em', + marginRight: '40px', + marginBottom: '1em', + marginLeft: '40px', + paddingLeft: '10px', + borderLeft: '3px solid rgb(200, 200, 200)', + }, + }, + ], + format: {}, + }; + + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + ], + format: {}, + segmentFormat: { textColor: 'rgb(102, 102, 102)' }, + }, + ], + format: { + marginTop: '1em', + marginRight: '40px', + marginBottom: '1em', + marginLeft: '40px', + paddingLeft: '10px', + borderLeft: '3px solid rgb(200, 200, 200)', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + { + segmentType: 'Br', + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + ], + format: {}, + segmentFormat: { textColor: 'rgb(102, 102, 102)' }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + ], + format: {}, + segmentFormat: { textColor: 'rgb(102, 102, 102)' }, + }, + ], + format: { + marginTop: '1em', + marginRight: '40px', + marginBottom: '1em', + marginLeft: '40px', + paddingLeft: '10px', + borderLeft: '3px solid rgb(200, 200, 200)', + }, + }, + ], + format: {}, + }; + runTest(model, expectedModel, 'range'); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnParagraphTest.ts b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnParagraphTest.ts new file mode 100644 index 00000000000..cbab594af4e --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnParagraphTest.ts @@ -0,0 +1,102 @@ +import { handleEnterOnParagraph } from '../../../lib/edit/inputSteps/handleEnterOnParagraph'; +import { ValidDeleteSelectionContext } from 'roosterjs-content-model-types'; +import { + createContentModelDocument, + createParagraph, + createSelectionMarker, + createText, +} from 'roosterjs-content-model-dom'; + +describe('handleEnterOnParagraph', () => { + it('Already deleted', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const mockedCache = {} as any; + + para.segments.push(marker); + doc.blocks.push(para); + doc.cachedElement = mockedCache; + + const context: ValidDeleteSelectionContext = { + deleteResult: 'range', + insertPoint: { + paragraph: para, + marker: marker, + path: [doc], + }, + }; + + handleEnterOnParagraph(context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [para], + cachedElement: mockedCache, + }); + }); + + it('Not deleted, split current paragraph', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const mockedCache = {} as any; + const text1 = createText('test1'); + const text2 = createText('test1'); + + para.segments.push(text1, marker, text2); + doc.blocks.push(para); + doc.cachedElement = mockedCache; + + const context: ValidDeleteSelectionContext = { + deleteResult: 'notDeleted', + insertPoint: { + paragraph: para, + marker: marker, + path: [doc], + }, + }; + + handleEnterOnParagraph(context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + text2, + ], + format: {}, + }, + ], + }); + + expect(context.insertPoint).toEqual({ + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + text2, + ], + format: {}, + }, + marker: marker, + path: [doc], + }); + }); +}); From 1f90c24ce154526cca4a669a8f3655102d3dde2c Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 4 Jun 2024 16:57:12 -0700 Subject: [PATCH 6/6] add test --- .../lib/edit/deleteSteps/deleteEmptyQuote.ts | 4 +- .../lib/edit/inputSteps/handleEnterOnList.ts | 17 +- .../edit/inputSteps/handleEnterOnListTest.ts | 218 +++ .../test/edit/keyboardEnterTest.ts | 1418 +++++++++++++++++ .../test/edit/utils/splitParagraphTest.ts | 99 ++ 5 files changed, 1753 insertions(+), 3 deletions(-) create mode 100644 packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts create mode 100644 packages/roosterjs-content-model-plugins/test/edit/utils/splitParagraphTest.ts diff --git a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts index bb8ed065cc5..b3a21c69d40 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts @@ -29,8 +29,8 @@ export const deleteEmptyQuote: DeleteSelectionStep = context => { const rawEvent = formatContext?.rawEvent as KeyboardEvent; const index = getClosestAncestorBlockGroupIndex( path, - ['FormatContainer', 'ListItem'], - ['TableCell'] + ['FormatContainer'], + ['TableCell', 'ListItem'] ); const quote = path[index]; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts index ab091efbab4..5a7205a2700 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -24,7 +24,11 @@ export const handleEnterOnList: DeleteSelectionStep = context => { if (deleteResult == 'notDeleted' || deleteResult == 'nothingToDelete') { const { path } = insertPoint; - const index = getClosestAncestorBlockGroupIndex(path, ['ListItem'], ['TableCell']); + const index = getClosestAncestorBlockGroupIndex( + path, + ['ListItem'], + ['TableCell', 'FormatContainer'] + ); const readonlyListItem = path[index]; const listParent = path[index + 1]; @@ -84,6 +88,7 @@ const createNewListItem = ( const { insertPoint } = context; const listIndex = listParent.blocks.indexOf(listItem); const currentPara = insertPoint.paragraph; + const paraIndex = listItem.blocks.indexOf(currentPara); const newParagraph = splitParagraph(insertPoint); const levels = createNewListLevel(listItem); @@ -91,7 +96,17 @@ const createNewListItem = ( levels, insertPoint.marker.format ); + newListItem.blocks.push(newParagraph); + + const remainingBlockCount = listItem.blocks.length - paraIndex - 1; + + if (paraIndex >= 0 && remainingBlockCount > 0) { + newListItem.blocks.push( + ...mutateBlock(listItem).blocks.splice(paraIndex + 1, remainingBlockCount) + ); + } + insertPoint.paragraph = newParagraph; mutateBlock(listParent).blocks.splice(listIndex + 1, 0, newListItem); diff --git a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts index 69616999988..34a9ddd18d9 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -2438,4 +2438,222 @@ describe('handleEnterOnList - keyboardEnter', () => { }; runTest(input, true, expected, false, 1); }); + + it('List item contains multiple blocks', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test1', + segmentType: 'Text', + format: {}, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + { + text: 'test2', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + text: 'test3', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'test4', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + ], + }; + + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test1', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + dataset: {}, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + { + text: 'test2', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + text: 'test3', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: undefined, + }, + dataset: {}, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'test4', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + ], + }; + + runTest(model, false, expectedModel, false, 1); + }); }); diff --git a/packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts b/packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts new file mode 100644 index 00000000000..b18a6d0b6f6 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts @@ -0,0 +1,1418 @@ +import { keyboardEnter } from '../../lib/edit/keyboardEnter'; +import { + createBr, + createFormatContainer, + createListItem, + createListLevel, + createParagraph, + createSelectionMarker, + createTable, + createTableCell, + createText, +} from 'roosterjs-content-model-dom'; +import { + ContentModelDocument, + ContentModelSegmentFormat, + FormatContentModelContext, + IEditor, +} from 'roosterjs-content-model-types'; + +describe('keyboardEnter', () => { + let getDOMSelectionSpy: jasmine.Spy; + let formatContentModelSpy: jasmine.Spy; + let preventDefaultSpy: jasmine.Spy; + let editor: IEditor; + + beforeEach(() => { + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection').and.returnValue({ + type: 'range', + }); + formatContentModelSpy = jasmine.createSpy('formatContentModel'); + preventDefaultSpy = jasmine.createSpy('preventDefault'); + editor = { + getDOMSelection: getDOMSelectionSpy, + formatContentModel: formatContentModelSpy, + } as any; + }); + + function runTest( + input: ContentModelDocument, + shift: boolean, + output: ContentModelDocument, + isChanged: boolean, + pendingFormat: ContentModelSegmentFormat | undefined + ) { + const rawEvent: KeyboardEvent = { + key: 'Enter', + shiftKey: shift, + preventDefault: preventDefaultSpy, + } as any; + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + rawEvent, + }; + formatContentModelSpy.and.callFake((callback: Function) => { + const result = callback(input, context); + + expect(result).toBe(isChanged); + expect(); + }); + + keyboardEnter(editor, rawEvent); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(input).toEqual(output); + expect(context.newPendingFormat).toEqual(pendingFormat); + } + + it('Empty model, no selection', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [], + }, + false, + { + blockGroupType: 'Document', + blocks: [], + }, + false, + undefined + ); + }); + + it('Single paragraph, only have selection marker', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontSize: '10pt' }, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + ], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Br', + format: { fontSize: '10pt' }, + }, + ], + segmentFormat: { fontSize: '10pt' }, + }, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontSize: '10pt' }, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + ], + }, + true, + { fontSize: '10pt' } + ); + }); + + it('Single paragraph, all text are selected', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + isSelected: true, + text: 'test', + format: { fontSize: '10pt' }, + }, + ], + }, + ], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Br', + format: { fontSize: '10pt' }, + }, + ], + segmentFormat: { fontSize: '10pt' }, + }, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontSize: '10pt' }, + }, + { + segmentType: 'Br', + format: { fontSize: '10pt' }, + }, + ], + segmentFormat: { fontSize: '10pt' }, + }, + ], + }, + true, + { fontSize: '10pt' } + ); + }); + + it('Multiple paragraph, single selection', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(createBr()); + para2.segments.push(createBr()); + + runTest( + { + blockGroupType: 'Document', + blocks: [ + para1, + { + blockType: 'Paragraph', + format: {}, + segments: [ + text1, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + text2, + ], + }, + para2, + ], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + para1, + { + blockType: 'Paragraph', + format: {}, + segments: [text1], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + text2, + ], + }, + para2, + ], + }, + true, + {} + ); + }); + + it('Multiple paragraph, select from line end to line start', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const marker1 = createSelectionMarker(); + const marker2 = createSelectionMarker(); + + para1.segments.push(text1, marker1); + para2.segments.push(marker2, text2); + + runTest( + { + blockGroupType: 'Document', + blocks: [para1, para2], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [text1], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [marker1, text2], + }, + ], + }, + true, + {} + ); + }); + + it('Multiple paragraph, select text across lines', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + + para1.segments.push(text1, text2); + para2.segments.push(text3, text4); + + text2.isSelected = true; + text3.isSelected = true; + + runTest( + { + blockGroupType: 'Document', + blocks: [para1, para2], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [text1], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + text4, + ], + }, + ], + }, + true, + {} + ); + }); + + it('Empty paragraph in quote', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const quote = createFormatContainer('blockquote'); + const marker = createSelectionMarker(); + const br1 = createBr(); + const br2 = createBr(); + const br3 = createBr(); + + para1.segments.push(br1); + para2.segments.push(marker, br2); + para3.segments.push(br3); + quote.blocks.push(para2); + + runTest( + { + blockGroupType: 'Document', + blocks: [para1, quote, para3], + }, + false, + { + blockGroupType: 'Document', + blocks: [para1, para2, para3], + }, + true, + {} + ); + }); + + it('Empty paragraph in middle of quote', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const quote = createFormatContainer('blockquote'); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const br = createBr(); + const text3 = createText('test3'); + + para1.segments.push(text1); + para2.segments.push(marker, br); + para3.segments.push(text3); + quote.blocks.push(para1, para2, para3); + + runTest( + { + blockGroupType: 'Document', + blocks: [quote], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockGroupType: 'FormatContainer', + blockType: 'BlockGroup', + blocks: [para1], + format: {}, + tagName: 'blockquote', + }, + para2, + { + blockGroupType: 'FormatContainer', + blockType: 'BlockGroup', + blocks: [para3], + format: {}, + tagName: 'blockquote', + }, + ], + }, + true, + {} + ); + }); + + it('Empty paragraph in middle of quote, not empty', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const quote = createFormatContainer('blockquote'); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('text2'); + const text3 = createText('test3'); + + para1.segments.push(text1); + para2.segments.push(marker, text2); + para3.segments.push(text3); + quote.blocks.push(para1, para2, para3); + + runTest( + { + blockGroupType: 'Document', + blocks: [quote], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockGroupType: 'FormatContainer', + blockType: 'BlockGroup', + blocks: [ + para1, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [marker, text2], + }, + para3, + ], + format: {}, + tagName: 'blockquote', + }, + ], + }, + true, + {} + ); + }); + + it('Empty paragraph in middle of quote, shift', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const quote = createFormatContainer('blockquote'); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const br = createBr(); + const text3 = createText('test3'); + + para1.segments.push(text1); + para2.segments.push(marker, br); + para3.segments.push(text3); + quote.blocks.push(para1, para2, para3); + + runTest( + { + blockGroupType: 'Document', + blocks: [quote], + }, + true, + { + blockGroupType: 'Document', + blocks: [ + { + blockGroupType: 'FormatContainer', + blockType: 'BlockGroup', + blocks: [ + para1, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + para3, + ], + format: {}, + tagName: 'blockquote', + }, + ], + }, + true, + {} + ); + }); + + it('Single empty list item', () => { + const para1 = createParagraph(); + const listLevel = createListLevel('OL'); + const list1 = createListItem([listLevel]); + const marker = createSelectionMarker(); + const br = createBr(); + + para1.segments.push(marker, br); + list1.blocks.push(para1); + + runTest( + { + blockGroupType: 'Document', + blocks: [list1], + }, + false, + { + blockGroupType: 'Document', + blocks: [para1], + }, + true, + {} + ); + }); + + it('Single empty list item, shift', () => { + const para1 = createParagraph(); + const listLevel = createListLevel('OL'); + const list1 = createListItem([listLevel]); + const marker = createSelectionMarker(); + const br = createBr(); + + para1.segments.push(marker, br); + list1.blocks.push(para1); + + runTest( + { + blockGroupType: 'Document', + blocks: [list1], + }, + true, + { + blockGroupType: 'Document', + blocks: [ + { + blockGroupType: 'ListItem', + blockType: 'BlockGroup', + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [ + marker, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + ], + levels: [listLevel], + }, + ], + }, + true, + {} + ); + }); + + it('First empty list item', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const listLevel = createListLevel('OL'); + const list1 = createListItem([listLevel]); + const list2 = createListItem([listLevel]); + const list3 = createListItem([listLevel]); + const marker = createSelectionMarker(); + const br = createBr(); + const text2 = createText('test2'); + const text3 = createText('test3'); + + para1.segments.push(marker, br); + para2.segments.push(text2); + para3.segments.push(text3); + list1.blocks.push(para1); + list2.blocks.push(para2); + list3.blocks.push(para3); + + runTest( + { + blockGroupType: 'Document', + blocks: [list1, list2, list3], + }, + false, + { + blockGroupType: 'Document', + blocks: [para1, list2, list3], + }, + true, + {} + ); + }); + + it('List item with text', () => { + const para1 = createParagraph(); + const listLevel = createListLevel('OL'); + const list1 = createListItem([listLevel]); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(text1, marker, text2); + list1.blocks.push(para1); + + runTest( + { + blockGroupType: 'Document', + blocks: [list1], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + ], + levels: [listLevel], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, text2], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + dataset: {}, + format: { + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + ], + }, + true, + {} + ); + }); + + it('Selection across list items', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const listLevel = createListLevel('OL'); + const list1 = createListItem([listLevel]); + const list3 = createListItem([listLevel]); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + const text5 = createText('test5'); + + text2.isSelected = true; + text3.isSelected = true; + text4.isSelected = true; + + para1.segments.push(text1, text2); + para2.segments.push(text3); + para3.segments.push(text4, text5); + list1.blocks.push(para1); + list3.blocks.push(para3); + + runTest( + { + blockGroupType: 'Document', + blocks: [list1, para2, list3], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + ], + levels: [listLevel], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + text5, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + ], + }, + true, + {} + ); + }); + + it('Selection across list items, shift', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const listLevel = createListLevel('OL'); + const list1 = createListItem([listLevel]); + const list3 = createListItem([listLevel]); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + const text5 = createText('test5'); + + text2.isSelected = true; + text3.isSelected = true; + text4.isSelected = true; + + para1.segments.push(text1, text2); + para2.segments.push(text3); + para3.segments.push(text4, text5); + list1.blocks.push(para1); + list3.blocks.push(para3); + + runTest( + { + blockGroupType: 'Document', + blocks: [list1, para2, list3], + }, + true, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + text5, + ], + format: {}, + }, + ], + levels: [listLevel], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + ], + }, + true, + {} + ); + }); + + it('multiple blocks under list item', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const listLevel = createListLevel('OL'); + const list1 = createListItem([listLevel]); + const list2 = createListItem([listLevel]); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + + para1.segments.push(text1, marker, text2); + para2.segments.push(text3); + para3.segments.push(text4); + list1.blocks.push(para1, para2); + list2.blocks.push(para3); + + runTest( + { + blockGroupType: 'Document', + blocks: [list1, list2], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + ], + levels: [listLevel], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, text2], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [text3], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [text4], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: undefined, + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + ], + }, + true, + {} + ); + }); + + it('selection is in table', () => { + const para1 = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const td = createTableCell(); + const table = createTable(1); + + table.rows[0].cells.push(td); + td.blocks.push(para1); + para1.segments.push(text1, marker, text2); + + runTest( + { + blockGroupType: 'Document', + blocks: [table], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [marker, text2], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + }, + true, + {} + ); + }); + + it('selection is in table, under list', () => { + const para1 = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const td = createTableCell(); + const table = createTable(1); + + const listLevel = createListLevel('OL'); + const list = createListItem([listLevel]); + + table.rows[0].cells.push(td); + td.blocks.push(para1); + para1.segments.push(text1, marker, text2); + list.blocks.push(table); + + runTest( + { + blockGroupType: 'Document', + blocks: [list], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [marker, text2], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + levels: [{ listType: 'OL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + ], + }, + true, + {} + ); + }); + + it('selection is in table, under quote', () => { + const para1 = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const td = createTableCell(); + const table = createTable(1); + + const quote = createFormatContainer('blockquote'); + + table.rows[0].cells.push(td); + td.blocks.push(para1); + para1.segments.push(text1, marker, text2); + quote.blocks.push(table); + + runTest( + { + blockGroupType: 'Document', + blocks: [quote], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [marker, text2], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + format: {}, + tagName: 'blockquote', + }, + ], + }, + true, + {} + ); + }); + + it('selection across table 1', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + const td = createTableCell(); + const table = createTable(1); + + table.rows[0].cells.push(td); + td.blocks.push(para2); + para1.segments.push(text1, text2); + para2.segments.push(text3, text4); + + text2.isSelected = true; + text3.isSelected = true; + + runTest( + { + blockGroupType: 'Document', + blocks: [para1, table], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + }, + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [text4], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + }, + true, + {} + ); + }); + + it('selection across table 2', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + const td = createTableCell(); + const table = createTable(1); + + table.rows[0].cells.push(td); + td.blocks.push(para1); + para1.segments.push(text1, text2); + para2.segments.push(text3, text4); + + text2.isSelected = true; + text3.isSelected = true; + + runTest( + { + blockGroupType: 'Document', + blocks: [table, para2], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + { + blockType: 'Paragraph', + segments: [text4], + format: {}, + }, + ], + }, + true, + {} + ); + }); + + it('selection cover table', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + const text5 = createText('test5'); + const td = createTableCell(); + const table = createTable(1); + + table.rows[0].cells.push(td); + td.blocks.push(para2); + para1.segments.push(text1, text2); + para2.segments.push(text3); + para3.segments.push(text4, text5); + + text2.isSelected = true; + text3.isSelected = true; + text4.isSelected = true; + td.isSelected = true; + + runTest( + { + blockGroupType: 'Document', + blocks: [para1, table, para3], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + text5, + ], + format: {}, + }, + ], + }, + true, + {} + ); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/edit/utils/splitParagraphTest.ts b/packages/roosterjs-content-model-plugins/test/edit/utils/splitParagraphTest.ts new file mode 100644 index 00000000000..f810ee29904 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/edit/utils/splitParagraphTest.ts @@ -0,0 +1,99 @@ +import { ContentModelParagraph, InsertPoint } from 'roosterjs-content-model-types'; +import { splitParagraph } from '../../../lib/edit/utils/splitParagraph'; +import { + createBr, + createContentModelDocument, + createParagraph, + createSelectionMarker, + createText, +} from 'roosterjs-content-model-dom'; + +describe('splitParagraph', () => { + it('empty paragraph with selection marker and BR', () => { + const doc = createContentModelDocument(); + const marker = createSelectionMarker({ fontFamily: 'Arial' }); + const br = createBr(); + const para = createParagraph(false, { direction: 'ltr' }, { fontSize: '10pt' }); + const ip: InsertPoint = { + marker: marker, + paragraph: para, + path: [doc], + }; + + para.segments.push(marker, br); + doc.blocks.push(para); + + const result = splitParagraph(ip); + + const expectedResult: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [marker, br], + format: { direction: 'ltr' }, + segmentFormat: { fontSize: '10pt' }, + }; + + expect(result).toEqual(expectedResult); + expect(ip).toEqual({ + marker: marker, + paragraph: expectedResult, + path: [doc], + }); + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: { fontFamily: 'Arial' }, + }, + ], + format: { direction: 'ltr' }, + segmentFormat: { fontSize: '10pt', fontFamily: 'Arial' }, + }, + ], + }); + }); + + it('Paragraph with more segments', () => { + const doc = createContentModelDocument(); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const para = createParagraph(false); + const ip: InsertPoint = { + marker: marker, + paragraph: para, + path: [doc], + }; + + para.segments.push(text1, marker, text2); + doc.blocks.push(para); + + const result = splitParagraph(ip); + + const expectedResult: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [marker, text2], + format: {}, + }; + + expect(result).toEqual(expectedResult); + expect(ip).toEqual({ + marker: marker, + paragraph: expectedResult, + path: [doc], + }); + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + ], + }); + }); +});