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-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-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-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-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index 91c23a861e6..5ee5b9232c6 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,11 @@ export class EditPlugin implements EditorPlugin { break; case 'Enter': - keyboardEnter(editor, rawEvent); + if (this.handleEnterKey) { + keyboardEnter(editor, rawEvent); + } else { + keyboardInput(editor, rawEvent); + } break; default: 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..b3a21c69d40 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,66 +19,54 @@ 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; const index = getClosestAncestorBlockGroupIndex( path, - ['FormatContainer', 'ListItem'], - ['TableCell'] + ['FormatContainer'], + ['TableCell', 'ListItem'] ); const quote = path[index]; 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<ContentModelFormatContainer>(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/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/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<string, WordMetadata> = 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/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 4ca88a12128..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<string, any>; 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,6 +34,7 @@ describe('EditPlugin', () => { ({ type: -1, } as any), // Force return invalid range to go through content model code + isExperimentalFeatureEnabled: isExperimentalFeatureEnabledSpy, } as any) as IEditor; }); @@ -40,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', () => { @@ -60,6 +68,8 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Delete', () => { @@ -75,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', () => { @@ -90,6 +102,8 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).not.toHaveBeenCalled(); expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Tab', () => { @@ -106,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', () => { @@ -124,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', () => { @@ -138,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', () => { @@ -174,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/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/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], + }); + }); +}); 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: {}, + }, + ], + }); + }); +}); 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 = '<p style="line-height:102%">Test</p>'; 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 = '<p style="line-height:122%">Test</p>'; 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', 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; }