From e446e1a2b5970b135fea728539952046827bafcb Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 16 Apr 2024 15:56:53 -0700 Subject: [PATCH 1/2] Preserve reverted selection info in Content Model --- .../lib/corePlugin/cache/domIndexerImpl.ts | 10 +++ .../corePlugin/cache/domIndexerImplTest.ts | 57 ++++++++++++++ .../lib/domToModel/domToContentModel.ts | 4 + .../lib/modelToDom/contentModelToDom.ts | 4 + .../test/domToModel/domToContentModelTest.ts | 45 ++++++++++- .../test/modelToDom/contentModelToDomTest.ts | 74 ++++++++++++++++--- .../lib/group/ContentModelDocument.ts | 7 +- 7 files changed, 188 insertions(+), 13 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts index 50b533230cf..5c42d7460ee 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts @@ -127,12 +127,18 @@ function reconcileSelection( collapsed, } = newRange; + delete model.hasRevertedRangeSelection; + if (collapsed) { return !!reconcileNodeSelection(startContainer, startOffset); } else if ( startContainer == endContainer && isNodeOfType(startContainer, 'TEXT_NODE') ) { + if (newSelection.isReverted) { + model.hasRevertedRangeSelection = true; + } + return ( isIndexedSegment(startContainer) && !!reconcileTextSelection(startContainer, startOffset, endOffset) @@ -142,6 +148,10 @@ function reconcileSelection( const marker2 = reconcileNodeSelection(endContainer, endOffset); if (marker1 && marker2) { + if (newSelection.isReverted) { + model.hasRevertedRangeSelection = true; + } + setSelection(model, marker1, marker2); return true; } else { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts index d0b8ebdcb9b..efe35870517 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerImplTest.ts @@ -194,6 +194,7 @@ describe('domIndexerImpl.reconcileSelection', () => { expect(result).toBeFalse(); expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('no old range, normal range on non-indexed text, collapsed', () => { @@ -208,6 +209,7 @@ describe('domIndexerImpl.reconcileSelection', () => { expect(result).toBeFalse(); expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('no old range, normal range on indexed text, collapsed', () => { @@ -255,6 +257,7 @@ describe('domIndexerImpl.reconcileSelection', () => { ], }); expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('no old range, normal range on indexed text, expanded on same node', () => { @@ -300,6 +303,53 @@ describe('domIndexerImpl.reconcileSelection', () => { segments: [segment1, segment2, segment3], }); expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); + }); + + it('no old range, normal range on indexed text, expanded on same node, reverted', () => { + const node = document.createTextNode('test') as any; + const newRangeEx: DOMSelection = { + type: 'range', + range: createRange(node, 1, node, 3), + isReverted: true, + }; + const paragraph = createParagraph(); + const segment = createText(''); + + paragraph.segments.push(segment); + domIndexerImpl.onSegment(node, paragraph, [segment]); + + const result = domIndexerImpl.reconcileSelection(model, newRangeEx); + + const segment1: ContentModelSegment = { + segmentType: 'Text', + text: 't', + format: {}, + }; + const segment2: ContentModelSegment = { + segmentType: 'Text', + text: 'es', + format: {}, + isSelected: true, + }; + const segment3: ContentModelSegment = { + segmentType: 'Text', + text: 't', + format: {}, + }; + + expect(result).toBeTrue(); + expect(node.__roosterjsContentModel).toEqual({ + paragraph, + segments: [segment1, segment2, segment3], + }); + expect(paragraph).toEqual({ + blockType: 'Paragraph', + format: {}, + segments: [segment1, segment2, segment3], + }); + expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeTrue(); }); it('no old range, normal range on indexed text, expanded on different node', () => { @@ -370,6 +420,7 @@ describe('domIndexerImpl.reconcileSelection', () => { blockGroupType: 'Document', blocks: [paragraph], }); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('no old range, normal range on indexed text, expanded on other type of node', () => { @@ -430,6 +481,7 @@ describe('domIndexerImpl.reconcileSelection', () => { blockGroupType: 'Document', blocks: [paragraph], }); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('no old range, image range on indexed text', () => { @@ -472,6 +524,7 @@ describe('domIndexerImpl.reconcileSelection', () => { format: {}, dataset: {}, }); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('no old range, table range on indexed text', () => { @@ -516,6 +569,7 @@ describe('domIndexerImpl.reconcileSelection', () => { blockGroupType: 'Document', blocks: [tableModel], }); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('no old range, collapsed range after last node', () => { @@ -548,6 +602,7 @@ describe('domIndexerImpl.reconcileSelection', () => { segments: [segment, createSelectionMarker({ fontFamily: 'Arial' })], }); expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('has old range - collapsed, expanded new range', () => { @@ -606,6 +661,7 @@ describe('domIndexerImpl.reconcileSelection', () => { segments: [segment1, segment2, segment3], }); expect(setSelectionSpy).not.toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); it('has old range - expanded, expanded new range', () => { @@ -664,5 +720,6 @@ describe('domIndexerImpl.reconcileSelection', () => { segments: [segment1, createSelectionMarker(), segment2], }); expect(setSelectionSpy).toHaveBeenCalled(); + expect(model.hasRevertedRangeSelection).toBeFalsy(); }); }); diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts b/packages/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts index 7ccaddfb2b7..eacb390f507 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/domToContentModel.ts @@ -14,6 +14,10 @@ export function domToContentModel( ): ContentModelDocument { const model = createContentModelDocument(context.defaultFormat); + if (context.selection?.type == 'range' && context.selection.isReverted) { + model.hasRevertedRangeSelection = true; + } + context.elementProcessors.child(model, root, context); normalizeContentModel(model); diff --git a/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts b/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts index 2a5e836f6bb..54bb520d0c7 100644 --- a/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts +++ b/packages/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts @@ -27,6 +27,10 @@ export function contentModelToDom( const range = extractSelectionRange(doc, context); + if (model.hasRevertedRangeSelection && range?.type == 'range') { + range.isReverted = true; + } + root.normalize(); return range; diff --git a/packages/roosterjs-content-model-dom/test/domToModel/domToContentModelTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/domToContentModelTest.ts index 399a2e138c5..bfb14652f51 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/domToContentModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/domToContentModelTest.ts @@ -1,6 +1,6 @@ import * as normalizeContentModel from '../../lib/modelApi/common/normalizeContentModel'; -import { domToContentModel } from '../../lib/domToModel/domToContentModel'; import { ContentModelDocument, DomToModelContext } from 'roosterjs-content-model-types'; +import { domToContentModel } from '../../lib/domToModel/domToContentModel'; describe('domToContentModel', () => { it('Not include root', () => { @@ -38,4 +38,47 @@ describe('domToContentModel', () => { expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledTimes(1); expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(result); }); + + it('With reverted selection', () => { + const elementProcessor = jasmine.createSpy('elementProcessor'); + const childProcessor = jasmine.createSpy('childProcessor'); + const mockedRange = 'RANGE' as any; + const mockContext: DomToModelContext = { + elementProcessors: { + element: elementProcessor, + child: childProcessor, + }, + defaultStyles: {}, + segmentFormat: {}, + isDarkMode: false, + defaultFormat: { + fontSize: '10pt', + }, + selection: { + type: 'range', + range: mockedRange, + isReverted: true, + }, + } as any; + + spyOn(normalizeContentModel, 'normalizeContentModel'); + + const rootElement = document.createElement('div'); + const model = domToContentModel(rootElement, mockContext); + const result: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [], + format: { + fontSize: '10pt', + }, + hasRevertedRangeSelection: true, + }; + + expect(model).toEqual(result); + expect(elementProcessor).not.toHaveBeenCalled(); + expect(childProcessor).toHaveBeenCalledTimes(1); + expect(childProcessor).toHaveBeenCalledWith(result, rootElement, mockContext); + expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledTimes(1); + expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(result); + }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts b/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts index 3593c544292..871c1a22baf 100644 --- a/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelToDom/contentModelToDomTest.ts @@ -66,6 +66,7 @@ describe('contentModelToDom', () => { expect((range as RangeSelection).range.startOffset).toBe(0); expect((range as RangeSelection).range.endContainer).toBe(parent.firstChild as HTMLElement); expect((range as RangeSelection).range.endOffset).toBe(0); + expect((range as RangeSelection).isReverted).toBe(false); expect(parent.innerHTML).toBe('

'); }); @@ -93,13 +94,14 @@ describe('contentModelToDom', () => { segment: br, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('range'); expect((range as RangeSelection).range.startContainer).toBe(div); expect((range as RangeSelection).range.startOffset).toBe(1); expect((range as RangeSelection).range.endContainer).toBe(div); expect((range as RangeSelection).range.endOffset).toBe(1); + expect((range as RangeSelection).isReverted).toBe(false); }); it('Extract selection range - normal collapsed range with empty text', () => { @@ -128,13 +130,14 @@ describe('contentModelToDom', () => { segment: txt, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('range'); expect((range as RangeSelection).range.startContainer).toBe(div); expect((range as RangeSelection).range.startOffset).toBe(0); expect((range as RangeSelection).range.endContainer).toBe(div); expect((range as RangeSelection).range.endOffset).toBe(0); + expect((range as RangeSelection).isReverted).toBe(false); }); it('Extract selection range - normal collapsed range in side text', () => { @@ -163,13 +166,14 @@ describe('contentModelToDom', () => { segment: txt1, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('range'); expect((range as RangeSelection).range.startContainer).toBe(txt1); expect((range as RangeSelection).range.startOffset).toBe(5); expect((range as RangeSelection).range.endContainer).toBe(txt1); expect((range as RangeSelection).range.endOffset).toBe(5); + expect((range as RangeSelection).isReverted).toBe(false); expect(txt1.nodeValue).toBe('test1test2'); }); @@ -197,7 +201,7 @@ describe('contentModelToDom', () => { segment: txt1, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range).toBeNull(); }); @@ -226,13 +230,14 @@ describe('contentModelToDom', () => { segment: null, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('range'); expect((range as RangeSelection).range.startContainer).toBe(div); expect((range as RangeSelection).range.startOffset).toBe(0); expect((range as RangeSelection).range.endContainer).toBe(div); expect((range as RangeSelection).range.endOffset).toBe(0); + expect((range as RangeSelection).isReverted).toBe(false); }); it('Extract selection range - no end', () => { @@ -255,7 +260,7 @@ describe('contentModelToDom', () => { segment: txt1, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range).toBeNull(); }); @@ -282,13 +287,14 @@ describe('contentModelToDom', () => { segment: txt1, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('range'); expect((range as RangeSelection).range.startContainer).toBe(txt1); expect((range as RangeSelection).range.startOffset).toBe(5); expect((range as RangeSelection).range.endContainer).toBe(txt1); expect((range as RangeSelection).range.endOffset).toBe(5); + expect((range as RangeSelection).isReverted).toBe(false); }); it('Extract selection range - root is fragment - 2', () => { @@ -315,13 +321,14 @@ describe('contentModelToDom', () => { segment: span, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('range'); expect((range as RangeSelection).range.startContainer).toBe(span); expect((range as RangeSelection).range.startOffset).toBe(1); expect((range as RangeSelection).range.endContainer).toBe(span); expect((range as RangeSelection).range.endOffset).toBe(1); + expect((range as RangeSelection).isReverted).toBe(false); }); it('Extract selection range - expanded range', () => { @@ -352,7 +359,7 @@ describe('contentModelToDom', () => { segment: txt2, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('range'); expect((range as RangeSelection).range.startContainer).toBe(txt1); @@ -360,6 +367,51 @@ describe('contentModelToDom', () => { expect((range as RangeSelection).range.endContainer).toBe(txt1); expect((range as RangeSelection).range.endOffset).toBe(10); expect(txt1.nodeValue).toEqual('test1test2test3'); + expect((range as RangeSelection).isReverted).toBe(false); + }); + + it('Extract selection range - reverted expanded range', () => { + const mockedHandler = jasmine.createSpy('blockGroupChildren'); + const context = createModelToDomContext(undefined, { + modelHandlerOverride: { + blockGroupChildren: mockedHandler, + }, + }); + + const root = document.createElement('div'); + const span = document.createElement('span'); + const txt1 = document.createTextNode('test1'); + const txt2 = document.createTextNode('test2'); + const txt3 = document.createTextNode('test3'); + + root.appendChild(span); + span.appendChild(txt1); + span.appendChild(txt2); + span.appendChild(txt3); + + context.regularSelection.start = { + block: span, + segment: txt1, + }; + context.regularSelection.end = { + block: span, + segment: txt2, + }; + + const range = contentModelToDom( + document, + root, + { hasRevertedRangeSelection: true } as any, + context + ); + + expect(range!.type).toBe('range'); + expect((range as RangeSelection).range.startContainer).toBe(txt1); + expect((range as RangeSelection).range.startOffset).toBe(5); + expect((range as RangeSelection).range.endContainer).toBe(txt1); + expect((range as RangeSelection).range.endOffset).toBe(10); + expect((range as RangeSelection).isReverted).toBe(true); + expect(txt1.nodeValue).toEqual('test1test2test3'); }); it('Extract selection range - image range', () => { @@ -378,7 +430,7 @@ describe('contentModelToDom', () => { image: image, }; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range!.type).toBe('image'); expect((range as ImageSelection).image).toBe(image); @@ -397,7 +449,7 @@ describe('contentModelToDom', () => { context.tableSelection = mockedSelection; - const range = contentModelToDom(document, root, null!, context); + const range = contentModelToDom(document, root, {} as any, context); expect(range).toBe(mockedSelection); }); diff --git a/packages/roosterjs-content-model-types/lib/group/ContentModelDocument.ts b/packages/roosterjs-content-model-types/lib/group/ContentModelDocument.ts index 0d9126a3ae0..7f1eac6188c 100644 --- a/packages/roosterjs-content-model-types/lib/group/ContentModelDocument.ts +++ b/packages/roosterjs-content-model-types/lib/group/ContentModelDocument.ts @@ -7,4 +7,9 @@ import type { ContentModelWithFormat } from '../format/ContentModelWithFormat'; */ export interface ContentModelDocument extends ContentModelBlockGroupBase<'Document'>, - Partial> {} + Partial> { + /** + * Whether the selection in model (if any) is a revert selection (end is before start) + */ + hasRevertedRangeSelection?: boolean; +} From 6f4bd7c1db48c03f2aa608ccc9904561206f7979 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 16 Apr 2024 17:10:43 -0700 Subject: [PATCH 2/2] improve --- .../model/ContentModelDocumentView.tsx | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/demo/scripts/controlsV2/sidePane/contentModel/components/model/ContentModelDocumentView.tsx b/demo/scripts/controlsV2/sidePane/contentModel/components/model/ContentModelDocumentView.tsx index fdaf3574a84..6a7568b93b3 100644 --- a/demo/scripts/controlsV2/sidePane/contentModel/components/model/ContentModelDocumentView.tsx +++ b/demo/scripts/controlsV2/sidePane/contentModel/components/model/ContentModelDocumentView.tsx @@ -3,14 +3,41 @@ import { BlockGroupContentView } from './BlockGroupContentView'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { ContentModelView } from '../ContentModelView'; import { hasSelectionInBlockGroup } from 'roosterjs-content-model-dom'; +import { SegmentFormatView } from '../format/SegmentFormatView'; +import { useProperty } from '../../hooks/useProperty'; const styles = require('./ContentModelDocumentView.scss'); export function ContentModelDocumentView(props: { doc: ContentModelDocument }) { const { doc } = props; + const [isReverted, setIsReverted] = useProperty(!!doc.hasRevertedRangeSelection); + const revertedCheckbox = React.useRef(null); + const onIsRevertedChange = React.useCallback(() => { + const newValue = revertedCheckbox.current.checked; + doc.hasRevertedRangeSelection = newValue; + setIsReverted(newValue); + }, [doc, setIsReverted]); + const getContent = React.useCallback(() => { - return ; - }, [doc]); + return ( + <> +
+ + Reverted range selection +
+ + + ); + }, [doc, isReverted]); + + const getFormat = React.useCallback(() => { + return doc.format ? : null; + }, [doc.format]); return ( ); }