From 2b74dcb53bb7c39219d1846d06564c8d306d695f Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 23 May 2024 16:57:23 -0700 Subject: [PATCH] Content Model Cache improvement - Step 5: Port roosterjs-content-model-dom package (#2648) * Readonly types (3rd try * Improve * fix build * Improve * improve * Improve * Add shallow mutable type * improve * Improve * improve * improve * add test * Readonly types step 2 * Readonly types step 3 * Readonly type step 4 * add test * Improve * improve * improve * Readonly types step 5: dom package * add change * improve * improve * Improve * improve * fix test * Improve * fix build * improve --- .../lib/modelApi/block/setModelIndentation.ts | 26 ++-- .../lib/modelApi/entity/insertEntityModel.ts | 21 +-- .../image/adjustImageSelectionTest.ts | 1 - .../publicApi/link/adjustLinkSelectionTest.ts | 2 - .../test/publicApi/link/removeLinkTest.ts | 1 - .../corePlugin/copyPaste/deleteEmptyList.ts | 7 +- .../modelApi/block/setParagraphNotImplicit.ts | 7 +- .../modelApi/common/normalizeContentModel.ts | 7 +- .../lib/modelApi/common/normalizeParagraph.ts | 28 ++-- .../lib/modelApi/common/normalizeSegment.ts | 74 +++++++---- .../lib/modelApi/common/unwrapBlock.ts | 14 +- .../lib/modelApi/editing/applyTableFormat.ts | 55 ++++---- .../lib/modelApi/editing/deleteBlock.ts | 8 +- .../editing/deleteExpandedSelection.ts | 42 +++--- .../lib/modelApi/editing/deleteSegment.ts | 33 +++-- .../lib/modelApi/editing/deleteSelection.ts | 11 +- .../lib/modelApi/editing/mergeModel.ts | 43 +++--- .../lib/modelApi/editing/normalizeTable.ts | 28 ++-- .../editing/setTableCellBackgroundColor.ts | 21 +-- .../lib/modelApi/selection/setSelection.ts | 124 ++++++++++++------ .../block/setParagraphNotImplicitTest.ts | 16 +++ .../test/modelApi/common/addSegmentTest.ts | 30 +++-- .../modelApi/common/ensureParagraphTest.ts | 17 ++- .../common/normalizeContentModelTest.ts | 17 +-- .../modelApi/common/normalizeParagraphTest.ts | 11 +- .../modelApi/common/normalizeSegmentTest.ts | 42 ++++-- .../test/modelApi/common/unwrapBlockTest.ts | 16 ++- .../modelApi/editing/applyTableFormatTest.ts | 27 ++-- .../modelApi/editing/normalizeTableTest.ts | 72 +++++----- .../setTableCellBackgroundColorTest.ts | 7 +- .../modelApi/selection/setSelectionTest.ts | 1 - .../deleteSteps/deleteCollapsedSelection.ts | 36 ++--- .../lib/edit/deleteSteps/deleteEmptyQuote.ts | 7 +- .../edit/deleteSteps/deleteWordSelection.ts | 4 +- .../lib/edit/inputSteps/handleEnterOnList.ts | 27 ++-- .../test/tableEdit/tableInserterTest.ts | 4 +- .../lib/parameter/DeleteSelectionStep.ts | 8 +- .../lib/selection/InsertPoint.ts | 12 +- 38 files changed, 548 insertions(+), 359 deletions(-) diff --git a/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts b/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts index 828635044a0..7abcf8e17f8 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts @@ -4,17 +4,19 @@ import { createListLevel, getOperationalBlocks, isBlockGroupOfType, + mutateBlock, parseValueWithUnit, updateListMetadata, } from 'roosterjs-content-model-dom'; import type { - ContentModelBlock, ContentModelBlockFormat, - ContentModelBlockGroup, - ContentModelDocument, ContentModelListItem, ContentModelListLevel, FormatContentModelContext, + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelDocument, + ReadonlyContentModelListItem, } from 'roosterjs-content-model-types'; const IndentStepInPixel = 40; @@ -26,7 +28,7 @@ const IndentStepInPixel = 40; * Set indentation for selected list items or paragraphs */ export function setModelIndentation( - model: ContentModelDocument, + model: ReadonlyContentModelDocument, indentation: 'indent' | 'outdent', length: number = IndentStepInPixel, context?: FormatContentModelContext @@ -37,7 +39,7 @@ export function setModelIndentation( ['TableCell'] ); const isIndent = indentation == 'indent'; - const modifiedBlocks: ContentModelBlock[] = []; + const modifiedBlocks: ReadonlyContentModelBlock[] = []; paragraphOrListItem.forEach(({ block, parent, path }) => { if (isBlockGroupOfType(block, 'ListItem')) { @@ -89,12 +91,12 @@ export function setModelIndentation( } } } else if (block) { - let currentBlock: ContentModelBlock = block; - let currentParent: ContentModelBlockGroup = parent; + let currentBlock: ReadonlyContentModelBlock = block; + let currentParent: ReadonlyContentModelBlockGroup = parent; while (currentParent && modifiedBlocks.indexOf(currentBlock) < 0) { const index = path.indexOf(currentParent); - const { format } = currentBlock; + const { format } = mutateBlock(currentBlock); const newValue = calculateMarginValue(format, isIndent, length); if (newValue !== null) { @@ -124,7 +126,7 @@ export function setModelIndentation( return paragraphOrListItem.length > 0; } -function isSelected(listItem: ContentModelListItem) { +function isSelected(listItem: ReadonlyContentModelListItem) { return listItem.blocks.some(block => { if (block.blockType == 'Paragraph') { return block.segments.some(segment => segment.isSelected); @@ -137,9 +139,9 @@ function isSelected(listItem: ContentModelListItem) { * Otherwise, the margin of the first item will be changed, and the sub list will be created, creating a unintentional margin difference between the list items. */ function isMultilevelSelection( - model: ContentModelDocument, - listItem: ContentModelListItem, - parent: ContentModelBlockGroup + model: ReadonlyContentModelDocument, + listItem: ReadonlyContentModelListItem, + parent: ReadonlyContentModelBlockGroup ) { const listIndex = parent.blocks.indexOf(listItem); for (let i = listIndex - 1; i >= 0; i--) { diff --git a/packages/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts b/packages/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts index faa023c42a8..d7db2f0a7c2 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts @@ -6,16 +6,18 @@ import { deleteSelection, getClosestAncestorBlockGroupIndex, setSelection, + mutateBlock, } from 'roosterjs-content-model-dom'; import type { - ContentModelBlock, - ContentModelBlockGroup, ContentModelDocument, ContentModelEntity, - ContentModelParagraph, FormatContentModelContext, InsertEntityPosition, InsertPoint, + ReadonlyContentModelBlock, + ShallowMutableContentModelBlock, + ShallowMutableContentModelBlockGroup, + ShallowMutableContentModelParagraph, } from 'roosterjs-content-model-types'; /** @@ -30,7 +32,7 @@ export function insertEntityModel( context?: FormatContentModelContext, insertPointOverride?: InsertPoint ) { - let blockParent: ContentModelBlockGroup | undefined; + let blockParent: ShallowMutableContentModelBlockGroup | undefined; let blockIndex = -1; let insertPoint: InsertPoint | null; @@ -57,9 +59,10 @@ export function insertEntityModel( position == 'root' ? getClosestAncestorBlockGroupIndex(path, ['TableCell', 'Document']) : 0; - blockParent = path[pathIndex]; + blockParent = mutateBlock(path[pathIndex]); + const child = path[pathIndex - 1]; - const directChild: ContentModelBlock = + const directChild: ReadonlyContentModelBlock = child?.blockGroupType == 'FormatContainer' || child?.blockGroupType == 'General' || child?.blockGroupType == 'ListItem' @@ -71,8 +74,8 @@ export function insertEntityModel( } if (blockIndex >= 0 && blockParent) { - const blocksToInsert: ContentModelBlock[] = []; - let nextParagraph: ContentModelParagraph | undefined; + const blocksToInsert: ShallowMutableContentModelBlock[] = []; + let nextParagraph: ShallowMutableContentModelParagraph | undefined; if (isBlock) { const nextBlock = blockParent.blocks[blockIndex]; @@ -80,7 +83,7 @@ export function insertEntityModel( blocksToInsert.push(entityModel); if (nextBlock?.blockType == 'Paragraph') { - nextParagraph = nextBlock; + nextParagraph = mutateBlock(nextBlock); } else if (!nextBlock || nextBlock.blockType == 'Entity' || focusAfterEntity) { nextParagraph = createParagraph(false /*isImplicit*/, {}, model.format); nextParagraph.segments.push(createBr(model.format)); diff --git a/packages/roosterjs-content-model-api/test/publicApi/image/adjustImageSelectionTest.ts b/packages/roosterjs-content-model-api/test/publicApi/image/adjustImageSelectionTest.ts index 5e60019d87a..a0e6b54b11f 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/image/adjustImageSelectionTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/image/adjustImageSelectionTest.ts @@ -189,7 +189,6 @@ describe('adjustImageSelection', () => { format: {}, src: 'img2', dataset: {}, - isSelectedAsImageSelection: false, }, { segmentType: 'Text', diff --git a/packages/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts b/packages/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts index fdac2d121dc..c53b8428ac0 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts @@ -154,7 +154,6 @@ describe('adjustLinkSelection', () => { link: link, dataset: {}, isSelected: true, - isSelectedAsImageSelection: false, }, { segmentType: 'Text', @@ -228,7 +227,6 @@ describe('adjustLinkSelection', () => { link: link, dataset: {}, isSelected: true, - isSelectedAsImageSelection: false, }, { segmentType: 'Text', diff --git a/packages/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts b/packages/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts index 0d0f1f081bf..ac563a97d05 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts @@ -150,7 +150,6 @@ describe('removeLink', () => { dataset: {}, format: {}, isSelected: true, - isSelectedAsImageSelection: false, }, ], }, diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/deleteEmptyList.ts b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/deleteEmptyList.ts index 0031bb195a0..7aae91ee484 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/deleteEmptyList.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/deleteEmptyList.ts @@ -2,14 +2,15 @@ import { getClosestAncestorBlockGroupIndex, hasSelectionInBlock, hasSelectionInBlockGroup, + mutateBlock, } from 'roosterjs-content-model-dom'; import type { - ContentModelBlock, DeleteSelectionContext, DeleteSelectionStep, + ReadonlyContentModelBlock, } from 'roosterjs-content-model-types'; -function isEmptyBlock(block: ContentModelBlock | undefined): boolean { +function isEmptyBlock(block: ReadonlyContentModelBlock | undefined): boolean { if (block && block.blockType == 'Paragraph') { return block.segments.every( segment => segment.segmentType !== 'SelectionMarker' && segment.segmentType == 'Br' @@ -53,7 +54,7 @@ export const deleteEmptyList: DeleteSelectionStep = (context: DeleteSelectionCon nextBlock && isEmptyBlock(nextBlock) ) { - item.levels = []; + mutateBlock(item).levels = []; } } } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/block/setParagraphNotImplicit.ts b/packages/roosterjs-content-model-dom/lib/modelApi/block/setParagraphNotImplicit.ts index 949e3cb3b50..734c9999a0c 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/block/setParagraphNotImplicit.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/block/setParagraphNotImplicit.ts @@ -1,11 +1,12 @@ -import type { ContentModelBlock } from 'roosterjs-content-model-types'; +import { mutateBlock } from '../common/mutate'; +import type { ReadonlyContentModelBlock } from 'roosterjs-content-model-types'; /** * For a given block, if it is a paragraph, set it to be not-implicit * @param block The block to check */ -export function setParagraphNotImplicit(block: ContentModelBlock) { +export function setParagraphNotImplicit(block: ReadonlyContentModelBlock) { if (block.blockType == 'Paragraph' && block.isImplicit) { - block.isImplicit = false; + mutateBlock(block).isImplicit = false; } } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel.ts index a808e975a4c..46f648c85a1 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel.ts @@ -1,7 +1,8 @@ import { isBlockEmpty } from './isEmpty'; +import { mutateBlock } from './mutate'; import { normalizeParagraph } from './normalizeParagraph'; import { unwrapBlock } from './unwrapBlock'; -import type { ContentModelBlockGroup } from 'roosterjs-content-model-types'; +import type { ReadonlyContentModelBlockGroup } from 'roosterjs-content-model-types'; /** * For a given content model, normalize it to make the model be consistent. @@ -12,7 +13,7 @@ import type { ContentModelBlockGroup } from 'roosterjs-content-model-types'; * - For an empty block, remove it * @param group The root level block group of content model to normalize */ -export function normalizeContentModel(group: ContentModelBlockGroup) { +export function normalizeContentModel(group: ReadonlyContentModelBlockGroup) { for (let i = group.blocks.length - 1; i >= 0; i--) { const block = group.blocks[i]; @@ -40,7 +41,7 @@ export function normalizeContentModel(group: ContentModelBlockGroup) { } if (isBlockEmpty(block)) { - group.blocks.splice(i, 1); + mutateBlock(group).blocks.splice(i, 1); } } } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts index 14616124d47..b9c120c5a83 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeParagraph.ts @@ -2,18 +2,19 @@ import { areSameFormats } from '../../domToModel/utils/areSameFormats'; import { createBr } from '../creators/createBr'; import { isSegmentEmpty } from './isEmpty'; import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; +import { mutateBlock, mutateSegment } from './mutate'; import { normalizeAllSegments } from './normalizeSegment'; import type { - ContentModelParagraph, - ContentModelSegment, ContentModelSegmentFormat, + ReadonlyContentModelParagraph, + ReadonlyContentModelSegment, } from 'roosterjs-content-model-types'; /** * @param paragraph The paragraph to normalize * Normalize a paragraph. If it is empty, add a BR segment to make sure it can insert content */ -export function normalizeParagraph(paragraph: ContentModelParagraph) { +export function normalizeParagraph(paragraph: ReadonlyContentModelParagraph) { const segments = paragraph.segments; if (!paragraph.isImplicit && segments.length > 0) { @@ -24,7 +25,7 @@ export function normalizeParagraph(paragraph: ContentModelParagraph) { last.segmentType == 'SelectionMarker' && (!secondLast || secondLast.segmentType == 'Br') ) { - segments.push(createBr(last.format)); + mutateBlock(paragraph).segments.push(createBr(last.format)); } else if (segments.length > 1 && segments[segments.length - 1].segmentType == 'Br') { const noMarkerSegments = segments.filter(x => x.segmentType != 'SelectionMarker'); @@ -34,7 +35,7 @@ export function normalizeParagraph(paragraph: ContentModelParagraph) { noMarkerSegments.length > 1 && noMarkerSegments[noMarkerSegments.length - 2].segmentType != 'Br' ) { - segments.pop(); + mutateBlock(paragraph).segments.pop(); } } } @@ -50,20 +51,21 @@ export function normalizeParagraph(paragraph: ContentModelParagraph) { moveUpSegmentFormat(paragraph); } -function removeEmptySegments(block: ContentModelParagraph) { +function removeEmptySegments(block: ReadonlyContentModelParagraph) { for (let j = block.segments.length - 1; j >= 0; j--) { if (isSegmentEmpty(block.segments[j])) { - block.segments.splice(j, 1); + mutateBlock(block).segments.splice(j, 1); } } } -function removeEmptyLinks(paragraph: ContentModelParagraph) { +function removeEmptyLinks(paragraph: ReadonlyContentModelParagraph) { const marker = paragraph.segments.find(x => x.segmentType == 'SelectionMarker'); if (marker) { const markerIndex = paragraph.segments.indexOf(marker); const prev = paragraph.segments[markerIndex - 1]; const next = paragraph.segments[markerIndex + 1]; + if ( (prev && !prev.link && @@ -76,7 +78,9 @@ function removeEmptyLinks(paragraph: ContentModelParagraph) { !next.link && areSameFormats(next.format, marker.format)) ) { - delete marker.link; + mutateSegment(paragraph, marker, mutableMarker => { + delete mutableMarker.link; + }); } } } @@ -85,7 +89,7 @@ type FormatsToMoveUp = 'fontFamily' | 'fontSize' | 'textColor'; const formatsToMoveUp: FormatsToMoveUp[] = ['fontFamily', 'fontSize', 'textColor']; // When all segments are sharing the same segment format (font name, size and color), we can move its format to paragraph -function moveUpSegmentFormat(paragraph: ContentModelParagraph) { +function moveUpSegmentFormat(paragraph: ReadonlyContentModelParagraph) { if (!paragraph.decorator) { const segments = paragraph.segments.filter(x => x.segmentType != 'SelectionMarker'); const target = paragraph.segmentFormat || {}; @@ -96,13 +100,13 @@ function moveUpSegmentFormat(paragraph: ContentModelParagraph) { }); if (changed) { - paragraph.segmentFormat = target; + mutateBlock(paragraph).segmentFormat = target; } } } function internalMoveUpSegmentFormat( - segments: ContentModelSegment[], + segments: ReadonlyContentModelSegment[], target: ContentModelSegmentFormat, formatKey: FormatsToMoveUp ): boolean { diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeSegment.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeSegment.ts index 59bb9be59e8..d191f4ab61c 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeSegment.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/normalizeSegment.ts @@ -1,8 +1,9 @@ import { hasSpacesOnly } from './hasSpacesOnly'; +import { mutateSegment } from './mutate'; import type { - ContentModelParagraph, - ContentModelSegment, - ContentModelText, + ReadonlyContentModelParagraph, + ReadonlyContentModelSegment, + ReadonlyContentModelText, } from 'roosterjs-content-model-types'; const SPACE = '\u0020'; @@ -13,15 +14,15 @@ const TRAILING_SPACE_REGEX = /\u0020+$/; /** * @internal */ -export function normalizeAllSegments(paragraph: ContentModelParagraph) { +export function normalizeAllSegments(paragraph: ReadonlyContentModelParagraph) { const context = resetNormalizeSegmentContext(); paragraph.segments.forEach(segment => { - normalizeSegment(segment, context); + normalizeSegment(paragraph, segment, context); }); - normalizeTextSegments(context.textSegments, context.lastInlineSegment); - normalizeLastTextSegment(context.lastTextSegment, context.lastInlineSegment); + normalizeTextSegments(paragraph, context.textSegments, context.lastInlineSegment); + normalizeLastTextSegment(paragraph, context.lastTextSegment, context.lastInlineSegment); } /** @@ -30,24 +31,25 @@ export function normalizeAllSegments(paragraph: ContentModelParagraph) { * @param ignoreTrailingSpaces Whether we should ignore the trailing space of the text segment @default false */ export function normalizeSingleSegment( - segment: ContentModelSegment, + paragraph: ReadonlyContentModelParagraph, + segment: ReadonlyContentModelSegment, ignoreTrailingSpaces: boolean = false ) { const context = resetNormalizeSegmentContext(); context.ignoreTrailingSpaces = ignoreTrailingSpaces; - normalizeSegment(segment, context); + normalizeSegment(paragraph, segment, context); } /** * @internal Export for test only */ export interface NormalizeSegmentContext { - textSegments: ContentModelText[]; + textSegments: ReadonlyContentModelText[]; ignoreLeadingSpaces: boolean; ignoreTrailingSpaces: boolean; - lastTextSegment: ContentModelText | undefined; - lastInlineSegment: ContentModelSegment | undefined; + lastTextSegment: ReadonlyContentModelText | undefined; + lastInlineSegment: ReadonlyContentModelSegment | undefined; } /** @@ -72,11 +74,15 @@ function resetNormalizeSegmentContext( /** * @internal Export for test only */ -export function normalizeSegment(segment: ContentModelSegment, context: NormalizeSegmentContext) { +export function normalizeSegment( + paragraph: ReadonlyContentModelParagraph, + segment: ReadonlyContentModelSegment, + context: NormalizeSegmentContext +) { switch (segment.segmentType) { case 'Br': - normalizeTextSegments(context.textSegments, context.lastInlineSegment); - normalizeLastTextSegment(context.lastTextSegment, context.lastInlineSegment); + normalizeTextSegments(paragraph, context.textSegments, context.lastInlineSegment); + normalizeLastTextSegment(paragraph, context.lastTextSegment, context.lastInlineSegment); // Line ends, reset all states resetNormalizeSegmentContext(context); @@ -103,18 +109,22 @@ export function normalizeSegment(segment: ContentModelSegment, context: Normaliz if (!hasSpacesOnly(segment.text)) { if (first == SPACE) { // 1. Multiple leading space => single   or empty (depends on if previous segment ends with space) - segment.text = segment.text.replace( - LEADING_SPACE_REGEX, - context.ignoreLeadingSpaces ? '' : NONE_BREAK_SPACE - ); + mutateSegment(paragraph, segment, textSegment => { + textSegment.text = textSegment.text.replace( + LEADING_SPACE_REGEX, + context.ignoreLeadingSpaces ? '' : NONE_BREAK_SPACE + ); + }); } if (last == SPACE) { // 2. Multiple trailing space => single space - segment.text = segment.text.replace( - TRAILING_SPACE_REGEX, - context.ignoreTrailingSpaces ? SPACE : NONE_BREAK_SPACE - ); + mutateSegment(paragraph, segment, textSegment => { + textSegment.text = textSegment.text.replace( + TRAILING_SPACE_REGEX, + context.ignoreTrailingSpaces ? SPACE : NONE_BREAK_SPACE + ); + }); } } @@ -125,8 +135,9 @@ export function normalizeSegment(segment: ContentModelSegment, context: Normaliz } function normalizeTextSegments( - segments: ContentModelText[], - lastInlineSegment: ContentModelSegment | undefined + paragraph: ReadonlyContentModelParagraph, + segments: ReadonlyContentModelText[], + lastInlineSegment: ReadonlyContentModelSegment | undefined ) { segments.forEach(segment => { // 3. Segment ends with   replace it with space if the previous char is not space so that next segment can wrap @@ -139,18 +150,23 @@ function normalizeTextSegments( text.length > 1 && text.substr(-2, 1) != SPACE ) { - segment.text = text.substring(0, text.length - 1) + SPACE; + mutateSegment(paragraph, segment, textSegment => { + textSegment.text = text.substring(0, text.length - 1) + SPACE; + }); } } }); } function normalizeLastTextSegment( - segment: ContentModelText | undefined, - lastInlineSegment: ContentModelSegment | undefined + paragraph: ReadonlyContentModelParagraph, + segment: ReadonlyContentModelText | undefined, + lastInlineSegment: ReadonlyContentModelSegment | undefined ) { if (segment && segment == lastInlineSegment && segment?.text.substr(-1) == SPACE) { // 4. last text segment of the paragraph, remove trailing space - segment.text = segment.text.replace(TRAILING_SPACE_REGEX, ''); + mutateSegment(paragraph, segment, textSegment => { + textSegment.text = textSegment.text.replace(TRAILING_SPACE_REGEX, ''); + }); } } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/unwrapBlock.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/unwrapBlock.ts index 312f007bfab..375f0a22117 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/unwrapBlock.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/unwrapBlock.ts @@ -1,5 +1,9 @@ +import { mutateBlock } from './mutate'; import { setParagraphNotImplicit } from '../block/setParagraphNotImplicit'; -import type { ContentModelBlock, ContentModelBlockGroup } from 'roosterjs-content-model-types'; +import type { + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, +} from 'roosterjs-content-model-types'; /** * Unwrap a given block group, move its child blocks to be under its parent group @@ -7,14 +11,16 @@ import type { ContentModelBlock, ContentModelBlockGroup } from 'roosterjs-conten * @param groupToUnwrap The block group to unwrap */ export function unwrapBlock( - parent: ContentModelBlockGroup | null, - groupToUnwrap: ContentModelBlockGroup & ContentModelBlock + parent: ReadonlyContentModelBlockGroup | null, + groupToUnwrap: ReadonlyContentModelBlockGroup & ReadonlyContentModelBlock ) { const index = parent?.blocks.indexOf(groupToUnwrap) ?? -1; if (index >= 0) { groupToUnwrap.blocks.forEach(setParagraphNotImplicit); - parent?.blocks.splice(index, 1, ...groupToUnwrap.blocks); + if (parent) { + mutateBlock(parent)?.blocks.splice(index, 1, ...groupToUnwrap.blocks.map(mutateBlock)); + } } } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/applyTableFormat.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/applyTableFormat.ts index 28b51645fc9..f7e9065a775 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/applyTableFormat.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/applyTableFormat.ts @@ -1,13 +1,14 @@ import { BorderKeys } from '../../formatHandlers/common/borderFormatHandler'; import { combineBorderValue, extractBorderValues } from '../../domUtils/style/borderValues'; +import { mutateBlock } from '../common/mutate'; import { setTableCellBackgroundColor } from './setTableCellBackgroundColor'; import { TableBorderFormat } from '../../constants/TableBorderFormat'; import { updateTableCellMetadata } from '../metadata/updateTableCellMetadata'; import { updateTableMetadata } from '../metadata/updateTableMetadata'; import type { BorderFormat, - ContentModelTable, - ContentModelTableRow, + ReadonlyContentModelTable, + ShallowMutableContentModelTableRow, TableMetadataFormat, } from 'roosterjs-content-model-types'; @@ -39,42 +40,34 @@ type MetaOverrides = { * @param keepCellShade @optional When pass true, table cells with customized shade color will not be overwritten. @default false */ export function applyTableFormat( - table: ContentModelTable, + table: ReadonlyContentModelTable, newFormat?: TableMetadataFormat, keepCellShade?: boolean ) { - const { rows } = table; + const mutableTable = mutateBlock(table); + const { rows } = mutableTable; - updateTableMetadata(table, format => { + updateTableMetadata(mutableTable, format => { const effectiveMetadata = { ...DEFAULT_FORMAT, ...format, - ...(newFormat || {}), + ...newFormat, }; const metaOverrides: MetaOverrides = updateOverrides(rows, !keepCellShade); - delete table.cachedElement; - - clearCache(rows); formatCells(rows, effectiveMetadata, metaOverrides); setFirstColumnFormatBorders(rows, effectiveMetadata); setHeaderRowFormat(rows, effectiveMetadata, metaOverrides); - return effectiveMetadata; - }); -} - -function clearCache(rows: ContentModelTableRow[]) { - rows.forEach(row => { - row.cells.forEach(cell => { - delete cell.cachedElement; - }); - delete row.cachedElement; + return effectiveMetadata; }); } -function updateOverrides(rows: ContentModelTableRow[], removeCellShade: boolean): MetaOverrides { +function updateOverrides( + rows: ShallowMutableContentModelTableRow[], + removeCellShade: boolean +): MetaOverrides { const overrides: MetaOverrides = { bgColorOverrides: [], vAlignOverrides: [], @@ -91,7 +84,7 @@ function updateOverrides(rows: ContentModelTableRow[], removeCellShade: boolean) overrides.borderOverrides.push(borderOverrides); row.cells.forEach(cell => { - updateTableCellMetadata(cell, metadata => { + updateTableCellMetadata(mutateBlock(cell), metadata => { if (metadata && removeCellShade) { bgColorOverrides.push(false); delete metadata.bgColorOverride; @@ -172,14 +165,16 @@ const BorderFormatters: Record = * Apply vertical align, borders, and background color to all cells in the table */ function formatCells( - rows: ContentModelTableRow[], + rows: ShallowMutableContentModelTableRow[], format: TableMetadataFormat, metaOverrides: MetaOverrides ) { const { hasBandedRows, hasBandedColumns, bgColorOdd, bgColorEven, hasFirstColumn } = format; rows.forEach((row, rowIndex) => { - row.cells.forEach((cell, colIndex) => { + row.cells.forEach((readonlyCell, colIndex) => { + const cell = mutateBlock(readonlyCell); + // Format Borders if ( !metaOverrides.borderOverrides[rowIndex][colIndex] && @@ -249,7 +244,7 @@ function formatCells( * @param format The table metadata format */ export function setFirstColumnFormatBorders( - rows: ContentModelTableRow[], + rows: ShallowMutableContentModelTableRow[], format: Partial ) { // Exit early hasFirstColumn is not set @@ -258,8 +253,10 @@ export function setFirstColumnFormatBorders( } rows.forEach((row, rowIndex) => { - row.cells.forEach((cell, cellIndex) => { - if (cellIndex == 0) { + row.cells.forEach((readonlyCell, cellIndex) => { + const cell = mutateBlock(readonlyCell); + + if (cellIndex === 0) { cell.isHeader = true; switch (rowIndex) { @@ -282,7 +279,7 @@ export function setFirstColumnFormatBorders( } function setHeaderRowFormat( - rows: ContentModelTableRow[], + rows: ShallowMutableContentModelTableRow[], format: TableMetadataFormat, metaOverrides: MetaOverrides ) { @@ -293,7 +290,9 @@ function setHeaderRowFormat( const rowIndex = 0; - rows[rowIndex]?.cells.forEach((cell, cellIndex) => { + rows[rowIndex]?.cells.forEach((readonlyCell, cellIndex) => { + const cell = mutateBlock(readonlyCell); + cell.isHeader = true; if (format.headerRowColor) { diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteBlock.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteBlock.ts index 6b58dc828e9..a7acdd276a8 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteBlock.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteBlock.ts @@ -1,7 +1,7 @@ import type { - ContentModelBlock, EntityRemovalOperation, FormatContentModelContext, + ReadonlyContentModelBlock, } from 'roosterjs-content-model-types'; /** @@ -14,9 +14,9 @@ import type { * If not specified, only selected entity will be deleted */ export function deleteBlock( - blocks: ContentModelBlock[], - blockToDelete: ContentModelBlock, - replacement?: ContentModelBlock, + blocks: ReadonlyContentModelBlock[], + blockToDelete: ReadonlyContentModelBlock, + replacement?: ReadonlyContentModelBlock, context?: FormatContentModelContext, direction?: 'forward' | 'backward' ): boolean { diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteExpandedSelection.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteExpandedSelection.ts index f2131c384a6..33ebafe00ca 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteExpandedSelection.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteExpandedSelection.ts @@ -5,17 +5,18 @@ import { deleteBlock } from './deleteBlock'; import { deleteSegment } from './deleteSegment'; import { getSegmentTextFormat } from './getSegmentTextFormat'; import { iterateSelections } from '../selection/iterateSelections'; +import { mutateBlock, mutateSegments } from '../common/mutate'; import { setParagraphNotImplicit } from '../block/setParagraphNotImplicit'; import type { - ContentModelBlockGroup, - ContentModelDocument, - ContentModelParagraph, ContentModelSelectionMarker, DeleteSelectionContext, FormatContentModelContext, InsertPoint, IterateSelectionsOption, - TableSelectionContext, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelDocument, + ReadonlyTableSelectionContext, + ShallowMutableContentModelParagraph, } from 'roosterjs-content-model-types'; const DeleteSelectionIteratingOptions: IterateSelectionsOption = { @@ -30,7 +31,7 @@ const DeleteSelectionIteratingOptions: IterateSelectionsOption = { * at the first deleted position so that we know where to put cursor to after delete */ export function deleteExpandedSelection( - model: ContentModelDocument, + model: ReadonlyContentModelDocument, formatContext?: FormatContentModelContext ): DeleteSelectionContext { const context: DeleteSelectionContext = { @@ -41,10 +42,10 @@ export function deleteExpandedSelection( iterateSelections( model, - (path, tableContext, block, segments) => { + (path, tableContext, readonlyBlock, readonlySegments) => { // Set paragraph, format and index for default position where we will put cursor to. // Later we can overwrite these info when process the selections - let paragraph = createParagraph( + let paragraph: ShallowMutableContentModelParagraph = createParagraph( true /*implicit*/, undefined /*blockFormat*/, model.format @@ -52,13 +53,15 @@ export function deleteExpandedSelection( let markerFormat = model.format; let insertMarkerIndex = 0; - if (segments) { + if (readonlySegments && readonlyBlock?.blockType == 'Paragraph') { + const [block, segments, indexes] = mutateSegments(readonlyBlock, readonlySegments); + // Delete segments inside a paragraph - if (segments[0] && block?.blockType == 'Paragraph') { + if (segments[0]) { // Now that we have found a paragraph with selections, we can overwrite the default paragraph with this one // so we can put cursor here after delete paragraph = block; - insertMarkerIndex = paragraph.segments.indexOf(segments[0]); + insertMarkerIndex = indexes[0]; markerFormat = getSegmentTextFormat(segments[0]); context.lastParagraph = paragraph; @@ -90,25 +93,24 @@ export function deleteExpandedSelection( setParagraphNotImplicit(block); } } - } else if (block) { + } else if (readonlyBlock) { // Delete a whole block (divider, table, ...) - const blocks = path[0].blocks; + const blocks = mutateBlock(path[0]).blocks; - if (deleteBlock(blocks, block, paragraph, context.formatContext)) { + if (deleteBlock(blocks, readonlyBlock, paragraph, context.formatContext)) { context.deleteResult = 'range'; } } else if (tableContext) { // Delete a whole table cell const { table, colIndex, rowIndex } = tableContext; - const row = table.rows[rowIndex]; - const cell = row.cells[colIndex]; + const mutableTable = mutateBlock(table); + const row = mutableTable.rows[rowIndex]; + const cell = mutateBlock(row.cells[colIndex]); path = [cell, ...path]; paragraph.segments.push(createBr(model.format)); cell.blocks = [paragraph]; - delete cell.cachedElement; - delete row.cachedElement; context.deleteResult = 'range'; } @@ -129,9 +131,9 @@ export function deleteExpandedSelection( function createInsertPoint( marker: ContentModelSelectionMarker, - paragraph: ContentModelParagraph, - path: ContentModelBlockGroup[], - tableContext: TableSelectionContext | undefined + paragraph: ShallowMutableContentModelParagraph, + path: ReadonlyContentModelBlockGroup[], + tableContext: ReadonlyTableSelectionContext | undefined ): InsertPoint { return { marker, diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSegment.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSegment.ts index ee809726160..d1018ec16dc 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSegment.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSegment.ts @@ -1,39 +1,43 @@ import { deleteSingleChar } from './deleteSingleChar'; import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; +import { mutateSegment } from '../common/mutate'; import { normalizeSingleSegment } from '../common/normalizeSegment'; import { normalizeText } from '../../domUtils/stringUtil'; import type { - ContentModelParagraph, - ContentModelSegment, EntityRemovalOperation, FormatContentModelContext, + ReadonlyContentModelParagraph, + ReadonlyContentModelSegment, } from 'roosterjs-content-model-types'; /** * Delete a content model segment from current selection - * @param paragraph Parent paragraph of the segment to delete - * @param segmentToDelete The segment to delete + * @param readonlyParagraph Parent paragraph of the segment to delete + * @param readonlySegmentToDelete The segment to delete * @param context @optional Context object provided by formatContentModel API * @param direction @optional Whether this is deleting forward or backward. This is only used for deleting entity. * If not specified, only selected entity will be deleted */ export function deleteSegment( - paragraph: ContentModelParagraph, - segmentToDelete: ContentModelSegment, + readonlyParagraph: ReadonlyContentModelParagraph, + readonlySegmentToDelete: ReadonlyContentModelSegment, context?: FormatContentModelContext, direction?: 'forward' | 'backward' ): boolean { + const [paragraph, segmentToDelete, index] = mutateSegment( + readonlyParagraph, + readonlySegmentToDelete + ); const segments = paragraph.segments; - const index = segments.indexOf(segmentToDelete); const preserveWhiteSpace = isWhiteSpacePreserved(paragraph.format.whiteSpace); const isForward = direction == 'forward'; const isBackward = direction == 'backward'; if (!preserveWhiteSpace) { - normalizePreviousSegment(segments, index); + normalizePreviousSegment(paragraph, segments, index); } - switch (segmentToDelete.segmentType) { + switch (segmentToDelete?.segmentType) { case 'Br': case 'Image': case 'SelectionMarker': @@ -86,10 +90,17 @@ export function deleteSegment( } else { return false; } + + default: + return false; } } -function normalizePreviousSegment(segments: ContentModelSegment[], currentIndex: number) { +function normalizePreviousSegment( + paragraph: ReadonlyContentModelParagraph, + segments: ReadonlyArray, + currentIndex: number +) { let index = currentIndex - 1; while (segments[index]?.segmentType == 'SelectionMarker') { @@ -99,6 +110,6 @@ function normalizePreviousSegment(segments: ContentModelSegment[], currentIndex: const segment = segments[index]; if (segment) { - normalizeSingleSegment(segment); + normalizeSingleSegment(paragraph, segment); } } 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..adcd718f964 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/deleteSelection.ts @@ -1,10 +1,11 @@ import { deleteExpandedSelection } from './deleteExpandedSelection'; +import { mutateBlock } from '../common/mutate'; import type { - ContentModelDocument, DeleteSelectionContext, DeleteSelectionResult, DeleteSelectionStep, FormatContentModelContext, + ReadonlyContentModelDocument, ValidDeleteSelectionContext, } from 'roosterjs-content-model-types'; @@ -16,7 +17,7 @@ import type { * @returns A DeleteSelectionResult object to specify the deletion result */ export function deleteSelection( - model: ContentModelDocument, + model: ReadonlyContentModelDocument, additionalSteps: (DeleteSelectionStep | null)[] = [], formatContext?: FormatContentModelContext ): DeleteSelectionResult { @@ -50,7 +51,9 @@ function mergeParagraphAfterDelete(context: DeleteSelectionContext) { lastParagraph != insertPoint.paragraph && lastTableContext == insertPoint.tableContext ) { - insertPoint.paragraph.segments.push(...lastParagraph.segments); - lastParagraph.segments = []; + const mutableLastParagraph = mutateBlock(lastParagraph); + + mutateBlock(insertPoint.paragraph).segments.push(...mutableLastParagraph.segments); + mutableLastParagraph.segments = []; } } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts index ed46c5c1d09..b440afd1f69 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts @@ -7,12 +7,12 @@ import { createTableCell } from '../creators/createTableCell'; import { deleteSelection } from './deleteSelection'; import { getClosestAncestorBlockGroupIndex } from './getClosestAncestorBlockGroupIndex'; import { getObjectKeys } from '../..//domUtils/getObjectKeys'; +import { mutateBlock } from '../common/mutate'; import { normalizeContentModel } from '../common/normalizeContentModel'; import { normalizeTable } from './normalizeTable'; import type { ContentModelBlock, ContentModelBlockFormat, - ContentModelBlockGroup, ContentModelDocument, ContentModelListItem, ContentModelParagraph, @@ -21,6 +21,10 @@ import type { FormatContentModelContext, InsertPoint, MergeModelOption, + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelDocument, + ShallowMutableContentModelParagraph, } from 'roosterjs-content-model-types'; const HeadingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; @@ -34,7 +38,7 @@ const HeadingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; * @returns Insert point after merge, or null if there is no insert point */ export function mergeModel( - target: ContentModelDocument, + target: ReadonlyContentModelDocument, source: ContentModelDocument, context?: FormatContentModelContext, options?: MergeModelOption @@ -170,7 +174,9 @@ function mergeTable( const { tableContext, marker } = markerPosition; if (tableContext && source.blocks.length == 1 && source.blocks[0] == newTable) { - const { table, colIndex, rowIndex } = tableContext; + const { table: readonlyTable, colIndex, rowIndex } = tableContext; + const table = mutateBlock(readonlyTable); + for (let i = 0; i < newTable.rows.length; i++) { for (let j = 0; j < newTable.rows[i].cells.length; j++) { const newCell = newTable.rows[i].cells[j]; @@ -242,7 +248,7 @@ function mergeList(markerPosition: InsertPoint, newList: ContentModelListItem) { const blockIndex = listParent.blocks.indexOf(listItem || paragraph); if (blockIndex >= 0) { - listParent.blocks.splice(blockIndex, 0, newList); + mutateBlock(listParent).blocks.splice(blockIndex, 0, newList); } if (listItem) { @@ -256,7 +262,7 @@ function splitParagraph(markerPosition: InsertPoint, newParaFormat: ContentModel const { paragraph, marker, path } = markerPosition; const segmentIndex = paragraph.segments.indexOf(marker); const paraIndex = path[0].blocks.indexOf(paragraph); - const newParagraph = createParagraph( + const newParagraph: ShallowMutableContentModelParagraph = createParagraph( false /*isImplicit*/, { ...paragraph.format, ...newParaFormat }, paragraph.segmentFormat @@ -267,7 +273,7 @@ function splitParagraph(markerPosition: InsertPoint, newParaFormat: ContentModel } if (paraIndex >= 0) { - path[0].blocks.splice(paraIndex + 1, 0, newParagraph); + mutateBlock(path[0]).blocks.splice(paraIndex + 1, 0, newParagraph); } const listItemIndex = getClosestAncestorBlockGroupIndex( @@ -289,7 +295,7 @@ function splitParagraph(markerPosition: InsertPoint, newParaFormat: ContentModel } if (blockIndex >= 0) { - listParent.blocks.splice(blockIndex + 1, 0, newListItem); + mutateBlock(listParent).blocks.splice(blockIndex + 1, 0, newListItem); } path[listItemIndex] = newListItem; @@ -308,21 +314,22 @@ function insertBlock(markerPosition: InsertPoint, block: ContentModelBlock) { const blockIndex = path[0].blocks.indexOf(newPara); if (blockIndex >= 0) { - path[0].blocks.splice(blockIndex, 0, block); + mutateBlock(path[0]).blocks.splice(blockIndex, 0, block); } } function applyDefaultFormat( - group: ContentModelBlockGroup, + group: ReadonlyContentModelBlockGroup, format: ContentModelSegmentFormat, applyDefaultFormatOption: 'mergeAll' | 'keepSourceEmphasisFormat' ) { group.blocks.forEach(block => { mergeBlockFormat(applyDefaultFormatOption, block); + switch (block.blockType) { case 'BlockGroup': if (block.blockGroupType == 'ListItem') { - block.formatHolder.format = mergeSegmentFormat( + mutateBlock(block).formatHolder.format = mergeSegmentFormat( applyDefaultFormatOption, format, block.formatHolder.format @@ -341,7 +348,9 @@ function applyDefaultFormat( case 'Paragraph': const paragraphFormat = block.decorator?.format || {}; - block.segments.forEach(segment => { + const paragraph = mutateBlock(block); + + paragraph.segments.forEach(segment => { if (segment.segmentType == 'General') { applyDefaultFormat(segment, format, applyDefaultFormatOption); } @@ -353,28 +362,28 @@ function applyDefaultFormat( }); if (applyDefaultFormatOption === 'keepSourceEmphasisFormat') { - delete block.decorator; + delete paragraph.decorator; } break; } }); } -function mergeBlockFormat(applyDefaultFormatOption: string, block: ContentModelBlock) { +function mergeBlockFormat(applyDefaultFormatOption: string, block: ReadonlyContentModelBlock) { if (applyDefaultFormatOption == 'keepSourceEmphasisFormat' && block.format.backgroundColor) { - delete block.format.backgroundColor; + delete mutateBlock(block).format.backgroundColor; } } function mergeSegmentFormat( applyDefaultFormatOption: 'mergeAll' | 'keepSourceEmphasisFormat', - targetformat: ContentModelSegmentFormat, + targetFormat: ContentModelSegmentFormat, sourceFormat: ContentModelSegmentFormat ): ContentModelSegmentFormat { return applyDefaultFormatOption == 'mergeAll' - ? { ...targetformat, ...sourceFormat } + ? { ...targetFormat, ...sourceFormat } : { - ...targetformat, + ...targetFormat, ...getSemanticFormat(sourceFormat), }; } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/normalizeTable.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/normalizeTable.ts index 354decd7884..76a57ab0bcf 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/normalizeTable.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/normalizeTable.ts @@ -2,11 +2,12 @@ import { addBlock } from '../common/addBlock'; import { addSegment } from '../common/addSegment'; import { createBr } from '../creators/createBr'; import { createParagraph } from '../creators/createParagraph'; +import { mutateBlock } from '../common/mutate'; import type { - ContentModelSegment, ContentModelSegmentFormat, - ContentModelTable, - ContentModelTableCell, + ReadonlyContentModelSegment, + ReadonlyContentModelTable, + ReadonlyContentModelTableCell, } from 'roosterjs-content-model-types'; /** @@ -23,13 +24,15 @@ const MIN_HEIGHT = 22; * 4. Table and table row have correct width/height * 5. Spanned cell has no child blocks * 6. default format is correctly applied - * @param table The table to normalize + * @param readonlyTable The table to normalize * @param defaultSegmentFormat @optional Default segment format to apply to cell */ export function normalizeTable( - table: ContentModelTable, + readonlyTable: ReadonlyContentModelTable, defaultSegmentFormat?: ContentModelSegmentFormat ) { + const table = mutateBlock(readonlyTable); + // Always collapse border and use border box for table in roosterjs to make layout simpler const format = table.format; @@ -42,7 +45,9 @@ export function normalizeTable( // Make sure all inner cells are not header // Make sure all cells have content and width table.rows.forEach((row, rowIndex) => { - row.cells.forEach((cell, colIndex) => { + row.cells.forEach((readonlyCell, colIndex) => { + const cell = mutateBlock(readonlyCell); + if (cell.blocks.length == 0) { const format = cell.format.textColor ? { @@ -137,18 +142,21 @@ function getTableCellWidth(columns: number): number { } } -function tryMoveBlocks(targetCell: ContentModelTableCell, sourceCell: ContentModelTableCell) { +function tryMoveBlocks( + targetCell: ReadonlyContentModelTableCell, + sourceCell: ReadonlyContentModelTableCell +) { const onlyHasEmptyOrBr = sourceCell.blocks.every( block => block.blockType == 'Paragraph' && hasOnlyBrSegment(block.segments) ); if (!onlyHasEmptyOrBr) { - targetCell.blocks.push(...sourceCell.blocks); - sourceCell.blocks = []; + mutateBlock(targetCell).blocks.push(...sourceCell.blocks); + mutateBlock(sourceCell).blocks = []; } } -function hasOnlyBrSegment(segments: ContentModelSegment[]): boolean { +function hasOnlyBrSegment(segments: ReadonlyArray): boolean { segments = segments.filter(s => s.segmentType != 'SelectionMarker'); return segments.length == 0 || (segments.length == 1 && segments[0].segmentType == 'Br'); diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/setTableCellBackgroundColor.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/setTableCellBackgroundColor.ts index 96df09590b7..258e3cbb24f 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/setTableCellBackgroundColor.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/setTableCellBackgroundColor.ts @@ -1,6 +1,7 @@ +import { mutateBlock } from '../common/mutate'; import { parseColor } from '../../formatHandlers/utils/color'; import { updateTableCellMetadata } from '../metadata/updateTableCellMetadata'; -import type { ContentModelTableCell } from 'roosterjs-content-model-types'; +import type { ShallowMutableContentModelTableCell } from 'roosterjs-content-model-types'; // Using the HSL (hue, saturation and lightness) representation for RGB color values. // If the value of the lightness is less than 20, the color is dark. @@ -18,7 +19,7 @@ const Black = '#000000'; * @param applyToSegments @optional When pass true, we will also apply text color from table cell to its child blocks and segments */ export function setTableCellBackgroundColor( - cell: ContentModelTableCell, + cell: ShallowMutableContentModelTableCell, color: string | null | undefined, isColorOverride?: boolean, applyToSegments?: boolean @@ -58,9 +59,11 @@ export function setTableCellBackgroundColor( delete cell.cachedElement; } -function removeAdaptiveCellColor(cell: ContentModelTableCell) { - cell.blocks.forEach(block => { - if (block.blockType == 'Paragraph') { +function removeAdaptiveCellColor(cell: ShallowMutableContentModelTableCell) { + cell.blocks.forEach(readonlyBlock => { + if (readonlyBlock.blockType == 'Paragraph') { + const block = mutateBlock(readonlyBlock); + if ( block.segmentFormat?.textColor && shouldRemoveColor(block.segmentFormat?.textColor, cell.format.backgroundColor || '') @@ -79,10 +82,12 @@ function removeAdaptiveCellColor(cell: ContentModelTableCell) { }); } -function setAdaptiveCellColor(cell: ContentModelTableCell) { +function setAdaptiveCellColor(cell: ShallowMutableContentModelTableCell) { if (cell.format.textColor) { - cell.blocks.forEach(block => { - if (block.blockType == 'Paragraph') { + cell.blocks.forEach(readonlyBlock => { + if (readonlyBlock.blockType == 'Paragraph') { + const block = mutateBlock(readonlyBlock); + if (!block.segmentFormat?.textColor) { block.segmentFormat = { ...block.segmentFormat, diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/selection/setSelection.ts b/packages/roosterjs-content-model-dom/lib/modelApi/selection/setSelection.ts index ca23f3468b2..484f74d863e 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/selection/setSelection.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/selection/setSelection.ts @@ -1,10 +1,14 @@ import { isGeneralSegment } from '../typeCheck/isGeneralSegment'; +import { mutateBlock, mutateSegment } from '../common/mutate'; import type { - ContentModelBlock, - ContentModelBlockGroup, - ContentModelSegment, - ContentModelTable, - Selectable, + MutableType, + ReadonlyContentModelBlock, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelParagraph, + ReadonlyContentModelSegment, + ReadonlyContentModelTable, + ReadonlySelectable, + ShallowMutableSelectable, TableCellCoordinate, } from 'roosterjs-content-model-types'; @@ -14,19 +18,23 @@ import type { * @param start The start selected element. If not passed, existing selection of content model will be cleared * @param end The end selected element. If not passed, only the start element will be selected. If passed, all elements between start and end elements will be selected */ -export function setSelection(group: ContentModelBlockGroup, start?: Selectable, end?: Selectable) { +export function setSelection( + group: ReadonlyContentModelBlockGroup, + start?: ReadonlySelectable, + end?: ReadonlySelectable +) { setSelectionToBlockGroup(group, false /*isInSelection*/, start || null, end || null); } function setSelectionToBlockGroup( - group: ContentModelBlockGroup, + group: ReadonlyContentModelBlockGroup, isInSelection: boolean, - start: Selectable | null, - end: Selectable | null + start: ReadonlySelectable | null, + end: ReadonlySelectable | null ): boolean { return handleSelection(isInSelection, group, start, end, isInSelection => { - if (isGeneralSegment(group)) { - setIsSelected(group, isInSelection); + if (isGeneralSegment(group) && needToSetSelection(group, isInSelection)) { + setIsSelected(mutateBlock(group), isInSelection); } group.blocks.forEach(block => { @@ -38,10 +46,10 @@ function setSelectionToBlockGroup( } function setSelectionToBlock( - block: ContentModelBlock, + block: ReadonlyContentModelBlock, isInSelection: boolean, - start: Selectable | null, - end: Selectable | null + start: ReadonlySelectable | null, + end: ReadonlySelectable | null ) { switch (block.blockType) { case 'BlockGroup': @@ -53,10 +61,14 @@ function setSelectionToBlock( case 'Divider': case 'Entity': return handleSelection(isInSelection, block, start, end, isInSelection => { - if (isInSelection) { - block.isSelected = true; - } else { - delete block.isSelected; + if (needToSetSelection(block, isInSelection)) { + const mutableBlock = mutateBlock(block); + + if (isInSelection) { + mutableBlock.isSelected = true; + } else { + delete mutableBlock.isSelected; + } } return isInSelection; @@ -73,6 +85,7 @@ function setSelectionToBlock( end, isInSelection => { return setSelectionToSegment( + block, segment, isInSelection, segmentsToDelete, @@ -84,11 +97,11 @@ function setSelectionToBlock( ); }); - while (segmentsToDelete.length > 0) { - const index = segmentsToDelete.pop()!; + let index: number | undefined; + while ((index = segmentsToDelete.pop()) !== undefined) { if (index >= 0) { - block.segments.splice(index, 1); + mutateBlock(block).segments.splice(index, 1); } } @@ -100,10 +113,10 @@ function setSelectionToBlock( } function setSelectionToTable( - table: ContentModelTable, + table: ReadonlyContentModelTable, isInSelection: boolean, - start: Selectable | null, - end: Selectable | null + start: ReadonlySelectable | null, + end: ReadonlySelectable | null ): boolean { const first = findCell(table, start); const last = end ? findCell(table, end) : first; @@ -116,7 +129,9 @@ function setSelectionToTable( const isSelected = row >= first.row && row <= last.row && col >= first.col && col <= last.col; - setIsSelected(currentCell, isSelected); + if (needToSetSelection(currentCell, isSelected)) { + setIsSelected(mutateBlock(currentCell), isSelected); + } if (!isSelected) { setSelectionToBlockGroup(currentCell, false /*isInSelection*/, start, end); @@ -134,21 +149,27 @@ function setSelectionToTable( return isInSelection; } -function findCell(table: ContentModelTable, cell: Selectable | null): TableCellCoordinate { +function findCell( + table: ReadonlyContentModelTable, + cell: ReadonlySelectable | null +): TableCellCoordinate { let col = -1; const row = cell - ? table.rows.findIndex(row => (col = (row.cells as Selectable[]).indexOf(cell)) >= 0) + ? table.rows.findIndex( + row => (col = (row.cells as ReadonlyArray).indexOf(cell)) >= 0 + ) : -1; return { row, col }; } function setSelectionToSegment( - segment: ContentModelSegment, + paragraph: ReadonlyContentModelParagraph, + segment: ReadonlyContentModelSegment, isInSelection: boolean, segmentsToDelete: number[], - start: Selectable | null, - end: Selectable | null, + start: ReadonlySelectable | null, + end: ReadonlySelectable | null, i: number ) { switch (segment.segmentType) { @@ -162,23 +183,50 @@ function setSelectionToSegment( return isInSelection; case 'General': - setIsSelected(segment, isInSelection); + internalSetSelectionToSegment(paragraph, segment, isInSelection); return segment != start && segment != end ? setSelectionToBlockGroup(segment, isInSelection, start, end) : isInSelection; case 'Image': - setIsSelected(segment, isInSelection); - segment.isSelectedAsImageSelection = start == segment && (!end || end == segment); + const isSelectedAsImageSelection = start == segment && (!end || end == segment); + + internalSetSelectionToSegment( + paragraph, + segment, + isInSelection, + !segment.isSelectedAsImageSelection != !isSelectedAsImageSelection + ? image => (image.isSelectedAsImageSelection = isSelectedAsImageSelection) + : undefined + ); + return isInSelection; default: - setIsSelected(segment, isInSelection); + internalSetSelectionToSegment(paragraph, segment, isInSelection); return isInSelection; } } -function setIsSelected(selectable: Selectable, value: boolean) { +function internalSetSelectionToSegment( + paragraph: ReadonlyContentModelParagraph, + segment: T, + isInSelection: boolean, + additionAction?: (segment: MutableType) => void +) { + if (additionAction || needToSetSelection(segment, isInSelection)) { + mutateSegment(paragraph, segment, mutableSegment => { + setIsSelected(mutableSegment, isInSelection); + additionAction?.(mutableSegment); + }); + } +} + +function needToSetSelection(selectable: ReadonlySelectable, isSelected: boolean) { + return !selectable.isSelected != !isSelected; +} + +function setIsSelected(selectable: ShallowMutableSelectable, value: boolean) { if (value) { selectable.isSelected = true; } else { @@ -190,9 +238,9 @@ function setIsSelected(selectable: Selectable, value: boolean) { function handleSelection( isInSelection: boolean, - model: ContentModelBlockGroup | ContentModelBlock | ContentModelSegment, - start: Selectable | null, - end: Selectable | null, + model: ReadonlyContentModelBlockGroup | ReadonlyContentModelBlock | ReadonlyContentModelSegment, + start: ReadonlySelectable | null, + end: ReadonlySelectable | null, callback: (isInSelection: boolean) => boolean ) { isInSelection = isInSelection || model == start; diff --git a/packages/roosterjs-content-model-dom/test/modelApi/block/setParagraphNotImplicitTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/block/setParagraphNotImplicitTest.ts index d87520a13c7..192964ca13c 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/block/setParagraphNotImplicitTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/block/setParagraphNotImplicitTest.ts @@ -1,5 +1,6 @@ import { createDivider } from '../../../lib/modelApi/creators/createDivider'; import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; +import { ReadonlyContentModelParagraph } from 'roosterjs-content-model-types'; import { setParagraphNotImplicit } from '../../../lib/modelApi/block/setParagraphNotImplicit'; describe('setParagraphNotImplicit', () => { @@ -33,4 +34,19 @@ describe('setParagraphNotImplicit', () => { isImplicit: false, }); }); + + it('Readonly paragraph', () => { + const block: ReadonlyContentModelParagraph = createParagraph(true); + + block.cachedElement = {} as any; + + setParagraphNotImplicit(block); + + expect(block).toEqual({ + blockType: 'Paragraph', + format: {}, + segments: [], + isImplicit: false, + }); + }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/addSegmentTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/addSegmentTest.ts index 9bcde0996dc..69bf30d8277 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/addSegmentTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/addSegmentTest.ts @@ -1,15 +1,19 @@ import { addBlock } from '../../../lib/modelApi/common/addBlock'; import { addSegment } from '../../../lib/modelApi/common/addSegment'; -import { ContentModelGeneralBlock, ContentModelParagraph } from 'roosterjs-content-model-types'; import { createBr } from '../../../lib/modelApi/creators/createBr'; import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; import { createSelectionMarker } from '../../../lib/modelApi/creators/createSelectionMarker'; import { createText } from '../../../lib/modelApi/creators/createText'; +import { + ContentModelGeneralBlock, + ContentModelParagraph, + ShallowMutableContentModelDocument, +} from 'roosterjs-content-model-types'; describe('addSegment', () => { it('Add segment to empty document', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const segment = createText('test'); const result = addSegment(doc, segment); @@ -34,7 +38,7 @@ describe('addSegment', () => { }); it('Add segment to document contains an empty paragraph', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(false); addBlock(doc, para); @@ -62,7 +66,7 @@ describe('addSegment', () => { }); it('Add segment to document contains a paragraph with existing text', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const block: ContentModelParagraph = { blockType: 'Paragraph', segments: [ @@ -104,7 +108,7 @@ describe('addSegment', () => { }); it('Add segment to document contains a paragraph with other type of block', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const div = document.createElement('div'); const block: ContentModelGeneralBlock = { blockType: 'BlockGroup', @@ -140,7 +144,7 @@ describe('addSegment', () => { }); it('Add selection marker in empty paragraph', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); doc.blocks.push(para); @@ -168,7 +172,7 @@ describe('addSegment', () => { }); it('Add selection marker after selection marker', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); const marker = createSelectionMarker(); @@ -198,7 +202,7 @@ describe('addSegment', () => { }); it('Add selection marker after selected segment', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); const br = createBr(); @@ -229,7 +233,7 @@ describe('addSegment', () => { }); it('Add selection marker after selection marker', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); const marker = createSelectionMarker(); @@ -259,7 +263,7 @@ describe('addSegment', () => { }); it('Add selection marker after selection marker that is not selected', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); const marker = createSelectionMarker(); @@ -295,7 +299,7 @@ describe('addSegment', () => { }); it('Add unselected selection marker after selection marker', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); const marker = createSelectionMarker(); @@ -332,7 +336,7 @@ describe('addSegment', () => { }); it('Add selected segment after selection marker', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); const marker = createSelectionMarker(); @@ -364,7 +368,7 @@ describe('addSegment', () => { }); it('Add selected segment after unselected selection marker', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const para = createParagraph(); const marker = createSelectionMarker(); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/ensureParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/ensureParagraphTest.ts index 5d4c57b8f81..cec013a545d 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/ensureParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/ensureParagraphTest.ts @@ -1,12 +1,15 @@ -import { ContentModelBlockFormat } from 'roosterjs-content-model-types'; import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createDivider } from '../../../lib/modelApi/creators/createDivider'; import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; import { ensureParagraph } from '../../../lib/modelApi/common/ensureParagraph'; +import { + ContentModelBlockFormat, + ShallowMutableContentModelDocument, +} from 'roosterjs-content-model-types'; describe('ensureParagraph', () => { it('Empty group', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const result = ensureParagraph(doc); expect(doc).toEqual({ @@ -22,7 +25,7 @@ describe('ensureParagraph', () => { }); it('Empty group with format', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const format: ContentModelBlockFormat = { backgroundColor: 'red', }; @@ -43,7 +46,7 @@ describe('ensureParagraph', () => { }); it('Last block is not paragraph', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const divider = createDivider('hr'); doc.blocks.push(divider); @@ -63,9 +66,11 @@ describe('ensureParagraph', () => { }); it('Last block is paragraph', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const paragraph = createParagraph(); + paragraph.cachedElement = {} as any; + doc.blocks.push(paragraph); const result = ensureParagraph(doc); @@ -83,7 +88,7 @@ describe('ensureParagraph', () => { }); it('Last block is paragraph, do not overwrite format', () => { - const doc = createContentModelDocument(); + const doc: ShallowMutableContentModelDocument = createContentModelDocument(); const format: ContentModelBlockFormat = { backgroundColor: 'red', }; diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts index a821e02816a..bc3aa260c52 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeContentModelTest.ts @@ -7,12 +7,13 @@ import { createTable } from '../../../lib/modelApi/creators/createTable'; import { createTableCell } from '../../../lib/modelApi/creators/createTableCell'; import { createText } from '../../../lib/modelApi/creators/createText'; import { normalizeContentModel } from '../../../lib/modelApi/common/normalizeContentModel'; +import { ReadonlyContentModelDocument } from 'roosterjs-content-model-types'; describe('normalizeContentModel', () => { it('Empty model', () => { const model = createContentModelDocument(); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', @@ -28,7 +29,7 @@ describe('normalizeContentModel', () => { para.segments.push(text); model.blocks.push(para); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', @@ -46,7 +47,7 @@ describe('normalizeContentModel', () => { para.segments.push(text1, text2, text3); model.blocks.push(para); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', @@ -83,7 +84,7 @@ describe('normalizeContentModel', () => { para2.segments.push(text, br); model.blocks.push(para1, para2); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', @@ -132,7 +133,7 @@ describe('normalizeContentModel', () => { para2.segments.push(br3, br4); model.blocks.push(para1, para2); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', @@ -186,7 +187,7 @@ describe('normalizeContentModel', () => { para2.segments.push(text, br); model.blocks.push(para1, para2); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', @@ -235,7 +236,7 @@ describe('normalizeContentModel', () => { table.rows[0].cells.push(cell); model.blocks.push(table); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', @@ -277,7 +278,7 @@ describe('normalizeContentModel', () => { listItem.blocks.push(para); model.blocks.push(listItem); - normalizeContentModel(model); + normalizeContentModel(model as ReadonlyContentModelDocument); expect(model).toEqual({ blockGroupType: 'Document', diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts index 95f6f6175f5..a28e3aa5a38 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeParagraphTest.ts @@ -6,6 +6,7 @@ import { createSelectionMarker } from '../../../lib/modelApi/creators/createSele import { createText } from '../../../lib/modelApi/creators/createText'; import { normalizeContentModel } from '../../../lib/modelApi/common/normalizeContentModel'; import { normalizeParagraph } from '../../../lib/modelApi/common/normalizeParagraph'; +import { ReadonlyContentModelParagraph } from 'roosterjs-content-model-types'; describe('Normalize text that contains space', () => { function runTest(texts: string[], expected: string[], whiteSpace?: string) { @@ -360,7 +361,7 @@ describe('Normalize paragraph with segmentFormat', () => { it('Empty paragraph', () => { const paragraph = createParagraph(); - normalizeParagraph(paragraph); + normalizeParagraph(paragraph as ReadonlyContentModelParagraph); expect(paragraph).toEqual({ blockType: 'Paragraph', @@ -377,7 +378,7 @@ describe('Normalize paragraph with segmentFormat', () => { paragraph.segments.push(text); - normalizeParagraph(paragraph); + normalizeParagraph(paragraph as ReadonlyContentModelParagraph); expect(paragraph).toEqual({ blockType: 'Paragraph', @@ -405,7 +406,7 @@ describe('Normalize paragraph with segmentFormat', () => { paragraph.segments.push(text1, marker, text2); - normalizeParagraph(paragraph); + normalizeParagraph(paragraph as ReadonlyContentModelParagraph); expect(paragraph).toEqual({ blockType: 'Paragraph', @@ -443,7 +444,7 @@ describe('Normalize paragraph with segmentFormat', () => { paragraph.segments.push(text1, marker, text2); - normalizeParagraph(paragraph); + normalizeParagraph(paragraph as ReadonlyContentModelParagraph); expect(paragraph).toEqual({ blockType: 'Paragraph', @@ -482,7 +483,7 @@ describe('Normalize paragraph with segmentFormat', () => { paragraph.segments.push(text1, marker, text2); - normalizeParagraph(paragraph); + normalizeParagraph(paragraph as ReadonlyContentModelParagraph); expect(paragraph).toEqual({ blockType: 'Paragraph', diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeSegmentTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeSegmentTest.ts index e33785530fd..7faa0cc8996 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeSegmentTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/normalizeSegmentTest.ts @@ -1,6 +1,8 @@ import { createBr } from '../../../lib/modelApi/creators/createBr'; import { createImage } from '../../../lib/modelApi/creators/createImage'; +import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; import { createText } from '../../../lib/modelApi/creators/createText'; +import { ReadonlyContentModelParagraph } from 'roosterjs-content-model-types'; import { createNormalizeSegmentContext, normalizeSegment, @@ -10,8 +12,11 @@ describe('normalizeSegment', () => { it('With initial context, image', () => { const context = createNormalizeSegmentContext(); const image = createImage('test'); + const para = createParagraph(); - normalizeSegment(image, context); + para.segments.push(image); + + normalizeSegment(para as ReadonlyContentModelParagraph, image, context); expect(image).toEqual({ segmentType: 'Image', @@ -32,8 +37,11 @@ describe('normalizeSegment', () => { it('With initial context, regular text', () => { const context = createNormalizeSegmentContext(); const text = createText('test'); + const para = createParagraph(); + + para.segments.push(text); - normalizeSegment(text, context); + normalizeSegment(para as ReadonlyContentModelParagraph, text, context); expect(text).toEqual({ segmentType: 'Text', @@ -53,8 +61,11 @@ describe('normalizeSegment', () => { it('With initial context, br', () => { const context = createNormalizeSegmentContext(); const br = createBr(); + const para = createParagraph(); - normalizeSegment(br, context); + para.segments.push(br); + + normalizeSegment(para as ReadonlyContentModelParagraph, br, context); expect(br).toEqual({ segmentType: 'Br', @@ -73,8 +84,11 @@ describe('normalizeSegment', () => { it('Normalize an empty string', () => { const context = createNormalizeSegmentContext(); const text = createText(''); + const para = createParagraph(); + + para.segments.push(text); - normalizeSegment(text, context); + normalizeSegment(para as ReadonlyContentModelParagraph, text, context); expect(text).toEqual({ segmentType: 'Text', @@ -94,8 +108,11 @@ describe('normalizeSegment', () => { it('Normalize an string with spaces', () => { const context = createNormalizeSegmentContext(); const text = createText(' aa '); + const para = createParagraph(); - normalizeSegment(text, context); + para.segments.push(text); + + normalizeSegment(para as ReadonlyContentModelParagraph, text, context); expect(text).toEqual({ segmentType: 'Text', @@ -115,8 +132,11 @@ describe('normalizeSegment', () => { it('Normalize an string with  ', () => { const context = createNormalizeSegmentContext(); const text = createText('\u00A0\u00A0aa\u00A0\u00A0'); + const para = createParagraph(); + + para.segments.push(text); - normalizeSegment(text, context); + normalizeSegment(para as ReadonlyContentModelParagraph, text, context); expect(text).toEqual({ segmentType: 'Text', @@ -136,10 +156,13 @@ describe('normalizeSegment', () => { it('Normalize an string space and ignoreLeadingSpaces = false', () => { const context = createNormalizeSegmentContext(); const text = createText(' aa '); + const para = createParagraph(); + + para.segments.push(text); context.ignoreLeadingSpaces = false; - normalizeSegment(text, context); + normalizeSegment(para as ReadonlyContentModelParagraph, text, context); expect(text).toEqual({ segmentType: 'Text', @@ -159,10 +182,13 @@ describe('normalizeSegment', () => { it('Normalize an string space and ignoreTrailingSpaces = false', () => { const context = createNormalizeSegmentContext(); const text = createText(' aa '); + const para = createParagraph(); + + para.segments.push(text); context.ignoreTrailingSpaces = false; - normalizeSegment(text, context); + normalizeSegment(para as ReadonlyContentModelParagraph, text, context); expect(text).toEqual({ segmentType: 'Text', diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/unwrapBlockTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/unwrapBlockTest.ts index ad8c24cfecb..3bd0e062abb 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/unwrapBlockTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/unwrapBlockTest.ts @@ -1,7 +1,11 @@ -import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createFormatContainer } from '../../../lib/modelApi/creators/createFormatContainer'; import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; import { unwrapBlock } from '../../../lib/modelApi/common/unwrapBlock'; +import { + ContentModelDocument, + ReadonlyContentModelDocument, + ReadonlyContentModelFormatContainer, +} from 'roosterjs-content-model-types'; describe('unwrapBlock', () => { it('no parent', () => { @@ -40,7 +44,10 @@ describe('unwrapBlock', () => { para.isImplicit = true; quote.blocks.push(para); - unwrapBlock(doc, quote); + unwrapBlock( + doc as ReadonlyContentModelDocument, + quote as ReadonlyContentModelFormatContainer + ); expect(doc).toEqual({ blockGroupType: 'Document', @@ -76,7 +83,10 @@ describe('unwrapBlock', () => { para.isImplicit = true; quote.blocks.push(para); - unwrapBlock(doc, quote); + unwrapBlock( + doc as ReadonlyContentModelDocument, + quote as ReadonlyContentModelFormatContainer + ); expect(doc).toEqual({ blockGroupType: 'Document', diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/applyTableFormatTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/applyTableFormatTest.ts index e7810d76afc..548505bbe12 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/applyTableFormatTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/applyTableFormatTest.ts @@ -4,6 +4,7 @@ import { ContentModelTable, ContentModelTableCell, ContentModelTableRow, + ReadonlyContentModelTable, TableMetadataFormat, } from 'roosterjs-content-model-types'; @@ -57,7 +58,7 @@ describe('applyTableFormat', () => { exportedBackgroundColors: string[][], expectedBorders: string[][][] ) { - const table = createTable(3, 4); + const table: ReadonlyContentModelTable = createTable(3, 4); applyTableFormat(table, format); @@ -457,7 +458,7 @@ describe('applyTableFormat', () => { const table = createTable(1, 1); table.rows[0].cells[0].format.backgroundColor = 'red'; - applyTableFormat(table, { + applyTableFormat(table as ReadonlyContentModelTable, { bgColorEven: 'green', }); @@ -466,7 +467,7 @@ describe('applyTableFormat', () => { table.rows[0].cells[0].dataset.editingInfo = '{"bgColorOverride":true}'; - applyTableFormat(table, { + applyTableFormat(table as ReadonlyContentModelTable, { bgColorEven: 'blue', }); @@ -476,7 +477,7 @@ describe('applyTableFormat', () => { table.rows[0].cells[0].dataset.editingInfo = '{"bgColorOverride":true}'; applyTableFormat( - table, + table as ReadonlyContentModelTable, { bgColorEven: 'yellow', }, @@ -492,7 +493,7 @@ describe('applyTableFormat', () => { table.rows[0].cells[0].format.borderLeft = '1px solid red'; // Try to apply green - applyTableFormat(table, { + applyTableFormat(table as ReadonlyContentModelTable, { topBorderColor: 'green', }); @@ -503,7 +504,7 @@ describe('applyTableFormat', () => { table.rows[0].cells[0].dataset.editingInfo = '{"borderOverride":true}'; // Try to apply blue - applyTableFormat(table, { + applyTableFormat(table as ReadonlyContentModelTable, { topBorderColor: 'blue', }); @@ -531,10 +532,10 @@ describe('applyTableFormat', () => { }; // Try to apply default format black - applyTableFormat(table, format); + applyTableFormat(table as ReadonlyContentModelTable, format); //apply HeaderRowColor - applyTableFormat(table, { ...format, hasHeaderRow: true }); + applyTableFormat(table as ReadonlyContentModelTable, { ...format, hasHeaderRow: true }); //expect HeaderRowColor text color to be applied table.rows[0].cells[0].blocks.forEach(block => { @@ -578,10 +579,10 @@ describe('applyTableFormat', () => { }; // Try to apply default format black - applyTableFormat(table, format); + applyTableFormat(table as ReadonlyContentModelTable, format); //apply HeaderRowColor - applyTableFormat(table, { ...format, hasHeaderRow: true }); + applyTableFormat(table as ReadonlyContentModelTable, { ...format, hasHeaderRow: true }); //expect HeaderRowColor text color to be applied table.rows[0].cells[0].blocks.forEach(block => { @@ -613,13 +614,13 @@ describe('applyTableFormat', () => { }; // Try to apply default format black - applyTableFormat(table, format); + applyTableFormat(table as ReadonlyContentModelTable, format); //apply HeaderRowColor - applyTableFormat(table, { ...format, hasHeaderRow: true }); + applyTableFormat(table as ReadonlyContentModelTable, { ...format, hasHeaderRow: true }); //Toggle HeaderRowColor - applyTableFormat(table, { ...format, hasHeaderRow: false }); + applyTableFormat(table as ReadonlyContentModelTable, { ...format, hasHeaderRow: false }); //expect HeaderRowColor text color to be applied table.rows[0].cells[0].blocks.forEach(block => { diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/normalizeTableTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/normalizeTableTest.ts index 9e81b4d9d37..50187d7548b 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/normalizeTableTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/normalizeTableTest.ts @@ -5,9 +5,10 @@ import { ContentModelTable, ContentModelTableCellFormat, ContentModelTableFormat, + ReadonlyContentModelTable, } from 'roosterjs-content-model-types'; import { - createParagraph, + createParagraph as originalCreateParagraph, createTable as originalCreateTable, createTableCell as originalCreateTableCell, createText, @@ -15,6 +16,14 @@ import { const mockedCachedElement = {} as any; +function createParagraph(): ContentModelParagraph { + const paragraph = originalCreateParagraph(); + + paragraph.cachedElement = mockedCachedElement; + + return paragraph; +} + function createTable(rowCount: number, format?: ContentModelTableFormat): ContentModelTable { const table = originalCreateTable(rowCount, format); @@ -40,7 +49,7 @@ describe('normalizeTable', () => { it('Normalize an empty table', () => { const table = createTable(0); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -51,7 +60,6 @@ describe('normalizeTable', () => { }, widths: [], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -60,7 +68,7 @@ describe('normalizeTable', () => { table.rows[0].cells.push(createTableCell(1, 1, false)); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -88,7 +96,6 @@ describe('normalizeTable', () => { }, ], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -99,7 +106,6 @@ describe('normalizeTable', () => { }, widths: [120], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -118,7 +124,7 @@ describe('normalizeTable', () => { table.rows[0].cells.push(cell1); table.rows[1].cells.push(cell2); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -138,10 +144,10 @@ describe('normalizeTable', () => { blockType: 'Paragraph', segments: [], format: {}, + cachedElement: mockedCachedElement, }, ], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -160,6 +166,7 @@ describe('normalizeTable', () => { blockType: 'Paragraph', segments: [], format: {}, + cachedElement: mockedCachedElement, }, ], dataset: {}, @@ -173,7 +180,6 @@ describe('normalizeTable', () => { }, widths: [120], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -204,7 +210,7 @@ describe('normalizeTable', () => { table.rows[0].cells.push(cell2); table.rows[0].cells.push(cell3); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -230,6 +236,7 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, { blockType: 'Paragraph', @@ -241,10 +248,10 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, ], dataset: {}, - cachedElement: mockedCachedElement, }, { blockGroupType: 'TableCell', @@ -263,10 +270,10 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, ], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -277,7 +284,6 @@ describe('normalizeTable', () => { }, widths: [240, 120], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -296,7 +302,7 @@ describe('normalizeTable', () => { table.rows[0].cells.push(cell1); table.rows[0].cells.push(cell2); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -316,10 +322,10 @@ describe('normalizeTable', () => { blockType: 'Paragraph', segments: [], format: {}, + cachedElement: mockedCachedElement, }, ], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -330,7 +336,6 @@ describe('normalizeTable', () => { }, widths: [240], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -362,7 +367,7 @@ describe('normalizeTable', () => { table.rows[1].cells.push(cell3); table.rows[1].cells.push(cell4); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -379,7 +384,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [block1, block2], dataset: {}, - cachedElement: mockedCachedElement, }, { blockGroupType: 'TableCell', @@ -389,7 +393,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -405,7 +408,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [block3], dataset: {}, - cachedElement: mockedCachedElement, }, { blockGroupType: 'TableCell', @@ -415,7 +417,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [block4], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -426,7 +427,6 @@ describe('normalizeTable', () => { }, widths: [120, 120], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -458,7 +458,7 @@ describe('normalizeTable', () => { table.rows[1].cells.push(cell3); table.rows[1].cells.push(cell4); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -484,6 +484,7 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, { blockType: 'Paragraph', @@ -495,10 +496,10 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, ], dataset: {}, - cachedElement: mockedCachedElement, }, { blockGroupType: 'TableCell', @@ -517,6 +518,7 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, { blockType: 'Paragraph', @@ -528,10 +530,10 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, ], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -542,7 +544,6 @@ describe('normalizeTable', () => { }, widths: [120, 120], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -574,7 +575,7 @@ describe('normalizeTable', () => { table.rows[1].cells.push(cell3); table.rows[1].cells.push(cell4); - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); expect(table).toEqual({ blockType: 'Table', @@ -601,6 +602,7 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, { blockType: 'Paragraph', @@ -612,6 +614,7 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, { blockType: 'Paragraph', @@ -623,6 +626,7 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, { blockType: 'Paragraph', @@ -634,9 +638,9 @@ describe('normalizeTable', () => { }, ], format: {}, + cachedElement: mockedCachedElement, }, ], - cachedElement: mockedCachedElement, }, ], }, @@ -647,7 +651,6 @@ describe('normalizeTable', () => { }, widths: [240], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -659,7 +662,7 @@ describe('normalizeTable', () => { table.rows[0].cells.push(createTableCell(1, 1, false)); - normalizeTable(table, format); + normalizeTable(table as ReadonlyContentModelTable, format); expect(table).toEqual({ blockType: 'Table', @@ -690,7 +693,6 @@ describe('normalizeTable', () => { }, ], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -701,7 +703,6 @@ describe('normalizeTable', () => { }, widths: [120], dataset: {}, - cachedElement: mockedCachedElement, }); }); @@ -720,7 +721,7 @@ describe('normalizeTable', () => { table.rows[0].height = 200; table.rows[1].height = 200; - normalizeTable(table); + normalizeTable(table as ReadonlyContentModelTable); const block: ContentModelParagraph = { blockType: 'Paragraph', @@ -748,7 +749,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [block], dataset: {}, - cachedElement: mockedCachedElement, }, { blockGroupType: 'TableCell', @@ -758,7 +758,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [block], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -774,7 +773,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [block], dataset: {}, - cachedElement: mockedCachedElement, }, { blockGroupType: 'TableCell', @@ -784,7 +782,6 @@ describe('normalizeTable', () => { format: { useBorderBox: true }, blocks: [block], dataset: {}, - cachedElement: mockedCachedElement, }, ], }, @@ -795,7 +792,6 @@ describe('normalizeTable', () => { }, widths: [100, 100], dataset: {}, - cachedElement: mockedCachedElement, }); }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/setTableCellBackgroundColorTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/setTableCellBackgroundColorTest.ts index 6fbe2ce6fc2..ca884d9774a 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/setTableCellBackgroundColorTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/setTableCellBackgroundColorTest.ts @@ -1,13 +1,16 @@ -import { ContentModelTableCell, ContentModelTableCellFormat } from 'roosterjs-content-model-types'; import { createTableCell as originalCreateTableCell } from 'roosterjs-content-model-dom'; import { setTableCellBackgroundColor } from '../../../lib/modelApi/editing/setTableCellBackgroundColor'; +import { + ContentModelTableCellFormat, + ShallowMutableContentModelTableCell, +} from 'roosterjs-content-model-types'; function createTableCell( spanLeftOrColSpan?: boolean | number, spanAboveOrRowSpan?: boolean | number, isHeader?: boolean, format?: ContentModelTableCellFormat -): ContentModelTableCell { +): ShallowMutableContentModelTableCell { const cell = originalCreateTableCell(spanLeftOrColSpan, spanAboveOrRowSpan, isHeader, format); cell.cachedElement = {} as any; diff --git a/packages/roosterjs-content-model-dom/test/modelApi/selection/setSelectionTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/selection/setSelectionTest.ts index c7c5d7fcefe..b87aa5d56b9 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/selection/setSelectionTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/selection/setSelectionTest.ts @@ -599,7 +599,6 @@ describe('setSelection', () => { src: '', dataset: {}, isSelected: true, - isSelectedAsImageSelection: false, }, { segmentType: 'Text', diff --git a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts index fdaf459ab6f..06d6c066100 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts @@ -4,15 +4,16 @@ import { deleteBlock, deleteSegment, getClosestAncestorBlockGroupIndex, + mutateBlock, setParagraphNotImplicit, } from 'roosterjs-content-model-dom'; -import type { BlockAndPath } from '../utils/getLeafSiblingBlock'; +import type { ReadonlyBlockAndPath } from '../utils/getLeafSiblingBlock'; import type { - ContentModelBlockGroup, - ContentModelDocument, - ContentModelParagraph, - ContentModelSegment, DeleteSelectionStep, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelDocument, + ShallowMutableContentModelParagraph, + ShallowMutableContentModelSegment, } from 'roosterjs-content-model-types'; function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteSelectionStep { @@ -29,8 +30,8 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS const index = segments.indexOf(marker) + (isForward ? 1 : -1); const segmentToDelete = segments[index]; - let blockToDelete: BlockAndPath | null; - let root: ContentModelDocument | null; + let blockToDelete: ReadonlyBlockAndPath | null; + let root: ReadonlyContentModelDocument | null; if (segmentToDelete) { if (deleteSegment(paragraph, segmentToDelete, context.formatContext, direction)) { @@ -47,9 +48,11 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS setModelIndentation(root, 'outdent'); context.deleteResult = 'range'; } else if ((blockToDelete = getLeafSiblingBlock(path, paragraph, isForward))) { - const { block, path, siblingSegment } = blockToDelete; + const { block: readonlyBlock, path, siblingSegment } = blockToDelete; + + if (readonlyBlock.blockType == 'Paragraph') { + const block = mutateBlock(readonlyBlock); - if (block.blockType == 'Paragraph') { if (siblingSegment) { // When selection is under general segment, need to check if it has a sibling sibling, and delete from it if (deleteSegment(block, siblingSegment, context.formatContext, direction)) { @@ -70,7 +73,6 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS tableContext, }; context.lastParagraph = paragraph; - delete block.cachedElement; } context.deleteResult = 'range'; @@ -81,8 +83,8 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS } else { if ( deleteBlock( - path[0].blocks, - block, + mutateBlock(path[0]).blocks, + readonlyBlock, undefined /*replacement*/, context.formatContext, direction @@ -100,16 +102,16 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS }; } -function getRoot(path: ContentModelBlockGroup[]): ContentModelDocument | null { +function getRoot(path: ReadonlyContentModelBlockGroup[]): ReadonlyContentModelDocument | null { const lastInPath = path[path.length - 1]; return lastInPath.blockGroupType == 'Document' ? lastInPath : null; } function shouldOutdentParagraph( isForward: boolean, - segments: ContentModelSegment[], - paragraph: ContentModelParagraph, - path: ContentModelBlockGroup[] + segments: ShallowMutableContentModelSegment[], + paragraph: ShallowMutableContentModelParagraph, + path: ReadonlyContentModelBlockGroup[] ) { return ( !isForward && @@ -125,7 +127,7 @@ function shouldOutdentParagraph( * If the last segment is BR, remove it for now. We may add it back later when normalize model. * So that if this is an empty paragraph, it will start to delete next block */ -function fixupBr(segments: ContentModelSegment[]) { +function fixupBr(segments: ShallowMutableContentModelSegment[]) { if (segments[segments.length - 1]?.segmentType == 'Br') { const segmentsWithoutBr = segments.filter(x => x.segmentType != 'SelectionMarker'); 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..8148d5f9a66 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteEmptyQuote.ts @@ -4,11 +4,12 @@ import { unwrapBlock, getClosestAncestorBlockGroupIndex, isBlockGroupOfType, + mutateBlock, } from 'roosterjs-content-model-dom'; import type { - ContentModelBlockGroup, ContentModelFormatContainer, DeleteSelectionStep, + ReadonlyContentModelBlockGroup, } from 'roosterjs-content-model-types'; /** @@ -75,7 +76,7 @@ const isSelectionOnEmptyLine = (quote: ContentModelFormatContainer) => { const insertNewLine = ( quote: ContentModelFormatContainer, - parent: ContentModelBlockGroup, + parent: ReadonlyContentModelBlockGroup, index: number ) => { const quoteLength = quote.blocks.length; @@ -83,5 +84,5 @@ const insertNewLine = ( const marker = createSelectionMarker(); const newParagraph = createParagraph(false /* isImplicit */); newParagraph.segments.push(marker); - parent.blocks.splice(index + 1, 0, newParagraph); + mutateBlock(parent).blocks.splice(index + 1, 0, newParagraph); }; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts index 303dd449eae..b612d813a53 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts @@ -5,9 +5,9 @@ import { normalizeText, } from 'roosterjs-content-model-dom'; import type { - ContentModelParagraph, DeleteSelectionContext, DeleteSelectionStep, + ShallowMutableContentModelParagraph, } from 'roosterjs-content-model-types'; const enum DeleteWordState { @@ -101,7 +101,7 @@ function getDeleteWordSelection(direction: 'forward' | 'backward'): DeleteSelect } function* iterateSegments( - paragraph: ContentModelParagraph, + paragraph: ShallowMutableContentModelParagraph, markerIndex: number, forward: boolean, context: DeleteSelectionContext 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..112cbb1a6c9 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/inputSteps/handleEnterOnList.ts @@ -10,12 +10,16 @@ import { setParagraphNotImplicit, getClosestAncestorBlockGroupIndex, isBlockGroupOfType, + mutateBlock, } from 'roosterjs-content-model-dom'; import type { - ContentModelBlockGroup, ContentModelListItem, DeleteSelectionStep, InsertPoint, + ReadonlyContentModelBlockGroup, + ReadonlyContentModelListItem, + ShallowMutableContentModelListItem, + ShallowMutableContentModelParagraph, ValidDeleteSelectionContext, } from 'roosterjs-content-model-types'; @@ -66,7 +70,7 @@ export const handleEnterOnList: DeleteSelectionStep = context => { lastParagraph.segments[lastParagraph.segments.length - 1].segmentType === 'SelectionMarker' ) { - lastParagraph.segments.pop(); + mutateBlock(lastParagraph).segments.pop(); nextParagraph.segments.unshift( createSelectionMarker(insertPoint.marker.format) @@ -77,7 +81,7 @@ export const handleEnterOnList: DeleteSelectionStep = context => { } } else if (deleteResult !== 'range') { if (isEmptyListItem(listItem)) { - listItem.levels.pop(); + mutateBlock(listItem).levels.pop(); } else { const newListItem = createNewListItem(context, listItem, listParent); @@ -96,7 +100,7 @@ export const handleEnterOnList: DeleteSelectionStep = context => { } }; -const isEmptyListItem = (listItem: ContentModelListItem) => { +const isEmptyListItem = (listItem: ReadonlyContentModelListItem) => { return ( listItem.blocks.length === 1 && listItem.blocks[0].blockType === 'Paragraph' && @@ -108,24 +112,27 @@ const isEmptyListItem = (listItem: ContentModelListItem) => { const createNewListItem = ( context: ValidDeleteSelectionContext, - listItem: ContentModelListItem, - listParent: ContentModelBlockGroup + listItem: ReadonlyContentModelListItem, + listParent: ReadonlyContentModelBlockGroup ) => { const { insertPoint } = context; const listIndex = listParent.blocks.indexOf(listItem); const newParagraph = createNewParagraph(insertPoint); const levels = createNewListLevel(listItem); - const newListItem = createListItem(levels, insertPoint.marker.format); + const newListItem: ShallowMutableContentModelListItem = createListItem( + levels, + insertPoint.marker.format + ); newListItem.blocks.push(newParagraph); insertPoint.paragraph = newParagraph; context.lastParagraph = newParagraph; - listParent.blocks.splice(listIndex + 1, 0, newListItem); + mutateBlock(listParent).blocks.splice(listIndex + 1, 0, newListItem); return newListItem; }; -const createNewListLevel = (listItem: ContentModelListItem) => { +const createNewListLevel = (listItem: ReadonlyContentModelListItem) => { return listItem.levels.map(level => { return createListLevel( level.listType, @@ -141,7 +148,7 @@ const createNewListLevel = (listItem: ContentModelListItem) => { const createNewParagraph = (insertPoint: InsertPoint) => { const { paragraph, marker } = insertPoint; - const newParagraph = createParagraph( + const newParagraph: ShallowMutableContentModelParagraph = createParagraph( false /*isImplicit*/, paragraph.format, paragraph.segmentFormat diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts index 48e6c778b47..74386776bf7 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableInserterTest.ts @@ -59,10 +59,12 @@ describe('Table Inserter tests', () => { // Inserter is visible, but pointer is not over it return 'not clickable'; } - const table = getCurrentTable(editor); + let table = getCurrentTable(editor); const rows = getTableRows(table); const cols = getTableColumns(table); inserter.dispatchEvent(new MouseEvent('click')); + + table = getCurrentTable(editor); const newRows = getTableRows(table); const newCols = getTableColumns(table); expect(newRows).toBe(inserterType == VERTICAL_INSERTER_ID ? rows : rows + 1); diff --git a/packages/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts b/packages/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts index f396642b126..012f4987545 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts @@ -1,8 +1,8 @@ -import type { ContentModelParagraph } from '../contentModel/block/ContentModelParagraph'; +import type { ShallowMutableContentModelParagraph } from '../contentModel/block/ContentModelParagraph'; import type { DeleteResult } from '../enum/DeleteResult'; import type { FormatContentModelContext } from './FormatContentModelContext'; import type { InsertPoint } from '../selection/InsertPoint'; -import type { TableSelectionContext } from '../selection/TableSelectionContext'; +import type { ReadonlyTableSelectionContext } from '../selection/TableSelectionContext'; /** * Result of deleteSelection API @@ -26,12 +26,12 @@ export interface DeleteSelectionContext extends DeleteSelectionResult { /** * Last paragraph after previous step */ - lastParagraph?: ContentModelParagraph; + lastParagraph?: ShallowMutableContentModelParagraph; /** * Last table context after previous step */ - lastTableContext?: TableSelectionContext; + lastTableContext?: ReadonlyTableSelectionContext; /** * Format context provided by formatContentModel API diff --git a/packages/roosterjs-content-model-types/lib/selection/InsertPoint.ts b/packages/roosterjs-content-model-types/lib/selection/InsertPoint.ts index 49f1057d918..493d5073ee4 100644 --- a/packages/roosterjs-content-model-types/lib/selection/InsertPoint.ts +++ b/packages/roosterjs-content-model-types/lib/selection/InsertPoint.ts @@ -1,7 +1,7 @@ -import type { ContentModelBlockGroup } from '../contentModel/blockGroup/ContentModelBlockGroup'; -import type { ContentModelParagraph } from '../contentModel/block/ContentModelParagraph'; +import type { ReadonlyContentModelBlockGroup } from '../contentModel/blockGroup/ContentModelBlockGroup'; +import type { ShallowMutableContentModelParagraph } from '../contentModel/block/ContentModelParagraph'; import type { ContentModelSelectionMarker } from '../contentModel/segment/ContentModelSelectionMarker'; -import type { TableSelectionContext } from './TableSelectionContext'; +import type { ReadonlyTableSelectionContext } from './TableSelectionContext'; /** * Represent all related information of an insert point @@ -15,15 +15,15 @@ export interface InsertPoint { /** * The paragraph that contains this insert point */ - paragraph: ContentModelParagraph; + paragraph: ShallowMutableContentModelParagraph; /** * Block group path of this insert point, from direct parent group to the root group */ - path: ContentModelBlockGroup[]; + path: ReadonlyContentModelBlockGroup[]; /** * Table context of this insert point */ - tableContext?: TableSelectionContext; + tableContext?: ReadonlyTableSelectionContext; }