From 5ec573e8cddb21ecb9ae3548798968e9140c0e6f Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 16 Apr 2024 17:11:42 -0700 Subject: [PATCH] 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', () => {