From 48855d111dd20e67f7d763a5556d595da504c0bc Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 6 Jun 2024 11:30:23 -0700 Subject: [PATCH] Let Content Model handle ENTER key (#2610) * KeyboardEnter * fix comment * fix test * improve * Scroll caret into view when call formatContentModel * scroll caret into view * Fix build * improve * fix build * Improve * fix test * add test * add test * improve * do not scroll caret into view for now * scroll the view when press enter if necessary --- .../lib/editor/Editor.ts | 9 + .../test/editor/EditorTest.ts | 32 + .../roosterjs-content-model-dom/lib/index.ts | 4 +- .../lib/modelApi/editing/deleteSelection.ts | 17 +- .../lib/modelApi/editing/runEditSteps.ts | 25 + .../lib/edit/EditPlugin.ts | 7 + .../lib/edit/deleteSteps/deleteEmptyQuote.ts | 95 +- .../lib/edit/inputSteps/handleEnterOnList.ts | 133 +- .../edit/inputSteps/handleEnterOnParagraph.ts | 21 + .../lib/edit/keyboardEnter.ts | 54 + .../lib/edit/keyboardInput.ts | 27 +- .../lib/edit/utils/splitParagraph.ts | 46 + .../test/edit/EditPluginTest.ts | 64 + .../edit/deleteSteps/deleteEmptyQuoteTest.ts | 163 +- .../edit/inputSteps/handleEnterOnListTest.ts | 498 ++++-- .../inputSteps/handleEnterOnParagraphTest.ts | 102 ++ .../test/edit/keyboardEnterTest.ts | 1418 +++++++++++++++++ .../test/edit/keyboardInputTest.ts | 81 - .../test/edit/utils/splitParagraphTest.ts | 99 ++ .../lib/editor/IEditor.ts | 7 + 20 files changed, 2446 insertions(+), 456 deletions(-) create mode 100644 packages/roosterjs-content-model-dom/lib/modelApi/editing/runEditSteps.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnParagraph.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts create mode 100644 packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnParagraphTest.ts 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-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/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/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index 90bbd13f242..d57626c233c 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -104,8 +104,6 @@ export { cacheGetEventData } from './domUtils/event/cacheGetEventData'; export { isBlockGroupOfType } from './modelApi/typeCheck/isBlockGroupOfType'; -export { getClosestAncestorBlockGroupIndex } from './modelApi/editing/getClosestAncestorBlockGroupIndex'; - export { iterateSelections } from './modelApi/selection/iterateSelections'; export { getFirstSelectedListItem, @@ -136,6 +134,8 @@ export { setTableCellBackgroundColor } from './modelApi/editing/setTableCellBack export { retrieveModelFormatState } from './modelApi/editing/retrieveModelFormatState'; export { getListStyleTypeFromString } from './modelApi/editing/getListStyleTypeFromString'; export { getSegmentTextFormat } from './modelApi/editing/getSegmentTextFormat'; +export { getClosestAncestorBlockGroupIndex } from './modelApi/editing/getClosestAncestorBlockGroupIndex'; +export { runEditSteps } from './modelApi/editing/runEditSteps'; export { updateImageMetadata, getImageMetadata } from './modelApi/metadata/updateImageMetadata'; export { diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection.ts index adcd718f964..c97057efb06 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection.ts @@ -1,12 +1,12 @@ import { deleteExpandedSelection } from './deleteExpandedSelection'; import { mutateBlock } from '../common/mutate'; +import { runEditSteps } from './runEditSteps'; import type { DeleteSelectionContext, DeleteSelectionResult, DeleteSelectionStep, FormatContentModelContext, ReadonlyContentModelDocument, - ValidDeleteSelectionContext, } from 'roosterjs-content-model-types'; /** @@ -22,23 +22,16 @@ export function deleteSelection( formatContext?: FormatContentModelContext ): DeleteSelectionResult { const context = deleteExpandedSelection(model, formatContext); + const steps = additionalSteps.filter( + (x: DeleteSelectionStep | null): x is DeleteSelectionStep => !!x + ); - additionalSteps.forEach(step => { - if (step && isValidDeleteSelectionContext(context)) { - step(context); - } - }); + runEditSteps(steps, context); mergeParagraphAfterDelete(context); return context; } -function isValidDeleteSelectionContext( - context: DeleteSelectionContext -): context is ValidDeleteSelectionContext { - return !!context.insertPoint; -} - // If we end up with multiple paragraphs impacted, we need to merge them function mergeParagraphAfterDelete(context: DeleteSelectionContext) { const { insertPoint, deleteResult, lastParagraph, lastTableContext } = context; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/runEditSteps.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/runEditSteps.ts new file mode 100644 index 00000000000..368e97fcbd5 --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/runEditSteps.ts @@ -0,0 +1,25 @@ +import type { + DeleteSelectionContext, + DeleteSelectionResult, + DeleteSelectionStep, + ValidDeleteSelectionContext, +} from 'roosterjs-content-model-types'; + +/** + * Run editing steps on top of a given context object which includes current insert point and previous editing result + * @param steps The editing steps to run + * @param context Context for the editing steps. + */ +export function runEditSteps(steps: DeleteSelectionStep[], context: DeleteSelectionResult) { + steps.forEach(step => { + if (step && isValidDeleteSelectionContext(context)) { + step(context); + } + }); +} + +function isValidDeleteSelectionContext( + context: DeleteSelectionContext +): context is ValidDeleteSelectionContext { + return !!context.insertPoint; +} diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index 190f95bc4e3..add783a2ab1 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -1,4 +1,5 @@ import { keyboardDelete } from './keyboardDelete'; +import { keyboardEnter } from './keyboardEnter'; import { keyboardInput } from './keyboardInput'; import { keyboardTab } from './keyboardTab'; import { parseTableCells } from 'roosterjs-content-model-dom'; @@ -25,6 +26,7 @@ export class EditPlugin implements EditorPlugin { private disposer: (() => void) | null = null; private shouldHandleNextInputEvent = false; private selectionAfterDelete: DOMSelection | null = null; + private handleNormalEnter = false; /** * Get name of this plugin @@ -41,6 +43,8 @@ export class EditPlugin implements EditorPlugin { */ initialize(editor: IEditor) { this.editor = editor; + this.handleNormalEnter = this.editor.isExperimentalFeatureEnabled('PersistCache'); + if (editor.getEnvironment().isAndroid) { this.disposer = this.editor.attachDomEvent({ beforeinput: { @@ -153,6 +157,9 @@ export class EditPlugin implements EditorPlugin { break; case 'Enter': + keyboardEnter(editor, rawEvent, this.handleNormalEnter); + break; + default: 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 86406c0e402..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,15 +1,16 @@ import { - createParagraph, - createSelectionMarker, unwrapBlock, getClosestAncestorBlockGroupIndex, - isBlockGroupOfType, + createFormatContainer, mutateBlock, } from 'roosterjs-content-model-dom'; import type { - ContentModelFormatContainer, DeleteSelectionStep, ReadonlyContentModelBlockGroup, + ReadonlyContentModelFormatContainer, + ReadonlyContentModelParagraph, + ShallowMutableContentModelFormatContainer, + ShallowMutableContentModelParagraph, } from 'roosterjs-content-model-types'; /** @@ -17,72 +18,86 @@ import type { */ export const deleteEmptyQuote: DeleteSelectionStep = context => { const { deleteResult } = context; + if ( deleteResult == 'nothingToDelete' || deleteResult == 'notDeleted' || deleteResult == 'range' ) { const { insertPoint, formatContext } = context; - const { path } = insertPoint; + 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(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) && rawEvent?.key === 'Enter') { - insertNewLine(blockQuote, parent, quoteBlockIndex); - 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: ContentModelFormatContainer) => { - const quoteLength = quote.blocks.length; - const lastParagraph = quote.blocks[quoteLength - 1]; - if (lastParagraph && lastParagraph.blockType === 'Paragraph') { - return lastParagraph.segments.every( - s => s.segmentType === 'SelectionMarker' || s.segmentType === 'Br' - ); - } +const isEmptyParagraph = (paragraph: ReadonlyContentModelParagraph) => { + return paragraph.segments.every( + s => s.segmentType === 'SelectionMarker' || s.segmentType === 'Br' + ); }; const insertNewLine = ( - quote: ContentModelFormatContainer, + quote: ShallowMutableContentModelFormatContainer, parent: ReadonlyContentModelBlockGroup, - index: number + quoteIndex: number, + paragraph: ShallowMutableContentModelParagraph ) => { - const quoteLength = quote.blocks.length; - mutateBlock(quote).blocks.splice(quoteLength - 1, 1); - const marker = createSelectionMarker(); - const newParagraph = createParagraph(false /* isImplicit */); - newParagraph.segments.push(marker); - mutateBlock(parent).blocks.splice(index + 1, 0, newParagraph); + const paraIndex = quote.blocks.indexOf(paragraph); + + if (paraIndex >= 0) { + const mutableParent = mutateBlock(parent); + + if (paraIndex < quote.blocks.length - 1) { + const newQuote: ShallowMutableContentModelFormatContainer = createFormatContainer( + quote.tagName, + quote.format + ); + + newQuote.blocks.push( + ...quote.blocks.splice(paraIndex + 1, quote.blocks.length - paraIndex - 1) + ); + + mutableParent.blocks.splice(quoteIndex + 1, 0, newQuote); + } + + mutableParent.blocks.splice(quoteIndex + 1, 0, paragraph); + quote.blocks.splice(paraIndex, 1); + + if (quote.blocks.length == 0) { + mutableParent.blocks.splice(quoteIndex, 0); + } + } }; 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 811a26b7527..5a7205a2700 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -1,26 +1,18 @@ import { getListAnnounceData } from 'roosterjs-content-model-api'; +import { splitParagraph } from '../utils/splitParagraph'; import { - createBr, createListItem, createListLevel, - createParagraph, - createSelectionMarker, - normalizeContentModel, - normalizeParagraph, - setParagraphNotImplicit, getClosestAncestorBlockGroupIndex, isBlockGroupOfType, mutateBlock, - mutateSegment, } from 'roosterjs-content-model-dom'; import type { ContentModelListItem, DeleteSelectionStep, - InsertPoint, ReadonlyContentModelBlockGroup, ReadonlyContentModelListItem, ShallowMutableContentModelListItem, - ShallowMutableContentModelParagraph, ValidDeleteSelectionContext, } from 'roosterjs-content-model-types'; @@ -28,75 +20,51 @@ import type { * @internal */ export const handleEnterOnList: DeleteSelectionStep = context => { - const { deleteResult } = context; - if ( - deleteResult == 'nothingToDelete' || - deleteResult == 'notDeleted' || - deleteResult == 'range' - ) { - const { insertPoint, formatContext } = context; + const { deleteResult, insertPoint } = context; + + if (deleteResult == 'notDeleted' || deleteResult == 'nothingToDelete') { const { path } = insertPoint; - const rawEvent = formatContext?.rawEvent; - const index = getClosestAncestorBlockGroupIndex(path, ['ListItem'], ['TableCell']); + const index = getClosestAncestorBlockGroupIndex( + path, + ['ListItem'], + ['TableCell', 'FormatContainer'] + ); const readonlyListItem = path[index]; const listParent = path[index + 1]; - if (readonlyListItem && readonlyListItem.blockGroupType === 'ListItem' && listParent) { - const listItem = mutateBlock(readonlyListItem); + if (readonlyListItem?.blockGroupType === 'ListItem' && listParent) { + let listItem = mutateBlock(readonlyListItem); + + if (isEmptyListItem(listItem)) { + listItem.levels.pop(); + } else { + listItem = createNewListItem(context, listItem, listParent); + + if (context.formatContext) { + context.formatContext.announceData = getListAnnounceData([ + listItem, + ...path.slice(index + 1), + ]); + } + } + const listIndex = listParent.blocks.indexOf(listItem); const nextBlock = listParent.blocks[listIndex + 1]; - if (deleteResult == 'range' && nextBlock) { - normalizeContentModel(listParent); - + if (nextBlock) { const nextListItem = listParent.blocks[listIndex + 1]; if ( isBlockGroupOfType(nextListItem, 'ListItem') && nextListItem.levels[0] ) { - nextListItem.levels.forEach((level, index) => { + nextListItem.levels.forEach(level => { level.format.startNumberOverride = undefined; - level.dataset = listItem.levels[index] - ? listItem.levels[index].dataset - : {}; }); - - const lastParagraph = listItem.blocks[listItem.blocks.length - 1]; - const nextParagraph = nextListItem.blocks[0]; - - if ( - nextParagraph.blockType === 'Paragraph' && - lastParagraph.blockType === 'Paragraph' && - lastParagraph.segments[lastParagraph.segments.length - 1].segmentType === - 'SelectionMarker' - ) { - mutateBlock(lastParagraph).segments.pop(); - - nextParagraph.segments.unshift( - createSelectionMarker(insertPoint.marker.format) - ); - } - - context.lastParagraph = undefined; - } - } else if (deleteResult !== 'range') { - if (isEmptyListItem(listItem)) { - mutateBlock(listItem).levels.pop(); - } else { - const newListItem = createNewListItem(context, listItem, listParent); - - if (context.formatContext) { - context.formatContext.announceData = getListAnnounceData([ - newListItem, - ...path.slice(index + 1), - ]); - } } } - rawEvent?.preventDefault(); context.deleteResult = 'range'; } } @@ -119,18 +87,33 @@ const createNewListItem = ( ) => { const { insertPoint } = context; const listIndex = listParent.blocks.indexOf(listItem); - const newParagraph = createNewParagraph(insertPoint); + const currentPara = insertPoint.paragraph; + const paraIndex = listItem.blocks.indexOf(currentPara); + const newParagraph = splitParagraph(insertPoint); const levels = createNewListLevel(listItem); const newListItem: ShallowMutableContentModelListItem = createListItem( 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; - context.lastParagraph = newParagraph; mutateBlock(listParent).blocks.splice(listIndex + 1, 0, newListItem); + if (context.lastParagraph == currentPara) { + context.lastParagraph = newParagraph; + } + return newListItem; }; @@ -147,31 +130,3 @@ const createNewListLevel = (listItem: ReadonlyContentModelListItem) => { ); }); }; - -const createNewParagraph = (insertPoint: InsertPoint) => { - const { paragraph, marker } = insertPoint; - const newParagraph: ShallowMutableContentModelParagraph = createParagraph( - false /*isImplicit*/, - paragraph.format, - paragraph.segmentFormat - ); - - mutateSegment(paragraph, marker, (marker, paragraph, markerIndex) => { - const segments = paragraph.segments.splice( - markerIndex, - paragraph.segments.length - markerIndex - ); - - newParagraph.segments.push(...segments); - - setParagraphNotImplicit(paragraph); - - if (paragraph.segments.every(x => x.segmentType == 'SelectionMarker')) { - paragraph.segments.push(createBr(marker.format)); - } - - normalizeParagraph(newParagraph); - }); - - return newParagraph; -}; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnParagraph.ts b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnParagraph.ts new file mode 100644 index 00000000000..e70318cd375 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnParagraph.ts @@ -0,0 +1,21 @@ +import { mutateBlock } from 'roosterjs-content-model-dom'; +import { splitParagraph } from '../utils/splitParagraph'; +import type { DeleteSelectionStep } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export const handleEnterOnParagraph: DeleteSelectionStep = context => { + const { paragraph, path } = context.insertPoint; + const paraIndex = path[0]?.blocks.indexOf(paragraph) ?? -1; + + if (context.deleteResult == 'notDeleted' && paraIndex >= 0) { + const newPara = splitParagraph(context.insertPoint); + + mutateBlock(path[0]).blocks.splice(paraIndex + 1, 0, newPara); + + context.deleteResult = 'range'; + context.lastParagraph = newPara; + context.insertPoint.paragraph = newPara; + } +}; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts new file mode 100644 index 00000000000..ef765e5e231 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts @@ -0,0 +1,54 @@ +import { deleteEmptyQuote } from './deleteSteps/deleteEmptyQuote'; +import { deleteSelection, normalizeContentModel, runEditSteps } from 'roosterjs-content-model-dom'; +import { handleEnterOnList } from './inputSteps/handleEnterOnList'; +import { handleEnterOnParagraph } from './inputSteps/handleEnterOnParagraph'; +import type { IEditor } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function keyboardEnter( + editor: IEditor, + rawEvent: KeyboardEvent, + handleNormalEnter: boolean +) { + const selection = editor.getDOMSelection(); + + editor.formatContentModel( + (model, context) => { + // 1. delete the expanded selection if any, then merge paragraph + const result = deleteSelection(model, [], context); + + // 2. Add line break + if (selection && selection.type != 'table') { + // For ENTER key, although we may have deleted something, since we still need to split the line, we always treat it as not delete + // so further delete steps can keep working + result.deleteResult = 'notDeleted'; + + const steps = rawEvent.shiftKey ? [] : [handleEnterOnList, deleteEmptyQuote]; + + if (handleNormalEnter) { + steps.push(handleEnterOnParagraph); + } + + runEditSteps(steps, result); + } + + if (result.deleteResult == 'range') { + // We have deleted something, next input should inherit the segment format from deleted content, so set pending format here + context.newPendingFormat = result.insertPoint?.marker.format; + + normalizeContentModel(model); + + rawEvent.preventDefault(); + return true; + } else { + return false; + } + }, + { + rawEvent, + scrollCaretIntoView: true, + } + ); +} diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 91ecf99647e..fe8dc9e7bfc 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -1,6 +1,4 @@ -import { deleteEmptyQuote } from './deleteSteps/deleteEmptyQuote'; import { deleteSelection, isModifierKey, normalizeContentModel } from 'roosterjs-content-model-dom'; -import { handleEnterOnList } from './inputSteps/handleEnterOnList'; import type { DOMSelection, IEditor } from 'roosterjs-content-model-types'; /** @@ -14,11 +12,7 @@ export function keyboardInput(editor: IEditor, rawEvent: KeyboardEvent) { editor.formatContentModel( (model, context) => { - const result = deleteSelection(model, getInputSteps(selection, rawEvent), context); - - // We have deleted selection then we will let browser to handle the input. - // With this combined operation, we don't wan to mass up the cached model so clear it - context.clearModelCache = true; + const result = deleteSelection(model, [], context); // Skip undo snapshot here and add undo snapshot before the operation so that we don't add another undo snapshot in middle of this replace operation context.skipUndoSnapshot = true; @@ -45,27 +39,12 @@ export function keyboardInput(editor: IEditor, rawEvent: KeyboardEvent) { } } -function getInputSteps(selection: DOMSelection | null, rawEvent: KeyboardEvent) { - return shouldHandleEnterKey(selection, rawEvent) ? [handleEnterOnList, deleteEmptyQuote] : []; -} - function shouldInputWithContentModel(selection: DOMSelection | null, rawEvent: KeyboardEvent) { if (!selection) { return false; // Nothing to delete - } else if ( - !isModifierKey(rawEvent) && - (rawEvent.key == 'Enter' || rawEvent.key == 'Space' || rawEvent.key.length == 1) - ) { - return ( - selection.type != 'range' || - !selection.range.collapsed || - shouldHandleEnterKey(selection, rawEvent) - ); + } else if (!isModifierKey(rawEvent) && (rawEvent.key == 'Space' || rawEvent.key.length == 1)) { + return selection.type != 'range' || !selection.range.collapsed; } else { return false; } } - -const shouldHandleEnterKey = (selection: DOMSelection | null, rawEvent: KeyboardEvent) => { - return selection && selection.type == 'range' && rawEvent.key == 'Enter' && !rawEvent.shiftKey; -}; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts b/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts new file mode 100644 index 00000000000..963f9865f3e --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts @@ -0,0 +1,46 @@ +import { + createBr, + createParagraph, + normalizeParagraph, + setParagraphNotImplicit, +} from 'roosterjs-content-model-dom'; +import type { + InsertPoint, + ShallowMutableContentModelParagraph, +} from 'roosterjs-content-model-types'; + +/** + * @internal + * Split the given paragraph from insert point into two paragraphs, + * and move the selection marker to the beginning of the second paragraph + * @param insertPoint The input insert point which includes the paragraph and selection marker + * @returns The new paragraph it created + */ +export function splitParagraph(insertPoint: InsertPoint) { + const { paragraph, marker } = insertPoint; + const newParagraph: ShallowMutableContentModelParagraph = createParagraph( + false /*isImplicit*/, + paragraph.format, + paragraph.segmentFormat + ); + + const markerIndex = paragraph.segments.indexOf(marker); + const segments = paragraph.segments.splice( + markerIndex, + paragraph.segments.length - markerIndex + ); + + if (paragraph.segments.length == 0) { + paragraph.segments.push(createBr(marker.format)); + } + + newParagraph.segments.push(...segments); + + setParagraphNotImplicit(paragraph); + + insertPoint.paragraph = newParagraph; + + normalizeParagraph(paragraph); + + return newParagraph; +} diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 4ca88a12128..9296ddbc17a 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,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, normal enter 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).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, false); + expect(keyboardTabSpy).not.toHaveBeenCalled(); + }); + + it('Enter, normal enter 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, true); + 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 fbe4719c2fe..ab1f69fe948 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteEmptyQuoteTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteEmptyQuoteTest.ts @@ -1,8 +1,6 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; import { deleteEmptyQuote } from '../../../lib/edit/deleteSteps/deleteEmptyQuote'; import { deleteSelection, normalizeContentModel } from 'roosterjs-content-model-dom'; -import { editingTestCommon } from '../editingTestCommon'; -import { keyboardInput } from '../../../lib/edit/keyboardInput'; describe('deleteEmptyQuote', () => { function runTest( @@ -128,50 +126,7 @@ describe('deleteEmptyQuote', () => { ], format: {}, }; - runTest(model, model, 'notDeleted'); - }); -}); - -describe('deleteEmptyQuote - keyboardInput', () => { - function runTest( - input: ContentModelDocument, - key: string, - expectedResult: ContentModelDocument, - doNotCallDefaultFormat: boolean = false, - calledTimes: number = 1 - ) { - const preventDefault = jasmine.createSpy('preventDefault'); - const mockedEvent = ({ - key: key, - shiftKey: false, - preventDefault, - } as any) as KeyboardEvent; - - let editor: any; - - editingTestCommon( - undefined, - newEditor => { - editor = newEditor; - - editor.getDOMSelection = () => ({ - type: 'range', - range: { - collapsed: true, - }, - }); - - keyboardInput(editor, mockedEvent); - }, - input, - expectedResult, - calledTimes, - doNotCallDefaultFormat - ); - } - - it('should delete empty quote when press Enter', () => { - const model: ContentModelDocument = { + const expectedModel: ContentModelDocument = { blockGroupType: 'Document', blocks: [ { @@ -183,20 +138,22 @@ describe('deleteEmptyQuote - keyboardInput', () => { blockType: 'Paragraph', segments: [ { - segmentType: 'SelectionMarker', - isSelected: true, + segmentType: 'Text', + text: 'test', format: { textColor: 'rgb(102, 102, 102)', }, }, { - segmentType: 'Br', + segmentType: 'SelectionMarker', + isSelected: true, format: { textColor: 'rgb(102, 102, 102)', }, }, ], format: {}, + segmentFormat: { textColor: 'rgb(102, 102, 102)' }, }, ], format: { @@ -211,37 +168,31 @@ describe('deleteEmptyQuote - keyboardInput', () => { ], format: {}, }; - const expectedTestModel: ContentModelDocument = { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - textColor: 'rgb(102, 102, 102)', - }, - }, - { - segmentType: 'Br', - format: { - textColor: 'rgb(102, 102, 102)', - }, - }, - ], - segmentFormat: { textColor: 'rgb(102, 102, 102)' }, - format: {}, - }, - ], - format: {}, - }; - - runTest(model, 'Enter', expectedTestModel); + 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); + } - it('should exit quote when press Enter', () => { const model: ContentModelDocument = { blockGroupType: 'Document', blocks: [ @@ -262,9 +213,6 @@ describe('deleteEmptyQuote - keyboardInput', () => { }, ], format: {}, - segmentFormat: { - textColor: 'rgb(102, 102, 102)', - }, }, { blockType: 'Paragraph', @@ -276,17 +224,21 @@ describe('deleteEmptyQuote - keyboardInput', () => { textColor: 'rgb(102, 102, 102)', }, }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ { - segmentType: 'Br', + segmentType: 'Text', + text: 'test', format: { textColor: 'rgb(102, 102, 102)', }, }, ], format: {}, - segmentFormat: { - textColor: 'rgb(102, 102, 102)', - }, }, ], format: { @@ -301,7 +253,8 @@ describe('deleteEmptyQuote - keyboardInput', () => { ], format: {}, }; - const expectedTestModel: ContentModelDocument = { + + const expectedModel: ContentModelDocument = { blockGroupType: 'Document', blocks: [ { @@ -321,9 +274,7 @@ describe('deleteEmptyQuote - keyboardInput', () => { }, ], format: {}, - segmentFormat: { - textColor: 'rgb(102, 102, 102)', - }, + segmentFormat: { textColor: 'rgb(102, 102, 102)' }, }, ], format: { @@ -341,26 +292,20 @@ describe('deleteEmptyQuote - keyboardInput', () => { { segmentType: 'SelectionMarker', isSelected: true, - format: {}, + format: { + textColor: 'rgb(102, 102, 102)', + }, }, { segmentType: 'Br', - format: {}, + format: { + textColor: 'rgb(102, 102, 102)', + }, }, ], format: {}, + segmentFormat: { textColor: 'rgb(102, 102, 102)' }, }, - ], - format: {}, - }; - - runTest(model, 'Enter', expectedTestModel); - }); - - it('should not exit quote when press Enter', () => { - const model: ContentModelDocument = { - blockGroupType: 'Document', - blocks: [ { blockType: 'BlockGroup', blockGroupType: 'FormatContainer', @@ -376,18 +321,9 @@ describe('deleteEmptyQuote - keyboardInput', () => { textColor: 'rgb(102, 102, 102)', }, }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - textColor: 'rgb(102, 102, 102)', - }, - }, ], format: {}, - segmentFormat: { - textColor: 'rgb(102, 102, 102)', - }, + segmentFormat: { textColor: 'rgb(102, 102, 102)' }, }, ], format: { @@ -402,7 +338,6 @@ describe('deleteEmptyQuote - keyboardInput', () => { ], format: {}, }; - - runTest(model, 'Enter', model, false, 0); + 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 f1d394a1c40..81aeb2f74ca 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -2,7 +2,7 @@ import * as getListAnnounceData from 'roosterjs-content-model-api/lib/modelApi/l import { deleteSelection, normalizeContentModel } from 'roosterjs-content-model-dom'; import { editingTestCommon } from '../editingTestCommon'; import { handleEnterOnList } from '../../../lib/edit/inputSteps/handleEnterOnList'; -import { keyboardInput } from '../../../lib/edit/keyboardInput'; +import { keyboardEnter } from '../../../lib/edit/keyboardEnter'; import { ContentModelDocument, ContentModelListItem, @@ -30,7 +30,11 @@ describe('handleEnterOnList', () => { newEntities: [], newImages: [], }; - const result = deleteSelection(model, [handleEnterOnList], context); + const result = deleteSelection( + model, + [context => (context.deleteResult = 'notDeleted'), handleEnterOnList], + context + ); normalizeContentModel(model); expect(model).toEqual(expectedModel); @@ -961,6 +965,48 @@ describe('handleEnterOnList', () => { ], format: {}, }; + const listItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }; const expectedModel: ContentModelDocument = { blockGroupType: 'Document', blocks: [ @@ -971,11 +1017,6 @@ describe('handleEnterOnList', () => { { blockType: 'Paragraph', segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, { segmentType: 'Br', format: {}, @@ -1004,6 +1045,7 @@ describe('handleEnterOnList', () => { }, format: {}, }, + listItem, { blockType: 'BlockGroup', blockGroupType: 'ListItem', @@ -1044,7 +1086,7 @@ describe('handleEnterOnList', () => { ], format: {}, }; - runTest(model, expectedModel, 'range', null); + runTest(model, expectedModel, 'range', listItem); }); it('enter on multiple list items with selected text', () => { @@ -1210,6 +1252,48 @@ describe('handleEnterOnList', () => { ], format: {}, }; + const listItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }; const expectedModel: ContentModelDocument = { blockGroupType: 'Document', blocks: [ @@ -1256,11 +1340,6 @@ describe('handleEnterOnList', () => { { blockType: 'Paragraph', segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, { segmentType: 'Br', format: {}, @@ -1289,6 +1368,7 @@ describe('handleEnterOnList', () => { }, format: {}, }, + listItem, { blockType: 'BlockGroup', blockGroupType: 'ListItem', @@ -1312,7 +1392,6 @@ describe('handleEnterOnList', () => { marginTop: '0px', marginBottom: '0px', listStyleType: 'decimal', - startNumberOverride: undefined, }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -1329,7 +1408,7 @@ describe('handleEnterOnList', () => { ], format: {}, }; - runTest(model, expectedModel, 'range', null); + runTest(model, expectedModel, 'range', listItem); }); it('expanded range mixed list with paragraph', () => { @@ -1436,45 +1515,52 @@ describe('handleEnterOnList', () => { ], format: {}, }; - const expectedModel: ContentModelDocument = { - blockGroupType: 'Document', + const listItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', blocks: [ { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ + blockType: 'Paragraph', + segments: [ { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'te', - format: {}, - }, - ], + segmentType: 'SelectionMarker', + isSelected: true, format: {}, }, - ], - levels: [ { - listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - listStyleType: 'decimal', - }, - dataset: { - editingInfo: '{"orderedStyleType":1}', - }, + segmentType: 'Text', + text: 'st', + format: {}, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: {}, - }, format: {}, }, + ], + levels: [ + { + listType: 'OL', + format: { + marginTop: '0px', + marginBottom: '0px', + listStyleType: 'decimal', + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":1}', + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ { blockType: 'BlockGroup', blockGroupType: 'ListItem', @@ -1482,14 +1568,9 @@ describe('handleEnterOnList', () => { { blockType: 'Paragraph', segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, { segmentType: 'Text', - text: 'st', + text: 'te', format: {}, }, ], @@ -1503,7 +1584,6 @@ describe('handleEnterOnList', () => { marginTop: '0px', marginBottom: '0px', listStyleType: 'decimal', - startNumberOverride: undefined, }, dataset: { editingInfo: '{"orderedStyleType":1}', @@ -1517,10 +1597,11 @@ describe('handleEnterOnList', () => { }, format: {}, }, + listItem, ], format: {}, }; - runTest(model, expectedModel, 'range', null); + runTest(model, expectedModel, 'range', listItem); }); it('expanded range with mixed list with paragraph | different styles', () => { @@ -1628,46 +1709,51 @@ describe('handleEnterOnList', () => { ], format: {}, }; - const expectedModel: ContentModelDocument = { - blockGroupType: 'Document', + const listItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', blocks: [ { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ + blockType: 'Paragraph', + segments: [ { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'te', - format: {}, - }, - ], + segmentType: 'SelectionMarker', + isSelected: true, format: {}, }, - ], - levels: [ { - listType: 'OL', - format: { - marginTop: '0px', - marginBottom: '0px', - }, - dataset: { - editingInfo: '{"orderedStyleType":3}', - }, + segmentType: 'Text', + text: 'st', + format: {}, }, ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: false, - format: {}, - }, + format: {}, + }, + ], + levels: [ + { + listType: 'OL', format: { - listStyleType: '"1) "', + marginTop: '0px', + marginBottom: '0px', + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + dataset: { + editingInfo: '{"orderedStyleType":3}', }, }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }; + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ { blockType: 'BlockGroup', blockGroupType: 'ListItem', @@ -1675,14 +1761,9 @@ describe('handleEnterOnList', () => { { blockType: 'Paragraph', segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, { segmentType: 'Text', - text: 'st', + text: 'te', format: {}, }, ], @@ -1695,8 +1776,6 @@ describe('handleEnterOnList', () => { format: { marginTop: '0px', marginBottom: '0px', - startNumberOverride: undefined, - listStyleType: 'lower-alpha', }, dataset: { editingInfo: '{"orderedStyleType":3}', @@ -1708,16 +1787,19 @@ describe('handleEnterOnList', () => { isSelected: false, format: {}, }, - format: {}, + format: { + listStyleType: '"1) "', + }, }, + listItem, ], format: {}, }; - runTest(model, expectedModel, 'range', null); + runTest(model, expectedModel, 'range', listItem); }); }); -describe(' handleEnterOnList - keyboardInput', () => { +describe('handleEnterOnList - keyboardEnter', () => { function runTest( input: ContentModelDocument, isShiftKey: boolean, @@ -1746,7 +1828,7 @@ describe(' handleEnterOnList - keyboardInput', () => { }, }); - keyboardInput(editor, mockedEvent); + keyboardEnter(editor, mockedEvent, true); }, input, expectedResult, @@ -2312,11 +2394,21 @@ describe(' handleEnterOnList - keyboardInput', () => { text: 'test', format: {}, }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ { segmentType: 'SelectionMarker', isSelected: true, format: {}, }, + { + segmentType: 'Br', + format: {}, + }, ], format: {}, }, @@ -2344,6 +2436,224 @@ describe(' handleEnterOnList - keyboardInput', () => { ], format: {}, }; - runTest(input, true, expected, true, 0); + 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..8bfe3820021 --- /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, true); + + 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/keyboardInputTest.ts b/packages/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts index 3a4d6ef809d..b2435499c9f 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts @@ -1,7 +1,5 @@ import * as deleteSelection from 'roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import { deleteEmptyQuote } from '../../lib/edit/deleteSteps/deleteEmptyQuote'; -import { handleEnterOnList } from '../../lib/edit/inputSteps/handleEnterOnList'; import { keyboardInput } from '../../lib/edit/keyboardInput'; import { ContentModelDocument, @@ -107,7 +105,6 @@ describe('keyboardInput', () => { deletedEntities: [], newEntities: [], newImages: [], - clearModelCache: true, skipUndoSnapshot: true, }); expect(normalizeContentModelSpy).not.toHaveBeenCalled(); @@ -139,7 +136,6 @@ describe('keyboardInput', () => { deletedEntities: [], newEntities: [], newImages: [], - clearModelCache: true, skipUndoSnapshot: true, newPendingFormat: undefined, }); @@ -169,7 +165,6 @@ describe('keyboardInput', () => { deletedEntities: [], newEntities: [], newImages: [], - clearModelCache: true, skipUndoSnapshot: true, newPendingFormat: undefined, }); @@ -199,7 +194,6 @@ describe('keyboardInput', () => { deletedEntities: [], newEntities: [], newImages: [], - clearModelCache: true, skipUndoSnapshot: true, newPendingFormat: undefined, }); @@ -285,7 +279,6 @@ describe('keyboardInput', () => { deletedEntities: [], newEntities: [], newImages: [], - clearModelCache: true, skipUndoSnapshot: true, newPendingFormat: undefined, }); @@ -319,36 +312,6 @@ describe('keyboardInput', () => { expect(normalizeContentModelSpy).not.toHaveBeenCalled(); }); - it('Enter input, table selection, no modifier key, deleteSelection returns range', () => { - getDOMSelectionSpy.and.returnValue({ - type: 'table', - }); - deleteSelectionSpy.and.returnValue({ - deleteResult: 'range', - }); - - const rawEvent = { - key: 'Enter', - } as any; - - keyboardInput(editor, rawEvent); - - expect(getDOMSelectionSpy).toHaveBeenCalled(); - expect(takeSnapshotSpy).toHaveBeenCalled(); - expect(formatContentModelSpy).toHaveBeenCalled(); - expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); - expect(formatResult).toBeTrue(); - expect(mockedContext).toEqual({ - deletedEntities: [], - newEntities: [], - newImages: [], - clearModelCache: true, - skipUndoSnapshot: true, - newPendingFormat: undefined, - }); - expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); - }); - it('Letter input, expanded selection, no modifier key, deleteSelection returns range, has segment format', () => { const mockedFormat = 'FORMAT' as any; getDOMSelectionSpy.and.returnValue({ @@ -381,50 +344,6 @@ describe('keyboardInput', () => { deletedEntities: [], newEntities: [], newImages: [], - clearModelCache: true, - skipUndoSnapshot: true, - newPendingFormat: mockedFormat, - }); - expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); - }); - - it('Enter key input on collapsed range', () => { - const mockedFormat = 'FORMAT' as any; - getDOMSelectionSpy.and.returnValue({ - type: 'range', - range: { - collapsed: true, - }, - }); - deleteSelectionSpy.and.returnValue({ - deleteResult: 'range', - insertPoint: { - marker: { - format: mockedFormat, - }, - }, - }); - - const rawEvent = { - key: 'Enter', - } as any; - - keyboardInput(editor, rawEvent); - - expect(getDOMSelectionSpy).toHaveBeenCalled(); - expect(takeSnapshotSpy).toHaveBeenCalled(); - expect(formatContentModelSpy).toHaveBeenCalled(); - expect(deleteSelectionSpy).toHaveBeenCalledWith( - mockedModel, - [handleEnterOnList, deleteEmptyQuote], - mockedContext - ); - expect(formatResult).toBeTrue(); - expect(mockedContext).toEqual({ - deletedEntities: [], - newEntities: [], - newImages: [], - clearModelCache: true, skipUndoSnapshot: true, newPendingFormat: mockedFormat, }); 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-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; }