From f8a23bb492e1d08165f182dd27d6f9a3327d9cd4 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Thu, 6 Jun 2024 08:08:58 -0600 Subject: [PATCH 1/2] Merge Link & Image Format when using MergeModel (#2681) * mergeLinkFormat * Also fix for images * remove unneeded changes * Remove more unneeded changes * Address comment * nit --------- Co-authored-by: Jiuqing Song --- .../lib/modelApi/editing/mergeModel.ts | 60 ++++ .../test/modelApi/editing/mergeModelTest.ts | 302 ++++++++++++++++++ 2 files changed, 362 insertions(+) diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts index b440afd1f69..94ed4f65319 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts @@ -14,6 +14,7 @@ import type { ContentModelBlock, ContentModelBlockFormat, ContentModelDocument, + ContentModelHyperLinkFormat, ContentModelListItem, ContentModelParagraph, ContentModelSegmentFormat, @@ -28,6 +29,21 @@ import type { } from 'roosterjs-content-model-types'; const HeadingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; +// An object to provide keys of required properties of segment format, do NOT use any of its values +const RequiredEmptySegmentFormat: Required = { + backgroundColor: null!, + fontFamily: null!, + fontSize: null!, + fontWeight: null!, + italic: null!, + letterSpacing: null!, + lineHeight: null!, + strikethrough: null!, + superOrSubScriptSequence: null!, + textColor: null!, + underline: null!, +}; +const KeysOfSegmentFormat = getObjectKeys(RequiredEmptySegmentFormat); /** * Merge source model into target mode @@ -359,6 +375,14 @@ function applyDefaultFormat( ...paragraphFormat, ...segment.format, }); + + if (segment.link) { + segment.link.format = mergeSegmentFormat( + applyDefaultFormatOption, + getSegmentFormatInLinkFormat(format), + segment.link.format + ); + } }); if (applyDefaultFormatOption === 'keepSourceEmphasisFormat') { @@ -375,6 +399,27 @@ function mergeBlockFormat(applyDefaultFormatOption: string, block: ReadonlyConte } } +/** + * Hyperlink format type definition only contains textColor, backgroundColor and underline. + * So create a minimum object with the styles supported in Hyperlink to be used in merge. + */ +function getSegmentFormatInLinkFormat( + targetFormat: ContentModelSegmentFormat +): ContentModelSegmentFormat { + const result: ContentModelHyperLinkFormat = {}; + if (targetFormat.textColor) { + result.textColor = targetFormat.textColor; + } + if (targetFormat.backgroundColor) { + result.backgroundColor = targetFormat.backgroundColor; + } + if (targetFormat.underline) { + result.underline = targetFormat.underline; + } + + return result; +} + function mergeSegmentFormat( applyDefaultFormatOption: 'mergeAll' | 'keepSourceEmphasisFormat', targetFormat: ContentModelSegmentFormat, @@ -383,6 +428,7 @@ function mergeSegmentFormat( return applyDefaultFormatOption == 'mergeAll' ? { ...targetFormat, ...sourceFormat } : { + ...getFormatWithoutSegmentFormat(sourceFormat), ...targetFormat, ...getSemanticFormat(sourceFormat), }; @@ -405,3 +451,17 @@ function getSemanticFormat(segmentFormat: ContentModelSegmentFormat): ContentMod return result; } + +/** + * Segment format can also contain other type of metadata, for example in Images/Hyperlink, + * we want to preserve these properties when merging format + */ +function getFormatWithoutSegmentFormat( + sourceFormat: ContentModelSegmentFormat +): ContentModelSegmentFormat { + const resultFormat = { + ...sourceFormat, + }; + KeysOfSegmentFormat.forEach(key => delete resultFormat[key]); + return resultFormat; +} diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts index 538aaee4bd7..b22a2988f7b 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -3533,4 +3533,306 @@ describe('mergeModel', () => { tableContext: undefined, }); }); + + it('Merge Link Format with mergeAll option', () => { + const newTarget: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const mergeLinkSourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Work Item 222824', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + underline: true, + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + link: { + format: { + underline: true, + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + textColor: + 'var(--communication-foreground,rgba(0, 90, 158, 1))', + backgroundColor: 'rgb(32, 31, 30)', + borderRadius: '2px', + textAlign: 'start', + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'rgb(255, 255, 255)', + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + }, + ], + format: {}, + isImplicit: true, + segmentFormat: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + }, + }, + ], + }; + mergeModel(newTarget, mergeLinkSourceModel, undefined, { + mergeFormat: 'mergeAll', + }); + + const para = newTarget.blocks[0] as ContentModelParagraph; + expect(para.segments[0].link).toEqual({ + format: { + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + borderRadius: '2px', + textAlign: 'start', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + backgroundColor: 'rgb(32, 31, 30)', + underline: true, + }, + dataset: {}, + }); + }); + + it('Merge Link Format with keepSourceEmphasisFormat option', () => { + const targetModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + segments: [ + { + isSelected: true, + segmentType: 'SelectionMarker', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }, + ], + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + blockType: 'Paragraph', + format: {}, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Work Item 222824', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'var(--communication-foreground,rgba(0, 90, 158, 1))', + underline: true, + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + link: { + format: { + underline: true, + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + textColor: + 'var(--communication-foreground,rgba(0, 90, 158, 1))', + backgroundColor: 'rgb(32, 31, 30)', + borderRadius: '2px', + textAlign: 'start', + }, + dataset: {}, + }, + }, + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + textColor: 'rgb(255, 255, 255)', + italic: false, + backgroundColor: 'rgb(32, 31, 30)', + }, + }, + ], + format: {}, + isImplicit: true, + segmentFormat: { + fontFamily: + '"Segoe UI VSS (Regular)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", Helvetica, Ubuntu, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', + fontSize: '14px', + }, + }, + ], + }; + + mergeModel(targetModel, sourceModel, undefined, { + mergeFormat: 'keepSourceEmphasisFormat', + }); + + const para = targetModel.blocks[0] as ContentModelParagraph; + expect(para.segments[0].link).toEqual({ + format: { + href: 'https://www.bing.com', + anchorClass: 'bolt-link', + borderRadius: '2px', + textAlign: 'start', + textColor: '#000000', + underline: true, + }, + dataset: {}, + }); + }); + + it('Keep image width when merging with keepSourceEmphasisFormat', () => { + const targetModel = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + para.segments.push(marker); + targetModel.blocks.push(para); + + marker.format = { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }; + + const sourceModel = createContentModelDocument(); + sourceModel.blocks.push({ + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Image', + format: { + fontFamily: 'Remove this', + fontSize: 'Remove this', + textColor: 'Remove this', + backgroundColor: 'imageColor', + width: 'imageWidth', + maxWidth: 'imageMaxWidth', + height: 'imageHeight', + maxHeight: 'imageMaxHeight', + id: 'imageId', + marginBottom: '0px', + marginLeft: '0px', + marginRight: '0px', + marginTop: '0px', + borderBottom: 'border', + borderBottomLeftRadius: 'border', + borderBottomRightRadius: 'border', + borderLeft: 'border', + borderRadius: 'border', + borderTop: 'border', + borderRight: 'border', + borderTopLeftRadius: 'border', + borderTopRightRadius: 'border', + boxShadow: 'border', + display: 'display', + float: 'float', + minHeight: 'minHeight', + minWidth: 'minWidth', + verticalAlign: 'top', + }, + dataset: {}, + src: 'https://www.bing.com', + }, + ], + }); + + mergeModel(targetModel, sourceModel, undefined, { + mergeFormat: 'keepSourceEmphasisFormat', + }); + + const block = targetModel.blocks[0] as ContentModelParagraph; + expect(block.segments[0].format).toEqual({ + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + width: 'imageWidth', + maxWidth: 'imageMaxWidth', + height: 'imageHeight', + maxHeight: 'imageMaxHeight', + id: 'imageId', + marginBottom: '0px', + marginLeft: '0px', + marginRight: '0px', + marginTop: '0px', + borderBottom: 'border', + borderBottomLeftRadius: 'border', + borderBottomRightRadius: 'border', + borderLeft: 'border', + borderRadius: 'border', + borderTop: 'border', + borderRight: 'border', + borderTopLeftRadius: 'border', + borderTopRightRadius: 'border', + boxShadow: 'border', + display: 'display', + float: 'float', + minHeight: 'minHeight', + minWidth: 'minWidth', + verticalAlign: 'top', + }); + }); }); From a9dd82caef17acfcaf8990ec1a1e5fab46b85053 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 6 Jun 2024 09:18:16 -0700 Subject: [PATCH 2/2] Fix scrollIntoView (#2685) Co-authored-by: Bryan Valverde U --- .vscode/settings.json | 3 +- .../controlsV2/plugins/SamplePickerPlugin.tsx | 2 +- .../component/showPasteOptionPane.tsx | 3 +- .../sidePane/formatState/FormatStatePlugin.ts | 2 +- .../lib/command/paste/mergePasteContent.ts | 2 +- .../formatContentModel/formatContentModel.ts | 13 +- .../formatContentModel/scrollCaretIntoView.ts | 39 +++ .../formatContentModelTest.ts | 16 +- .../scrollCaretIntoViewTest.ts | 314 ++++++++++++++++++ .../selection}/getDOMInsertPointRect.ts | 3 +- .../roosterjs-content-model-dom/lib/index.ts | 1 + .../lib/edit/keyboardDelete.ts | 2 +- .../lib/edit/keyboardInput.ts | 2 +- .../lib/index.ts | 2 - 14 files changed, 377 insertions(+), 27 deletions(-) create mode 100644 packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/scrollCaretIntoView.ts create mode 100644 packages/roosterjs-content-model-core/test/coreApi/formatContentModel/scrollCaretIntoViewTest.ts rename packages/{roosterjs-content-model-plugins/lib/pluginUtils/Rect => roosterjs-content-model-dom/lib/domUtils/selection}/getDOMInsertPointRect.ts (94%) diff --git a/.vscode/settings.json b/.vscode/settings.json index fe47b63b344..b5aefa37617 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,7 +11,8 @@ "coverage": true, ".vscode/**": true, "dist/**": true, - "stats.json": true + "stats.json": true, + "assets/**": true }, "prettier.arrowParens": "avoid", "prettier.singleQuote": true, diff --git a/demo/scripts/controlsV2/plugins/SamplePickerPlugin.tsx b/demo/scripts/controlsV2/plugins/SamplePickerPlugin.tsx index 1b0cd13d122..8d0b136bb95 100644 --- a/demo/scripts/controlsV2/plugins/SamplePickerPlugin.tsx +++ b/demo/scripts/controlsV2/plugins/SamplePickerPlugin.tsx @@ -10,12 +10,12 @@ import { PickerHelper, PickerPlugin, PickerSelectionChangMode, - getDOMInsertPointRect, } from 'roosterjs-content-model-plugins'; import { createContentModelDocument, createEntity, createParagraph, + getDOMInsertPointRect, } from 'roosterjs-content-model-dom'; const itemStyle = mergeStyles({ diff --git a/demo/scripts/controlsV2/roosterjsReact/pasteOptions/component/showPasteOptionPane.tsx b/demo/scripts/controlsV2/roosterjsReact/pasteOptions/component/showPasteOptionPane.tsx index fd381afc379..809931ca623 100644 --- a/demo/scripts/controlsV2/roosterjsReact/pasteOptions/component/showPasteOptionPane.tsx +++ b/demo/scripts/controlsV2/roosterjsReact/pasteOptions/component/showPasteOptionPane.tsx @@ -1,9 +1,8 @@ import * as React from 'react'; import { ButtonKeys, Buttons } from '../utils/buttons'; import { Callout, DirectionalHint } from '@fluentui/react/lib/Callout'; -import { getDOMInsertPointRect } from 'roosterjs-content-model-plugins'; +import { getDOMInsertPointRect, getObjectKeys } from 'roosterjs-content-model-dom'; import { getLocalizedString } from '../../common/index'; -import { getObjectKeys } from 'roosterjs-content-model-dom'; import { Icon } from '@fluentui/react/lib/Icon'; import { IconButton } from '@fluentui/react/lib/Button'; import { memoizeFunction } from '@fluentui/react/lib/Utilities'; diff --git a/demo/scripts/controlsV2/sidePane/formatState/FormatStatePlugin.ts b/demo/scripts/controlsV2/sidePane/formatState/FormatStatePlugin.ts index 35c01f9408b..172aa4bae4f 100644 --- a/demo/scripts/controlsV2/sidePane/formatState/FormatStatePlugin.ts +++ b/demo/scripts/controlsV2/sidePane/formatState/FormatStatePlugin.ts @@ -1,5 +1,5 @@ import { FormatStatePane, FormatStatePaneProps, FormatStatePaneState } from './FormatStatePane'; -import { getDOMInsertPointRect } from 'roosterjs-content-model-plugins'; +import { getDOMInsertPointRect } from 'roosterjs-content-model-dom'; import { getFormatState } from 'roosterjs-content-model-api'; import { PluginEvent } from 'roosterjs-content-model-types'; import { SidePaneElementProps } from '../SidePaneElement'; diff --git a/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts b/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts index af149946960..50dc247814e 100644 --- a/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts +++ b/packages/roosterjs-content-model-core/lib/command/paste/mergePasteContent.ts @@ -95,7 +95,7 @@ export function mergePasteContent( { changeSource: ChangeSource.Paste, getChangeData: () => clipboardData, - scrollCaretIntoView: false, // TODO #2633: Make a full fix to the scroll behavior + scrollCaretIntoView: true, apiName: 'paste', } ); diff --git a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts index bcebd23f23b..0861964bb97 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts @@ -1,4 +1,5 @@ -import { ChangeSource, getSelectionRootNode } from 'roosterjs-content-model-dom'; +import { ChangeSource } from 'roosterjs-content-model-dom'; +import { scrollCaretIntoView } from './scrollCaretIntoView'; import type { ChangedEntity, ContentChangedEvent, @@ -31,7 +32,7 @@ export const formatContentModel: FormatContentModel = ( changeSource, rawEvent, selectionOverride, - scrollCaretIntoView, + scrollCaretIntoView: scroll, } = options || {}; const model = core.api.createContentModel(core, domToModelOptions, selectionOverride); const context: FormatContentModelContext = { @@ -70,12 +71,8 @@ export const formatContentModel: FormatContentModel = ( handlePendingFormat(core, context, selection); - if (selection && scrollCaretIntoView) { - const selectionRoot = getSelectionRootNode(selection); - const rootElement = - selectionRoot && core.domHelper.findClosestElementAncestor(selectionRoot); - - rootElement?.scrollIntoView(); + if (scroll && (selection?.type == 'range' || selection?.type == 'image')) { + scrollCaretIntoView(core, selection); } const eventData: ContentChangedEvent = { diff --git a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/scrollCaretIntoView.ts b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/scrollCaretIntoView.ts new file mode 100644 index 00000000000..7a5afb02f3d --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/scrollCaretIntoView.ts @@ -0,0 +1,39 @@ +import { getDOMInsertPointRect } from 'roosterjs-content-model-dom'; +import type { EditorCore, ImageSelection, RangeSelection } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function scrollCaretIntoView(core: EditorCore, selection: RangeSelection | ImageSelection) { + const rect = getDOMInsertPointRect( + core.physicalRoot.ownerDocument, + selection.type == 'image' + ? { + node: selection.image, + offset: 0, + } + : selection.isReverted + ? { + node: selection.range.startContainer, + offset: selection.range.startOffset, + } + : { + node: selection.range.endContainer, + offset: selection.range.endOffset, + } + ); + const visibleRect = core.api.getVisibleViewport(core); + const scrollContainer = core.domEvent.scrollContainer; + + if (rect && visibleRect) { + if (rect.bottom > visibleRect.bottom) { + const zoomScale = core.domHelper.calculateZoomScale(); + + scrollContainer.scrollTop += (rect.bottom - visibleRect.bottom) / zoomScale; + } else if (rect.top < visibleRect.top) { + const zoomScale = core.domHelper.calculateZoomScale(); + + scrollContainer.scrollTop += (rect.top - visibleRect.top) / zoomScale; + } + } +} diff --git a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts index 09996b39e09..5fdae3d372e 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts @@ -1,3 +1,4 @@ +import * as scrollCaretIntoView from '../../../lib/coreApi/formatContentModel/scrollCaretIntoView'; import * as transformColor from 'roosterjs-content-model-dom/lib/domUtils/style/transformColor'; import { ChangeSource, createImage } from 'roosterjs-content-model-dom'; import { formatContentModel } from '../../../lib/coreApi/formatContentModel/formatContentModel'; @@ -21,7 +22,6 @@ describe('formatContentModel', () => { let hasFocus: jasmine.Spy; let getClientWidth: jasmine.Spy; let announce: jasmine.Spy; - let findClosestElementAncestor: jasmine.Spy; const apiName = 'mockedApi'; const mockedContainer = 'C' as any; @@ -43,7 +43,6 @@ describe('formatContentModel', () => { hasFocus = jasmine.createSpy('hasFocus'); getClientWidth = jasmine.createSpy('getClientWidth'); announce = jasmine.createSpy('announce'); - findClosestElementAncestor = jasmine.createSpy('findClosestElementAncestor '); core = ({ api: { @@ -64,7 +63,6 @@ describe('formatContentModel', () => { domHelper: { hasFocus, getClientWidth, - findClosestElementAncestor, }, } as any) as EditorCore; }); @@ -554,14 +552,14 @@ describe('formatContentModel', () => { }); it('Has scrollCaretIntoView, and callback return true', () => { - const scrollIntoViewSpy = jasmine.createSpy('scrollIntoView'); - const mockedImage = { scrollIntoView: scrollIntoViewSpy } as any; + const scrollCaretIntoViewSpy = spyOn(scrollCaretIntoView, 'scrollCaretIntoView'); + const mockedImage = 'IMAGE' as any; - findClosestElementAncestor.and.returnValue(mockedImage); setContentModel.and.returnValue({ type: 'image', image: mockedImage, }); + formatContentModel( core, (model, context) => { @@ -574,8 +572,10 @@ describe('formatContentModel', () => { } ); - expect(findClosestElementAncestor).toHaveBeenCalledWith(mockedImage); - expect(scrollIntoViewSpy).toHaveBeenCalledTimes(1); + expect(scrollCaretIntoViewSpy).toHaveBeenCalledWith(core, { + type: 'image', + image: mockedImage, + }); }); }); diff --git a/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/scrollCaretIntoViewTest.ts b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/scrollCaretIntoViewTest.ts new file mode 100644 index 00000000000..3b635d7f2e5 --- /dev/null +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/scrollCaretIntoViewTest.ts @@ -0,0 +1,314 @@ +import * as getDOMInsertPointRect from 'roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect'; +import { EditorCore } from 'roosterjs-content-model-types'; +import { scrollCaretIntoView } from '../../../lib/coreApi/formatContentModel/scrollCaretIntoView'; + +describe('scrollCaretIntoView', () => { + let getDOMInsertPointRectSpy: jasmine.Spy; + let getVisibleViewportSpy: jasmine.Spy; + let calculateZoomScaleSpy: jasmine.Spy; + let core: EditorCore; + let mockedScrollContainer: HTMLElement; + const startContainer = 'STARTCONTAINER' as any; + const endContainer = 'ENDCONTAINER' as any; + const startOffset = 'STARTOFFSET' as any; + const endOffset = 'ENDOFFSET' as any; + const mockedDocument = 'DOCUMENT' as any; + + beforeEach(() => { + getDOMInsertPointRectSpy = spyOn(getDOMInsertPointRect, 'getDOMInsertPointRect'); + getVisibleViewportSpy = jasmine.createSpy('getVisibleViewport'); + calculateZoomScaleSpy = jasmine.createSpy('calculateZoomScale').and.returnValue(1); + + mockedScrollContainer = { + scrollTop: 50, + } as any; + + core = { + physicalRoot: { + ownerDocument: mockedDocument, + }, + api: { + getVisibleViewport: getVisibleViewportSpy, + }, + domEvent: { + scrollContainer: mockedScrollContainer, + }, + domHelper: { + calculateZoomScale: calculateZoomScaleSpy, + }, + } as any; + }); + + it('range selection, not reverted, caret is in view, zoom scale=1', () => { + getDOMInsertPointRectSpy.and.returnValue({ + top: 30, + bottom: 40, + } as any); + getVisibleViewportSpy.and.returnValue({ + top: 0, + bottom: 100, + } as any); + + scrollCaretIntoView(core, { + type: 'range', + isReverted: false, + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + }); + + expect(getDOMInsertPointRectSpy).toHaveBeenCalledWith(mockedDocument, { + node: endContainer, + offset: endOffset, + }); + expect(getVisibleViewportSpy).toHaveBeenCalledWith(core); + expect(calculateZoomScaleSpy).not.toHaveBeenCalled(); + expect(mockedScrollContainer.scrollTop).toBe(50); + }); + + it('range selection, not reverted, caret is in view, zoom scale=2', () => { + getDOMInsertPointRectSpy.and.returnValue({ + top: 30, + bottom: 40, + } as any); + getVisibleViewportSpy.and.returnValue({ + top: 0, + bottom: 100, + } as any); + calculateZoomScaleSpy.and.returnValue(2); + + scrollCaretIntoView(core, { + type: 'range', + isReverted: false, + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + }); + + expect(getDOMInsertPointRectSpy).toHaveBeenCalledWith(mockedDocument, { + node: endContainer, + offset: endOffset, + }); + expect(getVisibleViewportSpy).toHaveBeenCalledWith(core); + expect(calculateZoomScaleSpy).not.toHaveBeenCalled(); + expect(mockedScrollContainer.scrollTop).toBe(50); + }); + + it('range selection, not reverted, caret is above view, zoom scale=1', () => { + getDOMInsertPointRectSpy.and.returnValue({ + top: 30, + bottom: 45, + } as any); + getVisibleViewportSpy.and.returnValue({ + top: 40, + bottom: 100, + } as any); + + scrollCaretIntoView(core, { + type: 'range', + isReverted: false, + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + }); + + expect(getDOMInsertPointRectSpy).toHaveBeenCalledWith(mockedDocument, { + node: endContainer, + offset: endOffset, + }); + expect(getVisibleViewportSpy).toHaveBeenCalledWith(core); + expect(calculateZoomScaleSpy).toHaveBeenCalledWith(); + expect(mockedScrollContainer.scrollTop).toBe(40); + }); + + it('range selection, not reverted, caret is above view, zoom scale=2', () => { + getDOMInsertPointRectSpy.and.returnValue({ + top: 30, + bottom: 45, + } as any); + getVisibleViewportSpy.and.returnValue({ + top: 40, + bottom: 100, + } as any); + calculateZoomScaleSpy.and.returnValue(2); + + scrollCaretIntoView(core, { + type: 'range', + isReverted: false, + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + }); + + expect(getDOMInsertPointRectSpy).toHaveBeenCalledWith(mockedDocument, { + node: endContainer, + offset: endOffset, + }); + expect(getVisibleViewportSpy).toHaveBeenCalledWith(core); + expect(calculateZoomScaleSpy).toHaveBeenCalledWith(); + expect(mockedScrollContainer.scrollTop).toBe(45); + }); + + it('range selection, not reverted, caret is below view, zoom scale=1', () => { + getDOMInsertPointRectSpy.and.returnValue({ + top: 120, + bottom: 140, + } as any); + getVisibleViewportSpy.and.returnValue({ + top: 40, + bottom: 100, + } as any); + calculateZoomScaleSpy.and.returnValue(1); + + scrollCaretIntoView(core, { + type: 'range', + isReverted: false, + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + }); + + expect(getDOMInsertPointRectSpy).toHaveBeenCalledWith(mockedDocument, { + node: endContainer, + offset: endOffset, + }); + expect(getVisibleViewportSpy).toHaveBeenCalledWith(core); + expect(calculateZoomScaleSpy).toHaveBeenCalledWith(); + expect(mockedScrollContainer.scrollTop).toBe(90); + }); + + it('range selection, not reverted, caret is below view, zoom scale=0.5', () => { + getDOMInsertPointRectSpy.and.returnValue({ + top: 120, + bottom: 140, + } as any); + getVisibleViewportSpy.and.returnValue({ + top: 40, + bottom: 100, + } as any); + calculateZoomScaleSpy.and.returnValue(0.5); + + scrollCaretIntoView(core, { + type: 'range', + isReverted: false, + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + }); + + expect(getDOMInsertPointRectSpy).toHaveBeenCalledWith(mockedDocument, { + node: endContainer, + offset: endOffset, + }); + expect(getVisibleViewportSpy).toHaveBeenCalledWith(core); + expect(calculateZoomScaleSpy).toHaveBeenCalledWith(); + expect(mockedScrollContainer.scrollTop).toBe(130); + }); + + it('range selection, not reverted, caret is above and below view, zoom scale=1', () => { + getDOMInsertPointRectSpy.and.returnValue({ + top: 10, + bottom: 80, + } as any); + getVisibleViewportSpy.and.returnValue({ + top: 30, + bottom: 40, + } as any); + calculateZoomScaleSpy.and.returnValue(1); + + scrollCaretIntoView(core, { + type: 'range', + isReverted: false, + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + }); + + expect(getDOMInsertPointRectSpy).toHaveBeenCalledWith(mockedDocument, { + node: endContainer, + offset: endOffset, + }); + expect(getVisibleViewportSpy).toHaveBeenCalledWith(core); + expect(calculateZoomScaleSpy).toHaveBeenCalledWith(); + expect(mockedScrollContainer.scrollTop).toBe(90); + }); + + it('range selection, reverted, caret is above, zoom scale=1', () => { + getDOMInsertPointRectSpy.and.returnValue({ + top: 10, + bottom: 20, + } as any); + getVisibleViewportSpy.and.returnValue({ + top: 30, + bottom: 40, + } as any); + calculateZoomScaleSpy.and.returnValue(1); + + scrollCaretIntoView(core, { + type: 'range', + isReverted: true, + range: { + startContainer, + endContainer, + startOffset, + endOffset, + } as any, + }); + + expect(getDOMInsertPointRectSpy).toHaveBeenCalledWith(mockedDocument, { + node: startContainer, + offset: startOffset, + }); + expect(getVisibleViewportSpy).toHaveBeenCalledWith(core); + expect(calculateZoomScaleSpy).toHaveBeenCalledWith(); + expect(mockedScrollContainer.scrollTop).toBe(30); + }); + + it('image selection, not reverted, caret is above view, zoom scale=1', () => { + getDOMInsertPointRectSpy.and.returnValue({ + top: 10, + bottom: 20, + } as any); + getVisibleViewportSpy.and.returnValue({ + top: 30, + bottom: 40, + } as any); + calculateZoomScaleSpy.and.returnValue(1); + + const mockedImage = 'IMAGE' as any; + + scrollCaretIntoView(core, { + type: 'image', + image: mockedImage, + }); + + expect(getDOMInsertPointRectSpy).toHaveBeenCalledWith(mockedDocument, { + node: mockedImage, + offset: 0, + }); + expect(getVisibleViewportSpy).toHaveBeenCalledWith(core); + expect(calculateZoomScaleSpy).toHaveBeenCalledWith(); + expect(mockedScrollContainer.scrollTop).toBe(30); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/lib/pluginUtils/Rect/getDOMInsertPointRect.ts b/packages/roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect.ts similarity index 94% rename from packages/roosterjs-content-model-plugins/lib/pluginUtils/Rect/getDOMInsertPointRect.ts rename to packages/roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect.ts index 5085a9f1950..b4fb3795f55 100644 --- a/packages/roosterjs-content-model-plugins/lib/pluginUtils/Rect/getDOMInsertPointRect.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/selection/getDOMInsertPointRect.ts @@ -1,4 +1,5 @@ -import { isNodeOfType, normalizeRect } from 'roosterjs-content-model-dom'; +import { isNodeOfType } from '../isNodeOfType'; +import { normalizeRect } from '../normalizeRect'; import type { DOMInsertPoint, Rect } from 'roosterjs-content-model-types'; /** diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index 9a1f9e338c6..90bbd13f242 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -92,6 +92,7 @@ export { export { isBold } from './domUtils/style/isBold'; export { getSelectionRootNode } from './domUtils/selection/getSelectionRootNode'; +export { getDOMInsertPointRect } from './domUtils/selection/getDOMInsertPointRect'; export { isCharacterValue, isModifierKey, isCursorMovingKey } from './domUtils/event/eventUtils'; export { combineBorderValue, extractBorderValues } from './domUtils/style/borderValues'; export { isPunctuation, isSpace, normalizeText } from './domUtils/stringUtil'; diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts index a16c45bc9b0..44c73db93aa 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts @@ -49,7 +49,7 @@ export function keyboardDelete(editor: IEditor, rawEvent: KeyboardEvent) { rawEvent, changeSource: ChangeSource.Keyboard, getChangeData: () => rawEvent.which, - scrollCaretIntoView: false, // TODO #2633: Make a full fix to the scroll behavior + scrollCaretIntoView: true, apiName: rawEvent.key == 'Delete' ? 'handleDeleteKey' : 'handleBackspaceKey', } ); diff --git a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 36a9a0b894d..91ecf99647e 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -36,7 +36,7 @@ export function keyboardInput(editor: IEditor, rawEvent: KeyboardEvent) { } }, { - scrollCaretIntoView: false, // TODO #2633: Make a full fix to the scroll behavior + scrollCaretIntoView: true, rawEvent, } ); diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index 6ae637a1d56..50920741895 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -36,5 +36,3 @@ export { PickerSelectionChangMode, PickerDirection, PickerHandler } from './pick export { CustomReplacePlugin, CustomReplace } from './customReplace/CustomReplacePlugin'; export { ImageEditPlugin } from './imageEdit/ImageEditPlugin'; export { ImageEditOptions } from './imageEdit/types/ImageEditOptions'; - -export { getDOMInsertPointRect } from './pluginUtils/Rect/getDOMInsertPointRect';