From c30c84afa86d08438b95b707f91724d8b9055448 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 29 Apr 2024 16:57:33 -0700 Subject: [PATCH 01/16] KeyboardEnter --- .../lib/edit/EditPlugin.ts | 4 + .../lib/edit/deleteSteps/deleteEmptyQuote.ts | 65 ++++++++++----- .../lib/edit/inputSteps/handleEnterOnList.ts | 81 ++++++------------- .../edit/inputSteps/handleEnterOnParagraph.ts | 20 +++++ .../lib/edit/keyboardEnter.ts | 53 ++++++++++++ .../lib/edit/keyboardInput.ts | 27 +------ .../lib/edit/utils/splitParagraph.ts | 34 ++++++++ 7 files changed, 180 insertions(+), 104 deletions(-) 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 diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index 59cfe12ccc8..5350dccfaff 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 type { @@ -104,6 +105,9 @@ export class EditPlugin implements EditorPlugin { break; case 'Enter': + keyboardEnter(editor, rawEvent); + 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 cb337857438..647073f25be 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts @@ -1,13 +1,13 @@ import { - createParagraph, - createSelectionMarker, unwrapBlock, getClosestAncestorBlockGroupIndex, isBlockGroupOfType, + createFormatContainer, } from 'roosterjs-content-model-dom'; import type { ContentModelBlockGroup, ContentModelFormatContainer, + ContentModelParagraph, DeleteSelectionStep, } from 'roosterjs-content-model-types'; @@ -16,13 +16,10 @@ import type { */ export const deleteEmptyQuote: DeleteSelectionStep = context => { const { deleteResult } = context; - if ( - deleteResult == 'nothingToDelete' || - deleteResult == 'notDeleted' || - deleteResult == 'range' - ) { + + if (deleteResult == 'nothingToDelete' || deleteResult == 'notDeleted') { const { insertPoint, formatContext } = context; - const { path } = insertPoint; + const { path, paragraph } = insertPoint; const rawEvent = formatContext?.rawEvent as KeyboardEvent; const index = getClosestAncestorBlockGroupIndex( path, @@ -35,6 +32,7 @@ export const deleteEmptyQuote: DeleteSelectionStep = context => { const parent = path[index + 1]; const quoteBlockIndex = parent.blocks.indexOf(quote); const blockQuote = parent.blocks[quoteBlockIndex]; + if ( isBlockGroupOfType(blockQuote, 'FormatContainer') && blockQuote.tagName === 'blockquote' @@ -43,8 +41,11 @@ export const deleteEmptyQuote: DeleteSelectionStep = context => { unwrapBlock(parent, blockQuote); rawEvent?.preventDefault(); context.deleteResult = 'range'; - } else if (isSelectionOnEmptyLine(blockQuote) && rawEvent?.key === 'Enter') { - insertNewLine(blockQuote, parent, quoteBlockIndex); + } else if ( + isSelectionOnEmptyLine(blockQuote, paragraph) && + rawEvent?.key === 'Enter' + ) { + insertNewLine(blockQuote, parent, quoteBlockIndex, paragraph); rawEvent?.preventDefault(); context.deleteResult = 'range'; } @@ -63,25 +64,45 @@ const isEmptyQuote = (quote: ContentModelFormatContainer) => { ); }; -const isSelectionOnEmptyLine = (quote: ContentModelFormatContainer) => { - const quoteLength = quote.blocks.length; - const lastParagraph = quote.blocks[quoteLength - 1]; - if (lastParagraph && lastParagraph.blockType === 'Paragraph') { - return lastParagraph.segments.every( +const isSelectionOnEmptyLine = ( + quote: ContentModelFormatContainer, + paragraph: ContentModelParagraph +) => { + const paraIndex = quote.blocks.indexOf(paragraph); + + if (paraIndex >= 0) { + return paragraph.segments.every( s => s.segmentType === 'SelectionMarker' || s.segmentType === 'Br' ); + } else { + return false; } }; const insertNewLine = ( quote: ContentModelFormatContainer, parent: ContentModelBlockGroup, - index: number + quoteIndex: number, + paragraph: ContentModelParagraph ) => { - const quoteLength = quote.blocks.length; - quote.blocks.splice(quoteLength - 1, 1); - const marker = createSelectionMarker(); - const newParagraph = createParagraph(false /* isImplicit */); - newParagraph.segments.push(marker); - parent.blocks.splice(index + 1, 0, newParagraph); + const paraIndex = quote.blocks.indexOf(paragraph); + + if (paraIndex >= 0) { + if (paraIndex < quote.blocks.length - 1) { + const newQuote = createFormatContainer(quote.tagName, quote.format); + + newQuote.blocks.push( + ...quote.blocks.splice(paraIndex + 1, quote.blocks.length - paraIndex - 1) + ); + + parent.blocks.splice(quoteIndex + 1, 0, newQuote); + } + + parent.blocks.splice(quoteIndex + 1, 0, paragraph); + quote.blocks.splice(paraIndex, 1); + + if (quote.blocks.length == 0) { + parent.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 869d20ac8f5..19a1b70ae73 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -1,13 +1,10 @@ import { getListAnnounceData } from 'roosterjs-content-model-api'; +import { splitParagraph } from '../utils/splitParagraph'; import { - createBr, createListItem, createListLevel, - createParagraph, createSelectionMarker, normalizeContentModel, - normalizeParagraph, - setParagraphNotImplicit, getClosestAncestorBlockGroupIndex, isBlockGroupOfType, } from 'roosterjs-content-model-dom'; @@ -15,7 +12,6 @@ import type { ContentModelBlockGroup, ContentModelListItem, DeleteSelectionStep, - InsertPoint, ValidDeleteSelectionContext, } from 'roosterjs-content-model-types'; @@ -23,25 +19,35 @@ 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 listItem = path[index]; const listParent = path[index + 1]; + const parentBlock = path[index]; + + if (parentBlock?.blockGroupType === 'ListItem' && listParent) { + let listItem: ContentModelListItem = parentBlock; + + if (isEmptyListItem(listItem)) { + listItem.levels.pop(); + } else { + listItem = createNewListItem(context, listItem, listParent); + + if (context.formatContext) { + context.formatContext.announceData = getListAnnounceData([ + listItem, + ...path.slice(index + 1), + ]); + } + } - if (listItem && listItem.blockGroupType === 'ListItem' && listParent) { const listIndex = listParent.blocks.indexOf(listItem); const nextBlock = listParent.blocks[listIndex + 1]; - if (deleteResult == 'range' && nextBlock) { + if (nextBlock) { normalizeContentModel(listParent); const nextListItem = listParent.blocks[listIndex + 1]; @@ -75,22 +81,8 @@ export const handleEnterOnList: DeleteSelectionStep = context => { context.lastParagraph = undefined; } - } else if (deleteResult !== 'range') { - if (isEmptyListItem(listItem)) { - 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'; } } @@ -113,7 +105,7 @@ const createNewListItem = ( ) => { const { insertPoint } = context; const listIndex = listParent.blocks.indexOf(listItem); - const newParagraph = createNewParagraph(insertPoint); + const newParagraph = splitParagraph(insertPoint); const levels = createNewListLevel(listItem); const newListItem = createListItem(levels, insertPoint.marker.format); @@ -138,30 +130,3 @@ const createNewListLevel = (listItem: ContentModelListItem) => { ); }); }; - -const createNewParagraph = (insertPoint: InsertPoint) => { - const { paragraph, marker } = insertPoint; - const newParagraph = createParagraph( - false /*isImplicit*/, - paragraph.format, - paragraph.segmentFormat - ); - - const markerIndex = paragraph.segments.indexOf(marker); - 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..312966a5fa7 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnParagraph.ts @@ -0,0 +1,20 @@ +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); + + 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..5120c060d36 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts @@ -0,0 +1,53 @@ +import { deleteEmptyQuote } from './deleteSteps/deleteEmptyQuote'; +import { deleteSelection, normalizeContentModel } from 'roosterjs-content-model-dom'; +import { handleEnterOnList } from './inputSteps/handleEnterOnList'; +import { handleEnterOnParagraph } from './inputSteps/handleEnterOnParagraph'; +import type { DOMSelection, DeleteSelectionStep, IEditor } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function keyboardEnter(editor: IEditor, rawEvent: KeyboardEvent) { + const selection = editor.getDOMSelection(); + + editor.formatContentModel( + (model, context) => { + const result = deleteSelection(model, getInputSteps(selection, rawEvent), context); + + 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, + } + ); +} + +function getInputSteps(selection: DOMSelection | null, rawEvent: KeyboardEvent) { + const result: DeleteSelectionStep[] = [clearDeleteResult]; + + if (selection && selection.type != 'table') { + if (rawEvent.shiftKey) { + result.push(handleEnterOnParagraph); + } else { + result.push(handleEnterOnList, deleteEmptyQuote, handleEnterOnParagraph); + } + } + + return result; +} + +const clearDeleteResult: DeleteSelectionStep = context => { + // 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 + context.deleteResult = 'notDeleted'; +}; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index a25f2d6beaa..dc7db829ad2 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; @@ -44,27 +38,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..da31ec0a6b3 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts @@ -0,0 +1,34 @@ +import { + createBr, + createParagraph, + normalizeParagraph, + setParagraphNotImplicit, +} from 'roosterjs-content-model-dom'; +import type { InsertPoint } from 'roosterjs-content-model-types'; + +export function splitParagraph(insertPoint: InsertPoint) { + const { paragraph, marker } = insertPoint; + const newParagraph = createParagraph( + false /*isImplicit*/, + paragraph.format, + paragraph.segmentFormat + ); + + const markerIndex = paragraph.segments.indexOf(marker); + 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; +} From 98655527a72fb8156141112c63bb4ea631aa7ea5 Mon Sep 17 00:00:00 2001 From: JiuqingSong Date: Tue, 30 Apr 2024 09:31:02 -0700 Subject: [PATCH 02/16] fix comment --- .../lib/edit/utils/splitParagraph.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts b/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts index da31ec0a6b3..e6f4c38bbf3 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts @@ -1,11 +1,17 @@ import { - createBr, createParagraph, normalizeParagraph, setParagraphNotImplicit, } from 'roosterjs-content-model-dom'; import type { InsertPoint } 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 = createParagraph( @@ -24,10 +30,9 @@ export function splitParagraph(insertPoint: InsertPoint) { setParagraphNotImplicit(paragraph); - if (paragraph.segments.every(x => x.segmentType == 'SelectionMarker')) { - paragraph.segments.push(createBr(marker.format)); - } + insertPoint.paragraph = newParagraph; + normalizeParagraph(paragraph); normalizeParagraph(newParagraph); return newParagraph; From a8af3674b0094dd7168a9d0b8c88ddd09d63e9e1 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 30 Apr 2024 16:08:40 -0700 Subject: [PATCH 03/16] fix test --- .../lib/modelApi/editing/deleteSelection.ts | 6 +- .../lib/edit/inputSteps/handleEnterOnList.ts | 33 +-- .../lib/edit/utils/splitParagraph.ts | 6 +- .../edit/deleteSteps/deleteEmptyQuoteTest.ts | 277 ----------------- .../edit/inputSteps/handleEnterOnListTest.ts | 280 ++++++++++++------ .../test/edit/keyboardInputTest.ts | 81 ----- 6 files changed, 201 insertions(+), 482 deletions(-) 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 20ff0629c65..5373bd63af3 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection.ts @@ -41,16 +41,18 @@ function isValidDeleteSelectionContext( // If we end up with multiple paragraphs impacted, we need to merge them function mergeParagraphAfterDelete(context: DeleteSelectionContext) { const { insertPoint, deleteResult, lastParagraph, lastTableContext } = context; + const prevParagraph = insertPoint?.paragraph; if ( insertPoint && + prevParagraph && deleteResult != 'notDeleted' && deleteResult != 'nothingToDelete' && lastParagraph && - lastParagraph != insertPoint.paragraph && + lastParagraph != prevParagraph && lastTableContext == insertPoint.tableContext ) { - insertPoint.paragraph.segments.push(...lastParagraph.segments); + prevParagraph.segments.push(...lastParagraph.segments); lastParagraph.segments = []; } } 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 19a1b70ae73..3646a3a07b7 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -3,8 +3,6 @@ import { splitParagraph } from '../utils/splitParagraph'; import { createListItem, createListLevel, - createSelectionMarker, - normalizeContentModel, getClosestAncestorBlockGroupIndex, isBlockGroupOfType, } from 'roosterjs-content-model-dom'; @@ -48,38 +46,15 @@ export const handleEnterOnList: DeleteSelectionStep = context => { const nextBlock = listParent.blocks[listIndex + 1]; if (nextBlock) { - normalizeContentModel(listParent); - 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' - ) { - lastParagraph.segments.pop(); - - nextParagraph.segments.unshift( - createSelectionMarker(insertPoint.marker.format) - ); - } - - context.lastParagraph = undefined; } } @@ -105,15 +80,19 @@ const createNewListItem = ( ) => { const { insertPoint } = context; const listIndex = listParent.blocks.indexOf(listItem); + const currentPara = insertPoint.paragraph; const newParagraph = splitParagraph(insertPoint); const levels = createNewListLevel(listItem); const newListItem = createListItem(levels, insertPoint.marker.format); newListItem.blocks.push(newParagraph); insertPoint.paragraph = newParagraph; - context.lastParagraph = newParagraph; listParent.blocks.splice(listIndex + 1, 0, newListItem); + if (context.lastParagraph == currentPara) { + context.lastParagraph = newParagraph; + } + return newListItem; }; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts b/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts index e6f4c38bbf3..9fd83d3668a 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts @@ -1,4 +1,5 @@ import { + createBr, createParagraph, normalizeParagraph, setParagraphNotImplicit, @@ -26,6 +27,10 @@ export function splitParagraph(insertPoint: InsertPoint) { paragraph.segments.length - markerIndex ); + if (paragraph.segments.length == 0) { + paragraph.segments.push(createBr(marker.format)); + } + newParagraph.segments.push(...segments); setParagraphNotImplicit(paragraph); @@ -33,7 +38,6 @@ export function splitParagraph(insertPoint: InsertPoint) { insertPoint.paragraph = newParagraph; normalizeParagraph(paragraph); - normalizeParagraph(newParagraph); return newParagraph; } 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..3494db5d813 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( @@ -131,278 +129,3 @@ describe('deleteEmptyQuote', () => { 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 = { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'BlockGroup', - blockGroupType: 'FormatContainer', - tagName: 'blockquote', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - textColor: 'rgb(102, 102, 102)', - }, - }, - { - segmentType: 'Br', - 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 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); - }); - - it('should exit quote when press Enter', () => { - 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: {}, - segmentFormat: { - textColor: 'rgb(102, 102, 102)', - }, - }, - { - 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)', - }, - }, - ], - format: { - marginTop: '1em', - marginRight: '40px', - marginBottom: '1em', - marginLeft: '40px', - paddingLeft: '10px', - borderLeft: '3px solid rgb(200, 200, 200)', - }, - }, - ], - format: {}, - }; - const expectedTestModel: 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: {}, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - format: {}, - }, - ], - format: {}, - }; - - runTest(model, 'Enter', expectedTestModel); - }); - - it('should not exit quote when press Enter', () => { - 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)', - }, - }, - { - 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, 'Enter', model, false, 0); - }); -}); 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..69616999988 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); }, 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,6 @@ describe(' handleEnterOnList - keyboardInput', () => { ], format: {}, }; - runTest(input, true, expected, true, 0); + runTest(input, true, expected, false, 1); }); }); 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, }); From ac1a2c1824fc687049842ab6b74fafb2f44a4a8b Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 1 May 2024 10:00:57 -0700 Subject: [PATCH 04/16] improve --- .../lib/modelApi/editing/deleteSelection.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 5373bd63af3..20ff0629c65 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection.ts @@ -41,18 +41,16 @@ function isValidDeleteSelectionContext( // If we end up with multiple paragraphs impacted, we need to merge them function mergeParagraphAfterDelete(context: DeleteSelectionContext) { const { insertPoint, deleteResult, lastParagraph, lastTableContext } = context; - const prevParagraph = insertPoint?.paragraph; if ( insertPoint && - prevParagraph && deleteResult != 'notDeleted' && deleteResult != 'nothingToDelete' && lastParagraph && - lastParagraph != prevParagraph && + lastParagraph != insertPoint.paragraph && lastTableContext == insertPoint.tableContext ) { - prevParagraph.segments.push(...lastParagraph.segments); + insertPoint.paragraph.segments.push(...lastParagraph.segments); lastParagraph.segments = []; } } From 8304dc9c1905d3f02725e5ef58a9ebb9d3c4824f Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 6 May 2024 10:28:54 -0700 Subject: [PATCH 05/16] Scroll caret into view when call formatContentModel --- .../lib/command/paste/mergePasteContent.ts | 1 + .../formatContentModel/formatContentModel.ts | 21 ++++++++++++-- .../formatContentModelTest.ts | 28 +++++++++++++++++++ .../lib/edit/keyboardDelete.ts | 1 + .../lib/edit/keyboardInput.ts | 1 + .../parameter/FormatContentModelOptions.ts | 5 ++++ 6 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts b/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts index 9f191f235c9..9cd3302ad4b 100644 --- a/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts +++ b/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts @@ -94,6 +94,7 @@ export function mergePasteContent( { changeSource: ChangeSource.Paste, getChangeData: () => clipboardData, + scrollCaretIntoView: true, apiName: 'paste', } ); diff --git a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts index 6515ff40b50..bcebd23f23b 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts @@ -1,4 +1,4 @@ -import { ChangeSource } from 'roosterjs-content-model-dom'; +import { ChangeSource, getSelectionRootNode } from 'roosterjs-content-model-dom'; import type { ChangedEntity, ContentChangedEvent, @@ -24,8 +24,15 @@ export const formatContentModel: FormatContentModel = ( options, domToModelOptions ) => { - const { apiName, onNodeCreated, getChangeData, changeSource, rawEvent, selectionOverride } = - options || {}; + const { + apiName, + onNodeCreated, + getChangeData, + changeSource, + rawEvent, + selectionOverride, + scrollCaretIntoView, + } = options || {}; const model = core.api.createContentModel(core, domToModelOptions, selectionOverride); const context: FormatContentModelContext = { newEntities: [], @@ -63,6 +70,14 @@ export const formatContentModel: FormatContentModel = ( handlePendingFormat(core, context, selection); + if (selection && scrollCaretIntoView) { + const selectionRoot = getSelectionRootNode(selection); + const rootElement = + selectionRoot && core.domHelper.findClosestElementAncestor(selectionRoot); + + rootElement?.scrollIntoView(); + } + const eventData: ContentChangedEvent = { eventType: 'contentChanged', contentModel: clearModelCache ? undefined : model, diff --git a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts index dc1a0afae0c..09996b39e09 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts @@ -21,6 +21,7 @@ describe('formatContentModel', () => { let hasFocus: jasmine.Spy; let getClientWidth: jasmine.Spy; let announce: jasmine.Spy; + let findClosestElementAncestor: jasmine.Spy; const apiName = 'mockedApi'; const mockedContainer = 'C' as any; @@ -42,6 +43,7 @@ describe('formatContentModel', () => { hasFocus = jasmine.createSpy('hasFocus'); getClientWidth = jasmine.createSpy('getClientWidth'); announce = jasmine.createSpy('announce'); + findClosestElementAncestor = jasmine.createSpy('findClosestElementAncestor '); core = ({ api: { @@ -62,6 +64,7 @@ describe('formatContentModel', () => { domHelper: { hasFocus, getClientWidth, + findClosestElementAncestor, }, } as any) as EditorCore; }); @@ -549,6 +552,31 @@ describe('formatContentModel', () => { }); expect(announce).not.toHaveBeenCalled(); }); + + it('Has scrollCaretIntoView, and callback return true', () => { + const scrollIntoViewSpy = jasmine.createSpy('scrollIntoView'); + const mockedImage = { scrollIntoView: scrollIntoViewSpy } as any; + + findClosestElementAncestor.and.returnValue(mockedImage); + setContentModel.and.returnValue({ + type: 'image', + image: mockedImage, + }); + formatContentModel( + core, + (model, context) => { + context.clearModelCache = true; + return true; + }, + { + scrollCaretIntoView: true, + apiName, + } + ); + + expect(findClosestElementAncestor).toHaveBeenCalledWith(mockedImage); + expect(scrollIntoViewSpy).toHaveBeenCalledTimes(1); + }); }); describe('Editor does not have focus', () => { diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts index e4fadcc1a1b..44c73db93aa 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts @@ -49,6 +49,7 @@ export function keyboardDelete(editor: IEditor, rawEvent: KeyboardEvent) { rawEvent, changeSource: ChangeSource.Keyboard, getChangeData: () => rawEvent.which, + scrollCaretIntoView: true, apiName: rawEvent.key == 'Delete' ? 'handleDeleteKey' : 'handleBackspaceKey', } ); diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index a25f2d6beaa..91ecf99647e 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -36,6 +36,7 @@ export function keyboardInput(editor: IEditor, rawEvent: KeyboardEvent) { } }, { + scrollCaretIntoView: true, rawEvent, } ); diff --git a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts index c2ac135af77..ec43eae1ed1 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts @@ -38,6 +38,11 @@ export interface FormatContentModelOptions { * When specified, use this selection range to override current selection inside editor */ selectionOverride?: DOMSelection; + + /** + * When pass to true, scroll the editing caret into view after write DOM tree if need + */ + scrollCaretIntoView?: boolean; } /** From 65913c0d30149fb7e63b6ca217b792d115c83dd5 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 6 May 2024 11:08:00 -0700 Subject: [PATCH 06/16] scroll caret into view --- .../roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts index 5120c060d36..6f5d17e1c3b 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts @@ -28,6 +28,7 @@ export function keyboardEnter(editor: IEditor, rawEvent: KeyboardEvent) { }, { rawEvent, + scrollCaretIntoView: true, } ); } From a5ef96891fb1df16891e0777899d705f088ee183 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 29 May 2024 12:15:00 -0700 Subject: [PATCH 07/16] Fix build --- .../lib/edit/deleteSteps/deleteEmptyQuote.ts | 12 ++++++++---- .../lib/edit/inputSteps/handleEnterOnParagraph.ts | 3 ++- .../lib/edit/utils/splitParagraph.ts | 7 +++++-- 3 files changed, 15 insertions(+), 7 deletions(-) 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 40b2752af45..57abbb39218 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts @@ -7,11 +7,12 @@ import { } from 'roosterjs-content-model-dom'; import type { ContentModelFormatContainer, - ContentModelParagraph, DeleteSelectionStep, ReadonlyContentModelBlockGroup, ReadonlyContentModelFormatContainer, ReadonlyContentModelParagraph, + ShallowMutableContentModelFormatContainer, + ShallowMutableContentModelParagraph, } from 'roosterjs-content-model-types'; /** @@ -83,10 +84,10 @@ const isSelectionOnEmptyLine = ( }; const insertNewLine = ( - quote: ContentModelFormatContainer, + quote: ShallowMutableContentModelFormatContainer, parent: ReadonlyContentModelBlockGroup, quoteIndex: number, - paragraph: ContentModelParagraph + paragraph: ShallowMutableContentModelParagraph ) => { const paraIndex = quote.blocks.indexOf(paragraph); @@ -94,7 +95,10 @@ const insertNewLine = ( const mutableParent = mutateBlock(parent); if (paraIndex < quote.blocks.length - 1) { - const newQuote = createFormatContainer(quote.tagName, quote.format); + const newQuote: ShallowMutableContentModelFormatContainer = createFormatContainer( + quote.tagName, + quote.format + ); newQuote.blocks.push( ...quote.blocks.splice(paraIndex + 1, quote.blocks.length - paraIndex - 1) diff --git a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnParagraph.ts b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnParagraph.ts index 312966a5fa7..e70318cd375 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnParagraph.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnParagraph.ts @@ -1,3 +1,4 @@ +import { mutateBlock } from 'roosterjs-content-model-dom'; import { splitParagraph } from '../utils/splitParagraph'; import type { DeleteSelectionStep } from 'roosterjs-content-model-types'; @@ -11,7 +12,7 @@ export const handleEnterOnParagraph: DeleteSelectionStep = context => { if (context.deleteResult == 'notDeleted' && paraIndex >= 0) { const newPara = splitParagraph(context.insertPoint); - path[0].blocks.splice(paraIndex + 1, 0, newPara); + mutateBlock(path[0]).blocks.splice(paraIndex + 1, 0, newPara); context.deleteResult = 'range'; context.lastParagraph = newPara; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts b/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts index 9fd83d3668a..963f9865f3e 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/utils/splitParagraph.ts @@ -4,7 +4,10 @@ import { normalizeParagraph, setParagraphNotImplicit, } from 'roosterjs-content-model-dom'; -import type { InsertPoint } from 'roosterjs-content-model-types'; +import type { + InsertPoint, + ShallowMutableContentModelParagraph, +} from 'roosterjs-content-model-types'; /** * @internal @@ -15,7 +18,7 @@ import type { InsertPoint } from 'roosterjs-content-model-types'; */ export function splitParagraph(insertPoint: InsertPoint) { const { paragraph, marker } = insertPoint; - const newParagraph = createParagraph( + const newParagraph: ShallowMutableContentModelParagraph = createParagraph( false /*isImplicit*/, paragraph.format, paragraph.segmentFormat From ad9feeb9f6dfef51602cc3bd51f027bd4eb78daf Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 30 May 2024 16:41:53 -0700 Subject: [PATCH 08/16] improve --- .../lib/edit/keyboardEnter.ts | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts index 6f5d17e1c3b..1e66b98ddb5 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts @@ -2,7 +2,11 @@ import { deleteEmptyQuote } from './deleteSteps/deleteEmptyQuote'; import { deleteSelection, normalizeContentModel } from 'roosterjs-content-model-dom'; import { handleEnterOnList } from './inputSteps/handleEnterOnList'; import { handleEnterOnParagraph } from './inputSteps/handleEnterOnParagraph'; -import type { DOMSelection, DeleteSelectionStep, IEditor } from 'roosterjs-content-model-types'; +import type { + DeleteSelectionContext, + IEditor, + ValidDeleteSelectionContext, +} from 'roosterjs-content-model-types'; /** * @internal @@ -12,7 +16,25 @@ export function keyboardEnter(editor: IEditor, rawEvent: KeyboardEvent) { editor.formatContentModel( (model, context) => { - const result = deleteSelection(model, getInputSteps(selection, rawEvent), context); + // 1. delete the expanded selection if any, then merge paragraph + let 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 + ? [handleEnterOnParagraph] + : [handleEnterOnList, deleteEmptyQuote, handleEnterOnParagraph]; + + steps.forEach(step => { + if (isValidDeleteSelectionContext(result)) { + step(result); + } + }); + } if (result.deleteResult == 'range') { // We have deleted something, next input should inherit the segment format from deleted content, so set pending format here @@ -33,22 +55,8 @@ export function keyboardEnter(editor: IEditor, rawEvent: KeyboardEvent) { ); } -function getInputSteps(selection: DOMSelection | null, rawEvent: KeyboardEvent) { - const result: DeleteSelectionStep[] = [clearDeleteResult]; - - if (selection && selection.type != 'table') { - if (rawEvent.shiftKey) { - result.push(handleEnterOnParagraph); - } else { - result.push(handleEnterOnList, deleteEmptyQuote, handleEnterOnParagraph); - } - } - - return result; +function isValidDeleteSelectionContext( + context: DeleteSelectionContext +): context is ValidDeleteSelectionContext { + return !!context.insertPoint; } - -const clearDeleteResult: DeleteSelectionStep = context => { - // 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 - context.deleteResult = 'notDeleted'; -}; From 3a7d92e14a5ea252980479f920c277f215dad3a5 Mon Sep 17 00:00:00 2001 From: JiuqingSong Date: Thu, 30 May 2024 17:20:10 -0700 Subject: [PATCH 09/16] fix build --- .../roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts index 1e66b98ddb5..07ed60355ca 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts @@ -17,7 +17,7 @@ export function keyboardEnter(editor: IEditor, rawEvent: KeyboardEvent) { editor.formatContentModel( (model, context) => { // 1. delete the expanded selection if any, then merge paragraph - let result = deleteSelection(model, [], context); + const result = deleteSelection(model, [], context); // 2. Add line break if (selection && selection.type != 'table') { From 0698c39f1b6a32692f8cdd444251a89ca1a16cfc Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 3 Jun 2024 11:44:58 -0700 Subject: [PATCH 10/16] Improve --- .../roosterjs-content-model-core/lib/editor/Editor.ts | 9 +++++++++ .../lib/edit/EditPlugin.ts | 7 ++++++- .../roosterjs-content-model-types/lib/editor/IEditor.ts | 7 +++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 3301ff252ee..6bad5976a24 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -32,6 +32,7 @@ import type { CachedElementHandler, DomToModelOptionForCreateModel, AnnounceData, + ExperimentalFeature, } from 'roosterjs-content-model-types'; /** @@ -406,6 +407,14 @@ export class Editor implements IEditor { core.api.announce(core, announceData); } + /** + * Check if a given feature is enabled + * @param featureName The name of feature to check + */ + isExperimentalFeatureEnabled(featureName: ExperimentalFeature | string): boolean { + return this.getCore().experimentalFeatures.indexOf(featureName) >= 0; + } + /** * @returns the current EditorCore object * @throws a standard Error if there's no core object diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index 91c23a861e6..d358fd9bec5 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -26,6 +26,7 @@ export class EditPlugin implements EditorPlugin { private disposer: (() => void) | null = null; private shouldHandleNextInputEvent = false; private selectionAfterDelete: DOMSelection | null = null; + private handleEnterKey = false; /** * Get name of this plugin @@ -42,6 +43,8 @@ export class EditPlugin implements EditorPlugin { */ initialize(editor: IEditor) { this.editor = editor; + this.handleEnterKey = this.editor.isExperimentalFeatureEnabled('PersistCache'); + if (editor.getEnvironment().isAndroid) { this.disposer = this.editor.attachDomEvent({ beforeinput: { @@ -154,7 +157,9 @@ export class EditPlugin implements EditorPlugin { break; case 'Enter': - keyboardEnter(editor, rawEvent); + if (this.handleEnterKey) { + keyboardEnter(editor, rawEvent); + } break; default: diff --git a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts index 2ff5e28bad0..39c5e08e7ae 100644 --- a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts +++ b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts @@ -18,6 +18,7 @@ import type { DarkColorHandler } from '../context/DarkColorHandler'; import type { TrustedHTMLHandler } from '../parameter/TrustedHTMLHandler'; import type { Rect } from '../parameter/Rect'; import type { EntityState } from '../parameter/FormatContentModelContext'; +import type { ExperimentalFeature } from './ExperimentalFeature'; /** * An interface of Editor, built on top of Content Model @@ -227,4 +228,10 @@ export interface IEditor { * @param announceData Data to announce */ announce(announceData: AnnounceData): void; + + /** + * Check if a given feature is enabled + * @param featureName The name of feature to check + */ + isExperimentalFeatureEnabled(featureName: ExperimentalFeature | string): boolean; } From f1dfe8014e62361a7cef475b69fb5b5156a9cd24 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 3 Jun 2024 11:52:20 -0700 Subject: [PATCH 11/16] fix test --- .../roosterjs-content-model-plugins/test/edit/EditPluginTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 4ca88a12128..016b1b1bf68 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -29,6 +29,7 @@ describe('EditPlugin', () => { ({ type: -1, } as any), // Force return invalid range to go through content model code + isExperimentalFeatureEnabled: () => true, } as any) as IEditor; }); From 37f6b4b3ec1ed70746859db464e97cd7faf57e19 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 3 Jun 2024 16:48:46 -0700 Subject: [PATCH 12/16] add test --- .../test/editor/EditorTest.ts | 32 +++ .../lib/edit/EditPlugin.ts | 2 + .../lib/edit/deleteSteps/deleteEmptyQuote.ts | 58 ++--- .../test/edit/EditPluginTest.ts | 65 +++++- .../edit/deleteSteps/deleteEmptyQuoteTest.ts | 214 +++++++++++++++++- .../inputSteps/handleEnterOnParagraphTest.ts | 102 +++++++++ 6 files changed, 435 insertions(+), 38 deletions(-) create mode 100644 packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnParagraphTest.ts diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index cb415755e7c..3db0c62136f 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -1116,4 +1116,36 @@ describe('Editor', () => { expect(resetSpy).toHaveBeenCalledWith(); expect(() => editor.announce(mockedData)).toThrow(); }); + + it('isExperimentalFeatureEnabled', () => { + const div = document.createElement('div'); + const resetSpy = jasmine.createSpy('reset'); + const mockedCore = { + plugins: [], + darkColorHandler: { + updateKnownColor: updateKnownColorSpy, + reset: resetSpy, + }, + api: { + setContentModel: setContentModelSpy, + }, + experimentalFeatures: ['Feature1', 'Feature2'], + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new Editor(div); + + const result1 = editor.isExperimentalFeatureEnabled('Feature1'); + const result2 = editor.isExperimentalFeatureEnabled('Feature2'); + const result3 = editor.isExperimentalFeatureEnabled('Feature3'); + + expect(result1).toBeTrue(); + expect(result2).toBeTrue(); + expect(result3).toBeFalse(); + + editor.dispose(); + expect(resetSpy).toHaveBeenCalledWith(); + expect(() => editor.isExperimentalFeatureEnabled('Feature4')).toThrow(); + }); }); diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index d358fd9bec5..5ee5b9232c6 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -159,6 +159,8 @@ export class EditPlugin implements EditorPlugin { case 'Enter': if (this.handleEnterKey) { keyboardEnter(editor, rawEvent); + } else { + keyboardInput(editor, rawEvent); } break; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts index 57abbb39218..bb8ed065cc5 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts @@ -1,12 +1,10 @@ import { unwrapBlock, getClosestAncestorBlockGroupIndex, - isBlockGroupOfType, createFormatContainer, mutateBlock, } from 'roosterjs-content-model-dom'; import type { - ContentModelFormatContainer, DeleteSelectionStep, ReadonlyContentModelBlockGroup, ReadonlyContentModelFormatContainer, @@ -21,7 +19,11 @@ import type { export const deleteEmptyQuote: DeleteSelectionStep = context => { const { deleteResult } = context; - if (deleteResult == 'nothingToDelete' || deleteResult == 'notDeleted') { + if ( + deleteResult == 'nothingToDelete' || + deleteResult == 'notDeleted' || + deleteResult == 'range' + ) { const { insertPoint, formatContext } = context; const { path, paragraph } = insertPoint; const rawEvent = formatContext?.rawEvent as KeyboardEvent; @@ -35,52 +37,36 @@ export const deleteEmptyQuote: DeleteSelectionStep = context => { if (quote && quote.blockGroupType === 'FormatContainer' && quote.tagName == 'blockquote') { const parent = path[index + 1]; const quoteBlockIndex = parent.blocks.indexOf(quote); - const blockQuote = parent.blocks[quoteBlockIndex]; - if ( - isBlockGroupOfType(blockQuote, 'FormatContainer') && - blockQuote.tagName === 'blockquote' + if (isEmptyQuote(quote)) { + unwrapBlock(parent, quote); + rawEvent?.preventDefault(); + context.deleteResult = 'range'; + } else if ( + rawEvent?.key === 'Enter' && + quote.blocks.indexOf(paragraph) >= 0 && + isEmptyParagraph(paragraph) ) { - if (isEmptyQuote(blockQuote)) { - unwrapBlock(parent, blockQuote); - rawEvent?.preventDefault(); - context.deleteResult = 'range'; - } else if ( - isSelectionOnEmptyLine(blockQuote, paragraph) && - rawEvent?.key === 'Enter' - ) { - insertNewLine(blockQuote, parent, quoteBlockIndex, paragraph); - rawEvent?.preventDefault(); - context.deleteResult = 'range'; - } + insertNewLine(mutateBlock(quote), parent, quoteBlockIndex, paragraph); + rawEvent?.preventDefault(); + context.deleteResult = 'range'; } } } }; -const isEmptyQuote = (quote: ContentModelFormatContainer) => { +const isEmptyQuote = (quote: ReadonlyContentModelFormatContainer) => { return ( quote.blocks.length === 1 && quote.blocks[0].blockType === 'Paragraph' && - quote.blocks[0].segments.every( - s => s.segmentType === 'SelectionMarker' || s.segmentType === 'Br' - ) + isEmptyParagraph(quote.blocks[0]) ); }; -const isSelectionOnEmptyLine = ( - quote: ReadonlyContentModelFormatContainer, - paragraph: ReadonlyContentModelParagraph -) => { - const paraIndex = quote.blocks.indexOf(paragraph); - - if (paraIndex >= 0) { - return paragraph.segments.every( - s => s.segmentType === 'SelectionMarker' || s.segmentType === 'Br' - ); - } else { - return false; - } +const isEmptyParagraph = (paragraph: ReadonlyContentModelParagraph) => { + return paragraph.segments.every( + s => s.segmentType === 'SelectionMarker' || s.segmentType === 'Br' + ); }; const insertNewLine = ( diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 016b1b1bf68..0c895000cbb 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -1,4 +1,5 @@ import * as keyboardDelete from '../../lib/edit/keyboardDelete'; +import * as keyboardEnter from '../../lib/edit/keyboardEnter'; import * as keyboardInput from '../../lib/edit/keyboardInput'; import * as keyboardTab from '../../lib/edit/keyboardTab'; import { DOMEventRecord, IEditor } from 'roosterjs-content-model-types'; @@ -10,6 +11,7 @@ describe('EditPlugin', () => { let eventMap: Record; let attachDOMEventSpy: jasmine.Spy; let getEnvironmentSpy: jasmine.Spy; + let isExperimentalFeatureEnabledSpy: jasmine.Spy; beforeEach(() => { attachDOMEventSpy = jasmine @@ -21,6 +23,9 @@ describe('EditPlugin', () => { getEnvironmentSpy = jasmine.createSpy('getEnvironment').and.returnValue({ isAndroid: true, }); + isExperimentalFeatureEnabledSpy = jasmine + .createSpy('isExperimentalFeatureEnabled') + .and.returnValue(false); editor = ({ attachDomEvent: attachDOMEventSpy, @@ -29,7 +34,7 @@ describe('EditPlugin', () => { ({ type: -1, } as any), // Force return invalid range to go through content model code - isExperimentalFeatureEnabled: () => true, + isExperimentalFeatureEnabled: isExperimentalFeatureEnabledSpy, } as any) as IEditor; }); @@ -41,11 +46,13 @@ describe('EditPlugin', () => { let keyboardDeleteSpy: jasmine.Spy; let keyboardInputSpy: jasmine.Spy; let keyboardTabSpy: jasmine.Spy; + let keyboardEnterSpy: jasmine.Spy; beforeEach(() => { keyboardDeleteSpy = spyOn(keyboardDelete, 'keyboardDelete'); keyboardInputSpy = spyOn(keyboardInput, 'keyboardInput'); keyboardTabSpy = spyOn(keyboardTab, 'keyboardTab'); + keyboardEnterSpy = spyOn(keyboardEnter, 'keyboardEnter'); }); it('Backspace', () => { @@ -61,6 +68,8 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Delete', () => { @@ -76,6 +85,8 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Shift+Delete', () => { @@ -91,6 +102,8 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).not.toHaveBeenCalled(); expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Tab', () => { @@ -107,6 +120,50 @@ describe('EditPlugin', () => { expect(keyboardTabSpy).toHaveBeenCalledWith(editor, rawEvent); expect(keyboardInputSpy).not.toHaveBeenCalled(); expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + }); + + it('Enter, keyboardEnter not enabled', () => { + plugin = new EditPlugin(); + const rawEvent = { which: 13, key: 'Enter' } as any; + const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + + editor.takeSnapshot = addUndoSnapshotSpy; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardInputSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); + }); + + it('Enter, keyboardEnter enabled', () => { + isExperimentalFeatureEnabledSpy.and.callFake( + (featureName: string) => featureName == 'PersistCache' + ); + plugin = new EditPlugin(); + const rawEvent = { which: 13, key: 'Enter' } as any; + const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + + editor.takeSnapshot = addUndoSnapshotSpy; + + plugin.initialize(editor); + + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent, + }); + + expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Other key', () => { @@ -125,6 +182,8 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).not.toHaveBeenCalled(); expect(keyboardInputSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Default prevented', () => { @@ -139,6 +198,8 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).not.toHaveBeenCalled(); expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); it('Trigger entity event first', () => { @@ -175,6 +236,8 @@ describe('EditPlugin', () => { key: 'Delete', } as any); expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardTabSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteEmptyQuoteTest.ts b/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteEmptyQuoteTest.ts index 3494db5d813..ab1f69fe948 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteEmptyQuoteTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteEmptyQuoteTest.ts @@ -126,6 +126,218 @@ describe('deleteEmptyQuote', () => { ], format: {}, }; - runTest(model, model, 'notDeleted'); + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + ], + format: {}, + segmentFormat: { textColor: 'rgb(102, 102, 102)' }, + }, + ], + format: { + marginTop: '1em', + marginRight: '40px', + marginBottom: '1em', + marginLeft: '40px', + paddingLeft: '10px', + borderLeft: '3px solid rgb(200, 200, 200)', + }, + }, + ], + format: {}, + }; + runTest(model, expectedModel, 'notDeleted'); + }); +}); + +describe('delete with Enter', () => { + it('Enter in empty paragraph in middle of quote', () => { + function runTest( + model: ContentModelDocument, + expectedModel: ContentModelDocument, + deleteResult: string + ) { + const result = deleteSelection(model, [deleteEmptyQuote], { + rawEvent: { + key: 'Enter', + preventDefault: () => {}, + } as any, + newEntities: [], + deletedEntities: [], + newImages: [], + }); + normalizeContentModel(model); + expect(result.deleteResult).toEqual(deleteResult); + expect(model).toEqual(expectedModel); + } + + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + ], + format: {}, + }, + ], + format: { + marginTop: '1em', + marginRight: '40px', + marginBottom: '1em', + marginLeft: '40px', + paddingLeft: '10px', + borderLeft: '3px solid rgb(200, 200, 200)', + }, + }, + ], + format: {}, + }; + + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + ], + format: {}, + segmentFormat: { textColor: 'rgb(102, 102, 102)' }, + }, + ], + format: { + marginTop: '1em', + marginRight: '40px', + marginBottom: '1em', + marginLeft: '40px', + paddingLeft: '10px', + borderLeft: '3px solid rgb(200, 200, 200)', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + { + segmentType: 'Br', + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + ], + format: {}, + segmentFormat: { textColor: 'rgb(102, 102, 102)' }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + textColor: 'rgb(102, 102, 102)', + }, + }, + ], + format: {}, + segmentFormat: { textColor: 'rgb(102, 102, 102)' }, + }, + ], + format: { + marginTop: '1em', + marginRight: '40px', + marginBottom: '1em', + marginLeft: '40px', + paddingLeft: '10px', + borderLeft: '3px solid rgb(200, 200, 200)', + }, + }, + ], + format: {}, + }; + runTest(model, expectedModel, 'range'); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnParagraphTest.ts b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnParagraphTest.ts new file mode 100644 index 00000000000..cbab594af4e --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnParagraphTest.ts @@ -0,0 +1,102 @@ +import { handleEnterOnParagraph } from '../../../lib/edit/inputSteps/handleEnterOnParagraph'; +import { ValidDeleteSelectionContext } from 'roosterjs-content-model-types'; +import { + createContentModelDocument, + createParagraph, + createSelectionMarker, + createText, +} from 'roosterjs-content-model-dom'; + +describe('handleEnterOnParagraph', () => { + it('Already deleted', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const mockedCache = {} as any; + + para.segments.push(marker); + doc.blocks.push(para); + doc.cachedElement = mockedCache; + + const context: ValidDeleteSelectionContext = { + deleteResult: 'range', + insertPoint: { + paragraph: para, + marker: marker, + path: [doc], + }, + }; + + handleEnterOnParagraph(context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [para], + cachedElement: mockedCache, + }); + }); + + it('Not deleted, split current paragraph', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const mockedCache = {} as any; + const text1 = createText('test1'); + const text2 = createText('test1'); + + para.segments.push(text1, marker, text2); + doc.blocks.push(para); + doc.cachedElement = mockedCache; + + const context: ValidDeleteSelectionContext = { + deleteResult: 'notDeleted', + insertPoint: { + paragraph: para, + marker: marker, + path: [doc], + }, + }; + + handleEnterOnParagraph(context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + text2, + ], + format: {}, + }, + ], + }); + + expect(context.insertPoint).toEqual({ + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + text2, + ], + format: {}, + }, + marker: marker, + path: [doc], + }); + }); +}); From 1f90c24ce154526cca4a669a8f3655102d3dde2c Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 4 Jun 2024 16:57:12 -0700 Subject: [PATCH 13/16] add test --- .../lib/edit/deleteSteps/deleteEmptyQuote.ts | 4 +- .../lib/edit/inputSteps/handleEnterOnList.ts | 17 +- .../edit/inputSteps/handleEnterOnListTest.ts | 218 +++ .../test/edit/keyboardEnterTest.ts | 1418 +++++++++++++++++ .../test/edit/utils/splitParagraphTest.ts | 99 ++ 5 files changed, 1753 insertions(+), 3 deletions(-) create mode 100644 packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts create mode 100644 packages/roosterjs-content-model-plugins/test/edit/utils/splitParagraphTest.ts diff --git a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts index bb8ed065cc5..b3a21c69d40 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts @@ -29,8 +29,8 @@ export const deleteEmptyQuote: DeleteSelectionStep = context => { const rawEvent = formatContext?.rawEvent as KeyboardEvent; const index = getClosestAncestorBlockGroupIndex( path, - ['FormatContainer', 'ListItem'], - ['TableCell'] + ['FormatContainer'], + ['TableCell', 'ListItem'] ); const quote = path[index]; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts index ab091efbab4..5a7205a2700 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -24,7 +24,11 @@ export const handleEnterOnList: DeleteSelectionStep = context => { if (deleteResult == 'notDeleted' || deleteResult == 'nothingToDelete') { const { path } = insertPoint; - const index = getClosestAncestorBlockGroupIndex(path, ['ListItem'], ['TableCell']); + const index = getClosestAncestorBlockGroupIndex( + path, + ['ListItem'], + ['TableCell', 'FormatContainer'] + ); const readonlyListItem = path[index]; const listParent = path[index + 1]; @@ -84,6 +88,7 @@ const createNewListItem = ( const { insertPoint } = context; const listIndex = listParent.blocks.indexOf(listItem); const currentPara = insertPoint.paragraph; + const paraIndex = listItem.blocks.indexOf(currentPara); const newParagraph = splitParagraph(insertPoint); const levels = createNewListLevel(listItem); @@ -91,7 +96,17 @@ const createNewListItem = ( levels, insertPoint.marker.format ); + newListItem.blocks.push(newParagraph); + + const remainingBlockCount = listItem.blocks.length - paraIndex - 1; + + if (paraIndex >= 0 && remainingBlockCount > 0) { + newListItem.blocks.push( + ...mutateBlock(listItem).blocks.splice(paraIndex + 1, remainingBlockCount) + ); + } + insertPoint.paragraph = newParagraph; mutateBlock(listParent).blocks.splice(listIndex + 1, 0, newListItem); diff --git a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts index 69616999988..34a9ddd18d9 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -2438,4 +2438,222 @@ describe('handleEnterOnList - keyboardEnter', () => { }; runTest(input, true, expected, false, 1); }); + + it('List item contains multiple blocks', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test1', + segmentType: 'Text', + format: {}, + }, + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + { + text: 'test2', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + text: 'test3', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'test4', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + ], + }; + + const expectedModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + text: 'test1', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + dataset: {}, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + { + text: 'test2', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + { + segments: [ + { + text: 'test3', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + { + formatHolder: { + isSelected: false, + segmentType: 'SelectionMarker', + format: {}, + }, + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: undefined, + }, + dataset: {}, + }, + ], + blockType: 'BlockGroup', + format: {}, + blockGroupType: 'ListItem', + blocks: [ + { + isImplicit: true, + segments: [ + { + text: 'test4', + segmentType: 'Text', + format: {}, + }, + ], + blockType: 'Paragraph', + format: {}, + }, + ], + }, + ], + }; + + runTest(model, false, expectedModel, false, 1); + }); }); diff --git a/packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts b/packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts new file mode 100644 index 00000000000..b18a6d0b6f6 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts @@ -0,0 +1,1418 @@ +import { keyboardEnter } from '../../lib/edit/keyboardEnter'; +import { + createBr, + createFormatContainer, + createListItem, + createListLevel, + createParagraph, + createSelectionMarker, + createTable, + createTableCell, + createText, +} from 'roosterjs-content-model-dom'; +import { + ContentModelDocument, + ContentModelSegmentFormat, + FormatContentModelContext, + IEditor, +} from 'roosterjs-content-model-types'; + +describe('keyboardEnter', () => { + let getDOMSelectionSpy: jasmine.Spy; + let formatContentModelSpy: jasmine.Spy; + let preventDefaultSpy: jasmine.Spy; + let editor: IEditor; + + beforeEach(() => { + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection').and.returnValue({ + type: 'range', + }); + formatContentModelSpy = jasmine.createSpy('formatContentModel'); + preventDefaultSpy = jasmine.createSpy('preventDefault'); + editor = { + getDOMSelection: getDOMSelectionSpy, + formatContentModel: formatContentModelSpy, + } as any; + }); + + function runTest( + input: ContentModelDocument, + shift: boolean, + output: ContentModelDocument, + isChanged: boolean, + pendingFormat: ContentModelSegmentFormat | undefined + ) { + const rawEvent: KeyboardEvent = { + key: 'Enter', + shiftKey: shift, + preventDefault: preventDefaultSpy, + } as any; + const context: FormatContentModelContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + rawEvent, + }; + formatContentModelSpy.and.callFake((callback: Function) => { + const result = callback(input, context); + + expect(result).toBe(isChanged); + expect(); + }); + + keyboardEnter(editor, rawEvent); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(input).toEqual(output); + expect(context.newPendingFormat).toEqual(pendingFormat); + } + + it('Empty model, no selection', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [], + }, + false, + { + blockGroupType: 'Document', + blocks: [], + }, + false, + undefined + ); + }); + + it('Single paragraph, only have selection marker', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontSize: '10pt' }, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + ], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Br', + format: { fontSize: '10pt' }, + }, + ], + segmentFormat: { fontSize: '10pt' }, + }, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontSize: '10pt' }, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + ], + }, + true, + { fontSize: '10pt' } + ); + }); + + it('Single paragraph, all text are selected', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + isSelected: true, + text: 'test', + format: { fontSize: '10pt' }, + }, + ], + }, + ], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Br', + format: { fontSize: '10pt' }, + }, + ], + segmentFormat: { fontSize: '10pt' }, + }, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: { fontSize: '10pt' }, + }, + { + segmentType: 'Br', + format: { fontSize: '10pt' }, + }, + ], + segmentFormat: { fontSize: '10pt' }, + }, + ], + }, + true, + { fontSize: '10pt' } + ); + }); + + it('Multiple paragraph, single selection', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(createBr()); + para2.segments.push(createBr()); + + runTest( + { + blockGroupType: 'Document', + blocks: [ + para1, + { + blockType: 'Paragraph', + format: {}, + segments: [ + text1, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + text2, + ], + }, + para2, + ], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + para1, + { + blockType: 'Paragraph', + format: {}, + segments: [text1], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + text2, + ], + }, + para2, + ], + }, + true, + {} + ); + }); + + it('Multiple paragraph, select from line end to line start', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const marker1 = createSelectionMarker(); + const marker2 = createSelectionMarker(); + + para1.segments.push(text1, marker1); + para2.segments.push(marker2, text2); + + runTest( + { + blockGroupType: 'Document', + blocks: [para1, para2], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [text1], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [marker1, text2], + }, + ], + }, + true, + {} + ); + }); + + it('Multiple paragraph, select text across lines', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + + para1.segments.push(text1, text2); + para2.segments.push(text3, text4); + + text2.isSelected = true; + text3.isSelected = true; + + runTest( + { + blockGroupType: 'Document', + blocks: [para1, para2], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [text1], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + text4, + ], + }, + ], + }, + true, + {} + ); + }); + + it('Empty paragraph in quote', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const quote = createFormatContainer('blockquote'); + const marker = createSelectionMarker(); + const br1 = createBr(); + const br2 = createBr(); + const br3 = createBr(); + + para1.segments.push(br1); + para2.segments.push(marker, br2); + para3.segments.push(br3); + quote.blocks.push(para2); + + runTest( + { + blockGroupType: 'Document', + blocks: [para1, quote, para3], + }, + false, + { + blockGroupType: 'Document', + blocks: [para1, para2, para3], + }, + true, + {} + ); + }); + + it('Empty paragraph in middle of quote', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const quote = createFormatContainer('blockquote'); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const br = createBr(); + const text3 = createText('test3'); + + para1.segments.push(text1); + para2.segments.push(marker, br); + para3.segments.push(text3); + quote.blocks.push(para1, para2, para3); + + runTest( + { + blockGroupType: 'Document', + blocks: [quote], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockGroupType: 'FormatContainer', + blockType: 'BlockGroup', + blocks: [para1], + format: {}, + tagName: 'blockquote', + }, + para2, + { + blockGroupType: 'FormatContainer', + blockType: 'BlockGroup', + blocks: [para3], + format: {}, + tagName: 'blockquote', + }, + ], + }, + true, + {} + ); + }); + + it('Empty paragraph in middle of quote, not empty', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const quote = createFormatContainer('blockquote'); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('text2'); + const text3 = createText('test3'); + + para1.segments.push(text1); + para2.segments.push(marker, text2); + para3.segments.push(text3); + quote.blocks.push(para1, para2, para3); + + runTest( + { + blockGroupType: 'Document', + blocks: [quote], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockGroupType: 'FormatContainer', + blockType: 'BlockGroup', + blocks: [ + para1, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [marker, text2], + }, + para3, + ], + format: {}, + tagName: 'blockquote', + }, + ], + }, + true, + {} + ); + }); + + it('Empty paragraph in middle of quote, shift', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const quote = createFormatContainer('blockquote'); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const br = createBr(); + const text3 = createText('test3'); + + para1.segments.push(text1); + para2.segments.push(marker, br); + para3.segments.push(text3); + quote.blocks.push(para1, para2, para3); + + runTest( + { + blockGroupType: 'Document', + blocks: [quote], + }, + true, + { + blockGroupType: 'Document', + blocks: [ + { + blockGroupType: 'FormatContainer', + blockType: 'BlockGroup', + blocks: [ + para1, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + para3, + ], + format: {}, + tagName: 'blockquote', + }, + ], + }, + true, + {} + ); + }); + + it('Single empty list item', () => { + const para1 = createParagraph(); + const listLevel = createListLevel('OL'); + const list1 = createListItem([listLevel]); + const marker = createSelectionMarker(); + const br = createBr(); + + para1.segments.push(marker, br); + list1.blocks.push(para1); + + runTest( + { + blockGroupType: 'Document', + blocks: [list1], + }, + false, + { + blockGroupType: 'Document', + blocks: [para1], + }, + true, + {} + ); + }); + + it('Single empty list item, shift', () => { + const para1 = createParagraph(); + const listLevel = createListLevel('OL'); + const list1 = createListItem([listLevel]); + const marker = createSelectionMarker(); + const br = createBr(); + + para1.segments.push(marker, br); + list1.blocks.push(para1); + + runTest( + { + blockGroupType: 'Document', + blocks: [list1], + }, + true, + { + blockGroupType: 'Document', + blocks: [ + { + blockGroupType: 'ListItem', + blockType: 'BlockGroup', + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Br', + format: {}, + }, + ], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [ + marker, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + ], + levels: [listLevel], + }, + ], + }, + true, + {} + ); + }); + + it('First empty list item', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const listLevel = createListLevel('OL'); + const list1 = createListItem([listLevel]); + const list2 = createListItem([listLevel]); + const list3 = createListItem([listLevel]); + const marker = createSelectionMarker(); + const br = createBr(); + const text2 = createText('test2'); + const text3 = createText('test3'); + + para1.segments.push(marker, br); + para2.segments.push(text2); + para3.segments.push(text3); + list1.blocks.push(para1); + list2.blocks.push(para2); + list3.blocks.push(para3); + + runTest( + { + blockGroupType: 'Document', + blocks: [list1, list2, list3], + }, + false, + { + blockGroupType: 'Document', + blocks: [para1, list2, list3], + }, + true, + {} + ); + }); + + it('List item with text', () => { + const para1 = createParagraph(); + const listLevel = createListLevel('OL'); + const list1 = createListItem([listLevel]); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(text1, marker, text2); + list1.blocks.push(para1); + + runTest( + { + blockGroupType: 'Document', + blocks: [list1], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + ], + levels: [listLevel], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, text2], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + dataset: {}, + format: { + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + ], + }, + true, + {} + ); + }); + + it('Selection across list items', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const listLevel = createListLevel('OL'); + const list1 = createListItem([listLevel]); + const list3 = createListItem([listLevel]); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + const text5 = createText('test5'); + + text2.isSelected = true; + text3.isSelected = true; + text4.isSelected = true; + + para1.segments.push(text1, text2); + para2.segments.push(text3); + para3.segments.push(text4, text5); + list1.blocks.push(para1); + list3.blocks.push(para3); + + runTest( + { + blockGroupType: 'Document', + blocks: [list1, para2, list3], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + ], + levels: [listLevel], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + text5, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + ], + }, + true, + {} + ); + }); + + it('Selection across list items, shift', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const listLevel = createListLevel('OL'); + const list1 = createListItem([listLevel]); + const list3 = createListItem([listLevel]); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + const text5 = createText('test5'); + + text2.isSelected = true; + text3.isSelected = true; + text4.isSelected = true; + + para1.segments.push(text1, text2); + para2.segments.push(text3); + para3.segments.push(text4, text5); + list1.blocks.push(para1); + list3.blocks.push(para3); + + runTest( + { + blockGroupType: 'Document', + blocks: [list1, para2, list3], + }, + true, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + text5, + ], + format: {}, + }, + ], + levels: [listLevel], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + ], + }, + true, + {} + ); + }); + + it('multiple blocks under list item', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const listLevel = createListLevel('OL'); + const list1 = createListItem([listLevel]); + const list2 = createListItem([listLevel]); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + + para1.segments.push(text1, marker, text2); + para2.segments.push(text3); + para3.segments.push(text4); + list1.blocks.push(para1, para2); + list2.blocks.push(para3); + + runTest( + { + blockGroupType: 'Document', + blocks: [list1, list2], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + ], + levels: [listLevel], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, text2], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [text3], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: undefined, + displayForDummyItem: undefined, + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [text4], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: { + startNumberOverride: undefined, + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + ], + }, + true, + {} + ); + }); + + it('selection is in table', () => { + const para1 = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const td = createTableCell(); + const table = createTable(1); + + table.rows[0].cells.push(td); + td.blocks.push(para1); + para1.segments.push(text1, marker, text2); + + runTest( + { + blockGroupType: 'Document', + blocks: [table], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [marker, text2], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + }, + true, + {} + ); + }); + + it('selection is in table, under list', () => { + const para1 = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const td = createTableCell(); + const table = createTable(1); + + const listLevel = createListLevel('OL'); + const list = createListItem([listLevel]); + + table.rows[0].cells.push(td); + td.blocks.push(para1); + para1.segments.push(text1, marker, text2); + list.blocks.push(table); + + runTest( + { + blockGroupType: 'Document', + blocks: [list], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [marker, text2], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + levels: [{ listType: 'OL', format: {}, dataset: {} }], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + format: {}, + }, + ], + }, + true, + {} + ); + }); + + it('selection is in table, under quote', () => { + const para1 = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const td = createTableCell(); + const table = createTable(1); + + const quote = createFormatContainer('blockquote'); + + table.rows[0].cells.push(td); + td.blocks.push(para1); + para1.segments.push(text1, marker, text2); + quote.blocks.push(table); + + runTest( + { + blockGroupType: 'Document', + blocks: [quote], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [marker, text2], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + format: {}, + tagName: 'blockquote', + }, + ], + }, + true, + {} + ); + }); + + it('selection across table 1', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + const td = createTableCell(); + const table = createTable(1); + + table.rows[0].cells.push(td); + td.blocks.push(para2); + para1.segments.push(text1, text2); + para2.segments.push(text3, text4); + + text2.isSelected = true; + text3.isSelected = true; + + runTest( + { + blockGroupType: 'Document', + blocks: [para1, table], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + }, + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [text4], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + }, + true, + {} + ); + }); + + it('selection across table 2', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + const td = createTableCell(); + const table = createTable(1); + + table.rows[0].cells.push(td); + td.blocks.push(para1); + para1.segments.push(text1, text2); + para2.segments.push(text3, text4); + + text2.isSelected = true; + text3.isSelected = true; + + runTest( + { + blockGroupType: 'Document', + blocks: [table, para2], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + { + blockType: 'Paragraph', + segments: [text4], + format: {}, + }, + ], + }, + true, + {} + ); + }); + + it('selection cover table', () => { + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + const text5 = createText('test5'); + const td = createTableCell(); + const table = createTable(1); + + table.rows[0].cells.push(td); + td.blocks.push(para2); + para1.segments.push(text1, text2); + para2.segments.push(text3); + para3.segments.push(text4, text5); + + text2.isSelected = true; + text3.isSelected = true; + text4.isSelected = true; + td.isSelected = true; + + runTest( + { + blockGroupType: 'Document', + blocks: [para1, table, para3], + }, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + text5, + ], + format: {}, + }, + ], + }, + true, + {} + ); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/edit/utils/splitParagraphTest.ts b/packages/roosterjs-content-model-plugins/test/edit/utils/splitParagraphTest.ts new file mode 100644 index 00000000000..f810ee29904 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/edit/utils/splitParagraphTest.ts @@ -0,0 +1,99 @@ +import { ContentModelParagraph, InsertPoint } from 'roosterjs-content-model-types'; +import { splitParagraph } from '../../../lib/edit/utils/splitParagraph'; +import { + createBr, + createContentModelDocument, + createParagraph, + createSelectionMarker, + createText, +} from 'roosterjs-content-model-dom'; + +describe('splitParagraph', () => { + it('empty paragraph with selection marker and BR', () => { + const doc = createContentModelDocument(); + const marker = createSelectionMarker({ fontFamily: 'Arial' }); + const br = createBr(); + const para = createParagraph(false, { direction: 'ltr' }, { fontSize: '10pt' }); + const ip: InsertPoint = { + marker: marker, + paragraph: para, + path: [doc], + }; + + para.segments.push(marker, br); + doc.blocks.push(para); + + const result = splitParagraph(ip); + + const expectedResult: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [marker, br], + format: { direction: 'ltr' }, + segmentFormat: { fontSize: '10pt' }, + }; + + expect(result).toEqual(expectedResult); + expect(ip).toEqual({ + marker: marker, + paragraph: expectedResult, + path: [doc], + }); + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: { fontFamily: 'Arial' }, + }, + ], + format: { direction: 'ltr' }, + segmentFormat: { fontSize: '10pt', fontFamily: 'Arial' }, + }, + ], + }); + }); + + it('Paragraph with more segments', () => { + const doc = createContentModelDocument(); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const para = createParagraph(false); + const ip: InsertPoint = { + marker: marker, + paragraph: para, + path: [doc], + }; + + para.segments.push(text1, marker, text2); + doc.blocks.push(para); + + const result = splitParagraph(ip); + + const expectedResult: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [marker, text2], + format: {}, + }; + + expect(result).toEqual(expectedResult); + expect(ip).toEqual({ + marker: marker, + paragraph: expectedResult, + path: [doc], + }); + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [text1], + format: {}, + }, + ], + }); + }); +}); From aaa58f674a1dd099d31cded9557cf29a0a6ad169 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 5 Jun 2024 11:01:46 -0700 Subject: [PATCH 14/16] improve --- .../roosterjs-content-model-dom/lib/index.ts | 4 +-- .../lib/modelApi/editing/deleteSelection.ts | 17 +++------- .../lib/modelApi/editing/runEditSteps.ts | 25 ++++++++++++++ .../lib/edit/EditPlugin.ts | 10 ++---- .../lib/edit/keyboardEnter.ts | 34 +++++++------------ .../test/edit/EditPluginTest.ts | 10 +++--- .../edit/inputSteps/handleEnterOnListTest.ts | 2 +- .../test/edit/keyboardEnterTest.ts | 2 +- 8 files changed, 55 insertions(+), 49 deletions(-) create mode 100644 packages/roosterjs-content-model-dom/lib/modelApi/editing/runEditSteps.ts diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index f10c7245898..9aa77b83c05 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -102,8 +102,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, @@ -134,6 +132,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 5ee5b9232c6..add783a2ab1 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -26,7 +26,7 @@ export class EditPlugin implements EditorPlugin { private disposer: (() => void) | null = null; private shouldHandleNextInputEvent = false; private selectionAfterDelete: DOMSelection | null = null; - private handleEnterKey = false; + private handleNormalEnter = false; /** * Get name of this plugin @@ -43,7 +43,7 @@ export class EditPlugin implements EditorPlugin { */ initialize(editor: IEditor) { this.editor = editor; - this.handleEnterKey = this.editor.isExperimentalFeatureEnabled('PersistCache'); + this.handleNormalEnter = this.editor.isExperimentalFeatureEnabled('PersistCache'); if (editor.getEnvironment().isAndroid) { this.disposer = this.editor.attachDomEvent({ @@ -157,11 +157,7 @@ export class EditPlugin implements EditorPlugin { break; case 'Enter': - if (this.handleEnterKey) { - keyboardEnter(editor, rawEvent); - } else { - keyboardInput(editor, rawEvent); - } + keyboardEnter(editor, rawEvent, this.handleNormalEnter); break; default: diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts index 07ed60355ca..ef765e5e231 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts @@ -1,17 +1,17 @@ import { deleteEmptyQuote } from './deleteSteps/deleteEmptyQuote'; -import { deleteSelection, normalizeContentModel } from 'roosterjs-content-model-dom'; +import { deleteSelection, normalizeContentModel, runEditSteps } from 'roosterjs-content-model-dom'; import { handleEnterOnList } from './inputSteps/handleEnterOnList'; import { handleEnterOnParagraph } from './inputSteps/handleEnterOnParagraph'; -import type { - DeleteSelectionContext, - IEditor, - ValidDeleteSelectionContext, -} from 'roosterjs-content-model-types'; +import type { IEditor } from 'roosterjs-content-model-types'; /** * @internal */ -export function keyboardEnter(editor: IEditor, rawEvent: KeyboardEvent) { +export function keyboardEnter( + editor: IEditor, + rawEvent: KeyboardEvent, + handleNormalEnter: boolean +) { const selection = editor.getDOMSelection(); editor.formatContentModel( @@ -25,15 +25,13 @@ export function keyboardEnter(editor: IEditor, rawEvent: KeyboardEvent) { // so further delete steps can keep working result.deleteResult = 'notDeleted'; - const steps = rawEvent.shiftKey - ? [handleEnterOnParagraph] - : [handleEnterOnList, deleteEmptyQuote, handleEnterOnParagraph]; + const steps = rawEvent.shiftKey ? [] : [handleEnterOnList, deleteEmptyQuote]; - steps.forEach(step => { - if (isValidDeleteSelectionContext(result)) { - step(result); - } - }); + if (handleNormalEnter) { + steps.push(handleEnterOnParagraph); + } + + runEditSteps(steps, result); } if (result.deleteResult == 'range') { @@ -54,9 +52,3 @@ export function keyboardEnter(editor: IEditor, rawEvent: KeyboardEvent) { } ); } - -function isValidDeleteSelectionContext( - context: DeleteSelectionContext -): context is ValidDeleteSelectionContext { - return !!context.insertPoint; -} diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index 0c895000cbb..9296ddbc17a 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -123,7 +123,7 @@ describe('EditPlugin', () => { expect(keyboardEnterSpy).not.toHaveBeenCalled(); }); - it('Enter, keyboardEnter not enabled', () => { + it('Enter, normal enter not enabled', () => { plugin = new EditPlugin(); const rawEvent = { which: 13, key: 'Enter' } as any; const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); @@ -138,12 +138,12 @@ describe('EditPlugin', () => { }); expect(keyboardDeleteSpy).not.toHaveBeenCalled(); - expect(keyboardInputSpy).toHaveBeenCalledWith(editor, rawEvent); - expect(keyboardEnterSpy).not.toHaveBeenCalled(); + expect(keyboardInputSpy).not.toHaveBeenCalled(); + expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, false); expect(keyboardTabSpy).not.toHaveBeenCalled(); }); - it('Enter, keyboardEnter enabled', () => { + it('Enter, normal enter enabled', () => { isExperimentalFeatureEnabledSpy.and.callFake( (featureName: string) => featureName == 'PersistCache' ); @@ -162,7 +162,7 @@ describe('EditPlugin', () => { expect(keyboardDeleteSpy).not.toHaveBeenCalled(); expect(keyboardInputSpy).not.toHaveBeenCalled(); - expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardEnterSpy).toHaveBeenCalledWith(editor, rawEvent, true); expect(keyboardTabSpy).not.toHaveBeenCalled(); }); 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 34a9ddd18d9..81aeb2f74ca 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/inputSteps/handleEnterOnListTest.ts @@ -1828,7 +1828,7 @@ describe('handleEnterOnList - keyboardEnter', () => { }, }); - keyboardEnter(editor, mockedEvent); + keyboardEnter(editor, mockedEvent, true); }, input, expectedResult, diff --git a/packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts b/packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts index b18a6d0b6f6..8bfe3820021 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/keyboardEnterTest.ts @@ -60,7 +60,7 @@ describe('keyboardEnter', () => { expect(); }); - keyboardEnter(editor, rawEvent); + keyboardEnter(editor, rawEvent, true); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(input).toEqual(output); From 280345fa2e4f820bceb472e2b4c4c7e389699d5a Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 5 Jun 2024 11:10:46 -0700 Subject: [PATCH 15/16] do not scroll caret into view for now --- .../roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts index ef765e5e231..d08dd37e948 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts @@ -48,7 +48,7 @@ export function keyboardEnter( }, { rawEvent, - scrollCaretIntoView: true, + scrollCaretIntoView: false, } ); } From 2ec36504cabc5a7d043478b34e58cf72b2368f65 Mon Sep 17 00:00:00 2001 From: JiuqingSong Date: Thu, 6 Jun 2024 09:19:40 -0700 Subject: [PATCH 16/16] scroll the view when press enter if necessary --- .../roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts index d08dd37e948..ef765e5e231 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardEnter.ts @@ -48,7 +48,7 @@ export function keyboardEnter( }, { rawEvent, - scrollCaretIntoView: false, + scrollCaretIntoView: true, } ); }