From 3e9397da46bb915276f7c5b52f869b9d15cdcee1 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 4 Apr 2024 11:03:03 -0700 Subject: [PATCH] Add background to maintain selection highlight --- .../formatContentModel/formatContentModel.ts | 9 +- .../setContentModel/persistHighlight.ts | 85 +++++++++++++++++++ .../setContentModel/setContentModel.ts | 17 ++++ .../modelToDom/utils/handleSegmentCommon.ts | 4 + .../utils/handleSegmentCommonTest.ts | 39 +++++++++ .../lib/context/EditorContext.ts | 5 ++ .../lib/context/ModelToDomOption.ts | 5 ++ .../parameter/FormatContentModelOptions.ts | 5 ++ 8 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 packages/roosterjs-content-model-core/lib/coreApi/setContentModel/persistHighlight.ts 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 35be053782e..83e7fe79db1 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts @@ -31,6 +31,7 @@ export const formatContentModel: FormatContentModel = ( rawEvent, selectionOverride, scrollCaretIntoView: scroll, + shouldMaintainSelection, } = options || {}; const model = core.api.createContentModel(core, domToModelOptions, selectionOverride); const context: FormatContentModelContext = { @@ -58,12 +59,16 @@ export const formatContentModel: FormatContentModel = ( try { handleImages(core, context); - selection = core.api.setContentModel( core, model, - hasFocus ? undefined : { ignoreSelection: true }, // If editor did not have focus before format, do not set focus after format + hasFocus || shouldMaintainSelection + ? { + ignoreSelection: hasFocus, // ..... (move the comment here) + shouldMaintainSelection, + } + : undefined, onNodeCreated ) ?? undefined; diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/persistHighlight.ts b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/persistHighlight.ts new file mode 100644 index 00000000000..4b801b47e2a --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/persistHighlight.ts @@ -0,0 +1,85 @@ +import type { DOMSelection, EditorCore } from 'roosterjs-content-model-types'; + +const SelectionClassName = '__persistedSelection'; +/** + * @internal + * Shim class to pass TS interpreter if the TS version does not have context of Highlight API + */ +declare class Highlight { + constructor(textRange: Range); +} + +/** + * @internal + * Shim interface to pass TS interpreter if the TS version does not have context of Highlight API + */ +interface WindowWithHighlight extends Window { + Highlight: typeof Highlight; +} + +/** + * @internal + * Shim class for HighlightRegistry to pass TS interpreter + */ +interface HighlightRegistryWithMap extends HighlightRegistry { + set(name: string, highlight: Highlight): void; + delete(name: string): void; +} + +interface HighlightRegistry {} + +interface CSSShim { + highlights: HighlightRegistry; +} + +declare const CSS: CSSShim; + +/** + * @internal + * @param win current window that Highlight is being used. + * @returns boolean indicates if Highlight api is available + */ +function isHighlightRegistryWithMap( + highlight: HighlightRegistry +): highlight is HighlightRegistryWithMap { + return !!(highlight as HighlightRegistryWithMap).set; +} + +/** + * @internal + * @param win current window that Highlight is being used. + * @returns boolean indicates if Highlight api is available + */ +export function isWindowWithHighlight(win: Window): win is WindowWithHighlight { + return !!(win as WindowWithHighlight).Highlight; +} + +/** + * @internal + * Persist highlight of a indicated selection object + * @param core The editor core object + * @param shouldMaintainSelection The flag indicate if the selection should be persisted + * @param selection The selection object that needs to be persisted. + */ +export function persistHighlight( + core: EditorCore, + shouldMaintainSelection: boolean, + selection: DOMSelection | null +) { + const currentWindow = core.logicalRoot.ownerDocument.defaultView; + + if ( + currentWindow && + isWindowWithHighlight(currentWindow) && + isHighlightRegistryWithMap(CSS.highlights) + ) { + if (shouldMaintainSelection) { + if (selection && selection.type == 'range') { + const highlight = new currentWindow.Highlight(selection.range); + CSS.highlights.set(SelectionClassName, highlight); + } + } else { + CSS.highlights.delete(SelectionClassName); + } + } +} 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 19d3da8e411..c92526e0551 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts @@ -1,4 +1,5 @@ import { updateCache } from '../../corePlugin/cache/updateCache'; +import { isWindowWithHighlight, persistHighlight } from './persistHighlight'; import { contentModelToDom, createModelToDomContext, @@ -6,6 +7,8 @@ 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,18 @@ import type { SetContentModel } from 'roosterjs-content-model-types'; */ export const setContentModel: SetContentModel = (core, model, option, onNodeCreated) => { const editorContext = core.api.createEditorContext(core, true /*saveIndex*/); + const currentWindow = core.logicalRoot.ownerDocument.defaultView; + if (currentWindow && isWindowWithHighlight(currentWindow)) { + 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, @@ -36,6 +51,8 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea modelToDomContext ); + persistHighlight(core, !!option?.shouldMaintainSelection, selection); + if (!core.lifecycle.shadowEditFragment) { // Clear pending mutations since we will use our latest model object to replace existing cache core.cache.textMutationObserver?.flushMutations(true /*ignoreMutations*/); 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; } diff --git a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts index c269e483078..9a73545e105 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/FormatContentModelOptions.ts @@ -43,6 +43,11 @@ export interface FormatContentModelOptions { * When pass to true, scroll the editing caret into view after write DOM tree if need */ scrollCaretIntoView?: boolean; + + /** + * When pass to true, selection is maintained even when focus is moved out of editor. + */ + shouldMaintainSelection?: boolean; } /**