diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts index 1c5909d5bf3..dac753d1d96 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts @@ -6,6 +6,9 @@ import { } from 'roosterjs-content-model-dom'; import type { SetContentModel } from 'roosterjs-content-model-types'; +const SelectionClassName = '__persistedSelection'; +const SelectionSelector = '.' + SelectionClassName; + /** * @internal * Set content with content model @@ -15,6 +18,16 @@ import type { SetContentModel } from 'roosterjs-content-model-types'; */ export const setContentModel: SetContentModel = (core, model, option, onNodeCreated) => { const editorContext = core.api.createEditorContext(core, true /*saveIndex*/); + + if (option?.shouldMaintainSelection) { + editorContext.selectionClassName = SelectionClassName; + core.api.setEditorStyle(core, SelectionClassName, 'background-color: #ddd!important', [ + SelectionSelector, + ]); + } else { + core.api.setEditorStyle(core, SelectionClassName, null /*rule*/); + } + const modelToDomContext = option ? createModelToDomContext( editorContext, diff --git a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts index 234717bcdb7..895b5aef77b 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts @@ -5,7 +5,7 @@ import { setContentModel } from '../../../lib/coreApi/setContentModel/setContent const mockedDoc = 'DOCUMENT' as any; const mockedModel = 'MODEL' as any; -const mockedEditorContext = 'EDITORCONTEXT' as any; +const mockedEditorContext = { context: 'EDITORCONTEXT' } as any; const mockedContext = { name: 'CONTEXT' } as any; const mockedDiv = { ownerDocument: mockedDoc } as any; const mockedConfig = 'CONFIG' as any; @@ -18,6 +18,7 @@ describe('setContentModel', () => { let createModelToDomContextWithConfigSpy: jasmine.Spy; let setDOMSelectionSpy: jasmine.Spy; let getDOMSelectionSpy: jasmine.Spy; + let setEditorStyleSpy: jasmine.Spy; beforeEach(() => { contentModelToDomSpy = spyOn(contentModelToDom, 'contentModelToDom'); @@ -34,6 +35,7 @@ describe('setContentModel', () => { ).and.returnValue(mockedContext); setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + setEditorStyleSpy = jasmine.createSpy('setEditorStyle'); core = ({ physicalRoot: mockedDiv, @@ -42,6 +44,7 @@ describe('setContentModel', () => { createEditorContext, setDOMSelection: setDOMSelectionSpy, getDOMSelection: getDOMSelectionSpy, + setEditorStyle: setEditorStyleSpy, }, lifecycle: {}, cache: {}, @@ -76,6 +79,7 @@ describe('setContentModel', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); expect(core.cache.cachedSelection).toBe(mockedRange); expect(core.cache.cachedModel).toBe(mockedModel); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '__persistedSelection', null); }); it('with default option, no shadow edit', () => { @@ -98,6 +102,7 @@ describe('setContentModel', () => { mockedContext ); expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '__persistedSelection', null); }); it('with default option, no shadow edit, with additional option', () => { @@ -125,6 +130,7 @@ describe('setContentModel', () => { mockedContext ); expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '__persistedSelection', null); }); it('no default option, with shadow edit', () => { @@ -181,6 +187,7 @@ describe('setContentModel', () => { ); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); expect(core.selection.selection).toBe(mockedRange); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '__persistedSelection', null); }); it('restore range selection ', () => { @@ -215,6 +222,7 @@ describe('setContentModel', () => { ); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); expect(core.selection.selection).toBe(mockedRange); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '__persistedSelection', null); }); it('restore null selection ', () => { @@ -244,5 +252,6 @@ describe('setContentModel', () => { ); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); expect(core.selection.selection).toBe(null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '__persistedSelection', null); }); }); diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/utils/handleSegmentCommon.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/utils/handleSegmentCommon.ts index ccf24a5c70b..91fc4a0cabd 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/utils/handleSegmentCommon.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/utils/handleSegmentCommon.ts @@ -23,5 +23,9 @@ export function handleSegmentCommon( applyFormat(containerNode, context.formatAppliers.elementBasedSegment, segment.format, context); + if (segment.isSelected && context.selectionClassName) { + containerNode.className = context.selectionClassName; + } + context.onNodeCreated?.(segment, segmentNode); } diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts index 8a3b3c923a4..f75f78e25d1 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts @@ -1,5 +1,6 @@ import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { createText } from '../../../lib/modelApi/creators/createText'; +import { expectHtml } from '../../testUtils'; import { handleSegmentCommon } from '../../../lib/modelToDom/utils/handleSegmentCommon'; describe('handleSegmentCommon', () => { @@ -60,4 +61,42 @@ describe('handleSegmentCommon', () => { expect(segmentNodes.length).toBe(1); expect(segmentNodes[0]).toBe(parent); }); + + it('selected text', () => { + const txt = document.createTextNode('test'); + const container = document.createElement('span'); + const segment = createText('test', { + textColor: 'red', + fontSize: '10pt', + lineHeight: '2', + fontWeight: 'bold', + }); + const onNodeCreated = jasmine.createSpy('onNodeCreated'); + const context = createModelToDomContext(); + + segment.isSelected = true; + context.onNodeCreated = onNodeCreated; + context.selectionClassName = 'test'; + + segment.link = { + dataset: {}, + format: { + href: 'href', + }, + }; + container.appendChild(txt); + const segmentNodes: Node[] = []; + + handleSegmentCommon(document, txt, container, segment, context, segmentNodes); + + expect(context.regularSelection.current.segment).toBe(txt); + expectHtml(container.outerHTML, [ + 'test', + 'test', + ]); + expect(onNodeCreated).toHaveBeenCalledWith(segment, txt); + expect(segmentNodes.length).toBe(2); + expect(segmentNodes[0]).toBe(txt); + expect(segmentNodes[1]).toBe(txt.parentNode!); + }); }); diff --git a/packages/roosterjs-content-model-types/lib/context/EditorContext.ts b/packages/roosterjs-content-model-types/lib/context/EditorContext.ts index 937a72788f6..edeca4073d7 100644 --- a/packages/roosterjs-content-model-types/lib/context/EditorContext.ts +++ b/packages/roosterjs-content-model-types/lib/context/EditorContext.ts @@ -62,4 +62,9 @@ export interface EditorContext { * Enabled experimental features */ experimentalFeatures?: ReadonlyArray; + + /** + * Optional parameter that indicate the customized classes to be applied on selection block. + */ + selectionClassName?: string; } diff --git a/packages/roosterjs-content-model-types/lib/context/ModelToDomOption.ts b/packages/roosterjs-content-model-types/lib/context/ModelToDomOption.ts index 3ca91ef31db..cf71f3d3341 100644 --- a/packages/roosterjs-content-model-types/lib/context/ModelToDomOption.ts +++ b/packages/roosterjs-content-model-types/lib/context/ModelToDomOption.ts @@ -33,4 +33,9 @@ export interface ModelToDomOption { * When set to true, selection from content model will not be applied */ ignoreSelection?: boolean; + + /** + * When set to true, selection will be maintained on text even if cursor has moved away from editor. + */ + shouldMaintainSelection?: boolean; }