From e446e1a2b5970b135fea728539952046827bafcb Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 16 Apr 2024 15:56:53 -0700 Subject: [PATCH 1/5] 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 5ec573e8cddb21ecb9ae3548798968e9140c0e6f Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 16 Apr 2024 17:11:42 -0700 Subject: [PATCH 2/5] Entity delimiter cursor moving (#2575) --- .../corePlugin/entity/entityDelimiterUtils.ts | 184 ++++++-- ...ilsTest.ts => entityDelimiterUtilsTest.ts} | 407 ++++++++++++++++++ .../modelApi/selection/collectSelections.ts | 9 +- .../selection/collectSelectionsTest.ts | 38 +- 4 files changed, 593 insertions(+), 45 deletions(-) rename packages/roosterjs-content-model-core/test/corePlugin/entity/{delimiterUtilsTest.ts => entityDelimiterUtilsTest.ts} (78%) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts b/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts index 4e1f1d29bc0..9aed05efb79 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts @@ -11,6 +11,9 @@ import { findClosestEntityWrapper, iterateSelections, isCharacterValue, + getSelectedSegmentsAndParagraphs, + createSelectionMarker, + setSelection, } from 'roosterjs-content-model-dom'; import type { CompositionEndEvent, @@ -195,58 +198,157 @@ export function handleCompositionEndEvent(editor: IEditor, event: CompositionEnd export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent) { const selection = editor.getDOMSelection(); - const { rawEvent } = event; if (!selection || selection.type != 'range') { return; } - const isEnter = rawEvent.key === 'Enter'; + + const { rawEvent } = event; + const { range, isReverted } = selection; + + switch (rawEvent.key) { + case 'Enter': + if (range.collapsed) { + handleInputOnDelimiter(editor, range, getFocusedElement(selection), rawEvent); + } else { + const helper = editor.getDOMHelper(); + const entity = findClosestEntityWrapper(range.startContainer, helper); + + if ( + entity && + isNodeOfType(entity, 'ELEMENT_NODE') && + helper.isNodeInEditor(entity) + ) { + triggerEntityEventOnEnter(editor, entity, rawEvent); + } + } + break; + + case 'ArrowLeft': + case 'ArrowRight': + handleMovingOnDelimiter(editor, isReverted, rawEvent); + break; + + default: + if (isCharacterValue(rawEvent) && range.collapsed) { + handleInputOnDelimiter(editor, range, getFocusedElement(selection), rawEvent); + } + + break; + } +} + +function handleInputOnDelimiter( + editor: IEditor, + range: Range, + focusedNode: HTMLElement | null, + rawEvent: KeyboardEvent +) { const helper = editor.getDOMHelper(); - if (selection.range.collapsed && (isCharacterValue(rawEvent) || isEnter)) { - const helper = editor.getDOMHelper(); - const node = getFocusedElement(selection); - if (node && isEntityDelimiter(node) && helper.isNodeInEditor(node)) { - const blockEntityContainer = node.closest(BlockEntityContainerSelector); - if (blockEntityContainer && helper.isNodeInEditor(blockEntityContainer)) { - const isAfter = node.classList.contains(DelimiterAfter); - - if (isAfter) { - selection.range.setStartAfter(blockEntityContainer); - } else { - selection.range.setStartBefore(blockEntityContainer); + + if (focusedNode && isEntityDelimiter(focusedNode) && helper.isNodeInEditor(focusedNode)) { + const blockEntityContainer = focusedNode.closest(BlockEntityContainerSelector); + const isEnter = rawEvent.key === 'Enter'; + + if (blockEntityContainer && helper.isNodeInEditor(blockEntityContainer)) { + const isAfter = focusedNode.classList.contains(DelimiterAfter); + + if (isAfter) { + range.setStartAfter(blockEntityContainer); + } else { + range.setStartBefore(blockEntityContainer); + } + + range.collapse(true /* toStart */); + + if (isEnter) { + rawEvent.preventDefault(); + } + + editor.formatContentModel(handleKeyDownInBlockDelimiter, { + selectionOverride: { + type: 'range', + isReverted: false, + range, + }, + }); + } else { + if (isEnter) { + rawEvent.preventDefault(); + editor.formatContentModel(handleEnterInlineEntity); + } else { + editor.takeSnapshot(); + editor + .getDocument() + .defaultView?.requestAnimationFrame(() => + preventTypeInDelimiter(focusedNode, editor) + ); + } + } + } +} + +function handleMovingOnDelimiter(editor: IEditor, isReverted: boolean, rawEvent: KeyboardEvent) { + editor.formatContentModel(model => { + const selections = getSelectedSegmentsAndParagraphs( + model, + false /*includingFormatHolder*/, + true /*includingEntity*/ + ); + const selection = isReverted ? selections[0] : selections[selections.length - 1]; + + if (selection?.[1]) { + const [segment, paragraph] = selection; + const movingBefore = + (rawEvent.key == 'ArrowLeft') != (paragraph.format.direction == 'rtl'); + const isShrinking = + rawEvent.shiftKey && + segment.segmentType != 'SelectionMarker' && + movingBefore != isReverted; + const index = paragraph.segments.indexOf(segment); + const targetIndex = isShrinking + ? index + : index >= 0 + ? movingBefore + ? index - 1 + : index + 1 + : -1; + const targetSegment = targetIndex >= 0 ? paragraph.segments[targetIndex] : null; + + if (targetSegment?.segmentType == 'Entity') { + if (rawEvent.shiftKey) { + targetSegment.isSelected = !isShrinking; + + if (!isShrinking && movingBefore) { + model.hasRevertedRangeSelection = true; + } } - selection.range.collapse(true /* toStart */); - if (isEnter) { - event.rawEvent.preventDefault(); + if (!rawEvent.shiftKey || (isShrinking && selections.length == 1)) { + const formatSegment = + paragraph.segments[movingBefore ? targetIndex - 1 : targetIndex + 1]; + const marker = createSelectionMarker( + formatSegment?.format ?? targetSegment.format + ); + + paragraph.segments.splice( + movingBefore ? targetIndex : targetIndex + 1, + 0, + marker + ); + + setSelection(model, marker); } - editor.formatContentModel(handleKeyDownInBlockDelimiter, { - selectionOverride: { - type: 'range', - isReverted: false, - range: selection.range, - }, - }); + rawEvent.preventDefault(); + + return true; } else { - if (isEnter) { - event.rawEvent.preventDefault(); - editor.formatContentModel(handleEnterInlineEntity); - } else { - editor.takeSnapshot(); - editor - .getDocument() - .defaultView?.requestAnimationFrame(() => - preventTypeInDelimiter(node, editor) - ); - } + return false; } } - } else if (isEnter) { - const entity = findClosestEntityWrapper(selection.range.startContainer, helper); - if (entity && isNodeOfType(entity, 'ELEMENT_NODE') && helper.isNodeInEditor(entity)) { - triggerEntityEventOnEnter(editor, entity, rawEvent); - } - } + + return false; + }); } /** diff --git a/packages/roosterjs-content-model-core/test/corePlugin/entity/delimiterUtilsTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/entity/entityDelimiterUtilsTest.ts similarity index 78% rename from packages/roosterjs-content-model-core/test/corePlugin/entity/delimiterUtilsTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/entity/entityDelimiterUtilsTest.ts index 4424b8a44d1..3e24dda3aed 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/entity/delimiterUtilsTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/entity/entityDelimiterUtilsTest.ts @@ -1365,3 +1365,410 @@ describe('handleEnterInlineEntity', () => { }); }); }); + +describe('Move cursor in delimiter', () => { + let mockedEditor: any; + let context: any; + let getDOMSelectionSpy: jasmine.Spy; + let formatContentModelSpy: jasmine.Spy; + const entityWrapper = document.createElement('span'); + + beforeEach(() => { + context = {}; + + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + formatContentModelSpy = jasmine.createSpy('formatContentModel'); + mockedEditor = { + formatContentModel: formatContentModelSpy, + getDOMSelection: getDOMSelectionSpy, + } as Partial; + }); + + function runTest( + inputModel: ContentModelDocument, + key: string, + shiftKey: boolean, + isReverted: boolean, + expectedModel: ContentModelDocument, + defaultPrevented: boolean + ) { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { collapsed: true }, + isReverted, + }); + formatContentModelSpy.and.callFake(formatter => { + const result = formatter(inputModel, context); + + expect(result).toBe(defaultPrevented); + }); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + key, + shiftKey, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(preventDefaultSpy).toHaveBeenCalledTimes(defaultPrevented ? 1 : 0); + expect(inputModel).toEqual(expectedModel); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + } + + it('After entity, move left, LTR', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + createEntity(entityWrapper, true), + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + }, + 'ArrowLeft', + false, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + id: undefined, + entityType: undefined, + isReadonly: true, + }, + wrapper: entityWrapper, + }, + ], + }, + ], + }, + true + ); + }); + + it('Before entity, move right, LTR', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + createEntity(entityWrapper, true), + ], + format: {}, + }, + ], + }, + 'ArrowRight', + false, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + id: undefined, + entityType: undefined, + isReadonly: true, + }, + wrapper: entityWrapper, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + }, + ], + }, + true + ); + }); + + it('Before entity, move shift+right, LTR', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + createEntity(entityWrapper, true), + ], + format: {}, + }, + ], + }, + 'ArrowRight', + true, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + isReadonly: true, + id: undefined, + entityType: undefined, + }, + wrapper: entityWrapper, + isSelected: true, + }, + ], + format: {}, + }, + ], + }, + true + ); + }); + + it('After entity, move shift+left, LTR', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + createEntity(entityWrapper, true), + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + }, + 'ArrowLeft', + true, + true, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + isReadonly: true, + id: undefined, + entityType: undefined, + }, + wrapper: entityWrapper, + isSelected: true, + }, + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + ], + format: {}, + }, + ], + hasRevertedRangeSelection: true, + }, + true + ); + }); + + it('Entity is selected, move right, not reverted, LTR', () => { + const entity = createEntity(entityWrapper, true); + + entity.isSelected = true; + + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [entity], + format: {}, + }, + ], + }, + 'ArrowRight', + true, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + isReadonly: true, + id: undefined, + entityType: undefined, + }, + wrapper: entityWrapper, + isSelected: true, + }, + ], + format: {}, + }, + ], + }, + false + ); + }); + + it('Entity is selected, move left, not reverted, LTR', () => { + const entity = createEntity(entityWrapper, true); + + entity.isSelected = true; + + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [entity], + format: {}, + }, + ], + }, + 'ArrowLeft', + true, + false, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + isReadonly: true, + id: undefined, + entityType: undefined, + }, + wrapper: entityWrapper, + }, + ], + format: {}, + }, + ], + }, + true + ); + }); + + it('Entity is selected, move right, reverted, LTR', () => { + const entity = createEntity(entityWrapper, true); + + entity.isSelected = true; + + runTest( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [entity], + format: {}, + }, + ], + }, + 'ArrowRight', + true, + true, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + isReadonly: true, + id: undefined, + entityType: undefined, + }, + wrapper: entityWrapper, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + }, + true + ); + }); +}); diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts b/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts index 91b4d2b307a..10d77bf5181 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts @@ -23,7 +23,8 @@ import type { */ export function getSelectedSegmentsAndParagraphs( model: ContentModelDocument, - includingFormatHolder: boolean + includingFormatHolder: boolean, + includingEntity?: boolean ): [ContentModelSegment, ContentModelParagraph | null][] { const selections = collectSelections(model, { includeListFormatHolder: includingFormatHolder ? 'allSegments' : 'never', @@ -33,7 +34,11 @@ export function getSelectedSegmentsAndParagraphs( selections.forEach(({ segments, block }) => { if (segments && ((includingFormatHolder && !block) || block?.blockType == 'Paragraph')) { segments.forEach(segment => { - if (segment.segmentType != 'Entity' || !segment.entityFormat.isReadonly) { + if ( + includingEntity || + segment.segmentType != 'Entity' || + !segment.entityFormat.isReadonly + ) { result.push([segment, block?.blockType == 'Paragraph' ? block : null]); } }); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts index 798e6688f70..46186b3da50 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts @@ -41,6 +41,7 @@ describe('getSelectedSegmentsAndParagraphs', () => { function runTest( selections: SelectionInfo[], includingFormatHolder: boolean, + includingEntity: boolean, expectedResult: [ContentModelSegment, ContentModelParagraph | null][] ) { spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -51,13 +52,17 @@ describe('getSelectedSegmentsAndParagraphs', () => { return false; }); - const result = getSelectedSegmentsAndParagraphs(null!, includingFormatHolder); + const result = getSelectedSegmentsAndParagraphs( + null!, + includingFormatHolder, + includingEntity + ); expect(result).toEqual(expectedResult); } it('Empty result', () => { - runTest([], false, []); + runTest([], false, false, []); }); it('Add segments', () => { @@ -82,6 +87,7 @@ describe('getSelectedSegmentsAndParagraphs', () => { }, ], false, + false, [ [s1, p1], [s2, p1], @@ -111,6 +117,7 @@ describe('getSelectedSegmentsAndParagraphs', () => { }, ], false, + false, [] ); }); @@ -135,6 +142,7 @@ describe('getSelectedSegmentsAndParagraphs', () => { }, ], true, + false, [ [s3, null], [s4, null], @@ -176,6 +184,7 @@ describe('getSelectedSegmentsAndParagraphs', () => { }, ], true, + false, [ [m1, p1], [s2, p2], @@ -201,9 +210,34 @@ describe('getSelectedSegmentsAndParagraphs', () => { }, ], false, + false, [[e2, p1]] ); }); + + it('Include entity', () => { + const e1 = createEntity(null!); + const e2 = createEntity(null!, false); + const p1 = createParagraph(); + + p1.segments.push(e1, e2); + + runTest( + [ + { + path: [], + block: p1, + segments: [e1, e2], + }, + ], + false, + true, + [ + [e1, p1], + [e2, p1], + ] + ); + }); }); describe('getSelectedParagraphs', () => { From 5d3babc79a088577766854d42a6b70c7611a3841 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 22 Apr 2024 16:52:25 -0700 Subject: [PATCH 3/5] improve --- .../corePlugin/entity/entityDelimiterUtils.ts | 160 ++++++++++-------- .../lib/domUtils/entityUtils.ts | 10 +- 2 files changed, 99 insertions(+), 71 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts b/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts index 9aed05efb79..19a4844db67 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts @@ -12,8 +12,6 @@ import { iterateSelections, isCharacterValue, getSelectedSegmentsAndParagraphs, - createSelectionMarker, - setSelection, } from 'roosterjs-content-model-dom'; import type { CompositionEndEvent, @@ -203,7 +201,7 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent } const { rawEvent } = event; - const { range, isReverted } = selection; + const { range } = selection; switch (rawEvent.key) { case 'Enter': @@ -225,7 +223,7 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent case 'ArrowLeft': case 'ArrowRight': - handleMovingOnDelimiter(editor, isReverted, rawEvent); + handleMovingOnDelimiter(editor, rawEvent); break; default: @@ -237,6 +235,96 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent } } +function handleMovingOnDelimiter(editor: IEditor, rawEvent: KeyboardEvent) { + editor.getDocument().defaultView?.requestAnimationFrame(() => { + if (editor.isDisposed()) { + return; + } + + const selection = editor.getDOMSelection(); + + if (!selection || selection.type != 'range') { + return; + } + + const { range, isReverted } = selection; + const anchorNode = isReverted ? range.startContainer : range.endContainer; + const offset = isReverted ? range.startOffset : range.endOffset; + const delimiter = isNodeOfType(anchorNode, 'ELEMENT_NODE') + ? anchorNode + : anchorNode.parentElement; + const isRtl = + delimiter && + editor.getDocument().defaultView?.getComputedStyle(delimiter).direction == 'rtl'; + const movingBefore = (rawEvent.key == 'ArrowLeft') != !!isRtl; + + if ( + delimiter && + isEntityDelimiter(delimiter, !movingBefore) && + ((movingBefore && offset == 0) || (!movingBefore && offset == 1)) + ) { + editor.formatContentModel(model => { + const allSel = getSelectedSegmentsAndParagraphs( + model, + false /*includingFormatHolder*/, + true + ); + const sel = allSel[isReverted ? 0 : allSel.length - 1]; + const index = sel?.[1]?.segments.indexOf(sel[0]) ?? -1; + + if (sel && sel[1] && index >= 0) { + const isShrinking = + rawEvent.shiftKey && !range.collapsed && movingBefore != !!isReverted; + const entity = isShrinking + ? sel[0] + : sel[1].segments[movingBefore ? index - 1 : index + 1]; + const pairedDelimiter = + entity?.segmentType == 'Entity' + ? movingBefore + ? entity.wrapper.previousElementSibling + : entity.wrapper.nextElementSibling + : null; + + if ( + pairedDelimiter && + isEntityDelimiter(pairedDelimiter as HTMLElement, movingBefore) + ) { + const newRange = range.cloneRange(); + + if (isShrinking) { + if (movingBefore) { + newRange.setEndBefore(pairedDelimiter); + } else { + newRange.setStartAfter(pairedDelimiter); + } + } else { + if (movingBefore) { + newRange.setStartBefore(pairedDelimiter); + } else { + newRange.setEndAfter(pairedDelimiter); + } + if (!rawEvent.shiftKey) { + if (movingBefore) { + newRange.setEndBefore(pairedDelimiter); + } else { + newRange.setStartAfter(pairedDelimiter); + } + } + } + editor.setDOMSelection({ + type: 'range', + range: newRange, + isReverted: newRange.collapsed ? false : isReverted, + }); + } + } + + return false; + }); + } + }); +} + function handleInputOnDelimiter( editor: IEditor, range: Range, @@ -287,70 +375,6 @@ function handleInputOnDelimiter( } } -function handleMovingOnDelimiter(editor: IEditor, isReverted: boolean, rawEvent: KeyboardEvent) { - editor.formatContentModel(model => { - const selections = getSelectedSegmentsAndParagraphs( - model, - false /*includingFormatHolder*/, - true /*includingEntity*/ - ); - const selection = isReverted ? selections[0] : selections[selections.length - 1]; - - if (selection?.[1]) { - const [segment, paragraph] = selection; - const movingBefore = - (rawEvent.key == 'ArrowLeft') != (paragraph.format.direction == 'rtl'); - const isShrinking = - rawEvent.shiftKey && - segment.segmentType != 'SelectionMarker' && - movingBefore != isReverted; - const index = paragraph.segments.indexOf(segment); - const targetIndex = isShrinking - ? index - : index >= 0 - ? movingBefore - ? index - 1 - : index + 1 - : -1; - const targetSegment = targetIndex >= 0 ? paragraph.segments[targetIndex] : null; - - if (targetSegment?.segmentType == 'Entity') { - if (rawEvent.shiftKey) { - targetSegment.isSelected = !isShrinking; - - if (!isShrinking && movingBefore) { - model.hasRevertedRangeSelection = true; - } - } - - if (!rawEvent.shiftKey || (isShrinking && selections.length == 1)) { - const formatSegment = - paragraph.segments[movingBefore ? targetIndex - 1 : targetIndex + 1]; - const marker = createSelectionMarker( - formatSegment?.format ?? targetSegment.format - ); - - paragraph.segments.splice( - movingBefore ? targetIndex : targetIndex + 1, - 0, - marker - ); - - setSelection(model, marker); - } - - rawEvent.preventDefault(); - - return true; - } else { - return false; - } - } - - return false; - }); -} - /** * @internal Exported Only for unit test * @returns diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts b/packages/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts index 2ad9159da11..fb4b5612e17 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts @@ -102,13 +102,17 @@ export function generateEntityClassNames(format: ContentModelEntityFormat): stri /** * Checks whether the node provided is a Entity delimiter * @param node the node to check + * @param isBefore True to match delimiter before entity only, false to match delimiter after entity, or undefined means match both * @return true if it is a delimiter */ -export function isEntityDelimiter(element: HTMLElement): boolean { +export function isEntityDelimiter(element: HTMLElement, isBefore?: boolean): boolean { + const matchBefore = isBefore === undefined || isBefore; + const matchAfter = isBefore === undefined || !isBefore; + return ( isElementOfType(element, 'span') && - (element.classList.contains(DELIMITER_AFTER) || - element.classList.contains(DELIMITER_BEFORE)) && + ((matchAfter && element.classList.contains(DELIMITER_AFTER)) || + (matchBefore && element.classList.contains(DELIMITER_BEFORE))) && element.textContent === ZERO_WIDTH_SPACE ); } From b0d9e7460c3c0aaac16db114a0b82d42c9c71fb3 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 23 Apr 2024 14:52:24 -0700 Subject: [PATCH 4/5] Add test --- .../utils/formatSegmentWithContentModel.ts | 3 +- .../restoreSnapshotHTML.ts | 7 +- .../entity/adjustSelectionAroundEntity.ts | 157 +++ .../corePlugin/entity/entityDelimiterUtils.ts | 111 +- .../entity/adjustSelectionAroundEntityTest.ts | 970 ++++++++++++++++++ .../entity/entityDelimiterUtilsTest.ts | 502 ++------- .../lib/domUtils/entityUtils.ts | 27 +- .../roosterjs-content-model-dom/lib/index.ts | 2 + .../modelApi/selection/collectSelections.ts | 12 +- .../selection/collectSelectionsTest.ts | 32 +- 10 files changed, 1283 insertions(+), 540 deletions(-) create mode 100644 packages/roosterjs-content-model-core/lib/corePlugin/entity/adjustSelectionAroundEntity.ts create mode 100644 packages/roosterjs-content-model-core/test/corePlugin/entity/adjustSelectionAroundEntityTest.ts diff --git a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts index 25dac400525..8031a24b6d6 100644 --- a/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts +++ b/packages/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts @@ -46,11 +46,12 @@ export function formatSegmentWithContentModel( if (isCollapsedSelection) { const para = segmentAndParagraphs[0][1]; + const path = segmentAndParagraphs[0][2]; segmentAndParagraphs = adjustWordSelection( model, segmentAndParagraphs[0][0] - ).map(x => [x, para]); + ).map(x => [x, para, path]); if (segmentAndParagraphs.length > 1) { isCollapsedSelection = false; diff --git a/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML.ts b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML.ts index 8d88512ca4e..96556c0e296 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML.ts @@ -1,5 +1,6 @@ import { getAllEntityWrappers, + isBlockEntityContainer, isEntityElement, isNodeOfType, parseEntityFormat, @@ -7,8 +8,6 @@ import { } from 'roosterjs-content-model-dom'; import type { Snapshot, EditorCore, KnownEntityItem } from 'roosterjs-content-model-types'; -const BlockEntityContainer = '_E_EBlockEntityContainer'; - /** * @internal */ @@ -85,10 +84,6 @@ function tryGetEntityElement( return result; } -function isBlockEntityContainer(node: HTMLElement) { - return node.classList.contains(BlockEntityContainer); -} - function tryGetEntityFromContainer( element: HTMLElement, entityMap: Record diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/entity/adjustSelectionAroundEntity.ts b/packages/roosterjs-content-model-core/lib/corePlugin/entity/adjustSelectionAroundEntity.ts new file mode 100644 index 00000000000..103d3ee68d2 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/corePlugin/entity/adjustSelectionAroundEntity.ts @@ -0,0 +1,157 @@ +import { + getSelectedSegmentsAndParagraphs, + isBlockEntityContainer, + isEntityDelimiter, + isNodeOfType, +} from 'roosterjs-content-model-dom'; +import type { + ContentModelBlockGroup, + ContentModelEntity, + ContentModelParagraph, + ContentModelSegment, + IEditor, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function adjustSelectionAroundEntity( + editor: IEditor, + key: 'ArrowLeft' | 'ArrowRight', + shiftKey: boolean +) { + const selection = editor.isDisposed() ? null : editor.getDOMSelection(); + + if (!selection || selection.type != 'range') { + return; + } + + const { range, isReverted } = selection; + const anchorNode = isReverted ? range.startContainer : range.endContainer; + const offset = isReverted ? range.startOffset : range.endOffset; + const delimiter = isNodeOfType(anchorNode, 'ELEMENT_NODE') + ? anchorNode + : anchorNode.parentElement; + const isRtl = + delimiter && + editor.getDocument().defaultView?.getComputedStyle(delimiter).direction == 'rtl'; + const movingBefore = (key == 'ArrowLeft') != !!isRtl; + + if ( + delimiter && + ((isEntityDelimiter(delimiter, !movingBefore) && + ((movingBefore && offset == 0) || (!movingBefore && offset == 1))) || + isBlockEntityContainer(delimiter)) + ) { + editor.formatContentModel(model => { + const allSel = getSelectedSegmentsAndParagraphs( + model, + false /*includingFormatHolder*/, + true + ); + const sel = allSel[isReverted ? 0 : allSel.length - 1]; + const index = sel?.[1]?.segments.indexOf(sel[0]) ?? -1; + + if (sel && sel[1] && index >= 0) { + const [segment, paragraph, path] = sel; + const isShrinking = shiftKey && !range.collapsed && movingBefore != !!isReverted; + const entitySegment = isShrinking + ? segment + : paragraph.segments[movingBefore ? index - 1 : index + 1]; + + const pairedDelimiter = findPairedDelimiter( + entitySegment, + path, + paragraph, + movingBefore + ); + + if (pairedDelimiter) { + const newRange = getNewRange( + range, + isShrinking, + movingBefore, + pairedDelimiter, + shiftKey + ); + + editor.setDOMSelection({ + type: 'range', + range: newRange, + isReverted: newRange.collapsed ? false : isReverted, + }); + } + } + + return false; + }); + } +} + +function getNewRange( + originalRange: Range, + isShrinking: boolean, + movingBefore: boolean, + pairedDelimiter: HTMLElement, + shiftKey: boolean +) { + const newRange = originalRange.cloneRange(); + + if (isShrinking) { + if (movingBefore) { + newRange.setEndBefore(pairedDelimiter); + } else { + newRange.setStartAfter(pairedDelimiter); + } + } else { + if (movingBefore) { + newRange.setStartBefore(pairedDelimiter); + } else { + newRange.setEndAfter(pairedDelimiter); + } + if (!shiftKey) { + if (movingBefore) { + newRange.setEndBefore(pairedDelimiter); + } else { + newRange.setStartAfter(pairedDelimiter); + } + } + } + + return newRange; +} + +function findPairedDelimiter( + entitySegment: ContentModelSegment, + path: ContentModelBlockGroup[], + paragraph: ContentModelParagraph, + movingBefore: boolean +) { + let entity: ContentModelEntity | null = null; + + if (entitySegment?.segmentType == 'Entity') { + // Inline entity + entity = entitySegment; + } else { + // Block entity + const blocks = path[0].blocks; + const paraIndex = blocks.indexOf(paragraph); + const entityBlock = + paraIndex >= 0 ? blocks[movingBefore ? paraIndex - 1 : paraIndex + 1] : null; + + if (entityBlock?.blockType == 'Entity') { + entity = entityBlock; + } + } + + const pairedDelimiter = entity + ? movingBefore + ? entity.wrapper.previousElementSibling + : entity.wrapper.nextElementSibling + : null; + + return isNodeOfType(pairedDelimiter, 'ELEMENT_NODE') && + isEntityDelimiter(pairedDelimiter, movingBefore) + ? pairedDelimiter + : null; +} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts b/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts index 19a4844db67..e86692c91a2 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts @@ -1,3 +1,4 @@ +import { adjustSelectionAroundEntity } from './adjustSelectionAroundEntity'; import { normalizePos } from '../selection/normalizePos'; import { addDelimiters, @@ -11,7 +12,7 @@ import { findClosestEntityWrapper, iterateSelections, isCharacterValue, - getSelectedSegmentsAndParagraphs, + findClosestBlockEntityContainer, } from 'roosterjs-content-model-dom'; import type { CompositionEndEvent, @@ -30,8 +31,6 @@ const DelimiterSelector = '.' + DelimiterAfter + ',.' + DelimiterBefore; const ZeroWidthSpace = '\u200B'; const EntityInfoName = '_Entity'; const InlineEntitySelector = 'span.' + EntityInfoName; -const BlockEntityContainer = '_E_EBlockEntityContainer'; -const BlockEntityContainerSelector = '.' + BlockEntityContainer; /** * @internal exported only for unit test @@ -200,10 +199,11 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent return; } - const { rawEvent } = event; - const { range } = selection; + const rawEvent = event.rawEvent; + const range = selection.range; + const key = rawEvent.key; - switch (rawEvent.key) { + switch (key) { case 'Enter': if (range.collapsed) { handleInputOnDelimiter(editor, range, getFocusedElement(selection), rawEvent); @@ -223,7 +223,12 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent case 'ArrowLeft': case 'ArrowRight': - handleMovingOnDelimiter(editor, rawEvent); + if (!rawEvent.altKey && !rawEvent.ctrlKey && !rawEvent.metaKey) { + // Handle in async so focus is already moved, this makes us easier to check if we should adjust the selection + editor.getDocument().defaultView?.requestAnimationFrame(() => { + adjustSelectionAroundEntity(editor, key, rawEvent.shiftKey); + }); + } break; default: @@ -235,96 +240,6 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent } } -function handleMovingOnDelimiter(editor: IEditor, rawEvent: KeyboardEvent) { - editor.getDocument().defaultView?.requestAnimationFrame(() => { - if (editor.isDisposed()) { - return; - } - - const selection = editor.getDOMSelection(); - - if (!selection || selection.type != 'range') { - return; - } - - const { range, isReverted } = selection; - const anchorNode = isReverted ? range.startContainer : range.endContainer; - const offset = isReverted ? range.startOffset : range.endOffset; - const delimiter = isNodeOfType(anchorNode, 'ELEMENT_NODE') - ? anchorNode - : anchorNode.parentElement; - const isRtl = - delimiter && - editor.getDocument().defaultView?.getComputedStyle(delimiter).direction == 'rtl'; - const movingBefore = (rawEvent.key == 'ArrowLeft') != !!isRtl; - - if ( - delimiter && - isEntityDelimiter(delimiter, !movingBefore) && - ((movingBefore && offset == 0) || (!movingBefore && offset == 1)) - ) { - editor.formatContentModel(model => { - const allSel = getSelectedSegmentsAndParagraphs( - model, - false /*includingFormatHolder*/, - true - ); - const sel = allSel[isReverted ? 0 : allSel.length - 1]; - const index = sel?.[1]?.segments.indexOf(sel[0]) ?? -1; - - if (sel && sel[1] && index >= 0) { - const isShrinking = - rawEvent.shiftKey && !range.collapsed && movingBefore != !!isReverted; - const entity = isShrinking - ? sel[0] - : sel[1].segments[movingBefore ? index - 1 : index + 1]; - const pairedDelimiter = - entity?.segmentType == 'Entity' - ? movingBefore - ? entity.wrapper.previousElementSibling - : entity.wrapper.nextElementSibling - : null; - - if ( - pairedDelimiter && - isEntityDelimiter(pairedDelimiter as HTMLElement, movingBefore) - ) { - const newRange = range.cloneRange(); - - if (isShrinking) { - if (movingBefore) { - newRange.setEndBefore(pairedDelimiter); - } else { - newRange.setStartAfter(pairedDelimiter); - } - } else { - if (movingBefore) { - newRange.setStartBefore(pairedDelimiter); - } else { - newRange.setEndAfter(pairedDelimiter); - } - if (!rawEvent.shiftKey) { - if (movingBefore) { - newRange.setEndBefore(pairedDelimiter); - } else { - newRange.setStartAfter(pairedDelimiter); - } - } - } - editor.setDOMSelection({ - type: 'range', - range: newRange, - isReverted: newRange.collapsed ? false : isReverted, - }); - } - } - - return false; - }); - } - }); -} - function handleInputOnDelimiter( editor: IEditor, range: Range, @@ -334,7 +249,7 @@ function handleInputOnDelimiter( const helper = editor.getDOMHelper(); if (focusedNode && isEntityDelimiter(focusedNode) && helper.isNodeInEditor(focusedNode)) { - const blockEntityContainer = focusedNode.closest(BlockEntityContainerSelector); + const blockEntityContainer = findClosestBlockEntityContainer(focusedNode, helper); const isEnter = rawEvent.key === 'Enter'; if (blockEntityContainer && helper.isNodeInEditor(blockEntityContainer)) { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/entity/adjustSelectionAroundEntityTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/entity/adjustSelectionAroundEntityTest.ts new file mode 100644 index 00000000000..9c9d8705537 --- /dev/null +++ b/packages/roosterjs-content-model-core/test/corePlugin/entity/adjustSelectionAroundEntityTest.ts @@ -0,0 +1,970 @@ +import { adjustSelectionAroundEntity } from '../../../lib/corePlugin/entity/adjustSelectionAroundEntity'; +import { ContentModelDocument, DOMSelection, IEditor } from 'roosterjs-content-model-types'; +import { + createContentModelDocument, + createDomToModelContext, + createParagraph, + createSelectionMarker, + domToContentModel, +} from 'roosterjs-content-model-dom'; + +describe('adjustSelectionAroundEntity', () => { + let editor: IEditor; + let formatContentModelSpy: jasmine.Spy; + let isDisposedSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let getComputedStyleSpy: jasmine.Spy; + let setStartBeforeSpy: jasmine.Spy; + let setEndBeforeSpy: jasmine.Spy; + let setStartAfterSpy: jasmine.Spy; + let setEndAfterSpy: jasmine.Spy; + let cloneRangeSpy: jasmine.Spy; + let setDOMSelectionSpy: jasmine.Spy; + let mockedRange: Range; + + beforeEach(() => { + formatContentModelSpy = jasmine.createSpy('formatContentModel'); + isDisposedSpy = jasmine.createSpy('isDisposed'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + getComputedStyleSpy = jasmine.createSpy('getComputedStyle').and.returnValue({ + direction: 'ltr', + }); + + setStartBeforeSpy = jasmine.createSpy('setStartBefore'); + setStartAfterSpy = jasmine.createSpy('setStartAfter'); + setEndBeforeSpy = jasmine.createSpy('setEndBefore'); + setEndAfterSpy = jasmine.createSpy('setEndAfter'); + + mockedRange = { + setStartBefore: setStartBeforeSpy, + setStartAfter: setStartAfterSpy, + setEndBefore: setEndBeforeSpy, + setEndAfter: setEndAfterSpy, + } as any; + + cloneRangeSpy = jasmine.createSpy('cloneRange').and.returnValue(mockedRange); + setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + + editor = { + formatContentModel: formatContentModelSpy, + isDisposed: isDisposedSpy, + getDOMSelection: getDOMSelectionSpy, + getDocument: () => ({ + defaultView: { + getComputedStyle: getComputedStyleSpy, + }, + }), + setDOMSelection: setDOMSelectionSpy, + } as any; + }); + + function runTest( + model: ContentModelDocument, + key: 'ArrowLeft' | 'ArrowRight', + shiftKey: boolean, + selection: DOMSelection | null, + formatCalled: boolean + ) { + formatContentModelSpy.and.callFake((callback: Function) => { + const result = callback(model); + + expect(result).toBeFalse(); + }); + getDOMSelectionSpy.and.returnValue(selection); + + adjustSelectionAroundEntity(editor, key, shiftKey); + + if (formatCalled) { + expect(formatContentModelSpy).toHaveBeenCalled(); + } else { + expect(formatContentModelSpy).not.toHaveBeenCalled(); + } + } + + it('Editor is disposed', () => { + isDisposedSpy.and.returnValue(true); + + runTest(null!, 'ArrowLeft', false, null, false); + }); + + it('Empty model, no selection, Left, no shift', () => { + runTest( + { + blockGroupType: 'Document', + blocks: [], + }, + 'ArrowLeft', + false, + null, + false + ); + }); + + it('Model has no entity, no selection, Left, no shift', () => { + const marker = createSelectionMarker(); + const para = createParagraph(); + const doc = createContentModelDocument(); + + para.segments.push(marker); + doc.blocks.push(para); + + runTest(doc, 'ArrowLeft', false, null, false); + }); + + it('Model has inline entity, selection is before delimiter, Right, no shift', () => { + const doc = createContentModelDocument(); + + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + addEntity(root, false); + + runTest( + doc, + 'ArrowRight', + false, + { + type: 'range', + range: { + endContainer: text, + endOffset: 2, + } as any, + isReverted: false, + }, + false + ); + }); + + it('Model has inline entity, selection is after delimiter, Left, no shift', () => { + const doc = createContentModelDocument(); + + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + addEntity(root, false); + root.appendChild(text); + + runTest( + doc, + 'ArrowLeft', + false, + { + type: 'range', + range: { + endContainer: text, + endOffset: 2, + } as any, + isReverted: false, + }, + false + ); + }); + + it('Model has inline entity, selection is on delimiter 1, Right, no shift', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + endContainer: delimiter1.firstChild, + endOffset: 1, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', false, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).toHaveBeenCalledWith(delimiter2); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).toHaveBeenCalledWith(delimiter2); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + isReverted: false, + range: mockedRange, + }); + }); + + it('Model has inline entity, selection is on delimiter 2, Left, no shift', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + const { delimiter1, delimiter2 } = addEntity(root, false); + root.appendChild(text); + + const selection: DOMSelection = { + type: 'range', + range: { + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', false, selection, true); + + expect(setStartBeforeSpy).toHaveBeenCalledWith(delimiter1); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).toHaveBeenCalledWith(delimiter1); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + isReverted: false, + range: mockedRange, + }); + }); + + it('Model has inline entity, selection is on delimiter 1, Shift+Right', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + endContainer: delimiter1.firstChild, + endOffset: 1, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', true, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).toHaveBeenCalledWith(delimiter2); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + isReverted: false, + range: mockedRange, + }); + }); + + it('Model has inline entity, selection is on delimiter 1, Shift+Right, reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 1, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', true, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).toHaveBeenCalledWith(delimiter2); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + isReverted: true, + range: mockedRange, + }); + }); + + it('Model has inline entity, selection is on delimiter 2, Shift+Left', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + const { delimiter2 } = addEntity(root, false); + root.appendChild(text); + + const selection: DOMSelection = { + type: 'range', + range: { + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', true, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on delimiter 2, Shift+Left, reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + const { delimiter1, delimiter2 } = addEntity(root, false); + root.appendChild(text); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter2.firstChild, + startOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', true, selection, true); + + expect(setStartBeforeSpy).toHaveBeenCalledWith(delimiter1); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + isReverted: true, + range: mockedRange, + }); + }); + + it('Model has inline entity, selection is on entity, Left', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest( + doc, + 'ArrowLeft', + false, + { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }, + true + ); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on entity, Left, reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest( + doc, + 'ArrowLeft', + false, + { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }, + true + ); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on entity, Right', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest( + doc, + 'ArrowRight', + false, + { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }, + false + ); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on entity, Right, reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest( + doc, + 'ArrowRight', + false, + { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }, + false + ); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on entity, Shift+Left', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', true, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).toHaveBeenCalledWith(delimiter1); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + isReverted: false, + range: mockedRange, + }); + }); + + it('Model has inline entity, selection is on entity, Shift+Left, Reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', true, selection, false); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on entity, Shift+Right', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', true, selection, false); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on entity, Shift+Right, Reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', true, selection, false); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on text and entity, Shift+Left', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter1, delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: text, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', true, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).toHaveBeenCalledWith(delimiter1); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + isReverted: false, + range: mockedRange, + }); + }); + + it('Model has inline entity, selection is on text and entity, Shift+Left, Reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + const { delimiter1 } = addEntity(root, false); + root.appendChild(text); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 1, + endContainer: text, + endOffset: 4, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', true, selection, false); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on text and entity, Shift+Right', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + root.appendChild(text); + const { delimiter2 } = addEntity(root, false); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: text, + startOffset: 0, + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', true, selection, false); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Model has inline entity, selection is on text and entity, Shift+Right, Reverted', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + const { delimiter1, delimiter2 } = addEntity(root, false); + root.appendChild(text); + + const selection: DOMSelection = { + type: 'range', + range: { + startContainer: delimiter1.firstChild, + startOffset: 1, + endContainer: text, + endOffset: 4, + cloneRange: cloneRangeSpy, + } as any, + isReverted: true, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', true, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).toHaveBeenCalledWith(delimiter2); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: true, + }); + }); + + it('Model has block entity, selection is on delimiter 1, Right', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + const { delimiter1, delimiter2 } = addEntity(root, true); + root.appendChild(text); + + const selection: DOMSelection = { + type: 'range', + range: { + endContainer: delimiter1.firstChild, + endOffset: 1, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowRight', false, selection, true); + + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).toHaveBeenCalledWith(delimiter2); + expect(setEndBeforeSpy).not.toHaveBeenCalled(); + expect(setEndAfterSpy).toHaveBeenCalledWith(delimiter2); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + }); + + it('Model has block entity, selection is on delimiter 2, Left', () => { + let root = document.createElement('div'); + let text = document.createTextNode('test'); + + const { delimiter1, delimiter2 } = addEntity(root, true); + root.appendChild(text); + + const selection: DOMSelection = { + type: 'range', + range: { + endContainer: delimiter2.firstChild, + endOffset: 0, + cloneRange: cloneRangeSpy, + } as any, + isReverted: false, + }; + + const context = createDomToModelContext(); + + context.selection = selection; + + const doc = domToContentModel(root, context); + + runTest(doc, 'ArrowLeft', false, selection, true); + + expect(setStartBeforeSpy).toHaveBeenCalledWith(delimiter1); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setEndBeforeSpy).toHaveBeenCalledWith(delimiter1); + expect(setEndAfterSpy).not.toHaveBeenCalledWith(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + }); +}); + +function addEntity( + parent: HTMLElement, + isBlock: boolean +): { delimiter1: HTMLElement; delimiter2: HTMLElement; container: HTMLElement | null } { + function addDelimiter(parent: HTMLElement, isBefore: boolean) { + const span = document.createElement('span'); + span.className = isBefore ? 'entityDelimiterBefore' : 'entityDelimiterAfter'; + span.textContent = '\u200B'; + parent.appendChild(span); + + return span; + } + + const wrapper = document.createElement(isBlock ? 'div' : 'span'); + wrapper.className = '_Entity _EType_A _EReadonly_1'; + + if (isBlock) { + wrapper.style.display = 'inline-block'; + wrapper.style.width = '100%'; + + const container = document.createElement('div'); + + container.className = '_E_EBlockEntityContainer'; + parent.appendChild(container); + + const delimiter1 = addDelimiter(container, true); + + container.appendChild(wrapper); + + const delimiter2 = addDelimiter(container, false); + + return { delimiter1, delimiter2, container }; + } else { + const delimiter1 = addDelimiter(parent, true); + + parent.appendChild(wrapper); + + const delimiter2 = addDelimiter(parent, false); + + return { delimiter1, delimiter2, container: null }; + } +} diff --git a/packages/roosterjs-content-model-core/test/corePlugin/entity/entityDelimiterUtilsTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/entity/entityDelimiterUtilsTest.ts index 3e24dda3aed..aa40486771e 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/entity/entityDelimiterUtilsTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/entity/entityDelimiterUtilsTest.ts @@ -1,3 +1,4 @@ +import * as adjustSelectionAroundEntity from '../../../lib/corePlugin/entity/adjustSelectionAroundEntity'; import * as DelimiterFile from '../../../lib/corePlugin/entity/entityDelimiterUtils'; import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; import * as isNodeOfType from 'roosterjs-content-model-dom/lib/domUtils/isNodeOfType'; @@ -19,7 +20,10 @@ describe('EntityDelimiterUtils |', () => { let queryElementsSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; let mockedEditor: any; + beforeEach(() => { + queryElementsSpy = jasmine.createSpy('queryElement'); + mockedEditor = ({ getDOMHelper: () => ({ queryElements: queryElementsSpy, @@ -59,9 +63,7 @@ describe('EntityDelimiterUtils |', () => { addDelimiterForEntity: true, }) ); - queryElementsSpy = jasmine - .createSpy('queryElement') - .and.callFake(sel => div.querySelectorAll(sel)); + queryElementsSpy.and.callFake(sel => div.querySelectorAll(sel)); entityWrapper.remove(); @@ -98,9 +100,7 @@ describe('EntityDelimiterUtils |', () => { }, createModelToDomContext({}) ); - queryElementsSpy = jasmine - .createSpy('queryElement') - .and.callFake(sel => div.querySelectorAll(sel)); + queryElementsSpy.and.callFake(sel => div.querySelectorAll(sel)); handleDelimiterContentChangedEvent(mockedEditor); @@ -137,9 +137,7 @@ describe('EntityDelimiterUtils |', () => { addDelimiterForEntity: true, }) ); - queryElementsSpy = jasmine - .createSpy('queryElement') - .and.callFake(sel => div.querySelectorAll(sel)); + queryElementsSpy.and.callFake(sel => div.querySelectorAll(sel)); const invalidDelimiter = entityWrapper.previousElementSibling; invalidDelimiter?.appendChild(document.createTextNode('_')); @@ -159,6 +157,7 @@ describe('EntityDelimiterUtils |', () => { let rafSpy: jasmine.Spy; let takeSnapshotSpy: jasmine.Spy; let triggerEventSpy: jasmine.Spy; + let findClosestElementAncestorSpy: jasmine.Spy; beforeEach(() => { mockedSelection = undefined!; @@ -166,6 +165,9 @@ describe('EntityDelimiterUtils |', () => { formatContentModelSpy = jasmine.createSpy('formatContentModel'); takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); triggerEventSpy = jasmine.createSpy('triggerEvent'); + findClosestElementAncestorSpy = jasmine + .createSpy('findClosestElementAncestor') + .and.callFake((node: HTMLElement, selector: string) => node.closest(selector)); mockedEditor = ({ getDOMSelection: () => mockedSelection, @@ -179,6 +181,7 @@ describe('EntityDelimiterUtils |', () => { getDOMHelper: () => ({ queryElements: queryElementsSpy, isNodeInEditor: () => true, + findClosestElementAncestor: findClosestElementAncestorSpy, }), triggerEvent: triggerEventSpy, takeSnapshot: takeSnapshotSpy, @@ -631,6 +634,80 @@ describe('EntityDelimiterUtils |', () => { rawEvent: mockedEvent, }); }); + + it('Handle, range selection | ArrowLeft Key', () => { + mockedSelection = { + type: 'range', + range: { + collapsed: true, + }, + isReverted: false, + }; + spyOn(mockedEditor, 'getDOMSelection').and.returnValue({ + type: 'range', + range: mockedSelection.range, + }); + const adjustSelectionAroundEntitySpy = spyOn( + adjustSelectionAroundEntity, + 'adjustSelectionAroundEntity' + ); + + const mockedEvent = { + ctrlKey: false, + altKey: false, + metaKey: false, + shiftKey: false, + key: 'ArrowLeft', + }; + + rafSpy.and.callFake((callback: Function) => callback()); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: mockedEvent, + }); + + expect(adjustSelectionAroundEntitySpy).toHaveBeenCalledWith( + mockedEditor, + 'ArrowLeft', + false + ); + }); + + it('Do not Handle, range selection | Ctrl+ArrowLeft Key', () => { + mockedSelection = { + type: 'range', + range: { + collapsed: true, + }, + isReverted: false, + }; + spyOn(mockedEditor, 'getDOMSelection').and.returnValue({ + type: 'range', + range: mockedSelection.range, + }); + const adjustSelectionAroundEntitySpy = spyOn( + adjustSelectionAroundEntity, + 'adjustSelectionAroundEntity' + ); + + const mockedEvent = { + ctrlKey: true, + altKey: false, + metaKey: false, + shiftKey: false, + key: 'ArrowLeft', + }; + + rafSpy.and.callFake((callback: Function) => callback()); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: mockedEvent, + }); + + expect(adjustSelectionAroundEntitySpy).not.toHaveBeenCalled(); + }); }); }); @@ -1365,410 +1442,3 @@ describe('handleEnterInlineEntity', () => { }); }); }); - -describe('Move cursor in delimiter', () => { - let mockedEditor: any; - let context: any; - let getDOMSelectionSpy: jasmine.Spy; - let formatContentModelSpy: jasmine.Spy; - const entityWrapper = document.createElement('span'); - - beforeEach(() => { - context = {}; - - getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); - formatContentModelSpy = jasmine.createSpy('formatContentModel'); - mockedEditor = { - formatContentModel: formatContentModelSpy, - getDOMSelection: getDOMSelectionSpy, - } as Partial; - }); - - function runTest( - inputModel: ContentModelDocument, - key: string, - shiftKey: boolean, - isReverted: boolean, - expectedModel: ContentModelDocument, - defaultPrevented: boolean - ) { - const preventDefaultSpy = jasmine.createSpy('preventDefault'); - - getDOMSelectionSpy.and.returnValue({ - type: 'range', - range: { collapsed: true }, - isReverted, - }); - formatContentModelSpy.and.callFake(formatter => { - const result = formatter(inputModel, context); - - expect(result).toBe(defaultPrevented); - }); - - handleDelimiterKeyDownEvent(mockedEditor, { - eventType: 'keyDown', - rawEvent: { - key, - shiftKey, - preventDefault: preventDefaultSpy, - } as any, - }); - - expect(preventDefaultSpy).toHaveBeenCalledTimes(defaultPrevented ? 1 : 0); - expect(inputModel).toEqual(expectedModel); - expect(formatContentModelSpy).toHaveBeenCalledTimes(1); - } - - it('After entity, move left, LTR', () => { - runTest( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - createEntity(entityWrapper, true), - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - }, - ], - }, - 'ArrowLeft', - false, - false, - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { - segmentType: 'Entity', - blockType: 'Entity', - format: {}, - entityFormat: { - id: undefined, - entityType: undefined, - isReadonly: true, - }, - wrapper: entityWrapper, - }, - ], - }, - ], - }, - true - ); - }); - - it('Before entity, move right, LTR', () => { - runTest( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - createEntity(entityWrapper, true), - ], - format: {}, - }, - ], - }, - 'ArrowRight', - false, - false, - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Entity', - blockType: 'Entity', - format: {}, - entityFormat: { - id: undefined, - entityType: undefined, - isReadonly: true, - }, - wrapper: entityWrapper, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - }, - ], - }, - true - ); - }); - - it('Before entity, move shift+right, LTR', () => { - runTest( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - createEntity(entityWrapper, true), - ], - format: {}, - }, - ], - }, - 'ArrowRight', - true, - false, - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { segmentType: 'SelectionMarker', isSelected: true, format: {} }, - { - segmentType: 'Entity', - blockType: 'Entity', - format: {}, - entityFormat: { - isReadonly: true, - id: undefined, - entityType: undefined, - }, - wrapper: entityWrapper, - isSelected: true, - }, - ], - format: {}, - }, - ], - }, - true - ); - }); - - it('After entity, move shift+left, LTR', () => { - runTest( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - createEntity(entityWrapper, true), - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - }, - ], - }, - 'ArrowLeft', - true, - true, - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Entity', - blockType: 'Entity', - format: {}, - entityFormat: { - isReadonly: true, - id: undefined, - entityType: undefined, - }, - wrapper: entityWrapper, - isSelected: true, - }, - { segmentType: 'SelectionMarker', isSelected: true, format: {} }, - ], - format: {}, - }, - ], - hasRevertedRangeSelection: true, - }, - true - ); - }); - - it('Entity is selected, move right, not reverted, LTR', () => { - const entity = createEntity(entityWrapper, true); - - entity.isSelected = true; - - runTest( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [entity], - format: {}, - }, - ], - }, - 'ArrowRight', - true, - false, - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Entity', - blockType: 'Entity', - format: {}, - entityFormat: { - isReadonly: true, - id: undefined, - entityType: undefined, - }, - wrapper: entityWrapper, - isSelected: true, - }, - ], - format: {}, - }, - ], - }, - false - ); - }); - - it('Entity is selected, move left, not reverted, LTR', () => { - const entity = createEntity(entityWrapper, true); - - entity.isSelected = true; - - runTest( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [entity], - format: {}, - }, - ], - }, - 'ArrowLeft', - true, - false, - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Entity', - blockType: 'Entity', - format: {}, - entityFormat: { - isReadonly: true, - id: undefined, - entityType: undefined, - }, - wrapper: entityWrapper, - }, - ], - format: {}, - }, - ], - }, - true - ); - }); - - it('Entity is selected, move right, reverted, LTR', () => { - const entity = createEntity(entityWrapper, true); - - entity.isSelected = true; - - runTest( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [entity], - format: {}, - }, - ], - }, - 'ArrowRight', - true, - true, - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Entity', - blockType: 'Entity', - format: {}, - entityFormat: { - isReadonly: true, - id: undefined, - entityType: undefined, - }, - wrapper: entityWrapper, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - format: {}, - }, - ], - }, - true - ); - }); -}); diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts b/packages/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts index fb4b5612e17..bca216c8496 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts @@ -10,12 +10,15 @@ import type { } from 'roosterjs-content-model-types'; const ENTITY_INFO_NAME = '_Entity'; +const ENTITY_INFO_SELECTOR = '.' + ENTITY_INFO_NAME; const ENTITY_TYPE_PREFIX = '_EType_'; const ENTITY_ID_PREFIX = '_EId_'; const ENTITY_READONLY_PREFIX = '_EReadonly_'; const ZERO_WIDTH_SPACE = '\u200B'; const DELIMITER_BEFORE = 'entityDelimiterBefore'; const DELIMITER_AFTER = 'entityDelimiterAfter'; +const BLOCK_ENTITY_CONTAINER = '_E_EBlockEntityContainer'; +const BLOCK_ENTITY_CONTAINER_SELECTOR = '.' + BLOCK_ENTITY_CONTAINER; /** * Check if the given DOM Node is an entity wrapper element @@ -33,7 +36,20 @@ export function findClosestEntityWrapper( startNode: Node, domHelper: DOMHelper ): HTMLElement | null { - return domHelper.findClosestElementAncestor(startNode, `.${ENTITY_INFO_NAME}`); + return domHelper.findClosestElementAncestor(startNode, ENTITY_INFO_SELECTOR); +} + +/** + * Find the closest block entity wrapper element from a given DOM node + * @param node The node to start looking for entity container + * @param domHelper The DOM helper + * @returns + */ +export function findClosestBlockEntityContainer( + node: Node, + domHelper: DOMHelper +): HTMLElement | null { + return domHelper.findClosestElementAncestor(node, BLOCK_ENTITY_CONTAINER_SELECTOR); } /** @@ -117,6 +133,15 @@ export function isEntityDelimiter(element: HTMLElement, isBefore?: boolean): boo ); } +/** + * Check if the given element is a container element of block entity + * @param element The element to check + * @returns True if the element is a block entity container, otherwise false + */ +export function isBlockEntityContainer(element: HTMLElement): boolean { + return isElementOfType(element, 'div') && element.classList.contains(BLOCK_ENTITY_CONTAINER); +} + /** * Adds delimiters to the element provided. If the delimiters already exists, will not be added * @param element the node to add the delimiters diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index 29880d5dd42..d2f8789135a 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -30,6 +30,8 @@ export { generateEntityClassNames, addDelimiters, isEntityDelimiter, + isBlockEntityContainer, + findClosestBlockEntityContainer, } from './domUtils/entityUtils'; export { reuseCachedElement } from './domUtils/reuseCachedElement'; export { isWhiteSpacePreserved } from './domUtils/isWhiteSpacePreserved'; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts b/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts index 10d77bf5181..e7c80919033 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/selection/collectSelections.ts @@ -25,13 +25,17 @@ export function getSelectedSegmentsAndParagraphs( model: ContentModelDocument, includingFormatHolder: boolean, includingEntity?: boolean -): [ContentModelSegment, ContentModelParagraph | null][] { +): [ContentModelSegment, ContentModelParagraph | null, ContentModelBlockGroup[]][] { const selections = collectSelections(model, { includeListFormatHolder: includingFormatHolder ? 'allSegments' : 'never', }); - const result: [ContentModelSegment, ContentModelParagraph | null][] = []; + const result: [ + ContentModelSegment, + ContentModelParagraph | null, + ContentModelBlockGroup[] + ][] = []; - selections.forEach(({ segments, block }) => { + selections.forEach(({ segments, block, path }) => { if (segments && ((includingFormatHolder && !block) || block?.blockType == 'Paragraph')) { segments.forEach(segment => { if ( @@ -39,7 +43,7 @@ export function getSelectedSegmentsAndParagraphs( segment.segmentType != 'Entity' || !segment.entityFormat.isReadonly ) { - result.push([segment, block?.blockType == 'Paragraph' ? block : null]); + result.push([segment, block?.blockType == 'Paragraph' ? block : null, path]); } }); } diff --git a/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts index 46186b3da50..1a3b5c23b47 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/selection/collectSelectionsTest.ts @@ -42,7 +42,11 @@ describe('getSelectedSegmentsAndParagraphs', () => { selections: SelectionInfo[], includingFormatHolder: boolean, includingEntity: boolean, - expectedResult: [ContentModelSegment, ContentModelParagraph | null][] + expectedResult: [ + ContentModelSegment, + ContentModelParagraph | null, + ContentModelBlockGroup[] + ][] ) { spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { selections.forEach(({ path, tableContext, block, segments }) => { @@ -89,10 +93,10 @@ describe('getSelectedSegmentsAndParagraphs', () => { false, false, [ - [s1, p1], - [s2, p1], - [s3, p2], - [s4, p2], + [s1, p1, []], + [s2, p1, []], + [s3, p2, []], + [s4, p2, []], ] ); }); @@ -144,8 +148,8 @@ describe('getSelectedSegmentsAndParagraphs', () => { true, false, [ - [s3, null], - [s4, null], + [s3, null, []], + [s4, null, []], ] ); }); @@ -186,10 +190,10 @@ describe('getSelectedSegmentsAndParagraphs', () => { true, false, [ - [m1, p1], - [s2, p2], - [s3, p2], - [m2, p3], + [m1, p1, []], + [s2, p2, []], + [s3, p2, []], + [m2, p3, []], ] ); }); @@ -211,7 +215,7 @@ describe('getSelectedSegmentsAndParagraphs', () => { ], false, false, - [[e2, p1]] + [[e2, p1, []]] ); }); @@ -233,8 +237,8 @@ describe('getSelectedSegmentsAndParagraphs', () => { false, true, [ - [e1, p1], - [e2, p1], + [e1, p1, []], + [e2, p1, []], ] ); }); From 4c1571af226dc4ca4fa63d9b2843f678d2241fce Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 23 Apr 2024 15:02:24 -0700 Subject: [PATCH 5/5] add test --- .../entity/adjustSelectionAroundEntity.ts | 2 +- .../test/domUtils/entityUtilTest.ts | 71 +++++++++++++++++-- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/entity/adjustSelectionAroundEntity.ts b/packages/roosterjs-content-model-core/lib/corePlugin/entity/adjustSelectionAroundEntity.ts index 103d3ee68d2..b7c1975da3f 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/entity/adjustSelectionAroundEntity.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/entity/adjustSelectionAroundEntity.ts @@ -47,7 +47,7 @@ export function adjustSelectionAroundEntity( const allSel = getSelectedSegmentsAndParagraphs( model, false /*includingFormatHolder*/, - true + true /*includingEntity*/ ); const sel = allSel[isReverted ? 0 : allSel.length - 1]; const index = sel?.[1]?.segments.indexOf(sel[0]) ?? -1; diff --git a/packages/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts b/packages/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts index 22a8d40d02c..c7d0c335acf 100644 --- a/packages/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts +++ b/packages/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts @@ -1,9 +1,12 @@ import { ContentModelEntityFormat } from 'roosterjs-content-model-types'; +import { createDOMHelper } from 'roosterjs-content-model-core/lib/editor/core/DOMHelperImpl'; import { addDelimiters, + findClosestBlockEntityContainer, findClosestEntityWrapper, generateEntityClassNames, getAllEntityWrappers, + isBlockEntityContainer, isEntityDelimiter, isEntityElement, parseEntityFormat, @@ -287,9 +290,7 @@ describe('findClosestEntityWrapper', () => { div.appendChild(span); - const result = findClosestEntityWrapper(span, { - findClosestElementAncestor: (): null => null, - } as any); + const result = findClosestEntityWrapper(span, createDOMHelper(div)); expect(result).toBeNull(); }); @@ -299,13 +300,71 @@ describe('findClosestEntityWrapper', () => { const span = document.createElement('span'); const wrapper = document.createElement('div'); + wrapper.className = '_Entity'; + div.appendChild(wrapper); wrapper.appendChild(span); - const result = findClosestEntityWrapper(span, { - findClosestElementAncestor: (): HTMLElement => wrapper, - } as any); + const result = findClosestEntityWrapper(span, createDOMHelper(div)); expect(result).toBe(wrapper); }); }); + +describe('findClosestBlockEntityContainer', () => { + it('no container', () => { + const div = document.createElement('div'); + const span = document.createElement('span'); + + div.appendChild(span); + + const result = findClosestBlockEntityContainer(span, createDOMHelper(div)); + + expect(result).toBeNull(); + }); + + it('has container', () => { + const div = document.createElement('div'); + const container = document.createElement('div'); + const wrapper = document.createElement('div'); + + container.className = '_E_EBlockEntityContainer'; + + div.appendChild(container); + container.appendChild(wrapper); + + const result = findClosestBlockEntityContainer(wrapper, createDOMHelper(div)); + + expect(result).toBe(container); + }); +}); + +describe('isBlockEntityContainer', () => { + it('DIV without container class', () => { + const div = document.createElement('div'); + + const result = isBlockEntityContainer(div); + + expect(result).toBeFalse(); + }); + + it('SPAN with container class', () => { + const span = document.createElement('span'); + + span.className = '_E_EBlockEntityContainer'; + + const result = isBlockEntityContainer(span); + + expect(result).toBeFalse(); + }); + + it('DIV with container class', () => { + const div = document.createElement('div'); + + div.className = '_E_EBlockEntityContainer'; + + const result = isBlockEntityContainer(div); + + expect(result).toBeTrue(); + }); +});