diff --git a/packages/roosterjs-content-model-api/lib/modelApi/list/setListType.ts b/packages/roosterjs-content-model-api/lib/modelApi/list/setListType.ts index 977f1d17118..1144f8aa1ae 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/list/setListType.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/list/setListType.ts @@ -6,6 +6,7 @@ import { mutateBlock, normalizeContentModel, setParagraphNotImplicit, + updateListMetadata, } from 'roosterjs-content-model-dom'; import type { ContentModelListItem, @@ -121,6 +122,17 @@ export function setListType( mutateBlock(parent).blocks.splice(index, 1, newListItem); existingListItems.push(newListItem); + + const levelIndex = newListItem.levels.length - 1; + const level = mutateBlock(newListItem).levels[levelIndex]; + + if (level) { + updateListMetadata(level, metadata => + Object.assign({}, metadata, { + applyListStyleFromLevel: true, + }) + ); + } } else { existingListItems.forEach( x => (mutateBlock(x).levels[0].format.marginBottom = '0px') diff --git a/packages/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts b/packages/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts index 2d9c7120c63..dc55413d65b 100644 --- a/packages/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts +++ b/packages/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts @@ -78,7 +78,7 @@ describe('indent', () => { marginTop: undefined, textAlign: undefined, }, - dataset: {}, + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, }, ], blocks: [para], @@ -138,7 +138,9 @@ describe('indent', () => { marginTop: '0px', textAlign: undefined, }, - dataset: {}, + dataset: { + editingInfo: '{"applyListStyleFromLevel":true}', + }, }, ], blocks: [para], @@ -364,7 +366,7 @@ describe('indent', () => { marginTop: undefined, textAlign: undefined, }, - dataset: {}, + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, }, ], blocks: [para3], @@ -420,7 +422,7 @@ describe('indent', () => { levels: [ { listType: 'OL', - dataset: {}, + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, format: { startNumberOverride: 1, direction: 'rtl', @@ -500,7 +502,7 @@ describe('indent', () => { levels: [ { listType: 'OL', - dataset: {}, + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, format: { startNumberOverride: 1, direction: undefined, @@ -529,7 +531,7 @@ describe('indent', () => { levels: [ { listType: 'OL', - dataset: {}, + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, format: { direction: undefined, marginBottom: undefined, @@ -584,7 +586,7 @@ describe('indent', () => { levels: [ { listType: 'OL', - dataset: {}, + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, format: { startNumberOverride: 1, direction: undefined, @@ -641,7 +643,7 @@ describe('indent', () => { levels: [ { listType: 'OL', - dataset: {}, + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, format: { startNumberOverride: 1, direction: undefined, @@ -669,7 +671,7 @@ describe('indent', () => { levels: [ { listType: 'OL', - dataset: {}, + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, format: { startNumberOverride: undefined, direction: undefined, @@ -697,7 +699,7 @@ describe('indent', () => { levels: [ { listType: 'OL', - dataset: {}, + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, format: { direction: undefined, marginBottom: undefined, @@ -779,7 +781,7 @@ describe('indent', () => { marginTop: undefined, textAlign: undefined, }, - dataset: {}, + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, }, ], formatHolder: { @@ -809,7 +811,7 @@ describe('indent', () => { marginTop: undefined, textAlign: undefined, }, - dataset: {}, + dataset: { editingInfo: '{"applyListStyleFromLevel":true}' }, }, ], formatHolder: { diff --git a/packages/roosterjs-content-model-api/test/publicApi/list/toggleNumberingTest.ts b/packages/roosterjs-content-model-api/test/publicApi/list/toggleNumberingTest.ts index a2056062992..febaeab11cc 100644 --- a/packages/roosterjs-content-model-api/test/publicApi/list/toggleNumberingTest.ts +++ b/packages/roosterjs-content-model-api/test/publicApi/list/toggleNumberingTest.ts @@ -1,6 +1,7 @@ import * as setListType from '../../../lib/modelApi/list/setListType'; import { IEditor } from 'roosterjs-content-model-types'; import { toggleNumbering } from '../../../lib/publicApi/list/toggleNumbering'; + import { ContentModelDocument, ContentModelFormatter, diff --git a/packages/roosterjs-content-model-core/lib/coreApi/announce/announce.ts b/packages/roosterjs-content-model-core/lib/coreApi/announce/announce.ts index 2f04873238a..ad92200bd39 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/announce/announce.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/announce/announce.ts @@ -1,5 +1,8 @@ +import { createAriaLiveElement } from '../../utils/createAriaLiveElement'; import type { Announce } from 'roosterjs-content-model-types'; +const DOT_STRING = '.'; + /** * @internal * Announce the given data @@ -10,16 +13,16 @@ export const announce: Announce = (core, announceData) => { const { text, defaultStrings, formatStrings = [] } = announceData; const { announcerStringGetter } = core.lifecycle; const template = defaultStrings && announcerStringGetter?.(defaultStrings); - const textToAnnounce = formatString(template || text, formatStrings); - - if (textToAnnounce) { - let announceContainer = core.lifecycle.announceContainer; + let textToAnnounce = formatString(template || text, formatStrings); - if (!announceContainer || textToAnnounce == announceContainer.textContent) { - announceContainer?.parentElement?.removeChild(announceContainer); - announceContainer = createAriaLiveElement(core.physicalRoot.ownerDocument); + if (!core.lifecycle.announceContainer) { + core.lifecycle.announceContainer = createAriaLiveElement(core.physicalRoot.ownerDocument); + } - core.lifecycle.announceContainer = announceContainer; + if (textToAnnounce && core.lifecycle.announceContainer) { + const { announceContainer } = core.lifecycle; + if (textToAnnounce == announceContainer.textContent) { + textToAnnounce += DOT_STRING; } if (announceContainer) { @@ -41,20 +44,3 @@ function formatString(text: string | undefined, formatStrings: string[]) { return text; } - -function createAriaLiveElement(document: Document): HTMLDivElement { - const div = document.createElement('div'); - - div.style.clip = 'rect(0px, 0px, 0px, 0px)'; - div.style.clipPath = 'inset(100%)'; - div.style.height = '1px'; - div.style.overflow = 'hidden'; - div.style.position = 'absolute'; - div.style.whiteSpace = 'nowrap'; - div.style.width = '1px'; - div.ariaLive = 'assertive'; - - document.body.appendChild(div); - - return div; -} diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts b/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts index c3cb20c5363..c52e9f0bc47 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts @@ -7,7 +7,7 @@ export function ensureUniqueId(element: HTMLElement, idPrefix: string): string { const doc = element.ownerDocument; let i = 0; - while (!element.id || doc.querySelectorAll('#' + element.id).length > 1) { + while (!element.id || doc.querySelectorAll(`[id="${element.id}"]`).length > 1) { element.id = idPrefix + '_' + i++; } diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts index 5e0cd991614..1e3e72b2aff 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts @@ -1,4 +1,5 @@ import { ChangeSource, getObjectKeys, setColor } from 'roosterjs-content-model-dom'; +import { createAriaLiveElement } from '../../utils/createAriaLiveElement'; import type { IEditor, LifecyclePluginState, @@ -74,6 +75,9 @@ class LifecyclePlugin implements PluginWithState { // Let other plugins know that we are ready this.editor.triggerEvent('editorReady', {}, true /*broadcast*/); + + // Initialize the Announce container. + this.state.announceContainer = createAriaLiveElement(editor.getDocument()); } /** diff --git a/packages/roosterjs-content-model-core/lib/utils/createAriaLiveElement.ts b/packages/roosterjs-content-model-core/lib/utils/createAriaLiveElement.ts new file mode 100644 index 00000000000..e255a8d7551 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/utils/createAriaLiveElement.ts @@ -0,0 +1,19 @@ +/** + * @internal + */ +export function createAriaLiveElement(document: Document): HTMLDivElement { + const div = document.createElement('div'); + + div.style.clip = 'rect(0px, 0px, 0px, 0px)'; + div.style.clipPath = 'inset(100%)'; + div.style.height = '1px'; + div.style.overflow = 'hidden'; + div.style.position = 'absolute'; + div.style.whiteSpace = 'nowrap'; + div.style.width = '1px'; + div.ariaLive = 'assertive'; + + document.body.appendChild(div); + + return div; +} diff --git a/packages/roosterjs-content-model-core/test/coreApi/announce/announceTest.ts b/packages/roosterjs-content-model-core/test/coreApi/announce/announceTest.ts index f614b638623..53727233613 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/announce/announceTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/announce/announceTest.ts @@ -28,9 +28,16 @@ describe('announce', () => { }); it('announce empty string', () => { + const mockedDiv = { + style: {}, + } as any; + createElementSpy.and.returnValue(mockedDiv); + announce(core, {}); - expect(createElementSpy).not.toHaveBeenCalled(); - expect(appendChildSpy).not.toHaveBeenCalled(); + + expect(createElementSpy).toHaveBeenCalled(); + expect(appendChildSpy).toHaveBeenCalled(); + expect(mockedDiv.textContent).toBeUndefined(); }); it('announce a given string', () => { @@ -180,39 +187,18 @@ describe('announce', () => { }); it('already has div with same text', () => { - const removeChildSpy = jasmine.createSpy('removeChild'); const mockedDiv = { textContent: 'test', - parentElement: { - removeChild: removeChildSpy, - }, - } as any; - const mockedDiv2 = { - style: {}, } as any; core.lifecycle.announceContainer = mockedDiv; - createElementSpy.and.returnValue(mockedDiv2); announce(core, { text: 'test', }); - expect(removeChildSpy).toHaveBeenCalledWith(mockedDiv); - expect(createElementSpy).toHaveBeenCalledWith('div'); - expect(appendChildSpy).toHaveBeenCalledWith(mockedDiv2); - expect(mockedDiv2).toEqual({ - style: { - clip: 'rect(0px, 0px, 0px, 0px)', - clipPath: 'inset(100%)', - height: '1px', - overflow: 'hidden', - position: 'absolute', - whiteSpace: 'nowrap', - width: '1px', - }, - ariaLive: 'assertive', - textContent: 'test', + expect(mockedDiv).toEqual({ + textContent: 'test.', }); }); }); diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts index 5ecbc2ffb25..26f8537f3c9 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts @@ -529,7 +529,7 @@ describe('setDOMSelection', () => { createRangeSpy.and.returnValue(mockedRange); querySelectorAllSpy.and.callFake(selector => { - return selector == '#image_0' ? ['', ''] : ['']; + return selector == '[id="image_0"]' ? ['', ''] : ['']; }); hasFocusSpy.and.returnValue(false); diff --git a/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/ensureUniqueIdTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/ensureUniqueIdTest.ts index d45aeeb931e..0237af7ef8e 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/ensureUniqueIdTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/ensureUniqueIdTest.ts @@ -38,10 +38,52 @@ describe('ensureUniqueId', () => { id: 'dup', } as any; querySelectorAllSpy.and.callFake((selector: string) => - selector == '#dup' ? [{}, {}] : [] + selector == '[id="dup"]' ? [{}, {}] : [] ); const result = ensureUniqueId(element, 'prefix'); expect(result).toBe('dup_0'); }); + + it('Should not throw when element id starts with number', () => { + const element = { + ownerDocument: doc, + id: '0', + } as any; + + let isFirst = true; + querySelectorAllSpy.and.callFake((_selector: string) => { + if (isFirst) { + isFirst = false; + return [{}, {}]; + } + return [{}]; + }); + + ensureUniqueId(element, 'prefix'); + + expect(querySelectorAllSpy).toHaveBeenCalledWith('[id="0"]'); + expect(element.id).toEqual('0_0'); + }); + + it('Should not throw when element id starts with hyphen', () => { + const element = { + ownerDocument: doc, + id: '-', + } as any; + + let isFirst = true; + querySelectorAllSpy.and.callFake((_selector: string) => { + if (isFirst) { + isFirst = false; + return [{}, {}]; + } + return [{}]; + }); + + ensureUniqueId(element, 'prefix'); + + expect(querySelectorAllSpy).toHaveBeenCalledWith('[id="-"]'); + expect(element.id).toEqual('-_0'); + }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts index f687a441fa5..e4b48f607ce 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts @@ -1,13 +1,24 @@ import * as color from 'roosterjs-content-model-dom/lib/formatHandlers/utils/color'; +import * as createAriaLiveElementFile from '../../../lib/utils/createAriaLiveElement'; import { ChangeSource } from 'roosterjs-content-model-dom'; import { createLifecyclePlugin } from '../../../lib/corePlugin/lifecycle/LifecyclePlugin'; import { DarkColorHandler, IEditor } from 'roosterjs-content-model-types'; +const announceContainer = {} as Readonly; + describe('LifecyclePlugin', () => { + beforeEach(() => { + spyOn(createAriaLiveElementFile, 'createAriaLiveElement').and.returnValue( + announceContainer + ); + }); + it('init', () => { const div = document.createElement('div'); const plugin = createLifecyclePlugin({}, div); const triggerEvent = jasmine.createSpy('triggerEvent'); + const getDocument = jasmine.createSpy('getDocument').and.returnValue(document); + const state = plugin.getState(); plugin.initialize(({ @@ -15,6 +26,7 @@ describe('LifecyclePlugin', () => { getFocusedPosition: () => null, getColorManager: () => null, isDarkMode: () => false, + getDocument, })); expect(state).toEqual({ @@ -22,6 +34,7 @@ describe('LifecyclePlugin', () => { shadowEditFragment: null, styleElements: {}, announcerStringGetter: undefined, + announceContainer, }); expect(div.isContentEditable).toBeTrue(); @@ -48,6 +61,7 @@ describe('LifecyclePlugin', () => { }, div ); + const getDocument = jasmine.createSpy('getDocument').and.returnValue(document); const triggerEvent = jasmine.createSpy('triggerEvent'); const state = plugin.getState(); @@ -56,6 +70,7 @@ describe('LifecyclePlugin', () => { getFocusedPosition: () => null, getColorManager: () => null, isDarkMode: () => false, + getDocument, })); expect(state).toEqual({ @@ -63,6 +78,7 @@ describe('LifecyclePlugin', () => { shadowEditFragment: null, styleElements: {}, announcerStringGetter: mockedAnnouncerStringGetter, + announceContainer, }); expect(div.isContentEditable).toBeTrue(); @@ -79,12 +95,14 @@ describe('LifecyclePlugin', () => { div.contentEditable = 'true'; const plugin = createLifecyclePlugin({}, div); const triggerEvent = jasmine.createSpy('triggerEvent'); + const getDocument = jasmine.createSpy('getDocument').and.returnValue(document); plugin.initialize(({ triggerEvent, getFocusedPosition: () => null, getColorManager: () => null, isDarkMode: () => false, + getDocument, })); expect(div.isContentEditable).toBeTrue(); @@ -101,12 +119,14 @@ describe('LifecyclePlugin', () => { div.contentEditable = 'false'; const plugin = createLifecyclePlugin({}, div); const triggerEvent = jasmine.createSpy('triggerEvent'); + const getDocument = jasmine.createSpy('getDocument').and.returnValue(document); plugin.initialize(({ triggerEvent, getFocusedPosition: () => null, getColorManager: () => null, isDarkMode: () => false, + getDocument, })); expect(div.isContentEditable).toBeFalse(); @@ -124,12 +144,14 @@ describe('LifecyclePlugin', () => { const triggerEvent = jasmine.createSpy('triggerEvent'); const state = plugin.getState(); const mockedDarkColorHandler = 'HANDLER' as any; + const getDocument = jasmine.createSpy('getDocument').and.returnValue(document); const setColorSpy = spyOn(color, 'setColor'); plugin.initialize(({ triggerEvent, getColorManager: () => mockedDarkColorHandler, + getDocument, })); expect(setColorSpy).toHaveBeenCalledTimes(2); @@ -139,6 +161,7 @@ describe('LifecyclePlugin', () => { shadowEditFragment: null, styleElements: {}, announcerStringGetter: undefined, + announceContainer, }); plugin.onPluginEvent({ @@ -156,12 +179,14 @@ describe('LifecyclePlugin', () => { const triggerEvent = jasmine.createSpy('triggerEvent'); const state = plugin.getState(); const mockedDarkColorHandler = 'HANDLER' as any; + const getDocument = jasmine.createSpy('getDocument').and.returnValue(document); const setColorSpy = spyOn(color, 'setColor'); plugin.initialize(({ triggerEvent, getColorManager: () => mockedDarkColorHandler, + getDocument, })); expect(setColorSpy).toHaveBeenCalledTimes(2); @@ -171,6 +196,7 @@ describe('LifecyclePlugin', () => { shadowEditFragment: null, styleElements: {}, announcerStringGetter: undefined, + announceContainer, }); const mockedIsDarkColor = 'Dark' as any; @@ -208,6 +234,7 @@ describe('LifecyclePlugin', () => { div ); const triggerEvent = jasmine.createSpy('triggerEvent'); + const getDocument = jasmine.createSpy('getDocument').and.returnValue(document); const state = plugin.getState(); const mockedDarkColorHandler = 'HANDLER' as any; @@ -216,6 +243,7 @@ describe('LifecyclePlugin', () => { plugin.initialize(({ triggerEvent, getColorManager: () => mockedDarkColorHandler, + getDocument, })); expect(setColorSpy).toHaveBeenCalledTimes(0); @@ -225,6 +253,7 @@ describe('LifecyclePlugin', () => { shadowEditFragment: null, styleElements: {}, announcerStringGetter: undefined, + announceContainer, }); const mockedIsDarkColor = 'Dark' as any; @@ -242,10 +271,12 @@ describe('LifecyclePlugin', () => { it('Dispose plugin and clean up style nodes', () => { const div = document.createElement('div'); const plugin = createLifecyclePlugin({}, div); + const getDocument = jasmine.createSpy('getDocument').and.returnValue(document); plugin.initialize({ getColorManager: jasmine.createSpy(), triggerEvent: jasmine.createSpy(), + getDocument, }); const state = plugin.getState(); diff --git a/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts b/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts index 14ee7c25f28..a3d707c1f7a 100644 --- a/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts +++ b/packages/roosterjs-content-model-plugins/lib/autoFormat/list/keyboardListTrigger.ts @@ -1,10 +1,13 @@ import { getListTypeStyle } from './getListTypeStyle'; +import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-dom'; import { + getListAnnounceData, setListType, setModelListStartNumber, setModelListStyle, } from 'roosterjs-content-model-api'; import type { + ContentModelListItem, FormatContentModelContext, ReadonlyContentModelDocument, ShallowMutableContentModelParagraph, @@ -26,6 +29,7 @@ export function keyboardListTrigger( const { listType, styleType, index } = listStyleType; triggerList(model, listType, styleType, index); context.canUndoByBackspace = true; + setAnnounceData(model, context); return true; } @@ -48,9 +52,23 @@ const triggerList = ( isOrderedList ? { orderedStyleType: styleType, + applyListStyleFromLevel: false, } : { unorderedStyleType: styleType, + applyListStyleFromLevel: false, } ); }; +function setAnnounceData(model: ReadonlyContentModelDocument, context: FormatContentModelContext) { + const [paragraphOrListItems] = getOperationalBlocks( + model, + ['ListItem'], + [] // Set stop types to be empty so we can find list items even cross the boundary of table, then we can always operation on the list item if any + ); + + if (paragraphOrListItems && isBlockGroupOfType(paragraphOrListItems.block, 'ListItem')) { + const { path, block } = paragraphOrListItems; + context.announceData = getListAnnounceData([block, ...path]); + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index ba856beb358..35cef77486a 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -9,6 +9,7 @@ import { getHTMLImageOptions } from './utils/getHTMLImageOptions'; import { getSelectedImage } from './utils/getSelectedImage'; import { getSelectedImageMetadata, updateImageEditInfo } from './utils/updateImageEditInfo'; import { ImageEditElementClass } from './types/ImageEditElementClass'; +import { normalizeImageSelection } from './utils/normalizeImageSelection'; import { Resizer } from './Resizer/resizerContext'; import { Rotator } from './Rotator/rotatorContext'; import { updateRotateHandle } from './Rotator/updateRotateHandle'; @@ -256,6 +257,9 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { image.isSelectedAsImageSelection = shouldSelectImage; } ); + if (shouldSelectImage) { + normalizeImageSelection(previousSelectedImage); + } this.cleanInfo(); result = true; } diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/normalizeImageSelection.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/normalizeImageSelection.ts new file mode 100644 index 00000000000..79698c77895 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/utils/normalizeImageSelection.ts @@ -0,0 +1,31 @@ +import { mutateBlock } from 'roosterjs-content-model-dom'; +import type { ImageAndParagraph } from '../types/ImageAndParagraph'; + +/** + * Selecting directly on the image will only capture the image segment. + * However, if the selection is made while the image is within a wrapper, it will capture the span that encloses the image. + * In the last case, the selection will be marked as <---SelectionMarker---><---Image---><---SelectionMarker--->. + * To fix this behavior the extra selection markers are removed. + * @internal + */ +export function normalizeImageSelection(imageAndParagraph: ImageAndParagraph) { + const paragraph = imageAndParagraph.paragraph; + const index = paragraph.segments.indexOf(imageAndParagraph.image); + if (index > 0) { + const markerBefore = paragraph.segments[index - 1]; + const markerAfter = paragraph.segments[index + 1]; + if ( + markerBefore && + markerAfter && + markerAfter.segmentType == 'SelectionMarker' && + markerBefore.segmentType == 'SelectionMarker' && + markerAfter.isSelected && + markerBefore.isSelected + ) { + const mutatedParagraph = mutateBlock(paragraph); + mutatedParagraph.segments.splice(index - 1, 1); + mutatedParagraph.segments.splice(index, 1); + } + return imageAndParagraph; + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index e00daab314d..4ff598eb9ce 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -2,6 +2,7 @@ export { TableEditPlugin } from './tableEdit/TableEditPlugin'; export { OnTableEditorCreatedCallback } from './tableEdit/OnTableEditorCreatedCallback'; export { TableEditFeatureName } from './tableEdit/editors/features/TableEditFeatureName'; export { PastePlugin } from './paste/PastePlugin'; +export { DefaultSanitizers } from './paste/DefaultSanitizers'; export { EditPlugin, EditOptions } from './edit/EditPlugin'; export { AutoFormatPlugin, AutoFormatOptions } from './autoFormat/AutoFormatPlugin'; diff --git a/packages/roosterjs-content-model-plugins/lib/paste/DefaultSanitizers.ts b/packages/roosterjs-content-model-plugins/lib/paste/DefaultSanitizers.ts index a1ca64d7e9c..c22f7987b20 100644 --- a/packages/roosterjs-content-model-plugins/lib/paste/DefaultSanitizers.ts +++ b/packages/roosterjs-content-model-plugins/lib/paste/DefaultSanitizers.ts @@ -1,7 +1,7 @@ import type { ValueSanitizer } from 'roosterjs-content-model-types'; /** - * @internal + * Default style sanitizers for PastePlugin. */ export const DefaultSanitizers: Record = { width: divParagraphSanitizer, diff --git a/packages/roosterjs-content-model-plugins/test/autoFormat/list/keyboardListTriggerTest.ts b/packages/roosterjs-content-model-plugins/test/autoFormat/list/keyboardListTriggerTest.ts index a2cb74f5401..efcc84ef7e9 100644 --- a/packages/roosterjs-content-model-plugins/test/autoFormat/list/keyboardListTriggerTest.ts +++ b/packages/roosterjs-content-model-plugins/test/autoFormat/list/keyboardListTriggerTest.ts @@ -12,7 +12,8 @@ describe('keyboardListTrigger', () => { context: FormatContentModelContext, expectedResult: boolean, shouldSearchForBullet: boolean = true, - shouldSearchForNumbering: boolean = true + shouldSearchForNumbering: boolean = true, + expectedContext?: any ) { const result = keyboardListTrigger( model, @@ -22,6 +23,9 @@ describe('keyboardListTrigger', () => { shouldSearchForNumbering ); expect(result).toBe(expectedResult); + if (expectedContext) { + expect(context).toEqual(expectedContext); + } } it('trigger numbering list', () => { @@ -49,7 +53,16 @@ describe('keyboardListTrigger', () => { }, paragraph, { canUndoByBackspace: true } as any, - true + true /* expectedResult */, + undefined /* shouldSearchForBullet */, + undefined /* shouldSearchForNumbering */, + { + canUndoByBackspace: true, + announceData: { + defaultStrings: 'announceListItemNumbering', + formatStrings: ['1'], + }, + } ); }); @@ -118,7 +131,13 @@ describe('keyboardListTrigger', () => { }, paragraph, { canUndoByBackspace: true } as any, - true + true, + undefined /* shouldSearchForBullet */, + undefined /* shouldSearchForNumbering */, + { + canUndoByBackspace: true, + announceData: { defaultStrings: 'announceListItemNumbering', formatStrings: ['2'] }, + } ); }); @@ -147,7 +166,10 @@ describe('keyboardListTrigger', () => { }, paragraph, { canUndoByBackspace: true } as any, - false + false, + undefined /* shouldSearchForBullet */, + undefined /* shouldSearchForNumbering */, + { canUndoByBackspace: true } ); }); @@ -176,7 +198,13 @@ describe('keyboardListTrigger', () => { }, paragraph, { canUndoByBackspace: true } as any, - true + true, + undefined /* shouldSearchForBullet */, + undefined /* shouldSearchForNumbering */, + { + canUndoByBackspace: true, + announceData: { defaultStrings: 'announceListItemBullet' }, + } ); }); @@ -205,7 +233,10 @@ describe('keyboardListTrigger', () => { }, paragraph, {} as any, - false + false, + undefined, + undefined, + {} ); }); @@ -384,7 +415,16 @@ describe('keyboardListTrigger', () => { }, paragraph, { canUndoByBackspace: true } as any, - true + true, + undefined /* shouldSearchForBullet */, + undefined /* shouldSearchForNumbering */, + { + canUndoByBackspace: true, + announceData: { + defaultStrings: 'announceListItemNumbering', + formatStrings: ['3'], + }, + } ); }); @@ -493,7 +533,13 @@ describe('keyboardListTrigger', () => { }, paragraph, { canUndoByBackspace: true } as any, - true + true, + undefined /* shouldSearchForBullet */, + undefined /* shouldSearchForNumbering */, + { + canUndoByBackspace: true, + announceData: { defaultStrings: 'announceListItemNumbering', formatStrings: ['A'] }, + } ); }); }); diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/utils/normalizeImageSelection.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/normalizeImageSelection.ts new file mode 100644 index 00000000000..22f8f732230 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/utils/normalizeImageSelection.ts @@ -0,0 +1,135 @@ +import { ImageAndParagraph } from '../../../lib/imageEdit/types/ImageAndParagraph'; +import { normalizeImageSelection } from '../../../lib/imageEdit/utils/normalizeImageSelection'; +import type { ReadonlyContentModelImage } from 'roosterjs-content-model-types'; + +describe('normalizeImageSelection', () => { + it('normalize image selection', () => { + const image: ReadonlyContentModelImage = { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }; + const imageAndParagraph: ImageAndParagraph = { + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + image, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + image: image, + }; + + const result = normalizeImageSelection(imageAndParagraph); + expect(result?.paragraph).toEqual({ + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }); + }); + + it('normalize image selection', () => { + const imageAndParagraph: ImageAndParagraph = { + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + image: { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + }; + + const result = normalizeImageSelection(imageAndParagraph); + expect(result).toBeUndefined(); + }); +}); diff --git a/versions.json b/versions.json index ab18c15499d..809b50b5f27 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ -{ - "react": "9.0.0", - "main": "9.7.0", - "legacyAdapter": "8.62.1" -} +{ + "react": "9.0.0", + "main": "9.8.0", + "legacyAdapter": "8.62.1" +}