Skip to content

Commit

Permalink
ShadowEdit selection
Browse files Browse the repository at this point in the history
fix test

fix test

Add background to maintain selection highlight

fix test

add comments to new context properties

Add background to maintain selection highlight

update

update

Add api check

Address comments

fix things with test

address comments

update declare

fix build

fix build

add export

improve

upate

fix build

Add comment to export member

fix build

add internal to comments
  • Loading branch information
JiuqingSong authored and francismengMS committed Jun 21, 2024
1 parent be7889d commit b49b845
Show file tree
Hide file tree
Showing 9 changed files with 178 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { updateCache } from '../../corePlugin/cache/updateCache';
import { isWindowWithHighlight, persistHighlight } from './persistHighlight';
import {
contentModelToDom,
createModelToDomContext,
createModelToDomContextWithConfig,
} 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
Expand All @@ -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,
Expand All @@ -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*/);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,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;
Expand All @@ -21,6 +21,7 @@ describe('setContentModel', () => {
let getDOMSelectionSpy: jasmine.Spy;
let flushMutationsSpy: jasmine.Spy;
let updateCacheSpy: jasmine.Spy;
let setEditorStyleSpy: jasmine.Spy;

beforeEach(() => {
contentModelToDomSpy = spyOn(contentModelToDom, 'contentModelToDom');
Expand All @@ -38,6 +39,7 @@ describe('setContentModel', () => {
setDOMSelectionSpy = jasmine.createSpy('setDOMSelection');
getDOMSelectionSpy = jasmine.createSpy('getDOMSelection');
flushMutationsSpy = jasmine.createSpy('flushMutations');
setEditorStyleSpy = jasmine.createSpy('setEditorStyle');

core = {
physicalRoot: mockedDiv,
Expand All @@ -46,6 +48,7 @@ describe('setContentModel', () => {
createEditorContext,
setDOMSelection: setDOMSelectionSpy,
getDOMSelection: getDOMSelectionSpy,
setEditorStyle: setEditorStyleSpy,
},
lifecycle: {},
cache: {
Expand Down Expand Up @@ -84,6 +87,8 @@ describe('setContentModel', () => {
expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange);
expect(core.cache.cachedSelection).toBe(mockedRange);
expect(flushMutationsSpy).toHaveBeenCalledWith(true);
expect(core.cache.cachedModel).toBe(mockedModel);
expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '__persistedSelection', null);
});

it('with default option, no shadow edit', () => {
Expand All @@ -106,6 +111,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', () => {
Expand Down Expand Up @@ -133,6 +139,7 @@ describe('setContentModel', () => {
mockedContext
);
expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedRange);
expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '__persistedSelection', null);
});

it('no default option, with shadow edit', () => {
Expand Down Expand Up @@ -189,6 +196,7 @@ describe('setContentModel', () => {
);
expect(setDOMSelectionSpy).not.toHaveBeenCalled();
expect(core.selection.selection).toBe(mockedRange);
expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '__persistedSelection', null);
});

it('restore range selection ', () => {
Expand Down Expand Up @@ -223,6 +231,7 @@ describe('setContentModel', () => {
);
expect(setDOMSelectionSpy).not.toHaveBeenCalled();
expect(core.selection.selection).toBe(mockedRange);
expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '__persistedSelection', null);
});

it('restore null selection ', () => {
Expand Down Expand Up @@ -252,6 +261,7 @@ describe('setContentModel', () => {
);
expect(setDOMSelectionSpy).not.toHaveBeenCalled();
expect(core.selection.selection).toBe(null);
expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '__persistedSelection', null);
});

it('Flush mutation before update cache', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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, [
'<span class="test" style="font-size: 10pt; color: red; line-height: 2;"><b><a href="href">test</a></b></span>',
'<span style="font-size: 10pt; color: red; line-height: 2;" class="test"><b><a href="href">test</a></b></span>',
]);
expect(onNodeCreated).toHaveBeenCalledWith(segment, txt);
expect(segmentNodes.length).toBe(2);
expect(segmentNodes[0]).toBe(txt);
expect(segmentNodes[1]).toBe(txt.parentNode!);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,9 @@ export interface EditorContext {
* Enabled experimental features
*/
experimentalFeatures?: ReadonlyArray<string>;

/**
* Optional parameter that indicate the customized classes to be applied on selection block.
*/
selectionClassName?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down

0 comments on commit b49b845

Please sign in to comment.