From 1376b9ee459d6238732d3ad476857a6a7318c166 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 27 Mar 2024 15:19:17 -0700 Subject: [PATCH 1/2] Fix 262677: Allow insert entity to a specified position without changing selection --- .../lib/modelApi/entity/insertEntityModel.ts | 34 +- .../lib/publicApi/entity/insertEntity.ts | 82 +- .../formatInsertPointWithContentModel.ts | 239 ++++++ .../modelApi/entity/insertEntityModelTest.ts | 105 ++- .../test/publicApi/entity/insertEntityTest.ts | 42 +- .../formatInsertPointWithContentModelTest.ts | 379 +++++++++ .../createContentModel/createContentModel.ts | 2 +- .../formatContentModel/formatContentModel.ts | 9 +- .../getPositionFromPath.ts | 11 +- .../lib/corePlugin/selection/normalizePos.ts | 4 +- .../lib/editor/Editor.ts | 33 +- .../createContentModelTest.ts | 14 + .../formatContentModelTest.ts | 32 + .../test/editor/EditorTest.ts | 28 +- .../domToModel/context/defaultProcessors.ts | 2 + .../domToModel/processors/textProcessor.ts | 73 +- .../processors/textWithSelectionProcessor.ts | 42 + .../domToModel/utils/addSelectionMarker.ts | 43 +- .../domToModel/utils/buildSelectionMarker.ts | 60 ++ .../roosterjs-content-model-dom/lib/index.ts | 2 + .../lib/modelApi/common/addSegment.ts | 8 +- .../lib/modelApi/common/addTextSegment.ts | 48 ++ .../textWithSelectionProcessorTest.ts | 735 ++++++++++++++++++ .../utils/buildSelectionMarkerTest.ts | 228 ++++++ .../test/modelApi/common/addSegmentTest.ts | 264 +++++++ .../modelApi/common/addTextSegmentTest.ts | 209 +++++ .../paste/e2e/cmPasteFromExcelOnlineTest.ts | 2 +- .../test/paste/e2e/cmPasteFromWacTest.ts | 2 +- .../processPastedContentFromExcelTest.ts | 1 - .../lib/context/DomToModelSettings.ts | 6 + .../lib/editor/EditorCore.ts | 3 +- .../lib/editor/IEditor.ts | 7 +- .../segment/ContentModelSelectionMarker.ts | 7 +- .../test/editor/EditorAdapterTest.ts | 2 +- 34 files changed, 2546 insertions(+), 212 deletions(-) create mode 100644 packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts create mode 100644 packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts create mode 100644 packages/roosterjs-content-model-dom/lib/domToModel/processors/textWithSelectionProcessor.ts create mode 100644 packages/roosterjs-content-model-dom/lib/domToModel/utils/buildSelectionMarker.ts create mode 100644 packages/roosterjs-content-model-dom/lib/modelApi/common/addTextSegment.ts create mode 100644 packages/roosterjs-content-model-dom/test/domToModel/processors/textWithSelectionProcessorTest.ts create mode 100644 packages/roosterjs-content-model-dom/test/domToModel/utils/buildSelectionMarkerTest.ts create mode 100644 packages/roosterjs-content-model-dom/test/modelApi/common/addTextSegmentTest.ts 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 91b2a33aac0..faa023c42a8 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts @@ -13,9 +13,9 @@ import type { ContentModelDocument, ContentModelEntity, ContentModelParagraph, - DeleteSelectionResult, FormatContentModelContext, InsertEntityPosition, + InsertPoint, } from 'roosterjs-content-model-types'; /** @@ -27,11 +27,12 @@ export function insertEntityModel( position: InsertEntityPosition, isBlock: boolean, focusAfterEntity?: boolean, - context?: FormatContentModelContext + context?: FormatContentModelContext, + insertPointOverride?: InsertPoint ) { let blockParent: ContentModelBlockGroup | undefined; let blockIndex = -1; - let deleteResult: DeleteSelectionResult; + let insertPoint: InsertPoint | null; if (position == 'begin' || position == 'end') { blockParent = model; @@ -40,12 +41,8 @@ export function insertEntityModel( if (!isBlock) { Object.assign(entityModel.format, model.format); } - } else if ((deleteResult = deleteSelection(model, [], context)).insertPoint) { - const { marker, paragraph, path } = deleteResult.insertPoint; - - if (deleteResult.deleteResult == 'range') { - normalizeContentModel(model); - } + } else if ((insertPoint = getInsertPoint(model, insertPointOverride, context))) { + const { marker, paragraph, path } = insertPoint; if (!isBlock) { const index = paragraph.segments.indexOf(marker); @@ -111,3 +108,22 @@ export function insertEntityModel( } } } + +function getInsertPoint( + model: ContentModelDocument, + insertPointOverride?: InsertPoint, + context?: FormatContentModelContext +): InsertPoint | null { + if (insertPointOverride) { + return insertPointOverride; + } else { + const deleteResult = deleteSelection(model, [], context); + const insertPoint = deleteResult.insertPoint; + + if (deleteResult.deleteResult == 'range') { + normalizeContentModel(model); + } + + return insertPoint; + } +} diff --git a/packages/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts b/packages/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts index d57acea5c3a..0eb92d5cc0f 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts @@ -1,3 +1,4 @@ +import { formatInsertPointWithContentModel } from '../utils/formatInsertPointWithContentModel'; import { insertEntityModel } from '../../modelApi/entity/insertEntityModel'; import { ChangeSource, @@ -7,11 +8,15 @@ import { } from 'roosterjs-content-model-dom'; import type { ContentModelEntity, - DOMSelection, InsertEntityPosition, InsertEntityOptions, IEditor, EntityState, + DOMInsertPoint, + FormatContentModelOptions, + ContentModelDocument, + FormatContentModelContext, + InsertPoint, } from 'roosterjs-content-model-types'; const BlockEntityTag = 'div'; @@ -32,7 +37,7 @@ export function insertEntity( editor: IEditor, type: string, isBlock: boolean, - position: 'focus' | 'begin' | 'end' | DOMSelection, + position: 'focus' | 'begin' | 'end' | DOMInsertPoint, options?: InsertEntityOptions ): ContentModelEntity | null; @@ -51,7 +56,7 @@ export function insertEntity( editor: IEditor, type: string, isBlock: true, - position: InsertEntityPosition | DOMSelection, + position: InsertEntityPosition | DOMInsertPoint, options?: InsertEntityOptions ): ContentModelEntity | null; @@ -59,7 +64,7 @@ export function insertEntity( editor: IEditor, type: string, isBlock: boolean, - position?: InsertEntityPosition | DOMSelection, + position?: InsertEntityPosition | DOMInsertPoint, options?: InsertEntityOptions ): ContentModelEntity | null { const { contentNode, focusAfterEntity, wrapperDisplay, skipUndoSnapshot, initialEntityState } = @@ -85,36 +90,45 @@ export function insertEntity( editor.takeSnapshot(); } - editor.formatContentModel( - (model, context) => { - insertEntityModel( - model, - entityModel, - typeof position == 'string' ? position : 'focus', - isBlock, - focusAfterEntity, - context - ); - - normalizeContentModel(model); - - context.skipUndoSnapshot = true; - context.newEntities.push(entityModel); - - return true; - }, - { - selectionOverride: typeof position === 'object' ? position : undefined, - changeSource: ChangeSource.InsertEntity, - getChangeData: () => ({ - wrapper, - type, - id: '', - isReadonly: true, - }), - apiName: 'insertEntity', - } - ); + const formatOptions: FormatContentModelOptions = { + changeSource: ChangeSource.InsertEntity, + getChangeData: () => ({ + wrapper, + type, + id: '', + isReadonly: true, + }), + apiName: 'insertEntity', + }; + + const callback = ( + model: ContentModelDocument, + context: FormatContentModelContext, + insertPoint?: InsertPoint + ) => { + insertEntityModel( + model, + entityModel, + typeof position == 'string' ? position : 'focus', + isBlock, + focusAfterEntity, + context, + insertPoint + ); + + normalizeContentModel(model); + + context.skipUndoSnapshot = true; + context.newEntities.push(entityModel); + + return true; + }; + + if (typeof position == 'object') { + formatInsertPointWithContentModel(editor, position, callback, formatOptions); + } else { + editor.formatContentModel(callback, formatOptions); + } if (!skipUndoSnapshot) { let entityState: EntityState | undefined; diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts new file mode 100644 index 00000000000..63018d7869a --- /dev/null +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatInsertPointWithContentModel.ts @@ -0,0 +1,239 @@ +import { + addSegment, + addTextSegment, + buildSelectionMarker, + getRegularSelectionOffsets, + processChildNode, +} from 'roosterjs-content-model-dom'; +import type { + ElementProcessor, + DOMInsertPoint, + FormatContentModelOptions, + IEditor, + InsertPoint, + DomToModelContext, + ContentModelBlockGroup, + ContentModelDocument, + FormatContentModelContext, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function formatInsertPointWithContentModel( + editor: IEditor, + insertPoint: DOMInsertPoint, + callback: ( + model: ContentModelDocument, + context: FormatContentModelContext, + insertPoint?: InsertPoint + ) => void, + options?: FormatContentModelOptions +) { + const bundle: InsertPointBundle = { + input: insertPoint, + }; + + editor.formatContentModel( + (model, context) => { + callback(model, context, bundle.result); + + if (bundle?.result) { + const { paragraph, marker } = bundle.result; + const index = paragraph.segments.indexOf(marker); + + if (index >= 0) { + paragraph.segments.splice(index, 1); + } + } + return true; + }, + options, + { + processorOverride: { + child: getShadowChildProcessor(bundle), + '#text': getShadowTextProcessor(bundle), + }, + } + ); +} + +/** + * @internal Export for test only + */ +export interface InsertPointBundle { + input: DOMInsertPoint; + result?: InsertPoint; +} + +/** + * @internal Export for test only + */ +export interface DomToModelContextWithPath extends DomToModelContext { + /** + * Block group path of this insert point, from direct parent group to the root group + */ + path?: ContentModelBlockGroup[]; +} + +/** + * @internal Export for test only + */ +export function getShadowChildProcessor(bundle: InsertPointBundle): ElementProcessor { + return (group, parent, context) => { + const contextWithPath = context as DomToModelContextWithPath; + + contextWithPath.path = contextWithPath.path || []; + + let shouldShiftPath = false; + if (contextWithPath.path[0] != group) { + contextWithPath.path.unshift(group); + shouldShiftPath = true; + } + + const offsets = getShadowSelectionOffsets(context, bundle, parent); + let index = 0; + + for (let child = parent.firstChild; child; child = child.nextSibling) { + handleElementShadowSelection(bundle, index, context, group, offsets, parent); + + processChildNode(group, child, context); + + index++; + } + + handleElementShadowSelection(bundle, index, context, group, offsets, parent); + + if (shouldShiftPath) { + contextWithPath.path.shift(); + } + }; +} + +function handleElementShadowSelection( + bundle: InsertPointBundle, + index: number, + context: DomToModelContext, + group: ContentModelBlockGroup, + offsets: [number, number, number], + container?: Node +) { + if ( + index == offsets[2] && + (index <= offsets[0] || offsets[0] < 0) && + (index < offsets[1] || offsets[1] < 0) + ) { + addSelectionMarker(group, context, container, index, bundle); + offsets[2] = -1; + } + + if (index == offsets[0]) { + context.isInSelection = true; + addSelectionMarker(group, context, container, index); + } + + if (index == offsets[2] && (index < offsets[1] || offsets[1] < 0)) { + addSelectionMarker(group, context, container, index, bundle); + offsets[2] = -1; + } + + if (index == offsets[1]) { + addSelectionMarker(group, context, container, index); + context.isInSelection = false; + } + + if (index == offsets[2]) { + addSelectionMarker(group, context, container, index, bundle); + } +} + +/** + * @internal export for test only + */ +export const getShadowTextProcessor = (bundle: InsertPointBundle): ElementProcessor => ( + group, + textNode, + context +) => { + let txt = textNode.nodeValue || ''; + const offsets = getShadowSelectionOffsets(context, bundle, textNode); + const [start, end, shadow] = offsets; + + const handleTextSelection = ( + subtract: number, + originalOffset: number, + bundle?: InsertPointBundle + ) => { + addTextSegment(group, txt.substring(0, subtract), context); + addSelectionMarker(group, context, textNode, originalOffset, bundle); + + offsets[0] -= subtract; + offsets[1] -= subtract; + offsets[2] = bundle ? -1 : offsets[2] - subtract; + + txt = txt.substring(subtract); + }; + + if ( + offsets[2] >= 0 && + (offsets[2] <= offsets[0] || offsets[0] < 0) && + (offsets[2] < offsets[1] || offsets[1] < 0) + ) { + handleTextSelection(offsets[2], shadow, bundle); + } + + if (offsets[0] >= 0) { + handleTextSelection(offsets[0], start); + + context.isInSelection = true; + } + + if (offsets[2] >= 0 && offsets[2] > offsets[0] && (offsets[2] < offsets[1] || offsets[1] < 0)) { + handleTextSelection(offsets[2], shadow, bundle); + } + + if (offsets[1] >= 0) { + handleTextSelection(offsets[1], end); + + context.isInSelection = false; + } + + if (offsets[2] >= 0 && offsets[2] >= offsets[1]) { + handleTextSelection(offsets[2], shadow, bundle); + } + + addTextSegment(group, txt, context); +}; + +function addSelectionMarker( + group: ContentModelBlockGroup, + context: DomToModelContextWithPath, + container?: Node, + offset?: number, + bundle?: InsertPointBundle +) { + const marker = buildSelectionMarker(group, context, container, offset); + + marker.isSelected = !bundle; + + const para = addSegment(group, marker, context.blockFormat, marker.format); + + if (bundle && context.path) { + bundle.result = { + path: [...context.path], + paragraph: para, + marker, + }; + } +} + +function getShadowSelectionOffsets( + context: DomToModelContext, + bundle: InsertPointBundle, + currentContainer: Node +): [number, number, number] { + const [start, end] = getRegularSelectionOffsets(context, currentContainer); + const shadow = bundle.input.node == currentContainer ? bundle.input.offset : -1; + + return [start, end, shadow]; +} diff --git a/packages/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts b/packages/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts index 612895b5b50..92c305d46c9 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts @@ -1,5 +1,9 @@ -import { ContentModelDocument, InsertEntityPosition } from 'roosterjs-content-model-types'; import { insertEntityModel } from '../../../lib/modelApi/entity/insertEntityModel'; +import { + ContentModelDocument, + InsertEntityPosition, + InsertPoint, +} from 'roosterjs-content-model-types'; import { createBr, createContentModelDocument, @@ -2518,3 +2522,102 @@ describe('insertEntityModel, inline element, focus after entity', () => { ); }); }); + +describe('insertEntityModel, use insert point', () => { + const Entity = { + format: {}, + } as any; + + it('Inline entity, Has insert point override', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test'); + const marker = createSelectionMarker(); + + text1.isSelected = true; + para1.segments.push(text1); + + marker.isSelected = false; + para2.segments.push(marker); + + model.blocks.push(para1, para2); + + const ip: InsertPoint = { + path: [model], + paragraph: para2, + marker, + }; + + insertEntityModel(model, Entity, 'focus', false, false, undefined, ip); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test', format: {}, isSelected: true }], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }, + Entity, + ], + format: {}, + }, + ], + }); + }); + + it('Block entity, Has insert point override', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test'); + const marker = createSelectionMarker(); + + text1.isSelected = true; + para1.segments.push(text1); + + marker.isSelected = false; + para2.segments.push(marker); + + model.blocks.push(para1, para2); + + const ip: InsertPoint = { + path: [model], + paragraph: para2, + marker, + }; + + insertEntityModel(model, Entity, 'focus', true, false, undefined, ip); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test', format: {}, isSelected: true }], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'SelectionMarker', isSelected: false, format: {} }], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + }); + }); +}); diff --git a/packages/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts b/packages/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts index 55c90196e66..2a04b21e9b9 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts @@ -1,8 +1,9 @@ import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; +import * as formatInsertPointWithContentModel from '../../../lib/publicApi/utils/formatInsertPointWithContentModel'; import * as insertEntityModel from '../../../lib/modelApi/entity/insertEntityModel'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { ChangeSource } from 'roosterjs-content-model-dom'; -import { IEditor } from 'roosterjs-content-model-types'; +import { DOMInsertPoint, IEditor } from 'roosterjs-content-model-types'; import { insertEntity } from '../../../lib/publicApi/entity/insertEntity'; import { FormatContentModelContext, @@ -16,6 +17,7 @@ describe('insertEntity', () => { const model = 'MockedModel' as any; let formatWithContentModelSpy: jasmine.Spy; + let formatInsertPointWithContentModelSpy: jasmine.Spy; let triggerContentChangedEventSpy: jasmine.Spy; let getDocumentSpy: jasmine.Spy; let createElementSpy: jasmine.Spy; @@ -57,6 +59,10 @@ describe('insertEntity', () => { .and.callFake((formatter: Function, options: FormatContentModelOptions) => { formatter(model, context); }); + formatInsertPointWithContentModelSpy = spyOn( + formatInsertPointWithContentModel, + 'formatInsertPointWithContentModel' + ); triggerContentChangedEventSpy = jasmine.createSpy('triggerContentChangedEventSpy'); createElementSpy = jasmine.createSpy('createElementSpy').and.returnValue(wrapper); @@ -105,7 +111,8 @@ describe('insertEntity', () => { 'begin', false, undefined, - context + context, + undefined ); expect(triggerContentChangedEventSpy).not.toHaveBeenCalled(); expect(normalizeContentModelSpy).toHaveBeenCalled(); @@ -153,7 +160,8 @@ describe('insertEntity', () => { 'root', true, undefined, - context + context, + undefined ); expect(triggerContentChangedEventSpy).not.toHaveBeenCalled(); expect(normalizeContentModelSpy).toHaveBeenCalled(); @@ -172,9 +180,9 @@ describe('insertEntity', () => { }); it('block inline entity with more options', () => { - const range = { range: 'RangeEx' } as any; + const domPos: DOMInsertPoint = { pos: 'DOMPOS' } as any; const contentNode = 'ContentNode' as any; - const entity = insertEntity(editor, type, true, range, { + const entity = insertEntity(editor, type, true, domPos, { contentNode: contentNode, focusAfterEntity: true, skipUndoSnapshot: true, @@ -187,11 +195,22 @@ describe('insertEntity', () => { expect(setPropertySpy).not.toHaveBeenCalledWith('display', 'inline-block'); expect(setPropertySpy).not.toHaveBeenCalledWith('width', '100%'); expect(appendChildSpy).toHaveBeenCalledWith(contentNode); - expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName); - expect(formatWithContentModelSpy.calls.argsFor(0)[1].changeSource).toEqual( + expect(formatWithContentModelSpy).not.toHaveBeenCalled(); + expect(formatInsertPointWithContentModelSpy).toHaveBeenCalledTimes(1); + expect(formatInsertPointWithContentModelSpy).toHaveBeenCalledWith( + editor, + domPos, + jasmine.anything() as any, + jasmine.anything() as any + ); + expect(formatInsertPointWithContentModelSpy.calls.argsFor(0)[3].apiName).toBe(apiName); + expect(formatInsertPointWithContentModelSpy.calls.argsFor(0)[3].changeSource).toEqual( ChangeSource.InsertEntity ); + const mockedIP = 'IP' as any; + formatInsertPointWithContentModelSpy.calls.argsFor(0)[2](model, context, mockedIP); + expect(insertEntityModelSpy).toHaveBeenCalledWith( model, { @@ -208,7 +227,8 @@ describe('insertEntity', () => { 'focus', true, true, - context + context, + mockedIP ); expect(triggerContentChangedEventSpy).not.toHaveBeenCalled(); expect(normalizeContentModelSpy).toHaveBeenCalled(); @@ -257,7 +277,8 @@ describe('insertEntity', () => { 'begin', false, undefined, - context + context, + undefined ); expect(triggerContentChangedEventSpy).not.toHaveBeenCalled(); expect(normalizeContentModelSpy).toHaveBeenCalled(); @@ -329,7 +350,8 @@ describe('insertEntity', () => { 'begin', false, undefined, - context + context, + undefined ); expect(triggerContentChangedEventSpy).not.toHaveBeenCalled(); expect(normalizeContentModelSpy).toHaveBeenCalled(); diff --git a/packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts b/packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts new file mode 100644 index 00000000000..7f490175440 --- /dev/null +++ b/packages/roosterjs-content-model-api/test/publicApi/utils/formatInsertPointWithContentModelTest.ts @@ -0,0 +1,379 @@ +import { createContentModelDocument, createDomToModelContext } from 'roosterjs-content-model-dom'; +import { + ContentModelParagraph, + ContentModelSegment, + DomToModelOption, +} from 'roosterjs-content-model-types'; +import { + DomToModelContextWithPath, + formatInsertPointWithContentModel, + getShadowChildProcessor, + getShadowTextProcessor, +} from '../../../lib/publicApi/utils/formatInsertPointWithContentModel'; + +describe('formatInsertPointWithContentModel', () => { + it('format with insertPoint', () => { + const node = document.createElement('div'); + const offset = 0; + const mockedInsertPoint = { node, offset }; + const mockedCallback = jasmine.createSpy('CALLBACK'); + const mockedOptions = 'OPTIONS' as any; + const mockedModel = createContentModelDocument(); + const mockedContext = createDomToModelContext(); + + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: Function, options: any, override: DomToModelOption) => { + expect(override.processorOverride?.child).toBeDefined(); + expect(override.processorOverride?.['#text']).toBeDefined(); + + override.processorOverride?.child!(mockedModel, node, mockedContext); + + callback(mockedModel, mockedContext); + }); + const mockedEditor = { + formatContentModel: formatContentModelSpy, + } as any; + + formatInsertPointWithContentModel( + mockedEditor, + mockedInsertPoint, + mockedCallback, + mockedOptions + ); + + expect(formatContentModelSpy).toHaveBeenCalledWith( + jasmine.anything() as any, + mockedOptions, + jasmine.anything() as any + ); + + const marker = { + segmentType: 'SelectionMarker', + isSelected: false, + format: {}, + }; + const para: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }; + expect(mockedCallback).toHaveBeenCalledWith(mockedModel, mockedContext, { + path: [mockedModel], + marker, + paragraph: para, + }); + expect(mockedModel).toEqual({ + blockGroupType: 'Document', + blocks: [para], + }); + }); + + it('format with insertPoint that is not in editor', () => { + const node1 = document.createElement('div'); + const node2 = document.createElement('div'); + const offset = 0; + const mockedInsertPoint = { node: node1, offset }; + const mockedCallback = jasmine.createSpy('CALLBACK'); + const mockedOptions = 'OPTIONS' as any; + const mockedModel = createContentModelDocument(); + const mockedContext = createDomToModelContext(); + + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: Function, options: any, override: DomToModelOption) => { + expect(override.processorOverride?.child).toBeDefined(); + expect(override.processorOverride?.['#text']).toBeDefined(); + + override.processorOverride?.child!(mockedModel, node2, mockedContext); + + callback(mockedModel, mockedContext); + }); + const mockedEditor = { + formatContentModel: formatContentModelSpy, + } as any; + + formatInsertPointWithContentModel( + mockedEditor, + mockedInsertPoint, + mockedCallback, + mockedOptions + ); + + expect(formatContentModelSpy).toHaveBeenCalledWith( + jasmine.anything() as any, + mockedOptions, + jasmine.anything() as any + ); + + expect(mockedCallback).toHaveBeenCalledWith(mockedModel, mockedContext, undefined); + expect(mockedModel).toEqual({ + blockGroupType: 'Document', + blocks: [], + }); + }); +}); + +describe('getShadowChildProcessor', () => { + function runTest(startOffset: number, endOffset: number, shadow: number, result: string[]) { + const div = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + + span1.textContent = 'a'; + span2.textContent = 'b'; + + div.appendChild(span1); + div.appendChild(span2); + + const group = createContentModelDocument(); + const context: DomToModelContextWithPath = createDomToModelContext(); + const bundle = { + input: { + node: div, + offset: shadow, + }, + }; + const processor = getShadowChildProcessor(bundle); + + context.selection = { + type: 'range', + range: { + startContainer: div, + startOffset, + endContainer: div, + endOffset, + } as any, + isReverted: false, + }; + + processor(group, div, context); + + const actualResult = (group.blocks[0] as ContentModelParagraph).segments.map(translate); + + expect(actualResult).toEqual(result); + expect(context.isInSelection).toBeFalse(); + } + + it('no insert point', () => { + runTest(-1, -1, -1, ['a', 'b']); + }); + + it('has insert point', () => { + runTest(-1, -1, 0, ['_', 'a', 'b']); + runTest(-1, -1, 1, ['a', '_', 'b']); + runTest(-1, -1, 2, ['a', 'b', '_']); + }); + + it('has insert point and collapsed regular selection', () => { + runTest(0, 0, 0, ['*', '_', 'a', 'b']); + runTest(1, 1, 0, ['_', 'a', '*', 'b']); + runTest(2, 2, 0, ['_', 'a', 'b', '*']); + runTest(0, 0, 1, ['*', 'a', '_', 'b']); + runTest(1, 1, 1, ['a', '*', '_', 'b']); + runTest(2, 2, 1, ['a', '_', 'b', '*']); + runTest(0, 0, 2, ['*', 'a', 'b', '_']); + runTest(1, 1, 2, ['a', '*', 'b', '_']); + runTest(2, 2, 2, ['a', 'b', '*', '_']); + }); + + it('has insert point and expanded regular selection', () => { + runTest(0, 1, 0, ['_', '*a', 'b']); + runTest(1, 2, 0, ['_', 'a', '*b']); + runTest(0, 2, 0, ['_', '*a', '*b']); + runTest(0, 1, 1, ['*a', '_', 'b']); + runTest(1, 2, 1, ['a', '_', '*b']); + runTest(0, 2, 1, ['*a', '_', '*b']); + runTest(0, 1, 2, ['*a', 'b', '_']); + runTest(1, 2, 2, ['a', '*b', '_']); + runTest(0, 2, 2, ['*a', '*b', '_']); + }); +}); + +describe('getShadowTextProcessor', () => { + const inputText = 'abcdef'; + + function runTest( + startOffset: number, + endOffset: number, + shadowOffset: number, + result: string[], + inSelectionResult: boolean, + alreadyInSelection?: boolean + ) { + const text = document.createTextNode(inputText); + const group = createContentModelDocument(); + const context: DomToModelContextWithPath = createDomToModelContext(); + + context.selection = { + type: 'range', + range: { + startContainer: text, + startOffset, + endContainer: text, + endOffset, + } as any, + isReverted: false, + }; + + if (alreadyInSelection) { + context.isInSelection = true; + } + + const bundle = { + input: { + node: text, + offset: shadowOffset, + }, + }; + const processor = getShadowTextProcessor(bundle); + + processor(group, text, context); + + const actualResult = (group.blocks[0] as ContentModelParagraph).segments.map(translate); + + expect(actualResult).toEqual(result); + expect(context.isInSelection).toBe(inSelectionResult); + } + + describe('no selection', () => { + it('No selection', () => { + runTest(-1, -1, -1, ['abcdef'], false); + runTest(-1, -1, 0, ['_', 'abcdef'], false); + runTest(-1, -1, 3, ['abc', '_', 'def'], false); + runTest(-1, -1, 6, ['abcdef', '_'], false); + }); + + it('No selection, but in selection', () => { + runTest(-1, -1, -1, ['*abcdef'], true, true); + 1081; + runTest(-1, -1, 0, ['_', '*abcdef'], true, true); + runTest(-1, -1, 3, ['*abc', '_', '*def'], true, true); + runTest(-1, -1, 6, ['*abcdef', '_'], true, true); + }); + }); + + describe('Has start', () => { + it('start at 0', () => { + runTest(0, -1, -1, ['*abcdef'], true); + runTest(0, -1, 0, ['_', '*abcdef'], true); + runTest(0, -1, 3, ['*abc', '_', '*def'], true); + runTest(0, -1, 6, ['*abcdef', '_'], true); + }); + + it('start at middle', () => { + runTest(2, -1, -1, ['ab', '*cdef'], true); + runTest(2, -1, 0, ['_', 'ab', '*cdef'], true); + runTest(2, -1, 1, ['a', '_', 'b', '*cdef'], true); + runTest(2, -1, 2, ['ab', '_', '*cdef'], true); + runTest(2, -1, 3, ['ab', '*c', '_', '*def'], true); + runTest(2, -1, 6, ['ab', '*cdef', '_'], true); + }); + + it('start at end', () => { + runTest(6, -1, -1, ['abcdef', '*'], true); + runTest(6, -1, 0, ['_', 'abcdef', '*'], true); + runTest(6, -1, 3, ['abc', '_', 'def', '*'], true); + runTest(6, -1, 6, ['abcdef', '_', '*'], true); + }); + }); + + describe('Has end', () => { + it('end at 0', () => { + runTest(-1, 0, -1, ['*', 'abcdef'], false, true); + runTest(-1, 0, 0, ['*', '_', 'abcdef'], false, true); + runTest(-1, 0, 3, ['*', 'abc', '_', 'def'], false, true); + runTest(-1, 0, 6, ['*', 'abcdef', '_'], false, true); + }); + + it('end at middle', () => { + runTest(-1, 4, -1, ['*abcd', 'ef'], false, true); + runTest(-1, 4, 0, ['_', '*abcd', 'ef'], false, true); + runTest(-1, 4, 3, ['*abc', '_', '*d', 'ef'], false, true); + runTest(-1, 4, 4, ['*abcd', '_', 'ef'], false, true); + runTest(-1, 4, 5, ['*abcd', 'e', '_', 'f'], false, true); + runTest(-1, 4, 6, ['*abcd', 'ef', '_'], false, true); + }); + + it('end at end', () => { + runTest(-1, 6, -1, ['*abcdef'], false, true); + runTest(-1, 6, 0, ['_', '*abcdef'], false, true); + runTest(-1, 6, 3, ['*abc', '_', '*def'], false, true); + runTest(-1, 6, 6, ['*abcdef', '_'], false, true); + }); + }); + + describe('Has same start and end', () => { + it('at 0', () => { + runTest(0, 0, -1, ['*', 'abcdef'], false); + runTest(0, 0, 0, ['*', '_', 'abcdef'], false); + runTest(0, 0, 3, ['*', 'abc', '_', 'def'], false); + runTest(0, 0, 6, ['*', 'abcdef', '_'], false); + }); + + it('at middle', () => { + runTest(3, 3, -1, ['abc', '*', 'def'], false); + runTest(3, 3, 0, ['_', 'abc', '*', 'def'], false); + runTest(3, 3, 2, ['ab', '_', 'c', '*', 'def'], false); + runTest(3, 3, 3, ['abc', '*', '_', 'def'], false); + runTest(3, 3, 4, ['abc', '*', 'd', '_', 'ef'], false); + runTest(3, 3, 6, ['abc', '*', 'def', '_'], false); + }); + + it('at end', () => { + runTest(6, 6, -1, ['abcdef', '*'], false); + runTest(6, 6, 0, ['_', 'abcdef', '*'], false); + runTest(6, 6, 3, ['abc', '_', 'def', '*'], false); + runTest(6, 6, 6, ['abcdef', '*', '_'], false); + }); + }); + + describe('Has different start and end', () => { + it('start at 0, end at end', () => { + runTest(0, 6, -1, ['*abcdef'], false); + runTest(0, 6, 0, ['_', '*abcdef'], false); + runTest(0, 6, 3, ['*abc', '_', '*def'], false); + runTest(0, 6, 6, ['*abcdef', '_'], false); + }); + + it('start at 0, end at 3', () => { + runTest(0, 3, -1, ['*abc', 'def'], false); + runTest(0, 3, 0, ['_', '*abc', 'def'], false); + runTest(0, 3, 2, ['*ab', '_', '*c', 'def'], false); + runTest(0, 3, 3, ['*abc', '_', 'def'], false); + runTest(0, 3, 4, ['*abc', 'd', '_', 'ef'], false); + runTest(0, 3, 6, ['*abc', 'def', '_'], false); + }); + + it('start at 3, end at end', () => { + runTest(3, 6, -1, ['abc', '*def'], false); + runTest(3, 6, 0, ['_', 'abc', '*def'], false); + runTest(3, 6, 2, ['ab', '_', 'c', '*def'], false); + runTest(3, 6, 3, ['abc', '_', '*def'], false); + runTest(3, 6, 4, ['abc', '*d', '_', '*ef'], false); + runTest(3, 6, 6, ['abc', '*def', '_'], false); + }); + + it('start at 2, end at 4', () => { + runTest(2, 4, -1, ['ab', '*cd', 'ef'], false); + runTest(2, 4, 0, ['_', 'ab', '*cd', 'ef'], false); + runTest(2, 4, 1, ['a', '_', 'b', '*cd', 'ef'], false); + runTest(2, 4, 2, ['ab', '_', '*cd', 'ef'], false); + runTest(2, 4, 3, ['ab', '*c', '_', '*d', 'ef'], false); + runTest(2, 4, 4, ['ab', '*cd', '_', 'ef'], false); + runTest(2, 4, 5, ['ab', '*cd', 'e', '_', 'f'], false); + runTest(2, 4, 6, ['ab', '*cd', 'ef', '_'], false); + }); + }); +}); + +function translate(input: ContentModelSegment): string { + if (input.segmentType == 'Text') { + return input.isSelected ? '*' + input.text : input.text; + } else if (input.segmentType == 'SelectionMarker') { + return input.isSelected ? '*' : '_'; + } else { + throw new Error('Wrong input type'); + } +} diff --git a/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts index b04ac08dcad..835d7032077 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts @@ -18,7 +18,7 @@ export const createContentModel: CreateContentModel = (core, option, selectionOv // Flush all mutations if any, so that we can get an up-to-date Content Model core.cache.textMutationObserver?.flushMutations(); - let cachedModel = selectionOverride ? null : core.cache.cachedModel; + let cachedModel = selectionOverride || option ? null : core.cache.cachedModel; if (cachedModel && core.lifecycle.shadowEditFragment) { // When in shadow edit, use a cloned model so we won't pollute the cached one diff --git a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts index 6e3df1385c0..7f2e777e90d 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts @@ -18,10 +18,15 @@ import type { * @param formatter Formatter function, see ContentModelFormatter * @param options More options, see FormatContentModelOptions */ -export const formatContentModel: FormatContentModel = (core, formatter, options) => { +export const formatContentModel: FormatContentModel = ( + core, + formatter, + options, + domToModelOptions +) => { const { apiName, onNodeCreated, getChangeData, changeSource, rawEvent, selectionOverride } = options || {}; - const model = core.api.createContentModel(core, undefined /*option*/, selectionOverride); + const model = core.api.createContentModel(core, domToModelOptions, selectionOverride); const context: FormatContentModelContext = { newEntities: [], deletedEntities: [], diff --git a/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/getPositionFromPath.ts b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/getPositionFromPath.ts index 0d05e019a20..14cd0207a36 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/getPositionFromPath.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/getPositionFromPath.ts @@ -1,19 +1,12 @@ import { isNodeOfType } from 'roosterjs-content-model-dom'; - -/** - * @internal - */ -export interface Pos { - node: Node; - offset: number; -} +import type { DOMInsertPoint } from 'roosterjs-content-model-types'; /** * @internal * * Use with paths generated by `getPath`. */ -export function getPositionFromPath(node: Node, path: number[]): Pos { +export function getPositionFromPath(node: Node, path: number[]): DOMInsertPoint { // Iterate with a for loop to avoid mutating the passed in element path stack // or needing to copy it. let offset: number = 0; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/normalizePos.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/normalizePos.ts index 4a9c0483eac..794b2c9ea36 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/normalizePos.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/normalizePos.ts @@ -1,10 +1,10 @@ import { isNodeOfType } from 'roosterjs-content-model-dom'; -import type { Pos } from '../../coreApi/restoreUndoSnapshot/getPositionFromPath'; +import type { DOMInsertPoint } from 'roosterjs-content-model-types'; /** * @internal */ -export function normalizePos(node: Node, offset: number): Pos { +export function normalizePos(node: Node, offset: number): DOMInsertPoint { const len = isNodeOfType(node, 'TEXT_NODE') ? node.nodeValue?.length ?? 0 : node.childNodes.length; diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 9e27b0be5ba..ac32832ca35 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -6,6 +6,8 @@ import { ChangeSource, cloneModel, transformColor, + createDomToModelContextWithConfig, + domToContentModel, } from 'roosterjs-content-model-dom'; import type { ContentModelDocument, @@ -29,6 +31,7 @@ import type { Rect, EntityState, CachedElementHandler, + DomToModelOption, } from 'roosterjs-content-model-types'; /** @@ -102,24 +105,27 @@ export class Editor implements IEditor { switch (mode) { case 'connected': - return core.api.createContentModel(core, { - processorOverride: { - table: tableProcessor, // Use the original table processor to create Content Model with real table content but not just an entity - }, - }); + return core.api.createContentModel(core); case 'disconnected': - case 'clean': return cloneModel( - core.api.createContentModel( - core, - undefined /*option*/, - mode == 'clean' ? 'none' : undefined /*selectionOverride*/ - ), + core.api.createContentModel(core, { + processorOverride: { + table: tableProcessor, + }, + }), { includeCachedElement: this.cloneOptionCallback, } ); + + case 'clean': + const domToModelContext = createDomToModelContextWithConfig( + core.environment.domToModelSettings.calculated, + core.api.createEditorContext(core, false /*saveIndex*/) + ); + return domToContentModel(core.physicalRoot, domToModelContext); + case 'reduced': return core.api.createContentModel(core, { processorOverride: { @@ -175,11 +181,12 @@ export class Editor implements IEditor { */ formatContentModel( formatter: ContentModelFormatter, - options?: FormatContentModelOptions + options?: FormatContentModelOptions, + domToModelOptions?: DomToModelOption ): void { const core = this.getCore(); - core.api.formatContentModel(core, formatter, options); + core.api.formatContentModel(core, formatter, options, domToModelOptions); } /** diff --git a/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts index 5af320900f3..e53c54653a5 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts @@ -86,6 +86,20 @@ describe('createContentModel', () => { expect(domToContentModelSpy).not.toHaveBeenCalled(); expect(model).toBe(mockedClonedModel); }); + + it('Do not reuse model, with cache, no shadow edit, has option', () => { + const currentContext = 'CURRENTCONTEXT' as any; + + spyOn(createDomToModelContext, 'createDomToModelContext').and.returnValue(currentContext); + + const model = createContentModel(core, {}); + + expect(cloneModelSpy).not.toHaveBeenCalled(); + expect(createEditorContext).toHaveBeenCalledWith(core, false); + expect(getDOMSelection).toHaveBeenCalledWith(core); + expect(domToContentModelSpy).toHaveBeenCalledWith(mockedDiv, currentContext); + expect(model).toBe(mockedModel); + }); }); describe('createContentModel with selection', () => { diff --git a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts index 0a57860e19d..60385b723ed 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts @@ -408,6 +408,38 @@ describe('formatContentModel', () => { ); }); + it('With domToModelOptions', () => { + const options = 'Options' as any; + + formatContentModel( + core, + () => true, + { + apiName, + }, + options + ); + + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(createContentModel).toHaveBeenCalledWith(core, options, undefined); + expect(setContentModel).toHaveBeenCalledTimes(1); + expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: 'contentChanged', + contentModel: mockedModel, + selection: mockedSelection, + source: ChangeSource.Format, + data: undefined, + formatApiName: apiName, + changedEntities: [], + }, + true + ); + }); + it('Has image', () => { const image = createImage('test'); const rawEvent = 'RawEvent' as any; diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index e7562316c78..8fc07741c48 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -130,11 +130,7 @@ describe('Editor', () => { const model1 = editor.getContentModelCopy('connected'); expect(model1).toBe(mockedModel); - expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { - processorOverride: { - table: tableProcessor, // Use the original table processor to create Content Model with real table content but not just an entity - }, - }); + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore); const model2 = editor.getContentModelCopy('reduced'); @@ -208,7 +204,11 @@ describe('Editor', () => { expect(cloneNodeSpy).toHaveBeenCalledWith(true); expect(model).toBe(mockedClonedModel); - expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, undefined, undefined); + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { + processorOverride: { + table: tableProcessor, + }, + }); expect(transformColorSpy).not.toHaveBeenCalled(); // Clone in dark mode @@ -217,7 +217,11 @@ describe('Editor', () => { expect(cloneNodeSpy).toHaveBeenCalledWith(true); expect(model).toBe(mockedClonedModel); - expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, undefined, undefined); + expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { + processorOverride: { + table: tableProcessor, + }, + }); expect(transformColorSpy).toHaveBeenCalledWith( clonedNode, true, @@ -354,14 +358,20 @@ describe('Editor', () => { editor.formatContentModel(mockedFormatter); - expect(formatContentModelSpy).toHaveBeenCalledWith(mockedCore, mockedFormatter, undefined); + expect(formatContentModelSpy).toHaveBeenCalledWith( + mockedCore, + mockedFormatter, + undefined, + undefined + ); editor.formatContentModel(mockedFormatter, mockedOptions); expect(formatContentModelSpy).toHaveBeenCalledWith( mockedCore, mockedFormatter, - mockedOptions + mockedOptions, + undefined ); editor.dispose(); diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts b/packages/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts index 31a4bda40cf..152334499f5 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts @@ -17,6 +17,7 @@ import { listProcessor } from '../processors/listProcessor'; import { pProcessor } from '../processors/pProcessor'; import { tableProcessor } from '../processors/tableProcessor'; import { textProcessor } from '../processors/textProcessor'; +import { textWithSelectionProcessor } from '../processors/textWithSelectionProcessor'; import type { ElementProcessorMap } from 'roosterjs-content-model-types'; /** @@ -57,6 +58,7 @@ export const defaultProcessorMap: ElementProcessorMap = { '*': generalProcessor, '#text': textProcessor, + textWithSelection: textWithSelectionProcessor, element: elementProcessor, entity: entityProcessor, child: childProcessor, diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts index 23c0210c9e7..a0a409aa499 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/textProcessor.ts @@ -1,15 +1,7 @@ -import { addDecorators } from '../../modelApi/common/addDecorators'; -import { addSegment } from '../../modelApi/common/addSegment'; -import { addSelectionMarker } from '../utils/addSelectionMarker'; -import { createText } from '../../modelApi/creators/createText'; import { ensureParagraph } from '../../modelApi/common/ensureParagraph'; -import { getRegularSelectionOffsets } from '../utils/getRegularSelectionOffsets'; -import { hasSpacesOnly } from '../../modelApi/common/hasSpacesOnly'; -import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; import { stackFormat } from '../utils/stackFormat'; import type { ContentModelBlockGroup, - ContentModelParagraph, ContentModelText, DomToModelContext, ElementProcessor, @@ -40,72 +32,15 @@ function internalTextProcessor( textNode: Text, context: DomToModelContext ) { - let txt = textNode.nodeValue || ''; - const offsets = getRegularSelectionOffsets(context, textNode); - const txtStartOffset = offsets[0]; - let txtEndOffset = offsets[1]; - const segments: (ContentModelText | undefined)[] = []; const paragraph = ensureParagraph(group, context.blockFormat); + const segmentCount = paragraph.segments.length; - if (txtStartOffset >= 0) { - const subText = txt.substring(0, txtStartOffset); - segments.push(addTextSegment(group, subText, paragraph, context)); - context.isInSelection = true; + context.elementProcessors.textWithSelection(group, textNode, context); - addSelectionMarker(group, context, textNode, txtStartOffset); - - txt = txt.substring(txtStartOffset); - txtEndOffset -= txtStartOffset; - } - - if (txtEndOffset >= 0) { - const subText = txt.substring(0, txtEndOffset); - segments.push(addTextSegment(group, subText, paragraph, context)); - - if ( - context.selection && - (context.selection.type != 'range' || !context.selection.range.collapsed) - ) { - addSelectionMarker(group, context, textNode, offsets[1]); // Must use offsets[1] here as the unchanged offset value, cannot use txtEndOffset since it has been modified - } - - context.isInSelection = false; - txt = txt.substring(txtEndOffset); - } - - segments.push(addTextSegment(group, txt, paragraph, context)); + const newSegments = paragraph.segments.slice(segmentCount); context.domIndexer?.onSegment( textNode, paragraph, - segments.filter((x): x is ContentModelText => !!x) + newSegments.filter((x): x is ContentModelText => x?.segmentType == 'Text') ); } - -function addTextSegment( - group: ContentModelBlockGroup, - text: string, - paragraph: ContentModelParagraph, - context: DomToModelContext -): ContentModelText | undefined { - let textModel: ContentModelText | undefined; - - if (text) { - if ( - !hasSpacesOnly(text) || - (paragraph?.segments.length ?? 0) > 0 || - isWhiteSpacePreserved(paragraph?.format.whiteSpace) - ) { - textModel = createText(text, context.segmentFormat); - - if (context.isInSelection) { - textModel.isSelected = true; - } - - addDecorators(textModel, context); - - addSegment(group, textModel, context.blockFormat); - } - } - - return textModel; -} diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/textWithSelectionProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/textWithSelectionProcessor.ts new file mode 100644 index 00000000000..f95c6f61bf3 --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/textWithSelectionProcessor.ts @@ -0,0 +1,42 @@ +import { addSelectionMarker } from '../utils/addSelectionMarker'; +import { addTextSegment } from '../../modelApi/common/addTextSegment'; +import { getRegularSelectionOffsets } from '../utils/getRegularSelectionOffsets'; +import type { ElementProcessor } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export const textWithSelectionProcessor: ElementProcessor = (group, textNode, context) => { + let txt = textNode.nodeValue || ''; + const offsets = getRegularSelectionOffsets(context, textNode); + const txtStartOffset = offsets[0]; + let txtEndOffset = offsets[1]; + + if (txtStartOffset >= 0) { + const subText = txt.substring(0, txtStartOffset); + addTextSegment(group, subText, context); + context.isInSelection = true; + + addSelectionMarker(group, context, textNode, txtStartOffset); + + txt = txt.substring(txtStartOffset); + txtEndOffset -= txtStartOffset; + } + + if (txtEndOffset >= 0) { + const subText = txt.substring(0, txtEndOffset); + addTextSegment(group, subText, context); + + if ( + context.selection && + (context.selection.type != 'range' || !context.selection.range.collapsed) + ) { + addSelectionMarker(group, context, textNode, offsets[1]); // Must use offsets[1] here as the unchanged offset value, cannot use txtEndOffset since it has been modified + } + + context.isInSelection = false; + txt = txt.substring(txtEndOffset); + } + + addTextSegment(group, txt, context); +}; diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts b/packages/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts index 9c23e18e7ca..3ba8cbc21e5 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts @@ -1,11 +1,6 @@ -import { addDecorators } from '../../modelApi/common/addDecorators'; import { addSegment } from '../../modelApi/common/addSegment'; -import { createSelectionMarker } from '../../modelApi/creators/createSelectionMarker'; -import type { - ContentModelBlockGroup, - ContentModelSegmentFormat, - DomToModelContext, -} from 'roosterjs-content-model-types'; +import { buildSelectionMarker } from './buildSelectionMarker'; +import type { ContentModelBlockGroup, DomToModelContext } from 'roosterjs-content-model-types'; /** * @internal @@ -16,37 +11,7 @@ export function addSelectionMarker( container?: Node, offset?: number ) { - const lastPara = group.blocks[group.blocks.length - 1]; - const formatFromParagraph: ContentModelSegmentFormat = - !lastPara || lastPara.blockType != 'Paragraph' - ? {} - : lastPara.decorator - ? { - fontFamily: lastPara.decorator.format.fontFamily, - fontSize: lastPara.decorator.format.fontSize, - } - : lastPara.segmentFormat - ? { - fontFamily: lastPara.segmentFormat.fontFamily, - fontSize: lastPara.segmentFormat.fontSize, - } - : {}; + const marker = buildSelectionMarker(group, context, container, offset); - const pendingFormat = - context.pendingFormat && - context.pendingFormat.posContainer === container && - context.pendingFormat.posOffset === offset - ? context.pendingFormat.format - : undefined; - const segmentFormat = { - ...context.defaultFormat, - ...formatFromParagraph, - ...context.segmentFormat, - ...pendingFormat, - }; - const marker = createSelectionMarker(segmentFormat); - - addDecorators(marker, context); - - addSegment(group, marker, context.blockFormat, segmentFormat); + addSegment(group, marker, context.blockFormat, marker.format); } diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/utils/buildSelectionMarker.ts b/packages/roosterjs-content-model-dom/lib/domToModel/utils/buildSelectionMarker.ts new file mode 100644 index 00000000000..f455dc583e3 --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/domToModel/utils/buildSelectionMarker.ts @@ -0,0 +1,60 @@ +import { addDecorators } from '../../modelApi/common/addDecorators'; +import { createSelectionMarker } from '../../modelApi/creators/createSelectionMarker'; +import type { + ContentModelBlockGroup, + ContentModelSegmentFormat, + ContentModelSelectionMarker, + DomToModelContext, +} from 'roosterjs-content-model-types'; + +/** + * Build a new selection marker with correct format according to its parent paragraph + * @param group The BlockGroup that paragraph belongs to + * @param context Current DOM to Model context + * @param container @optional Container Node, used for retrieving pending format + * @param offset @optional Container offset, used for retrieving pending format + * @returns A new selection marker + */ +export function buildSelectionMarker( + group: ContentModelBlockGroup, + context: DomToModelContext, + container?: Node, + offset?: number +): ContentModelSelectionMarker { + const lastPara = group.blocks[group.blocks.length - 1]; + const formatFromParagraph: ContentModelSegmentFormat = + !lastPara || lastPara.blockType != 'Paragraph' + ? {} + : lastPara.decorator + ? { + fontFamily: lastPara.decorator.format.fontFamily, + fontSize: lastPara.decorator.format.fontSize, + } + : lastPara.segmentFormat + ? { + fontFamily: lastPara.segmentFormat.fontFamily, + fontSize: lastPara.segmentFormat.fontSize, + } + : {}; + + const pendingFormat = + context.pendingFormat && + context.pendingFormat.posContainer === container && + context.pendingFormat.posOffset === offset + ? context.pendingFormat.format + : undefined; + + const format: ContentModelSegmentFormat = Object.assign( + {}, + context.defaultFormat, + formatFromParagraph, + context.segmentFormat, + pendingFormat + ); + + const marker = createSelectionMarker(format); + + addDecorators(marker, context); + + return marker; +} diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index 9a5caf29aad..b35a5aa5ac0 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -13,6 +13,7 @@ export { getRegularSelectionOffsets } from './domToModel/utils/getRegularSelecti export { parseFormat } from './domToModel/utils/parseFormat'; export { areSameFormats } from './domToModel/utils/areSameFormats'; export { isBlockElement } from './domToModel/utils/isBlockElement'; +export { buildSelectionMarker } from './domToModel/utils/buildSelectionMarker'; export { updateMetadata, hasMetadata } from './modelApi/metadata/updateMetadata'; export { isNodeOfType } from './domUtils/isNodeOfType'; @@ -55,6 +56,7 @@ export { createEmptyModel } from './modelApi/creators/createEmptyModel'; export { addBlock } from './modelApi/common/addBlock'; export { addCode } from './modelApi/common/addDecorators'; export { addLink } from './modelApi/common/addDecorators'; +export { addTextSegment } from './modelApi/common/addTextSegment'; export { normalizeParagraph } from './modelApi/common/normalizeParagraph'; export { normalizeContentModel } from './modelApi/common/normalizeContentModel'; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts index ea016c38388..d564538490a 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts @@ -36,11 +36,15 @@ export function addSegment( } if (newSegment.segmentType == 'SelectionMarker') { - if (!lastSegment || !lastSegment.isSelected) { + if (!lastSegment || !lastSegment.isSelected || !newSegment.isSelected) { paragraph.segments.push(newSegment); } } else { - if (newSegment.isSelected && lastSegment?.segmentType == 'SelectionMarker') { + if ( + newSegment.isSelected && + lastSegment?.segmentType == 'SelectionMarker' && + lastSegment.isSelected + ) { paragraph.segments.pop(); } diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/common/addTextSegment.ts b/packages/roosterjs-content-model-dom/lib/modelApi/common/addTextSegment.ts new file mode 100644 index 00000000000..dc8536468de --- /dev/null +++ b/packages/roosterjs-content-model-dom/lib/modelApi/common/addTextSegment.ts @@ -0,0 +1,48 @@ +import { addDecorators } from './addDecorators'; +import { addSegment } from './addSegment'; +import { createText } from '../creators/createText'; +import { ensureParagraph } from './ensureParagraph'; +import { hasSpacesOnly } from './hasSpacesOnly'; +import { isWhiteSpacePreserved } from '../../domUtils/isWhiteSpacePreserved'; +import type { + ContentModelBlockGroup, + ContentModelText, + DomToModelContext, +} from 'roosterjs-content-model-types'; + +/** + * Add a new text segment to current paragraph + * @param group Current BlockGroup that the paragraph belong to + * @param text Text content of the text segment + * @param context Current DOM to Model context + * @returns A new Text segment, or undefined if the input text is empty + */ +export function addTextSegment( + group: ContentModelBlockGroup, + text: string, + context: DomToModelContext +): ContentModelText | undefined { + let textModel: ContentModelText | undefined; + + if (text) { + const paragraph = ensureParagraph(group, context.blockFormat); + + if ( + !hasSpacesOnly(text) || + (paragraph?.segments.length ?? 0) > 0 || + isWhiteSpacePreserved(paragraph?.format.whiteSpace) + ) { + textModel = createText(text, context.segmentFormat); + + if (context.isInSelection) { + textModel.isSelected = true; + } + + addDecorators(textModel, context); + + addSegment(group, textModel, context.blockFormat); + } + } + + return textModel; +} diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/textWithSelectionProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/textWithSelectionProcessorTest.ts new file mode 100644 index 00000000000..db265ad1937 --- /dev/null +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/textWithSelectionProcessorTest.ts @@ -0,0 +1,735 @@ +import * as addSelectionMarker from '../../../lib/domToModel/utils/addSelectionMarker'; +import { addBlock } from '../../../lib/modelApi/common/addBlock'; +import { addSegment } from '../../../lib/modelApi/common/addSegment'; +import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; +import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; +import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; +import { createText } from '../../../lib/modelApi/creators/createText'; +import { DomToModelContext } from 'roosterjs-content-model-types'; +import { textWithSelectionProcessor } from '../../../lib/domToModel/processors/textWithSelectionProcessor'; + +describe('textWithSelectionProcessor', () => { + let context: DomToModelContext; + + beforeEach(() => { + context = createDomToModelContext(); + }); + + it('Empty group', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Group with empty paragraph', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + doc.blocks.push({ + blockType: 'Paragraph', + segments: [], + format: {}, + }); + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Group with paragraph with text segment', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test1'); + + doc.blocks.push({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test0', + format: {}, + }, + ], + format: {}, + }); + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test0', + format: {}, + }, + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Group with paragraph with different type of segment', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + + doc.blocks.push({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'General', + blockType: 'BlockGroup', + blockGroupType: 'General', + element: null!, + blocks: [], + format: {}, + }, + ], + format: {}, + }); + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'General', + blockType: 'BlockGroup', + blockGroupType: 'General', + element: null!, + blocks: [], + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Handle text with selection 1', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test2'); + + doc.blocks.push({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + ], + format: {}, + }); + + context.isInSelection = true; + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + isSelected: true, + format: {}, + }, + ], + format: {}, + }); + }); + + it('Handle text with selection 2', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test2'); + + doc.blocks.push({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + isSelected: true, + format: {}, + }, + ], + format: {}, + }); + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + format: {}, + }); + }); + + it('Handle text with selection 3', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test2'); + + doc.blocks.push({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + isSelected: true, + format: {}, + }, + ], + format: {}, + }); + + context.isInSelection = true; + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + isSelected: true, + format: {}, + }, + ], + format: {}, + }); + }); + + it('Handle text with format', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + + context.segmentFormat = { a: 'b' } as any; + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { a: 'b' } as any, + }, + ], + isImplicit: true, + format: {}, + }); + }); + + it('Handle text with link format', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + + context.link = { format: { href: '/test' }, dataset: {} }; + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + link: { format: { href: '/test' }, dataset: {} }, + }, + ], + isImplicit: true, + format: {}, + }); + }); + + it('Handle text with selection and link format 1', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test2'); + + doc.blocks.push({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + isSelected: true, + format: {}, + }, + ], + format: {}, + }); + + context.isInSelection = true; + context.link = { format: { href: '/test' }, dataset: {} }; + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + isSelected: true, + format: {}, + link: { format: { href: '/test' }, dataset: {} }, + }, + ], + format: {}, + }); + }); + + it('Handle text with selection and link format 2', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + + context.link = { format: { href: '/test' }, dataset: {} }; + context.selection = { + type: 'range', + range: { + startContainer: text, + startOffset: 2, + endContainer: text, + endOffset: 2, + collapsed: true, + } as any, + isReverted: false, + }; + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + link: { + format: { + href: '/test', + }, + dataset: {}, + }, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + link: { + format: { + href: '/test', + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + link: { format: { href: '/test' }, dataset: {} }, + }, + ], + format: {}, + }); + }); + + it('Handle text with code format', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + + context.code = { format: { fontFamily: 'monospace' } }; + + textWithSelectionProcessor(doc, text, context); + + expect(doc.blocks[0]).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + code: { format: { fontFamily: 'monospace' } }, + }, + ], + isImplicit: true, + format: {}, + }); + }); + + it('Empty text', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode(''); + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [], + }); + }); + + it('Space only text without existing paragraph', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode(' '); + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }, + ], + }); + }); + + it('Space only text with existing paragraph', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode(' '); + + addBlock(doc, createParagraph()); + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [], + }, + ], + }); + }); + + it('Space only text with existing implicit paragraph with existing segment', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode(' '); + + addSegment(doc, createText('test')); + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test', + }, + { + segmentType: 'Text', + format: {}, + text: ' ', + }, + ], + }, + ], + }); + }); + + it('Paragraph with white-space style', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode(' \n '); + const paragraph = createParagraph(false, { + whiteSpace: 'pre', + }); + + doc.blocks.push(paragraph); + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { + whiteSpace: 'pre', + }, + segments: [ + { + segmentType: 'Text', + format: {}, + text: ' \n ', + }, + ], + }, + ], + }); + }); + + it('With pending format, match collapsed selection', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + const addSelectionMarkerSpy = spyOn( + addSelectionMarker, + 'addSelectionMarker' + ).and.callThrough(); + + context.selection = { + type: 'range', + range: { + startContainer: text, + endContainer: text, + startOffset: 2, + endOffset: 2, + } as any, + isReverted: false, + }; + context.pendingFormat = { + format: { + a: 'a', + } as any, + posContainer: text, + posOffset: 2, + }; + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: { a: 'a' } as any, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + }); + expect(addSelectionMarkerSpy).toHaveBeenCalledTimes(2); + expect(addSelectionMarkerSpy).toHaveBeenCalledWith(doc, context, text, 2); + }); + + it('With pending format, match expanded selection', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + const addSelectionMarkerSpy = spyOn( + addSelectionMarker, + 'addSelectionMarker' + ).and.callThrough(); + + context.selection = { + type: 'range', + range: { + startContainer: text, + endContainer: text, + startOffset: 1, + endOffset: 3, + } as any, + isReverted: false, + }; + context.pendingFormat = { + format: { + a: 'a', + } as any, + posContainer: text, + posOffset: 3, + }; + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 't', + format: {}, + }, + { + segmentType: 'Text', + text: 'es', + format: {} as any, + isSelected: true, + }, + { + segmentType: 'Text', + text: 't', + format: {}, + }, + ], + format: {}, + }, + ], + }); + expect(addSelectionMarkerSpy).toHaveBeenCalledTimes(2); + expect(addSelectionMarkerSpy).toHaveBeenCalledWith(doc, context, text, 1); + expect(addSelectionMarkerSpy).toHaveBeenCalledWith(doc, context, text, 3); + }); + + it('With pending format, not match selection', () => { + const doc = createContentModelDocument(); + const text = document.createTextNode('test'); + const addSelectionMarkerSpy = spyOn( + addSelectionMarker, + 'addSelectionMarker' + ).and.callThrough(); + + context.selection = { + type: 'range', + range: { + startContainer: text, + endContainer: text, + startOffset: 2, + endOffset: 2, + } as any, + isReverted: false, + }; + context.pendingFormat = { + format: { + a: 'a', + } as any, + posContainer: text, + posOffset: 3, + }; + + textWithSelectionProcessor(doc, text, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'te', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'st', + format: {}, + }, + ], + format: {}, + }, + ], + }); + expect(addSelectionMarkerSpy).toHaveBeenCalledTimes(2); + expect(addSelectionMarkerSpy).toHaveBeenCalledWith(doc, context, text, 2); + }); +}); diff --git a/packages/roosterjs-content-model-dom/test/domToModel/utils/buildSelectionMarkerTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/utils/buildSelectionMarkerTest.ts new file mode 100644 index 00000000000..d1d7533f286 --- /dev/null +++ b/packages/roosterjs-content-model-dom/test/domToModel/utils/buildSelectionMarkerTest.ts @@ -0,0 +1,228 @@ +import * as addDecorator from '../../../lib/modelApi/common/addDecorators'; +import { buildSelectionMarker } from '../../../lib/domToModel/utils/buildSelectionMarker'; +import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; +import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; +import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; + +describe('buildSelectionMarker', () => { + it('no exiting para, no pending format, no default format, no segment format', () => { + spyOn(addDecorator, 'addDecorators'); + + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + const marker = buildSelectionMarker(group, context); + + expect(marker).toEqual({ + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }); + expect(addDecorator.addDecorators).toHaveBeenCalledWith(marker, context); + }); + + it('no exiting para, no pending format, has default format, has segment format', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + context.defaultFormat = { + fontFamily: 'Arial', + fontSize: '9pt', + }; + + context.segmentFormat = { + fontSize: '10pt', + textColor: 'red', + }; + + const marker = buildSelectionMarker(group, context); + + expect(marker).toEqual({ + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Arial', + fontSize: '10pt', + textColor: 'red', + }, + isSelected: true, + }); + }); + + it('no exiting para, has pending format, has default format, has segment format', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + const mockedContainer = 'CONTAINER' as any; + const mockedOffset = 'OFFSET' as any; + + context.pendingFormat = { + posContainer: mockedContainer, + posOffset: mockedOffset, + format: { + textColor: 'blue', + backgroundColor: 'green', + }, + }; + + context.defaultFormat = { + fontFamily: 'Arial', + fontSize: '9pt', + }; + + context.segmentFormat = { + fontSize: '10pt', + textColor: 'red', + }; + + const marker = buildSelectionMarker(group, context, mockedContainer, mockedOffset); + + expect(marker).toEqual({ + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Arial', + fontSize: '10pt', + textColor: 'blue', + backgroundColor: 'green', + }, + isSelected: true, + }); + }); + + it('no exiting para, has pending format but not match, has default format, has segment format', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + const mockedContainer = 'CONTAINER' as any; + const mockedOffset1 = 'OFFSET1' as any; + const mockedOffset2 = 'OFFSET2' as any; + + context.pendingFormat = { + posContainer: mockedContainer, + posOffset: mockedOffset1, + format: { + textColor: 'blue', + backgroundColor: 'green', + }, + }; + + context.defaultFormat = { + fontFamily: 'Arial', + fontSize: '9pt', + }; + + context.segmentFormat = { + fontSize: '10pt', + textColor: 'red', + }; + + const marker = buildSelectionMarker(group, context, mockedContainer, mockedOffset2); + + expect(marker).toEqual({ + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Arial', + fontSize: '10pt', + textColor: 'red', + }, + isSelected: true, + }); + }); + + it('has exiting para and format', () => { + spyOn(addDecorator, 'addDecorators'); + + const group = createContentModelDocument(); + const para = createParagraph(false, undefined, { fontFamily: 'Arial' }); + + group.blocks.push(para); + + const context = createDomToModelContext(); + + const marker = buildSelectionMarker(group, context); + + expect(marker).toEqual({ + segmentType: 'SelectionMarker', + format: { fontFamily: 'Arial', fontSize: undefined }, + isSelected: true, + }); + expect(addDecorator.addDecorators).toHaveBeenCalledWith(marker, context); + }); + + it('has exiting para and format, has decorator', () => { + spyOn(addDecorator, 'addDecorators'); + + const group = createContentModelDocument(); + const para = createParagraph(false, undefined, { fontFamily: 'Arial' }); + + para.decorator = { + tagName: 'div', + format: { + fontFamily: 'Tahoma', + }, + }; + + group.blocks.push(para); + + const context = createDomToModelContext(); + + const marker = buildSelectionMarker(group, context); + + expect(marker).toEqual({ + segmentType: 'SelectionMarker', + format: { fontFamily: 'Tahoma', fontSize: undefined }, + isSelected: true, + }); + expect(addDecorator.addDecorators).toHaveBeenCalledWith(marker, context); + }); + + it('has everything', () => { + spyOn(addDecorator, 'addDecorators'); + + const group = createContentModelDocument(); + const para = createParagraph(false, undefined, { fontFamily: 'Arial' }); + + para.decorator = { + tagName: 'div', + format: { + fontFamily: 'Tahoma', + }, + }; + + group.blocks.push(para); + + const context = createDomToModelContext(); + const mockedContainer = 'CONTAINER' as any; + const mockedOffset = 'OFFSET' as any; + + context.pendingFormat = { + posContainer: mockedContainer, + posOffset: mockedOffset, + format: { + textColor: 'blue', + backgroundColor: 'green', + }, + }; + + context.defaultFormat = { + fontFamily: 'Arial', + fontSize: '9pt', + }; + + context.segmentFormat = { + fontSize: '10pt', + textColor: 'red', + }; + + const marker = buildSelectionMarker(group, context, mockedContainer, mockedOffset); + + expect(marker).toEqual({ + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Tahoma', + fontSize: '10pt', + textColor: 'blue', + backgroundColor: 'green', + }, + isSelected: true, + }); + expect(addDecorator.addDecorators).toHaveBeenCalledWith(marker, context); + }); +}); 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 8d0688c8b07..9bcde0996dc 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/common/addSegmentTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/addSegmentTest.ts @@ -1,8 +1,10 @@ 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'; describe('addSegment', () => { @@ -136,4 +138,266 @@ describe('addSegment', () => { ], }); }); + + it('Add selection marker in empty paragraph', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + + doc.blocks.push(para); + + const newMarker = createSelectionMarker({ fontFamily: 'Arial' }); + + addSegment(doc, newMarker); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontFamily: 'Arial' }, + isSelected: true, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Add selection marker after selection marker', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + + para.segments.push(marker); + doc.blocks.push(para); + + const newMarker = createSelectionMarker({ fontFamily: 'Arial' }); + + addSegment(doc, newMarker); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Add selection marker after selected segment', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const br = createBr(); + + br.isSelected = true; + para.segments.push(br); + doc.blocks.push(para); + + const newMarker = createSelectionMarker({ fontFamily: 'Arial' }); + + addSegment(doc, newMarker); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Add selection marker after selection marker', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + + para.segments.push(marker); + doc.blocks.push(para); + + const newMarker = createSelectionMarker({ fontFamily: 'Arial' }); + + addSegment(doc, newMarker); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Add selection marker after selection marker that is not selected', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + + marker.isSelected = false; + para.segments.push(marker); + doc.blocks.push(para); + + const newMarker = createSelectionMarker({ fontFamily: 'Arial' }); + + addSegment(doc, newMarker); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: false, + }, + { + segmentType: 'SelectionMarker', + format: { fontFamily: 'Arial' }, + isSelected: true, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Add unselected selection marker after selection marker', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + + para.segments.push(marker); + doc.blocks.push(para); + + const newMarker = createSelectionMarker({ fontFamily: 'Arial' }); + + newMarker.isSelected = false; + + addSegment(doc, newMarker); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'SelectionMarker', + format: { fontFamily: 'Arial' }, + isSelected: false, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Add selected segment after selection marker', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + + para.segments.push(marker); + doc.blocks.push(para); + + const br = createBr({ fontFamily: 'Arial' }); + + br.isSelected = true; + + addSegment(doc, br); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: { fontFamily: 'Arial' }, + isSelected: true, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Add selected segment after unselected selection marker', () => { + const doc = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + + marker.isSelected = false; + para.segments.push(marker); + doc.blocks.push(para); + + const br = createBr({ fontFamily: 'Arial' }); + + br.isSelected = true; + + addSegment(doc, br); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: false, + }, + { + segmentType: 'Br', + format: { fontFamily: 'Arial' }, + isSelected: true, + }, + ], + format: {}, + }, + ], + }); + }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/common/addTextSegmentTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/common/addTextSegmentTest.ts new file mode 100644 index 00000000000..c3ccabd1dfa --- /dev/null +++ b/packages/roosterjs-content-model-dom/test/modelApi/common/addTextSegmentTest.ts @@ -0,0 +1,209 @@ +import * as isWhiteSpacePreserved from '../../../lib/domUtils/isWhiteSpacePreserved'; +import { addTextSegment } from '../../../lib/modelApi/common/addTextSegment'; +import { createBr } from '../../../lib/modelApi/creators/createBr'; +import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; +import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; +import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; + +describe('addTextSegment', () => { + it('Add empty text', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + addTextSegment(group, '', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [], + }); + }); + + it('Add text with space only', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + addTextSegment(group, ' ', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [], + isImplicit: true, + }, + ], + }); + }); + + it('Add text with space only, has existing segment', () => { + const group = createContentModelDocument(); + const para = createParagraph(); + const br = createBr(); + + para.segments.push(br); + group.blocks.push(para); + + const context = createDomToModelContext(); + + addTextSegment(group, ' ', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Br', + format: {}, + }, + { + segmentType: 'Text', + format: {}, + text: ' ', + }, + ], + }, + ], + }); + }); + + it('Add text with space only, white space preserved', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + spyOn(isWhiteSpacePreserved, 'isWhiteSpacePreserved').and.returnValue(true); + + addTextSegment(group, ' ', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: ' ', + }, + ], + isImplicit: true, + }, + ], + }); + }); + + it('Add text, no existing paragraph', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + addTextSegment(group, 'test', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + isImplicit: true, + }, + ], + }); + }); + + it('Add text, to existing paragraph', () => { + const group = createContentModelDocument(); + const paragraph = createParagraph(); + + group.blocks.push(paragraph); + + const context = createDomToModelContext(); + + addTextSegment(group, 'test', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Add text, already in selection', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + context.isInSelection = true; + + addTextSegment(group, 'test', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + isSelected: true, + }, + ], + isImplicit: true, + }, + ], + }); + }); + + it('Add text, has format', () => { + const group = createContentModelDocument(); + const context = createDomToModelContext(); + + context.segmentFormat.fontFamily = 'Arial'; + context.blockFormat.textAlign = 'end'; + + addTextSegment(group, 'test', context); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { textAlign: 'end' }, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { fontFamily: 'Arial' }, + }, + ], + isImplicit: true, + }, + ], + }); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts index 846a6583fa2..b858eedabf1 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts @@ -54,7 +54,7 @@ describe(ID, () => { paste(editor, CD); - const model = editor.getContentModelCopy('connected'); + const model = editor.getContentModelCopy('disconnected'); expectEqual(model, { blockGroupType: 'Document', diff --git a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts index 4e4df8022e7..db4122522ce 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts @@ -51,7 +51,7 @@ describe(ID, () => { paste(editor, clipboardData); - const model = editor.getContentModelCopy('connected'); + const model = editor.getContentModelCopy('disconnected'); expectEqual(model, { blockGroupType: 'Document', diff --git a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts index 20ff31b27cc..449cb65591b 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts @@ -6,7 +6,6 @@ import { contentModelToDom, createDomToModelContext, createModelToDomContext, - createTable, createTableCell, domToContentModel, moveChildNodes, diff --git a/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts b/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts index 589dde68a8a..042f7ab0532 100644 --- a/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts +++ b/packages/roosterjs-content-model-types/lib/context/DomToModelSettings.ts @@ -85,6 +85,12 @@ export type ElementProcessorMap = { */ '#text': ElementProcessor; + /** + * Processor for text node with selection. + * This is an internal processor used by #text processor + */ + textWithSelection: ElementProcessor; + /** * Processor for entity */ diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts index fc222c352d3..1f49594166e 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts @@ -91,7 +91,8 @@ export type SetLogicalRoot = (core: EditorCore, logicalRoot: HTMLDivElement | nu export type FormatContentModel = ( core: EditorCore, formatter: ContentModelFormatter, - options?: FormatContentModelOptions + options?: FormatContentModelOptions, + domToModelOptions?: DomToModelOption ) => void; /** diff --git a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts index c21e0231380..2b0de6c7002 100644 --- a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts +++ b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts @@ -1,3 +1,4 @@ +import type { DomToModelOption } from '../context/DomToModelOption'; import type { DOMHelper } from '../parameter/DOMHelper'; import type { PluginEventData, PluginEventFromType } from '../event/PluginEventData'; import type { PluginEventType } from '../event/PluginEventType'; @@ -72,7 +73,11 @@ export interface IEditor { * @param formatter Formatter function, see ContentModelFormatter * @param options More options, see FormatContentModelOptions */ - formatContentModel(formatter: ContentModelFormatter, options?: FormatContentModelOptions): void; + formatContentModel( + formatter: ContentModelFormatter, + options?: FormatContentModelOptions, + domToModelOption?: DomToModelOption + ): void; /** * Get pending format of editor if any, or return null diff --git a/packages/roosterjs-content-model-types/lib/segment/ContentModelSelectionMarker.ts b/packages/roosterjs-content-model-types/lib/segment/ContentModelSelectionMarker.ts index af6cf9fae6d..e99db906635 100644 --- a/packages/roosterjs-content-model-types/lib/segment/ContentModelSelectionMarker.ts +++ b/packages/roosterjs-content-model-types/lib/segment/ContentModelSelectionMarker.ts @@ -3,9 +3,4 @@ import type { ContentModelSegmentBase } from './ContentModelSegmentBase'; /** * Content Model of Selection Marker */ -export interface ContentModelSelectionMarker extends ContentModelSegmentBase<'SelectionMarker'> { - /** - * Whether this segment is selected - */ - isSelected: true; -} +export interface ContentModelSelectionMarker extends ContentModelSegmentBase<'SelectionMarker'> {} diff --git a/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts index 3ed3052748c..0036c9aafe8 100644 --- a/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts +++ b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts @@ -149,7 +149,7 @@ describe('EditorAdapter', () => { editor.formatContentModel(callback, options); - expect(formatContentModelSpy).toHaveBeenCalledWith(core, callback, options); + expect(formatContentModelSpy).toHaveBeenCalledWith(core, callback, options, undefined); }); it('default format', () => { From 6580e0d7bcead378e08baaa0702a154550447e0e Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 27 Mar 2024 16:36:57 -0700 Subject: [PATCH 2/2] Remove "reduced" from getContentModelCopy --- .../common}/reducedModelChildProcessor.ts | 1 - .../lib/publicApi/format/getFormatState.ts | 16 ++++++++++++++-- .../common}/reducedModelChildProcessorTest.ts | 2 +- .../test/publicApi/format/getFormatStateTest.ts | 6 +++--- .../lib/editor/Editor.ts | 14 +------------- .../test/editor/EditorTest.ts | 11 ----------- .../lib/editor/IEditor.ts | 6 +----- .../lib/editor/EditorAdapter.ts | 7 +++++-- 8 files changed, 25 insertions(+), 38 deletions(-) rename packages/{roosterjs-content-model-core/lib/override => roosterjs-content-model-api/lib/modelApi/common}/reducedModelChildProcessor.ts (99%) rename packages/{roosterjs-content-model-core/test/overrides => roosterjs-content-model-api/test/modelApi/common}/reducedModelChildProcessorTest.ts (99%) diff --git a/packages/roosterjs-content-model-core/lib/override/reducedModelChildProcessor.ts b/packages/roosterjs-content-model-api/lib/modelApi/common/reducedModelChildProcessor.ts similarity index 99% rename from packages/roosterjs-content-model-core/lib/override/reducedModelChildProcessor.ts rename to packages/roosterjs-content-model-api/lib/modelApi/common/reducedModelChildProcessor.ts index 1fe7f5188e6..14eff3e9811 100644 --- a/packages/roosterjs-content-model-core/lib/override/reducedModelChildProcessor.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/common/reducedModelChildProcessor.ts @@ -20,7 +20,6 @@ interface FormatStateContext extends DomToModelContext { /** * @internal - * Export for test only * In order to get format, we can still use the regular child processor. However, to improve performance, we don't need to create * content model for the whole doc, instead we only need to traverse the tree path that can arrive current selected node. * This "reduced" child processor will first create a node stack that stores DOM node from root to current common ancestor node of selection, diff --git a/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts b/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts index cb875a85415..d238121721b 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts @@ -1,3 +1,4 @@ +import { reducedModelChildProcessor } from '../../modelApi/common/reducedModelChildProcessor'; import { retrieveModelFormatState } from 'roosterjs-content-model-dom'; import type { IEditor, ContentModelFormatState } from 'roosterjs-content-model-types'; @@ -7,7 +8,6 @@ import type { IEditor, ContentModelFormatState } from 'roosterjs-content-model-t */ export function getFormatState(editor: IEditor): ContentModelFormatState { const pendingFormat = editor.getPendingFormat(); - const model = editor.getContentModelCopy('reduced'); const manager = editor.getSnapshotsManager(); const result: ContentModelFormatState = { canUndo: manager.hasNewContent || manager.canMove(-1), @@ -15,7 +15,19 @@ export function getFormatState(editor: IEditor): ContentModelFormatState { isDarkMode: editor.isDarkMode(), }; - retrieveModelFormatState(model, pendingFormat, result); + editor.formatContentModel( + model => { + retrieveModelFormatState(model, pendingFormat, result); + + return false; + }, + undefined /*options*/, + { + processorOverride: { + child: reducedModelChildProcessor, + }, + } + ); return result; } diff --git a/packages/roosterjs-content-model-core/test/overrides/reducedModelChildProcessorTest.ts b/packages/roosterjs-content-model-api/test/modelApi/common/reducedModelChildProcessorTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/overrides/reducedModelChildProcessorTest.ts rename to packages/roosterjs-content-model-api/test/modelApi/common/reducedModelChildProcessorTest.ts index 3570a3a8af3..019389b9afc 100644 --- a/packages/roosterjs-content-model-core/test/overrides/reducedModelChildProcessorTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/common/reducedModelChildProcessorTest.ts @@ -1,7 +1,7 @@ import * as getSelectionRootNode from 'roosterjs-content-model-dom/lib/domUtils/selection/getSelectionRootNode'; import { createContentModelDocument, createDomToModelContext } from 'roosterjs-content-model-dom'; import { DomToModelContext } from 'roosterjs-content-model-types'; -import { reducedModelChildProcessor } from '../../lib/override/reducedModelChildProcessor'; +import { reducedModelChildProcessor } from '../../../lib/modelApi/common/reducedModelChildProcessor'; describe('reducedModelChildProcessor', () => { let context: DomToModelContext; diff --git a/packages/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts b/packages/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts index 988795946be..a45acf4ef0b 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts @@ -3,7 +3,7 @@ import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-conte import { ContentModelFormatState } from 'roosterjs-content-model-types'; import { getFormatState } from '../../../lib/publicApi/format/getFormatState'; import { IEditor } from 'roosterjs-content-model-types'; -import { reducedModelChildProcessor } from 'roosterjs-content-model-core/lib/override/reducedModelChildProcessor'; +import { reducedModelChildProcessor } from '../../../lib/modelApi/common/reducedModelChildProcessor'; import { createContentModelDocument, createDomToModelContext, @@ -31,7 +31,7 @@ describe('getFormatState', () => { isDarkMode: () => false, getZoomScale: () => 1, getPendingFormat: () => pendingFormat, - getContentModelCopy: () => { + formatContentModel: (callback: Function) => { const model = createContentModelDocument(); const editorDiv = document.createElement('div'); @@ -58,7 +58,7 @@ describe('getFormatState', () => { normalizeContentModel(model); - return model; + callback(model); }, } as any) as IEditor; diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index ac32832ca35..3eaa83c288e 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -1,5 +1,4 @@ import { createEditorCore } from './core/createEditorCore'; -import { reducedModelChildProcessor } from '../override/reducedModelChildProcessor'; import { createEmptyModel, tableProcessor, @@ -93,14 +92,10 @@ export class Editor implements IEditor { * - disconnected: Returns a disconnected clone of Content Model from editor which you can do any change on it and it won't impact the editor content. * If there is any entity in editor, the returned object will contain cloned copy of entity wrapper element. * If editor is in dark mode, the cloned entity will be converted back to light mode. - * - reduced: Returns a reduced Content Model that only contains the model of current selection. If there is already a up-to-date cached model, use it - * instead to improve performance. This is mostly used for retrieve current format state. * - clean: Similar with disconnected, this will return a disconnected model, the difference is "clean" mode will not include any selection info. * This is usually used for exporting content */ - getContentModelCopy( - mode: 'connected' | 'disconnected' | 'reduced' | 'clean' - ): ContentModelDocument { + getContentModelCopy(mode: 'connected' | 'disconnected' | 'clean'): ContentModelDocument { const core = this.getCore(); switch (mode) { @@ -125,13 +120,6 @@ export class Editor implements IEditor { core.api.createEditorContext(core, false /*saveIndex*/) ); return domToContentModel(core.physicalRoot, domToModelContext); - - case 'reduced': - return core.api.createContentModel(core, { - processorOverride: { - child: reducedModelChildProcessor, - }, - }); } } diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index ca832e3577e..8e15690595a 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -7,7 +7,6 @@ import * as transformColor from 'roosterjs-content-model-dom/lib/domUtils/style/ import { CachedElementHandler, EditorCore, Rect } from 'roosterjs-content-model-types'; import { ChangeSource, tableProcessor } from 'roosterjs-content-model-dom'; import { Editor } from '../../lib/editor/Editor'; -import { reducedModelChildProcessor } from '../../lib/override/reducedModelChildProcessor'; describe('Editor', () => { let createEditorCoreSpy: jasmine.Spy; @@ -134,18 +133,8 @@ describe('Editor', () => { expect(model1).toBe(mockedModel); expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore); - const model2 = editor.getContentModelCopy('reduced'); - - expect(model2).toBe(mockedModel); - expect(createContentModelSpy).toHaveBeenCalledWith(mockedCore, { - processorOverride: { - child: reducedModelChildProcessor, - }, - }); - editor.dispose(); expect(() => editor.getContentModelCopy('connected')).toThrow(); - expect(() => editor.getContentModelCopy('reduced')).toThrow(); expect(resetSpy).toHaveBeenCalledWith(); }); diff --git a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts index 2b0de6c7002..8e2240233dc 100644 --- a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts +++ b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts @@ -31,14 +31,10 @@ export interface IEditor { * - disconnected: Returns a disconnected clone of Content Model from editor which you can do any change on it and it won't impact the editor content. * If there is any entity in editor, the returned object will contain cloned copy of entity wrapper element. * If editor is in dark mode, the cloned entity will be converted back to light mode. - * - reduced: Returns a reduced Content Model that only contains the model of current selection. If there is already a up-to-date cached model, use it - * instead to improve performance. This is mostly used for retrieve current format state. * - clean: Similar with disconnected, this will return a disconnected model, the difference is "clean" mode will not include any selection info. * This is usually used for exporting content */ - getContentModelCopy( - mode: 'connected' | 'disconnected' | 'reduced' | 'clean' - ): ContentModelDocument; + getContentModelCopy(mode: 'connected' | 'disconnected' | 'clean'): ContentModelDocument; /** * Get current running environment, such as if editor is running on Mac diff --git a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts index 8ee4773dca3..155abdf5994 100644 --- a/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts +++ b/packages/roosterjs-editor-adapter/lib/editor/EditorAdapter.ts @@ -1120,9 +1120,12 @@ export class EditorAdapter extends Editor implements ILegacyEditor { private retrieveFormatState(): ContentModelFormatState { const pendingFormat = this.getPendingFormat(); const result: ContentModelFormatState = {}; - const model = this.getContentModelCopy('reduced'); - retrieveModelFormatState(model, pendingFormat, result); + this.formatContentModel(model => { + retrieveModelFormatState(model, pendingFormat, result); + + return false; + }); return result; }