From 05135f736a766ff97fc35a44b5613bff922bf885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 16 Oct 2023 19:39:34 -0300 Subject: [PATCH 001/111] margin in lists --- .../block/marginBlockFormatHandler.ts | 33 +++++++ .../formatHandlers/defaultFormatHandlers.ts | 12 ++- .../block/marginBlockFormatHandlerTest.ts | 81 ++++++++++++++++++ .../lib/modelApi/list/setListType.ts | 2 + .../test/modelApi/list/setListTypeTest.ts | 18 ++++ .../format/ContentModelListItemLevelFormat.ts | 2 + .../lib/format/FormatHandlerTypeMap.ts | 6 ++ .../format/formatParts/MarginBlockFormat.ts | 14 +++ .../lib/index.ts | 1 + .../test/format/setIndentationTest.ts | 4 +- .../test/format/toggleBlockQuoteTest.ts | 10 +-- .../test/utils/toggleListTypeTest.ts | 8 +- .../roosterjs-editor-dom/lib/list/VList.ts | 15 +++- .../lib/list/createVListFromRegion.ts | 1 - .../test/list/VListChainTest.ts | 22 +++-- .../test/list/VListTest.ts | 85 ++++++++++--------- 16 files changed, 249 insertions(+), 65 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginBlockFormatHandler.ts create mode 100644 packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginBlockFormatHandlerTest.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginBlockFormat.ts diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginBlockFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginBlockFormatHandler.ts new file mode 100644 index 00000000000..37f0818c7b8 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginBlockFormatHandler.ts @@ -0,0 +1,33 @@ +import { parseValueWithUnit } from '../utils/parseValueWithUnit'; +import type { FormatHandler } from '../FormatHandler'; +import type { MarginBlockFormat } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export const marginBlockFormatHandler: FormatHandler = { + parse: (format, element, _, defaultStyle) => { + const marginBlockStart = element.style.marginBlockStart || defaultStyle.marginBlockStart; + const marginBlockEnd = element.style.marginBlockEnd || defaultStyle.marginBlockEnd; + + if (marginBlockStart) { + format.marginBlockStart = parseValueWithUnit(marginBlockStart) + 'px'; + } + + if (marginBlockEnd) { + format.marginBlockEnd = parseValueWithUnit(marginBlockEnd) + 'px'; + } + }, + apply: (format, element, context) => { + const marginBlockStart = format.marginBlockStart; + const marginBlockEnd = format.marginBlockEnd; + + if (marginBlockStart) { + element.style.marginBlockStart = marginBlockStart; + } + + if (marginBlockEnd) { + element.style.marginBlockEnd = marginBlockEnd; + } + }, +}; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts index 0e0a3bb08c7..f04ac927324 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts @@ -20,6 +20,7 @@ import { linkFormatHandler } from './segment/linkFormatHandler'; import { listItemThreadFormatHandler } from './list/listItemThreadFormatHandler'; import { listLevelThreadFormatHandler } from './list/listLevelThreadFormatHandler'; import { listStyleFormatHandler } from './list/listStyleFormatHandler'; +import { marginBlockFormatHandler } from './block/marginBlockFormatHandler'; import { marginFormatHandler } from './block/marginFormatHandler'; import { paddingFormatHandler } from './block/paddingFormatHandler'; import { sizeFormatHandler } from './common/sizeFormatHandler'; @@ -72,6 +73,7 @@ const defaultFormatHandlerMap: FormatHandlers = { listLevelThread: listLevelThreadFormatHandler, listStyle: listStyleFormatHandler, margin: marginFormatHandler, + marginBlock: marginBlockFormatHandler, padding: paddingFormatHandler, size: sizeFormatHandler, strike: strikeFormatHandler, @@ -130,7 +132,15 @@ export const defaultFormatKeysPerCategory: { 'margin', 'listStyle', ], - listLevel: ['direction', 'textAlign', 'margin', 'padding', 'listStyle', 'backgroundColor'], + listLevel: [ + 'direction', + 'textAlign', + 'margin', + 'padding', + 'listStyle', + 'backgroundColor', + 'marginBlock', + ], styleBasedSegment: [...styleBasedSegmentFormats, 'textColor', 'backgroundColor', 'lineHeight'], elementBasedSegment: elementBasedSegmentFormats, segment: [ diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginBlockFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginBlockFormatHandlerTest.ts new file mode 100644 index 00000000000..20236118dd9 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginBlockFormatHandlerTest.ts @@ -0,0 +1,81 @@ +import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; +import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; +import { marginBlockFormatHandler } from '../../../lib/formatHandlers/block/marginBlockFormatHandler'; +import { + DomToModelContext, + MarginBlockFormat, + ModelToDomContext, +} from 'roosterjs-content-model-types'; + +describe('marginBlockFormatHandler.parse', () => { + let div: HTMLElement; + let format: MarginBlockFormat; + let context: DomToModelContext; + + beforeEach(() => { + div = document.createElement('div'); + format = {}; + context = createDomToModelContext(); + }); + + it('No margin block', () => { + marginBlockFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({}); + }); + + it('Has margin block in CSS', () => { + div.style.marginBlockEnd = '1px'; + div.style.marginBlockStart = '1px'; + marginBlockFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ + marginBlockEnd: '1px', + marginBlockStart: '1px', + }); + }); + + it('Has margin block in default style', () => { + marginBlockFormatHandler.parse(format, div, context, { + marginBlockEnd: '1em', + marginBlockStart: '1em', + }); + expect(format).toEqual({ + marginBlockEnd: '0px', + marginBlockStart: '0px', + }); + }); + + it('Merge margin values', () => { + div.style.marginBlockStart = '15pt'; + format.marginBlockStart = '30px'; + marginBlockFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ + marginBlockStart: '20px', + }); + }); +}); + +describe('marginBlockFormatHandler.apply', () => { + let div: HTMLElement; + let format: MarginBlockFormat; + let context: ModelToDomContext; + + beforeEach(() => { + div = document.createElement('div'); + format = {}; + context = createModelToDomContext(); + }); + + it('No margin block', () => { + marginBlockFormatHandler.apply(format, div, context); + expect(div.outerHTML).toBe('
'); + }); + + it('Has margin block', () => { + format.marginBlockEnd = '1px'; + format.marginBlockStart = '2px'; + + marginBlockFormatHandler.apply(format, div, context); + + expect(div.outerHTML).toBe('
'); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/setListType.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/setListType.ts index f29470f2ec3..3379c1779c9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/setListType.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/setListType.ts @@ -60,6 +60,8 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') direction: block.format.direction, textAlign: block.format.textAlign, marginTop: hasIgnoredParagraphBefore ? '0' : undefined, + marginBlockStart: '0px', + marginBlockEnd: '0px', }), ], // For list bullet, we only want to carry over these formats from segments: diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/list/setListTypeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/list/setListTypeTest.ts index 0774263e390..72013ea6e08 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/list/setListTypeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/list/setListTypeTest.ts @@ -72,6 +72,8 @@ describe('indent', () => { direction: undefined, textAlign: undefined, marginTop: undefined, + marginBlockEnd: '0px', + marginBlockStart: '0px', }, dataset: {}, }, @@ -299,6 +301,8 @@ describe('indent', () => { direction: undefined, textAlign: undefined, marginTop: undefined, + marginBlockEnd: '0px', + marginBlockStart: '0px', }, dataset: {}, }, @@ -362,6 +366,8 @@ describe('indent', () => { direction: 'rtl', textAlign: 'start', marginTop: undefined, + marginBlockEnd: '0px', + marginBlockStart: '0px', }, }, ], @@ -441,6 +447,8 @@ describe('indent', () => { textAlign: undefined, marginTop: undefined, marginBottom: '0', + marginBlockEnd: '0px', + marginBlockStart: '0px', }, }, ], @@ -469,6 +477,8 @@ describe('indent', () => { textAlign: undefined, startNumberOverride: undefined, marginTop: '0', + marginBlockEnd: '0px', + marginBlockStart: '0px', }, }, ], @@ -523,6 +533,8 @@ describe('indent', () => { direction: undefined, textAlign: undefined, marginTop: undefined, + marginBlockEnd: '0px', + marginBlockStart: '0px', }, }, ], @@ -579,6 +591,8 @@ describe('indent', () => { direction: undefined, textAlign: undefined, marginTop: undefined, + marginBlockEnd: '0px', + marginBlockStart: '0px', }, }, ], @@ -606,6 +620,8 @@ describe('indent', () => { direction: undefined, textAlign: undefined, marginTop: undefined, + marginBlockEnd: '0px', + marginBlockStart: '0px', }, }, ], @@ -633,6 +649,8 @@ describe('indent', () => { direction: undefined, textAlign: undefined, startNumberOverride: undefined, + marginBlockEnd: '0px', + marginBlockStart: '0px', }, }, ], diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemLevelFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemLevelFormat.ts index c0ea2bf60b6..0d3bcb88e33 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemLevelFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemLevelFormat.ts @@ -1,3 +1,4 @@ +import { MarginBlockFormat } from './formatParts/MarginBlockFormat'; import type { DirectionFormat } from './formatParts/DirectionFormat'; import type { ListStyleFormat } from './formatParts/ListStyleFormat'; import type { ListThreadFormat } from './formatParts/ListThreadFormat'; @@ -12,5 +13,6 @@ export type ContentModelListItemLevelFormat = ListThreadFormat & DirectionFormat & TextAlignFormat & MarginFormat & + MarginBlockFormat & PaddingFormat & ListStyleFormat; diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts b/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts index a8c79565701..444f729a49d 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts @@ -1,3 +1,4 @@ +import type { MarginBlockFormat } from './formatParts/MarginBlockFormat'; import type { BackgroundColorFormat } from './formatParts/BackgroundColorFormat'; import type { BoldFormat } from './formatParts/BoldFormat'; import type { BorderBoxFormat } from './formatParts/BorderBoxFormat'; @@ -146,6 +147,11 @@ export interface FormatHandlerTypeMap { */ margin: MarginFormat; + /** + * Format for MarginFormat + */ + marginBlock: MarginBlockFormat; + /** * Format for PaddingFormat */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginBlockFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginBlockFormat.ts new file mode 100644 index 00000000000..5a9df447336 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginBlockFormat.ts @@ -0,0 +1,14 @@ +/** + * Format of margin-block + */ +export type MarginBlockFormat = { + /** + * Margin-block start value + */ + marginBlockStart?: string; + + /** + * Margin-block end value + */ + marginBlockEnd?: string; +}; diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index 7f785f513bc..d1f87f65bac 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -34,6 +34,7 @@ export { BorderFormat } from './format/formatParts/BorderFormat'; export { DirectionFormat } from './format/formatParts/DirectionFormat'; export { HtmlAlignFormat } from './format/formatParts/HtmlAlignFormat'; export { MarginFormat } from './format/formatParts/MarginFormat'; +export { MarginBlockFormat } from './format/formatParts/MarginBlockFormat'; export { PaddingFormat } from './format/formatParts/PaddingFormat'; export { TextAlignFormat } from './format/formatParts/TextAlignFormat'; export { WhiteSpaceFormat } from './format/formatParts/WhiteSpaceFormat'; diff --git a/packages/roosterjs-editor-api/test/format/setIndentationTest.ts b/packages/roosterjs-editor-api/test/format/setIndentationTest.ts index db2c21de36f..56ffbbe4cf9 100644 --- a/packages/roosterjs-editor-api/test/format/setIndentationTest.ts +++ b/packages/roosterjs-editor-api/test/format/setIndentationTest.ts @@ -41,7 +41,7 @@ describe('setIndentation()', () => { editor.select(range); }, Indentation.Increase, - '
  1. Text
' + '
  1. Text
' ); }); @@ -54,7 +54,7 @@ describe('setIndentation()', () => { editor.select(range); }, Indentation.Decrease, - '
  1. Text
' + '
  1. Text
' ); }); diff --git a/packages/roosterjs-editor-api/test/format/toggleBlockQuoteTest.ts b/packages/roosterjs-editor-api/test/format/toggleBlockQuoteTest.ts index a366a90c54b..dd6b2e7ec5e 100644 --- a/packages/roosterjs-editor-api/test/format/toggleBlockQuoteTest.ts +++ b/packages/roosterjs-editor-api/test/format/toggleBlockQuoteTest.ts @@ -76,21 +76,21 @@ describe('toggleBlockQuote', () => { it('Multi-level list', () => { runTest( '
  1. a
    1. b
      1. c
      2. d
    2. e
  2. f
g
h
', - '
  1. a
    1. b
      1. c
      1. d
    1. e
  1. f
g
h
' + '
  1. a
    1. b
      1. c
      1. d
    1. e
  1. f
g
h
' ); }); it('Two multilevel lists', () => { runTest( '
  1. a
    1. b
      1. c
      2. d
    2. e
  2. f
g
h
  1. a
    1. b
      1. c
      2. d
    2. e
  2. f
', - '
  1. a
    1. b
      1. c
      1. d
    1. e
  1. f
g
h
  1. a
    1. b
      1. c
      1. d
    1. e
  1. f
' + '
  1. a
    1. b
      1. c
      1. d
    1. e
  1. f
g
h
  1. a
    1. b
      1. c
      1. d
    1. e
  1. f
' ); }); it('Whole list selected', () => { runTest( - '0
  1. a
    1. b
      1. c
      2. d
    2. e
  2. f
g
h
', - '
0
  1. a
    1. b
      1. c
      2. d
    2. e
  2. f
g
h
' + '
  1. a
    1. b
      1. c
      2. d
    2. e
  2. f
g
h
', + '
  1. a
    1. b
      1. c
      2. d
    2. e
  2. f
g
h
' ); }); @@ -118,7 +118,7 @@ describe('toggleBlockQuote', () => { it('Mixed table and list', () => { runTest( '
test1test2
test3test4
test5
  1. test6
  2. test7
', - '
test1test2
test3
test4
test5
  1. test6
  1. test7
' + '
test1test2
test3
test4
test5
  1. test6
  1. test7
' ); }); diff --git a/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts b/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts index db43cb22ac7..5cbdeb0d77d 100644 --- a/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts +++ b/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts @@ -32,7 +32,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
default format

  • test
' + '
default format

  • test
' ); }); @@ -53,7 +53,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
default format

  • test
  • test
' + '
default format

  • test
  • test
' ); }); @@ -74,7 +74,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
default format

  • test
  • test

' + '
default format

  • test
  • test

' ); }); @@ -95,7 +95,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
default format

  • test

  • test
' + '
default format

  • test

  • test
' ); }); }); diff --git a/packages/roosterjs-editor-dom/lib/list/VList.ts b/packages/roosterjs-editor-dom/lib/list/VList.ts index ab2f3b94ab9..c45375fdcb1 100644 --- a/packages/roosterjs-editor-dom/lib/list/VList.ts +++ b/packages/roosterjs-editor-dom/lib/list/VList.ts @@ -195,6 +195,7 @@ export default class VList { // Use a placeholder to hold the position since the root list may be moved into document fragment later this.rootList.parentNode!.replaceChild(placeholder, this.rootList); + this.rootList.style.marginBlock = '0px'; this.items.forEach(item => { const newListStart = item.getNewListStart(); @@ -205,7 +206,11 @@ export default class VList { } item.writeBack(listStack, this.rootList, shouldReuseAllAncestorListElements); - const topList = listStack[1]; + const topList = listStack[1] as HTMLElement; + if (topList) { + topList.style.marginBlockStart = '0px'; + topList.style.marginBlockEnd = '0px'; + } item.applyListStyle(this.rootList, start); @@ -309,7 +314,6 @@ export default class VList { * @param end End position to operate to * @param alignment Align items left, center or right */ - setAlignment( start: NodePosition, end: NodePosition, @@ -328,6 +332,13 @@ export default class VList { }); } + /** + * AdjustListMarginBlock + */ + adjustListMarginBlock() { + this.rootList.style.marginBlock = '0px'; + } + /** * Change list type of the given range of this list. * If some of the items are not real list item yet, this will make them to be list item with given type diff --git a/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts b/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts index ddc83fb1465..634ca337ab1 100644 --- a/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts +++ b/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts @@ -139,6 +139,5 @@ function createVListFromItemNode(node: Node): VList { // Create the VList and append items const vList = new VList(listNode); vList.appendItem(nodeForItem, ListType.None); - return vList; } diff --git a/packages/roosterjs-editor-dom/test/list/VListChainTest.ts b/packages/roosterjs-editor-dom/test/list/VListChainTest.ts index e2f5b873adf..fe0a20f37be 100644 --- a/packages/roosterjs-editor-dom/test/list/VListChainTest.ts +++ b/packages/roosterjs-editor-dom/test/list/VListChainTest.ts @@ -316,7 +316,11 @@ describe('VListChain.commit', () => { } it('No op', () => { - runTest('
  1. test
', () => {}, '
  1. test
'); + runTest( + '
  1. test
', + () => {}, + '
  1. test
' + ); }); it('Add a new list', () => { @@ -332,7 +336,7 @@ describe('VListChain.commit', () => { ); vList.writeBack(); }, - '
  1. test
  1. test2
' + '
  1. test
  1. test2
' ); }); @@ -349,7 +353,7 @@ describe('VListChain.commit', () => { ); vList.writeBack(); }, - '
  1. item1
  1. item2
  1. item3
' + '
  1. item1
  1. item2
  1. item3
' ); }); @@ -366,7 +370,7 @@ describe('VListChain.commit', () => { ); vList.writeBack(); }, - '
  1. item1
  1. itemA
  2. itemB
  3. itemC
  1. item2
  1. item3
' + '
  1. item1
  1. itemA
  2. itemB
  3. itemC
  1. item2
  1. item3
' ); }); @@ -379,7 +383,7 @@ describe('VListChain.commit', () => { li.innerHTML = 'item2'; ol1.appendChild(li); }, - '
  1. item1
  2. item2
  1. item3
' + '
  1. item1
  2. item2
  1. item3
' ); }); @@ -390,7 +394,7 @@ describe('VListChain.commit', () => { const li = document.getElementById('li2'); li.parentNode.removeChild(li); }, - '
  1. item1
  1. item3
' + '
  1. item1
  1. item3
' ); }); @@ -401,7 +405,7 @@ describe('VListChain.commit', () => { const ol = document.getElementById('ol2'); ol.parentNode.removeChild(ol); }, - '
  1. item1
  2. item2
  1. item4
' + '
  1. item1
  2. item2
  1. item4
' ); }); @@ -412,7 +416,7 @@ describe('VListChain.commit', () => { const li = document.getElementById('li1'); li.parentNode.removeChild(li); }, - '
  1. item2
  1. item3
' + '
  1. item2
  1. item3
' ); }); @@ -423,7 +427,7 @@ describe('VListChain.commit', () => { const ol = document.getElementById('ol1'); ol.parentNode.removeChild(ol); }, - '
  1. item3
  1. item4
' + '
  1. item3
  1. item4
' ); }); }); diff --git a/packages/roosterjs-editor-dom/test/list/VListTest.ts b/packages/roosterjs-editor-dom/test/list/VListTest.ts index dd3af36b065..850b56513f1 100644 --- a/packages/roosterjs-editor-dom/test/list/VListTest.ts +++ b/packages/roosterjs-editor-dom/test/list/VListTest.ts @@ -53,16 +53,19 @@ describe('VList.ctor', () => { }); it('nested UL in OL', () => { - runTest(`
  1. line1
    • line2
`, [ - { - listTypes: [ListType.None, ListType.Ordered], - outerHTML: '
  • line1
  • ', - }, - { - listTypes: [ListType.None, ListType.Ordered, ListType.Unordered], - outerHTML: '
  • line2
  • ', - }, - ]); + runTest( + `
    1. line1
      • line2
    `, + [ + { + listTypes: [ListType.None, ListType.Ordered], + outerHTML: '
  • line1
  • ', + }, + { + listTypes: [ListType.None, ListType.Ordered, ListType.Unordered], + outerHTML: '
  • line2
  • ', + }, + ] + ); }); it('orphan item that will be merged', () => { @@ -109,7 +112,7 @@ describe('VList.ctor', () => { runTest( `
      ` + '
    1. line0
    2. ' + - '
        ' + + '
          ' + '
          line1
          ' + '
        • line2
        • ' + '
          line3
          ' + @@ -197,14 +200,14 @@ describe('VList.ctor', () => { it('disconnected nested list', () => { runTest( `
            ` + - '
          1. line1
            • line2
            • line3
            line4
          2. ' + + '
          3. line1
            • line2
            • line3
            line4
          4. ' + '
          5. line5
          6. ' + '
          ', [ { listTypes: [ListType.None, ListType.Ordered], outerHTML: - '
        • line1
          • line2
          • line3
          line4
        • ', + '
        • line1
          • line2
          • line3
          line4
        • ', }, { listTypes: [ListType.None, ListType.Ordered], @@ -294,7 +297,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
          1. test
          ' + '
          1. test
          ' ); }); @@ -310,7 +313,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
          1. item1
          2. item2
          ' + '
          1. item1
          2. item2
          ' ); }); @@ -330,7 +333,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
          1. item1
          • bullet
          1. item2
          ' + '
          1. item1
          • bullet
          1. item2
          ' ); }); @@ -346,7 +349,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
          • item1
          1. item2
          ' + '
          • item1
          1. item2
          ' ); }); @@ -366,7 +369,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
            1. item1
          • item2
          1. item3
          ' + '
            1. item1
          • item2
          1. item3
          ' ); }); @@ -386,7 +389,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
          • item1
          • item2
          1. item3
          ' + '
          • item1
          • item2
          1. item3
          ' ); }); @@ -406,7 +409,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
          • item1
          • item2
          1. item3
          ' + '
          • item1
          • item2
          1. item3
          ' ); }); @@ -426,7 +429,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
          • item1
          item2
          1. item3
          ' + '
          • item1
          item2
          1. item3
          ' ); }); @@ -440,7 +443,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
          1. item1
          ', + '
          1. item1
          ', ol ); }); @@ -467,7 +470,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
          1. item3
            1. item3.1
          • bullet
          1. item4
          ', + '
          1. item3
            1. item3.1
          • bullet
          1. item4
          ', ol ); }); @@ -498,14 +501,14 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
          text
          1. item3
          2. item4
          text
          1. item5
          ', + '
          text
          1. item3
          2. item4
          text
          1. item5
          ', ol ); }); it('Write back with Lists with list item types', () => { const styledList = - '
          1. 123
            1. 123
              1. 123

          '; + '
          1. 123
            1. 123
              1. 123

          '; const div = document.createElement('div'); document.body.append(div); div.innerHTML = styledList; @@ -598,7 +601,7 @@ describe('VList.setIndentation', () => { it('deep list', () => { runTest( - `
          1. line1
            • line2
          `, + `
          1. line1
            • line2
          `, [ { listTypes: [ListType.None, ListType.Ordered, ListType.Ordered], @@ -632,7 +635,7 @@ describe('VList.setIndentation', () => { `
            ` + '
          1. line1
          2. ' + `
          3. line2
          4. ` + - '
              ' + + '
                ' + `
              • line3
              • ` + '
              • line4
              • ' + '
              ' + @@ -686,7 +689,7 @@ describe('VList.setIndentation', () => { `
                ` + '
              1. line1
              2. ' + `
              3. line2
              4. ` + - '
                  ' + + '
                    ' + `
                  • line3
                  • ` + '
                  • line4
                  • ' + '
                  ' + @@ -830,7 +833,7 @@ describe('VList.changeListType', () => { it('deep list', () => { runTest( - `
                  1. line1
                    • line2
                  `, + `
                  1. line1
                    • line2
                  `, [ { listTypes: [ListType.None], @@ -869,7 +872,7 @@ describe('VList.changeListType', () => { `
                    ` + '
                  1. line1
                  2. ' + `
                  3. line2
                  4. ` + - '
                      ' + + '
                        ' + `
                      • line3
                      • ` + '
                      • line4
                      • ' + '
                      ' + @@ -935,9 +938,9 @@ describe('VList.changeListType', () => { runTest( `
                        ` + `
                      1. line1
                      2. ` + - '
                          ' + + '
                            ' + '
                          1. line2
                          2. ' + - '
                              ' + + '
                                ' + `
                              1. line3
                              2. ` + '
                              ' + '
                            ' + @@ -1194,7 +1197,7 @@ describe('VList.mergeVList', () => { it('List 2 has deep orphan item that cannot be merged', () => { runTest( `
                            1. line1
                            ` + - `
                                line2
                              • line3
                            `, + `
                                line2
                              • line3
                            `, [ { listTypes: [ListType.None, ListType.Ordered], @@ -1214,8 +1217,8 @@ describe('VList.mergeVList', () => { it('List 2 has deep orphan item that can be merged', () => { runTest( - `
                              • line1
                            ` + - `
                                line2
                              • line3
                            `, + `
                              • line1
                            ` + + `
                                line2
                              • line3
                            `, [ { listTypes: [ListType.None, ListType.Ordered, ListType.Unordered], @@ -1258,30 +1261,30 @@ describe('VList.split', () => { it('split List', () => { runTest( `
                            1. item1
                            2. bullet
                            3. item2
                            `, - '
                            1. item1
                            1. bullet
                            2. item2
                            ' + '
                            1. item1
                            1. bullet
                            2. item2
                            ' ); }); it('split List 2', () => { runTest( `
                            1. item1
                            2. bullet
                            3. item2
                            `, - '
                            1. item1
                            1. bullet
                            2. item2
                            ', + '
                            1. item1
                            1. bullet
                            2. item2
                            ', 5 ); }); it('split List 3', () => { runTest( - `
                            1. 1
                              1. 1
                              2. 2
                              3. 3
                            2. 3
                            3. 4
                            `, - '
                            1. 1
                              1. 1
                              1. 2
                              2. 3
                            1. 3
                            2. 4
                            ', + `
                            1. 1
                              1. 1
                              2. 2
                              3. 3
                            2. 3
                            3. 4
                            `, + '
                            1. 1
                              1. 1
                              1. 2
                              2. 3
                            1. 3
                            2. 4
                            ', 1 ); }); it('split List 4', () => { runTest( - `
                            1. 1
                              1. 1
                              2. 2
                              3. 3
                            2. 3
                            3. 4
                            `, - '
                            1. 1
                              1. 1
                              2. 2
                              3. 3
                            2. 3
                            3. 4
                            ', + `
                            1. 1
                              1. 1
                              2. 2
                              3. 3
                            2. 3
                            3. 4
                            `, + '
                            1. 1
                              1. 1
                              2. 2
                              3. 3
                            2. 3
                            3. 4
                            ', 9 ); }); From e331e6acbc92d70fb641c7491afc7b9d2343f321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 16 Oct 2023 20:01:20 -0300 Subject: [PATCH 002/111] remove code --- .../lib/format/FormatHandlerTypeMap.ts | 2 +- packages/roosterjs-editor-dom/lib/list/VList.ts | 7 ------- .../roosterjs-editor-dom/lib/list/createVListFromRegion.ts | 1 + 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts b/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts index 444f729a49d..12f99973894 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts @@ -148,7 +148,7 @@ export interface FormatHandlerTypeMap { margin: MarginFormat; /** - * Format for MarginFormat + * Format for MarginBlockFormat */ marginBlock: MarginBlockFormat; diff --git a/packages/roosterjs-editor-dom/lib/list/VList.ts b/packages/roosterjs-editor-dom/lib/list/VList.ts index c45375fdcb1..7965f8a14ee 100644 --- a/packages/roosterjs-editor-dom/lib/list/VList.ts +++ b/packages/roosterjs-editor-dom/lib/list/VList.ts @@ -332,13 +332,6 @@ export default class VList { }); } - /** - * AdjustListMarginBlock - */ - adjustListMarginBlock() { - this.rootList.style.marginBlock = '0px'; - } - /** * Change list type of the given range of this list. * If some of the items are not real list item yet, this will make them to be list item with given type diff --git a/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts b/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts index 634ca337ab1..ddc83fb1465 100644 --- a/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts +++ b/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts @@ -139,5 +139,6 @@ function createVListFromItemNode(node: Node): VList { // Create the VList and append items const vList = new VList(listNode); vList.appendItem(nodeForItem, ListType.None); + return vList; } From 2c94b1f81bab538bc1db56bc5a6bc2b2e8bf6116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 16 Oct 2023 20:03:59 -0300 Subject: [PATCH 003/111] type --- .../lib/format/ContentModelListItemLevelFormat.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemLevelFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemLevelFormat.ts index 0d3bcb88e33..b6a8d3f30d1 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemLevelFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemLevelFormat.ts @@ -1,4 +1,4 @@ -import { MarginBlockFormat } from './formatParts/MarginBlockFormat'; +import type { MarginBlockFormat } from './formatParts/MarginBlockFormat'; import type { DirectionFormat } from './formatParts/DirectionFormat'; import type { ListStyleFormat } from './formatParts/ListStyleFormat'; import type { ListThreadFormat } from './formatParts/ListThreadFormat'; From 5e0f45d1f4cc8a42c07eddd5408b08fdd88193c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 18 Oct 2023 10:32:30 -0300 Subject: [PATCH 004/111] refactor --- .../block/marginBlockFormatHandler.ts | 33 -------- .../block/marginFormatHandler.ts | 20 +++++ .../formatHandlers/defaultFormatHandlers.ts | 12 +-- .../block/marginBlockFormatHandlerTest.ts | 81 ------------------- .../block/marginFormatHandlerTest.ts | 70 ++++++++++++++++ .../lib/modelApi/list/setListType.ts | 2 +- .../format/ContentModelListItemLevelFormat.ts | 2 - .../lib/format/FormatHandlerTypeMap.ts | 6 -- .../format/formatParts/MarginBlockFormat.ts | 14 ---- .../lib/format/formatParts/MarginFormat.ts | 10 +++ .../lib/index.ts | 1 - .../roosterjs-editor-dom/lib/list/VList.ts | 5 +- 12 files changed, 104 insertions(+), 152 deletions(-) delete mode 100644 packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginBlockFormatHandler.ts delete mode 100644 packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginBlockFormatHandlerTest.ts delete mode 100644 packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginBlockFormat.ts diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginBlockFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginBlockFormatHandler.ts deleted file mode 100644 index 37f0818c7b8..00000000000 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginBlockFormatHandler.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { parseValueWithUnit } from '../utils/parseValueWithUnit'; -import type { FormatHandler } from '../FormatHandler'; -import type { MarginBlockFormat } from 'roosterjs-content-model-types'; - -/** - * @internal - */ -export const marginBlockFormatHandler: FormatHandler = { - parse: (format, element, _, defaultStyle) => { - const marginBlockStart = element.style.marginBlockStart || defaultStyle.marginBlockStart; - const marginBlockEnd = element.style.marginBlockEnd || defaultStyle.marginBlockEnd; - - if (marginBlockStart) { - format.marginBlockStart = parseValueWithUnit(marginBlockStart) + 'px'; - } - - if (marginBlockEnd) { - format.marginBlockEnd = parseValueWithUnit(marginBlockEnd) + 'px'; - } - }, - apply: (format, element, context) => { - const marginBlockStart = format.marginBlockStart; - const marginBlockEnd = format.marginBlockEnd; - - if (marginBlockStart) { - element.style.marginBlockStart = marginBlockStart; - } - - if (marginBlockEnd) { - element.style.marginBlockEnd = marginBlockEnd; - } - }, -}; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginFormatHandler.ts index 823735279b9..28d2b576ea7 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/block/marginFormatHandler.ts @@ -35,6 +35,18 @@ export const marginFormatHandler: FormatHandler = { } } }); + + const marginBlockStart = element.style.marginBlockStart || defaultStyle.marginBlockStart; + const marginTop = element.style.marginTop || defaultStyle.marginTop; + if (marginBlockStart && !marginTop) { + format.marginBlockStart = parseValueWithUnit(marginBlockStart) + 'px'; + } + + const marginBlockEnd = element.style.marginBlockEnd || defaultStyle.marginBlockEnd; + const marginBottom = element.style.marginBottom || defaultStyle.marginBottom; + if (marginBlockEnd && !marginBottom) { + format.marginBlockEnd = parseValueWithUnit(marginBlockEnd) + 'px'; + } }, apply: (format, element, context) => { MarginKeys.forEach(key => { @@ -44,5 +56,13 @@ export const marginFormatHandler: FormatHandler = { element.style[key] = value || '0'; } }); + + if (format.marginBlockStart && !format.marginTop) { + element.style.marginBlockStart = format.marginBlockStart; + } + + if (format.marginBlockEnd && !format.marginBottom) { + element.style.marginBlockEnd = format.marginBlockEnd; + } }, }; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts index f04ac927324..0e0a3bb08c7 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts @@ -20,7 +20,6 @@ import { linkFormatHandler } from './segment/linkFormatHandler'; import { listItemThreadFormatHandler } from './list/listItemThreadFormatHandler'; import { listLevelThreadFormatHandler } from './list/listLevelThreadFormatHandler'; import { listStyleFormatHandler } from './list/listStyleFormatHandler'; -import { marginBlockFormatHandler } from './block/marginBlockFormatHandler'; import { marginFormatHandler } from './block/marginFormatHandler'; import { paddingFormatHandler } from './block/paddingFormatHandler'; import { sizeFormatHandler } from './common/sizeFormatHandler'; @@ -73,7 +72,6 @@ const defaultFormatHandlerMap: FormatHandlers = { listLevelThread: listLevelThreadFormatHandler, listStyle: listStyleFormatHandler, margin: marginFormatHandler, - marginBlock: marginBlockFormatHandler, padding: paddingFormatHandler, size: sizeFormatHandler, strike: strikeFormatHandler, @@ -132,15 +130,7 @@ export const defaultFormatKeysPerCategory: { 'margin', 'listStyle', ], - listLevel: [ - 'direction', - 'textAlign', - 'margin', - 'padding', - 'listStyle', - 'backgroundColor', - 'marginBlock', - ], + listLevel: ['direction', 'textAlign', 'margin', 'padding', 'listStyle', 'backgroundColor'], styleBasedSegment: [...styleBasedSegmentFormats, 'textColor', 'backgroundColor', 'lineHeight'], elementBasedSegment: elementBasedSegmentFormats, segment: [ diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginBlockFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginBlockFormatHandlerTest.ts deleted file mode 100644 index 20236118dd9..00000000000 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginBlockFormatHandlerTest.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; -import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; -import { marginBlockFormatHandler } from '../../../lib/formatHandlers/block/marginBlockFormatHandler'; -import { - DomToModelContext, - MarginBlockFormat, - ModelToDomContext, -} from 'roosterjs-content-model-types'; - -describe('marginBlockFormatHandler.parse', () => { - let div: HTMLElement; - let format: MarginBlockFormat; - let context: DomToModelContext; - - beforeEach(() => { - div = document.createElement('div'); - format = {}; - context = createDomToModelContext(); - }); - - it('No margin block', () => { - marginBlockFormatHandler.parse(format, div, context, {}); - expect(format).toEqual({}); - }); - - it('Has margin block in CSS', () => { - div.style.marginBlockEnd = '1px'; - div.style.marginBlockStart = '1px'; - marginBlockFormatHandler.parse(format, div, context, {}); - expect(format).toEqual({ - marginBlockEnd: '1px', - marginBlockStart: '1px', - }); - }); - - it('Has margin block in default style', () => { - marginBlockFormatHandler.parse(format, div, context, { - marginBlockEnd: '1em', - marginBlockStart: '1em', - }); - expect(format).toEqual({ - marginBlockEnd: '0px', - marginBlockStart: '0px', - }); - }); - - it('Merge margin values', () => { - div.style.marginBlockStart = '15pt'; - format.marginBlockStart = '30px'; - marginBlockFormatHandler.parse(format, div, context, {}); - expect(format).toEqual({ - marginBlockStart: '20px', - }); - }); -}); - -describe('marginBlockFormatHandler.apply', () => { - let div: HTMLElement; - let format: MarginBlockFormat; - let context: ModelToDomContext; - - beforeEach(() => { - div = document.createElement('div'); - format = {}; - context = createModelToDomContext(); - }); - - it('No margin block', () => { - marginBlockFormatHandler.apply(format, div, context); - expect(div.outerHTML).toBe('
                            '); - }); - - it('Has margin block', () => { - format.marginBlockEnd = '1px'; - format.marginBlockStart = '2px'; - - marginBlockFormatHandler.apply(format, div, context); - - expect(div.outerHTML).toBe('
                            '); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginFormatHandlerTest.ts index 6ad5cad7a04..da4aa15fd5a 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/block/marginFormatHandlerTest.ts @@ -49,6 +49,49 @@ describe('marginFormatHandler.parse', () => { marginLeft: '50px', }); }); + + it('Has margin block in CSS', () => { + div.style.marginBlockEnd = '1px'; + div.style.marginBlockStart = '1px'; + marginFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ + marginBlockEnd: '1px', + marginBlockStart: '1px', + }); + }); + + it('Has margin block in default style', () => { + marginFormatHandler.parse(format, div, context, { + marginBlockEnd: '1em', + marginBlockStart: '1em', + }); + expect(format).toEqual({ + marginBlockEnd: '0px', + marginBlockStart: '0px', + }); + }); + + it('Merge margin values', () => { + div.style.marginBlockStart = '15pt'; + format.marginBlockStart = '30px'; + marginFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ + marginBlockStart: '20px', + }); + }); + + it('Do not overlay margin values with margin block values', () => { + div.style.margin = '1px 2px 3px 4px'; + div.style.marginBlockEnd = '5px'; + div.style.marginBlockStart = '6px'; + marginFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ + marginTop: '1px', + marginRight: '2px', + marginBottom: '3px', + marginLeft: '4px', + }); + }); }); describe('marginFormatHandler.apply', () => { @@ -110,4 +153,31 @@ describe('marginFormatHandler.apply', () => { marginFormatHandler.apply(format, div, context); expect(div.outerHTML).toBe('
                            '); }); + + it('No margin block', () => { + marginFormatHandler.apply(format, div, context); + expect(div.outerHTML).toBe('
                            '); + }); + + it('Has margin block', () => { + format.marginBlockEnd = '1px'; + format.marginBlockStart = '2px'; + + marginFormatHandler.apply(format, div, context); + + expect(div.outerHTML).toBe('
                            '); + }); + + it('Do not overlay margin values with margin block values', () => { + format.marginTop = '1px'; + format.marginRight = '2px'; + format.marginBottom = '3px'; + format.marginLeft = '4px'; + format.marginBlockEnd = '5px'; + format.marginBlockStart = '6px'; + + marginFormatHandler.apply(format, div, context); + + expect(div.outerHTML).toBe('
                            '); + }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/setListType.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/setListType.ts index 3379c1779c9..9d6427994c4 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/setListType.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/setListType.ts @@ -60,8 +60,8 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') direction: block.format.direction, textAlign: block.format.textAlign, marginTop: hasIgnoredParagraphBefore ? '0' : undefined, - marginBlockStart: '0px', marginBlockEnd: '0px', + marginBlockStart: '0px', }), ], // For list bullet, we only want to carry over these formats from segments: diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemLevelFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemLevelFormat.ts index b6a8d3f30d1..c0ea2bf60b6 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemLevelFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelListItemLevelFormat.ts @@ -1,4 +1,3 @@ -import type { MarginBlockFormat } from './formatParts/MarginBlockFormat'; import type { DirectionFormat } from './formatParts/DirectionFormat'; import type { ListStyleFormat } from './formatParts/ListStyleFormat'; import type { ListThreadFormat } from './formatParts/ListThreadFormat'; @@ -13,6 +12,5 @@ export type ContentModelListItemLevelFormat = ListThreadFormat & DirectionFormat & TextAlignFormat & MarginFormat & - MarginBlockFormat & PaddingFormat & ListStyleFormat; diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts b/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts index 12f99973894..a8c79565701 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts @@ -1,4 +1,3 @@ -import type { MarginBlockFormat } from './formatParts/MarginBlockFormat'; import type { BackgroundColorFormat } from './formatParts/BackgroundColorFormat'; import type { BoldFormat } from './formatParts/BoldFormat'; import type { BorderBoxFormat } from './formatParts/BorderBoxFormat'; @@ -147,11 +146,6 @@ export interface FormatHandlerTypeMap { */ margin: MarginFormat; - /** - * Format for MarginBlockFormat - */ - marginBlock: MarginBlockFormat; - /** * Format for PaddingFormat */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginBlockFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginBlockFormat.ts deleted file mode 100644 index 5a9df447336..00000000000 --- a/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginBlockFormat.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Format of margin-block - */ -export type MarginBlockFormat = { - /** - * Margin-block start value - */ - marginBlockStart?: string; - - /** - * Margin-block end value - */ - marginBlockEnd?: string; -}; diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginFormat.ts index 7377b7f3588..36deed9dcbc 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/MarginFormat.ts @@ -21,4 +21,14 @@ export type MarginFormat = { * Margin left value */ marginLeft?: string; + + /** + * Margin-block start value + */ + marginBlockStart?: string; + + /** + * Margin-block end value + */ + marginBlockEnd?: string; }; diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index d1f87f65bac..7f785f513bc 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -34,7 +34,6 @@ export { BorderFormat } from './format/formatParts/BorderFormat'; export { DirectionFormat } from './format/formatParts/DirectionFormat'; export { HtmlAlignFormat } from './format/formatParts/HtmlAlignFormat'; export { MarginFormat } from './format/formatParts/MarginFormat'; -export { MarginBlockFormat } from './format/formatParts/MarginBlockFormat'; export { PaddingFormat } from './format/formatParts/PaddingFormat'; export { TextAlignFormat } from './format/formatParts/TextAlignFormat'; export { WhiteSpaceFormat } from './format/formatParts/WhiteSpaceFormat'; diff --git a/packages/roosterjs-editor-dom/lib/list/VList.ts b/packages/roosterjs-editor-dom/lib/list/VList.ts index 7965f8a14ee..6dfb8e29b1f 100644 --- a/packages/roosterjs-editor-dom/lib/list/VList.ts +++ b/packages/roosterjs-editor-dom/lib/list/VList.ts @@ -195,7 +195,6 @@ export default class VList { // Use a placeholder to hold the position since the root list may be moved into document fragment later this.rootList.parentNode!.replaceChild(placeholder, this.rootList); - this.rootList.style.marginBlock = '0px'; this.items.forEach(item => { const newListStart = item.getNewListStart(); @@ -208,8 +207,8 @@ export default class VList { item.writeBack(listStack, this.rootList, shouldReuseAllAncestorListElements); const topList = listStack[1] as HTMLElement; if (topList) { - topList.style.marginBlockStart = '0px'; - topList.style.marginBlockEnd = '0px'; + topList.style.marginBlockStart = !topList.style.marginTop ? '0px' : ''; + topList.style.marginBlockEnd = !topList.style.marginBottom ? '0px' : ''; } item.applyListStyle(this.rootList, start); From 0f7ca825133eb6f65b9b9690228712b56ddb75dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 19 Oct 2023 16:26:20 -0300 Subject: [PATCH 005/111] fixes --- .../lib/utils/toggleListType.ts | 14 ++++----- .../test/format/setIndentationTest.ts | 4 +-- .../test/format/toggleBlockQuoteTest.ts | 8 ++--- .../roosterjs-editor-dom/lib/list/VList.ts | 17 ++++++++--- .../lib/list/createVListFromRegion.ts | 5 +++- .../test/list/VListChainTest.ts | 22 ++++++-------- .../test/list/VListTest.ts | 30 +++++++++---------- 7 files changed, 53 insertions(+), 47 deletions(-) diff --git a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts index a502bc769c8..e7457b8258a 100644 --- a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts +++ b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts @@ -49,20 +49,18 @@ export default function toggleListType( if (!block) { return; } - const vList = - chain && end && start?.equalTo(end) - ? chain.createVListAtBlock(block, startNumber) - : createVListFromRegion( - region, - startNumber === 1 ? false : includeSiblingLists - ); + const isExistingList = chain && end && start?.equalTo(end); + const vList = isExistingList + ? chain.createVListAtBlock(block, startNumber) + : createVListFromRegion(region, startNumber === 1 ? false : includeSiblingLists); if (vList && start && end) { vList.changeListType(start, end, listType); vList.setListStyleType(orderedStyle, unorderedStyle); vList.writeBack( editor.isFeatureEnabled(ExperimentalFeatures.ReuseAllAncestorListElements), - editor.isFeatureEnabled(ExperimentalFeatures.DisableListChain) + editor.isFeatureEnabled(ExperimentalFeatures.DisableListChain), + !isExistingList ); } }, diff --git a/packages/roosterjs-editor-api/test/format/setIndentationTest.ts b/packages/roosterjs-editor-api/test/format/setIndentationTest.ts index 56ffbbe4cf9..db2c21de36f 100644 --- a/packages/roosterjs-editor-api/test/format/setIndentationTest.ts +++ b/packages/roosterjs-editor-api/test/format/setIndentationTest.ts @@ -41,7 +41,7 @@ describe('setIndentation()', () => { editor.select(range); }, Indentation.Increase, - '
                            1. Text
                            ' + '
                            1. Text
                            ' ); }); @@ -54,7 +54,7 @@ describe('setIndentation()', () => { editor.select(range); }, Indentation.Decrease, - '
                            1. Text
                            ' + '
                            1. Text
                            ' ); }); diff --git a/packages/roosterjs-editor-api/test/format/toggleBlockQuoteTest.ts b/packages/roosterjs-editor-api/test/format/toggleBlockQuoteTest.ts index dd6b2e7ec5e..83e80e8caed 100644 --- a/packages/roosterjs-editor-api/test/format/toggleBlockQuoteTest.ts +++ b/packages/roosterjs-editor-api/test/format/toggleBlockQuoteTest.ts @@ -76,21 +76,21 @@ describe('toggleBlockQuote', () => { it('Multi-level list', () => { runTest( '
                            1. a
                              1. b
                                1. c
                                2. d
                              2. e
                            2. f
                            g
                            h
                            ', - '
                            1. a
                              1. b
                                1. c
                                1. d
                              1. e
                            1. f
                            g
                            h
                            ' + '
                            1. a
                              1. b
                                1. c
                                1. d
                              1. e
                            1. f
                            g
                            h
                            ' ); }); it('Two multilevel lists', () => { runTest( '
                            1. a
                              1. b
                                1. c
                                2. d
                              2. e
                            2. f
                            g
                            h
                            1. a
                              1. b
                                1. c
                                2. d
                              2. e
                            2. f
                            ', - '
                            1. a
                              1. b
                                1. c
                                1. d
                              1. e
                            1. f
                            g
                            h
                            1. a
                              1. b
                                1. c
                                1. d
                              1. e
                            1. f
                            ' + '
                            1. a
                              1. b
                                1. c
                                1. d
                              1. e
                            1. f
                            g
                            h
                            1. a
                              1. b
                                1. c
                                1. d
                              1. e
                            1. f
                            ' ); }); it('Whole list selected', () => { runTest( '
                            1. a
                              1. b
                                1. c
                                2. d
                              2. e
                            2. f
                            g
                            h
                            ', - '
                            1. a
                              1. b
                                1. c
                                2. d
                              2. e
                            2. f
                            g
                            h
                            ' + '
                            1. a
                              1. b
                                1. c
                                2. d
                              2. e
                            2. f
                            g
                            h
                            ' ); }); @@ -118,7 +118,7 @@ describe('toggleBlockQuote', () => { it('Mixed table and list', () => { runTest( '
                            test1test2
                            test3test4
                            test5
                            1. test6
                            2. test7
                            ', - '
                            test1test2
                            test3
                            test4
                            test5
                            1. test6
                            1. test7
                            ' + '
                            test1test2
                            test3
                            test4
                            test5
                            1. test6
                            1. test7
                            ' ); }); diff --git a/packages/roosterjs-editor-dom/lib/list/VList.ts b/packages/roosterjs-editor-dom/lib/list/VList.ts index 6dfb8e29b1f..47099daa7cf 100644 --- a/packages/roosterjs-editor-dom/lib/list/VList.ts +++ b/packages/roosterjs-editor-dom/lib/list/VList.ts @@ -182,7 +182,11 @@ export default class VList { * @param shouldReuseAllAncestorListElements Optional - defaults to false. * @param disableListChain Whether we want to disable list chain functionality. @default false */ - writeBack(shouldReuseAllAncestorListElements?: boolean, disableListChain?: boolean) { + writeBack( + shouldReuseAllAncestorListElements?: boolean, + disableListChain?: boolean, + newList?: boolean + ) { if (!this.rootList) { throw new Error('rootList must not be null'); } @@ -206,9 +210,9 @@ export default class VList { item.writeBack(listStack, this.rootList, shouldReuseAllAncestorListElements); const topList = listStack[1] as HTMLElement; - if (topList) { - topList.style.marginBlockStart = !topList.style.marginTop ? '0px' : ''; - topList.style.marginBlockEnd = !topList.style.marginBottom ? '0px' : ''; + if (newList && topList) { + topList.style.marginBlockEnd = '0px'; + topList.style.marginBlockStart = '0px'; } item.applyListStyle(this.rootList, start); @@ -517,6 +521,11 @@ export default class VList { } }); } + + removeMargins(list: VList) { + list.rootList.style.marginBlockEnd = '0px'; + list.rootList.style.marginBlockStart = '0px'; + } } //Normalization diff --git a/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts b/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts index ddc83fb1465..995557de1c0 100644 --- a/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts +++ b/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts @@ -47,7 +47,6 @@ export default function createVListFromRegion( ); blocks.forEach(block => { const list = getRootListNode(region, ListSelector, block.getStartNode()); - if (list) { if (nodes[nodes.length - 1] != list) { nodes.push(list); @@ -134,6 +133,10 @@ function createVListFromItemNode(node: Node): VList { // Create a temporary OL root element for this list. const listNode = node.ownerDocument!.createElement('ol'); // Either OL or UL is ok here + + listNode.style.marginBlockStart = '0px'; + listNode.style.marginBlockEnd = '0px'; + node.appendChild(listNode); // Create the VList and append items diff --git a/packages/roosterjs-editor-dom/test/list/VListChainTest.ts b/packages/roosterjs-editor-dom/test/list/VListChainTest.ts index fe0a20f37be..e2f5b873adf 100644 --- a/packages/roosterjs-editor-dom/test/list/VListChainTest.ts +++ b/packages/roosterjs-editor-dom/test/list/VListChainTest.ts @@ -316,11 +316,7 @@ describe('VListChain.commit', () => { } it('No op', () => { - runTest( - '
                            1. test
                            ', - () => {}, - '
                            1. test
                            ' - ); + runTest('
                            1. test
                            ', () => {}, '
                            1. test
                            '); }); it('Add a new list', () => { @@ -336,7 +332,7 @@ describe('VListChain.commit', () => { ); vList.writeBack(); }, - '
                            1. test
                            1. test2
                            ' + '
                            1. test
                            1. test2
                            ' ); }); @@ -353,7 +349,7 @@ describe('VListChain.commit', () => { ); vList.writeBack(); }, - '
                            1. item1
                            1. item2
                            1. item3
                            ' + '
                            1. item1
                            1. item2
                            1. item3
                            ' ); }); @@ -370,7 +366,7 @@ describe('VListChain.commit', () => { ); vList.writeBack(); }, - '
                            1. item1
                            1. itemA
                            2. itemB
                            3. itemC
                            1. item2
                            1. item3
                            ' + '
                            1. item1
                            1. itemA
                            2. itemB
                            3. itemC
                            1. item2
                            1. item3
                            ' ); }); @@ -383,7 +379,7 @@ describe('VListChain.commit', () => { li.innerHTML = 'item2'; ol1.appendChild(li); }, - '
                            1. item1
                            2. item2
                            1. item3
                            ' + '
                            1. item1
                            2. item2
                            1. item3
                            ' ); }); @@ -394,7 +390,7 @@ describe('VListChain.commit', () => { const li = document.getElementById('li2'); li.parentNode.removeChild(li); }, - '
                            1. item1
                            1. item3
                            ' + '
                            1. item1
                            1. item3
                            ' ); }); @@ -405,7 +401,7 @@ describe('VListChain.commit', () => { const ol = document.getElementById('ol2'); ol.parentNode.removeChild(ol); }, - '
                            1. item1
                            2. item2
                            1. item4
                            ' + '
                            1. item1
                            2. item2
                            1. item4
                            ' ); }); @@ -416,7 +412,7 @@ describe('VListChain.commit', () => { const li = document.getElementById('li1'); li.parentNode.removeChild(li); }, - '
                            1. item2
                            1. item3
                            ' + '
                            1. item2
                            1. item3
                            ' ); }); @@ -427,7 +423,7 @@ describe('VListChain.commit', () => { const ol = document.getElementById('ol1'); ol.parentNode.removeChild(ol); }, - '
                            1. item3
                            1. item4
                            ' + '
                            1. item3
                            1. item4
                            ' ); }); }); diff --git a/packages/roosterjs-editor-dom/test/list/VListTest.ts b/packages/roosterjs-editor-dom/test/list/VListTest.ts index 850b56513f1..3decc6a77b9 100644 --- a/packages/roosterjs-editor-dom/test/list/VListTest.ts +++ b/packages/roosterjs-editor-dom/test/list/VListTest.ts @@ -297,7 +297,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
                            1. test
                            ' + '
                            1. test
                            ' ); }); @@ -313,7 +313,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
                            1. item1
                            2. item2
                            ' + '
                            1. item1
                            2. item2
                            ' ); }); @@ -333,7 +333,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
                            1. item1
                            • bullet
                            1. item2
                            ' + '
                            1. item1
                            • bullet
                            1. item2
                            ' ); }); @@ -349,7 +349,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
                            • item1
                            1. item2
                            ' + '
                            • item1
                            1. item2
                            ' ); }); @@ -369,7 +369,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
                              1. item1
                            • item2
                            1. item3
                            ' + '
                              1. item1
                            • item2
                            1. item3
                            ' ); }); @@ -389,7 +389,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
                            • item1
                            • item2
                            1. item3
                            ' + '
                            • item1
                            • item2
                            1. item3
                            ' ); }); @@ -409,7 +409,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
                            • item1
                            • item2
                            1. item3
                            ' + '
                            • item1
                            • item2
                            1. item3
                            ' ); }); @@ -429,7 +429,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
                            • item1
                            item2
                            1. item3
                            ' + '
                            • item1
                            item2
                            1. item3
                            ' ); }); @@ -443,7 +443,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
                            1. item1
                            ', + '
                            1. item1
                            ', ol ); }); @@ -470,7 +470,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
                            1. item3
                              1. item3.1
                            • bullet
                            1. item4
                            ', + '
                            1. item3
                              1. item3.1
                            • bullet
                            1. item4
                            ', ol ); }); @@ -501,7 +501,7 @@ describe('VList.writeBack', () => { listTypes: [ListType.Ordered], }, ], - '
                            text
                            1. item3
                            2. item4
                            text
                            1. item5
                            ', + '
                            text
                            1. item3
                            2. item4
                            text
                            1. item5
                            ', ol ); }); @@ -1261,14 +1261,14 @@ describe('VList.split', () => { it('split List', () => { runTest( `
                            1. item1
                            2. bullet
                            3. item2
                            `, - '
                            1. item1
                            1. bullet
                            2. item2
                            ' + '
                            1. item1
                            1. bullet
                            2. item2
                            ' ); }); it('split List 2', () => { runTest( `
                            1. item1
                            2. bullet
                            3. item2
                            `, - '
                            1. item1
                            1. bullet
                            2. item2
                            ', + '
                            1. item1
                            1. bullet
                            2. item2
                            ', 5 ); }); @@ -1276,7 +1276,7 @@ describe('VList.split', () => { it('split List 3', () => { runTest( `
                            1. 1
                              1. 1
                              2. 2
                              3. 3
                            2. 3
                            3. 4
                            `, - '
                            1. 1
                              1. 1
                              1. 2
                              2. 3
                            1. 3
                            2. 4
                            ', + '
                            1. 1
                              1. 1
                              1. 2
                              2. 3
                            1. 3
                            2. 4
                            ', 1 ); }); @@ -1284,7 +1284,7 @@ describe('VList.split', () => { it('split List 4', () => { runTest( `
                            1. 1
                              1. 1
                              2. 2
                              3. 3
                            2. 3
                            3. 4
                            `, - '
                            1. 1
                              1. 1
                              2. 2
                              3. 3
                            2. 3
                            3. 4
                            ', + '
                            1. 1
                              1. 1
                              2. 2
                              3. 3
                            2. 3
                            3. 4
                            ', 9 ); }); From c400235de372e3ca706d069dbb1e85f41edcb2e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 20 Oct 2023 11:24:12 -0300 Subject: [PATCH 006/111] remove parameter --- .../lib/utils/toggleListType.ts | 15 +++++++++------ .../test/utils/toggleListTypeTest.ts | 8 ++++---- packages/roosterjs-editor-dom/lib/list/VList.ts | 10 ++++------ .../roosterjs-editor-dom/test/list/VListTest.ts | 7 ++++--- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts index e7457b8258a..86632ada83a 100644 --- a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts +++ b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts @@ -49,18 +49,21 @@ export default function toggleListType( if (!block) { return; } - const isExistingList = chain && end && start?.equalTo(end); - const vList = isExistingList - ? chain.createVListAtBlock(block, startNumber) - : createVListFromRegion(region, startNumber === 1 ? false : includeSiblingLists); + + const vList = + chain && end && start?.equalTo(end) + ? chain.createVListAtBlock(block, startNumber) + : createVListFromRegion( + region, + startNumber === 1 ? false : includeSiblingLists + ); if (vList && start && end) { vList.changeListType(start, end, listType); vList.setListStyleType(orderedStyle, unorderedStyle); vList.writeBack( editor.isFeatureEnabled(ExperimentalFeatures.ReuseAllAncestorListElements), - editor.isFeatureEnabled(ExperimentalFeatures.DisableListChain), - !isExistingList + editor.isFeatureEnabled(ExperimentalFeatures.DisableListChain) ); } }, diff --git a/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts b/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts index 5cbdeb0d77d..db43cb22ac7 100644 --- a/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts +++ b/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts @@ -32,7 +32,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
                            default format

                            • test
                            ' + '
                            default format

                            • test
                            ' ); }); @@ -53,7 +53,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
                            default format

                            • test
                            • test
                            ' + '
                            default format

                            • test
                            • test
                            ' ); }); @@ -74,7 +74,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
                            default format

                            • test
                            • test

                            ' + '
                            default format

                            • test
                            • test

                            ' ); }); @@ -95,7 +95,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
                            default format

                            • test

                            • test
                            ' + '
                            default format

                            • test

                            • test
                            ' ); }); }); diff --git a/packages/roosterjs-editor-dom/lib/list/VList.ts b/packages/roosterjs-editor-dom/lib/list/VList.ts index 47099daa7cf..cd42e3012a8 100644 --- a/packages/roosterjs-editor-dom/lib/list/VList.ts +++ b/packages/roosterjs-editor-dom/lib/list/VList.ts @@ -182,11 +182,7 @@ export default class VList { * @param shouldReuseAllAncestorListElements Optional - defaults to false. * @param disableListChain Whether we want to disable list chain functionality. @default false */ - writeBack( - shouldReuseAllAncestorListElements?: boolean, - disableListChain?: boolean, - newList?: boolean - ) { + writeBack(shouldReuseAllAncestorListElements?: boolean, disableListChain?: boolean) { if (!this.rootList) { throw new Error('rootList must not be null'); } @@ -209,8 +205,10 @@ export default class VList { } item.writeBack(listStack, this.rootList, shouldReuseAllAncestorListElements); + + const isNewList = this.rootList.childElementCount === 0; // If the root list is empty, is a new list const topList = listStack[1] as HTMLElement; - if (newList && topList) { + if (isNewList && topList) { topList.style.marginBlockEnd = '0px'; topList.style.marginBlockStart = '0px'; } diff --git a/packages/roosterjs-editor-dom/test/list/VListTest.ts b/packages/roosterjs-editor-dom/test/list/VListTest.ts index 3decc6a77b9..f60dec48c51 100644 --- a/packages/roosterjs-editor-dom/test/list/VListTest.ts +++ b/packages/roosterjs-editor-dom/test/list/VListTest.ts @@ -279,11 +279,12 @@ describe('VList.writeBack', () => { const vList = new VList(list); const items = (vList).items as VListItem[]; - newItems.forEach(newItem => + newItems.forEach(newItem => { + list.append(DomTestHelper.htmlToDom(newItem.html)[0]); items.push( new VListItem(DomTestHelper.htmlToDom(newItem.html)[0], ...newItem.listTypes) - ) - ); + ); + }); vList.writeBack(); expect(div.innerHTML).toBe(expectedHtml); From 05c642fd04fd425e69790bcd426cbae93905764d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 20 Oct 2023 11:28:16 -0300 Subject: [PATCH 007/111] refactor --- .../roosterjs-editor-api/test/format/toggleBlockQuoteTest.ts | 4 ++-- packages/roosterjs-editor-dom/lib/list/VList.ts | 5 ----- .../roosterjs-editor-dom/lib/list/createVListFromRegion.ts | 5 +---- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/roosterjs-editor-api/test/format/toggleBlockQuoteTest.ts b/packages/roosterjs-editor-api/test/format/toggleBlockQuoteTest.ts index 83e80e8caed..a366a90c54b 100644 --- a/packages/roosterjs-editor-api/test/format/toggleBlockQuoteTest.ts +++ b/packages/roosterjs-editor-api/test/format/toggleBlockQuoteTest.ts @@ -89,8 +89,8 @@ describe('toggleBlockQuote', () => { it('Whole list selected', () => { runTest( - '
                            1. a
                              1. b
                                1. c
                                2. d
                              2. e
                            2. f
                            g
                            h
                            ', - '
                            1. a
                              1. b
                                1. c
                                2. d
                              2. e
                            2. f
                            g
                            h
                            ' + '0
                            1. a
                              1. b
                                1. c
                                2. d
                              2. e
                            2. f
                            g
                            h
                            ', + '
                            0
                            1. a
                              1. b
                                1. c
                                2. d
                              2. e
                            2. f
                            g
                            h
                            ' ); }); diff --git a/packages/roosterjs-editor-dom/lib/list/VList.ts b/packages/roosterjs-editor-dom/lib/list/VList.ts index cd42e3012a8..dfcfc6cafa5 100644 --- a/packages/roosterjs-editor-dom/lib/list/VList.ts +++ b/packages/roosterjs-editor-dom/lib/list/VList.ts @@ -519,11 +519,6 @@ export default class VList { } }); } - - removeMargins(list: VList) { - list.rootList.style.marginBlockEnd = '0px'; - list.rootList.style.marginBlockStart = '0px'; - } } //Normalization diff --git a/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts b/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts index 995557de1c0..ddc83fb1465 100644 --- a/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts +++ b/packages/roosterjs-editor-dom/lib/list/createVListFromRegion.ts @@ -47,6 +47,7 @@ export default function createVListFromRegion( ); blocks.forEach(block => { const list = getRootListNode(region, ListSelector, block.getStartNode()); + if (list) { if (nodes[nodes.length - 1] != list) { nodes.push(list); @@ -133,10 +134,6 @@ function createVListFromItemNode(node: Node): VList { // Create a temporary OL root element for this list. const listNode = node.ownerDocument!.createElement('ol'); // Either OL or UL is ok here - - listNode.style.marginBlockStart = '0px'; - listNode.style.marginBlockEnd = '0px'; - node.appendChild(listNode); // Create the VList and append items From 7fad69f947b542c0e48f50dfb1a7410041daa1ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 20 Oct 2023 11:30:03 -0300 Subject: [PATCH 008/111] remove file change --- packages/roosterjs-editor-api/lib/utils/toggleListType.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts index 86632ada83a..a502bc769c8 100644 --- a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts +++ b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts @@ -49,7 +49,6 @@ export default function toggleListType( if (!block) { return; } - const vList = chain && end && start?.equalTo(end) ? chain.createVListAtBlock(block, startNumber) From bc809e7098097973e34daf32096f51a9973e266c Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 24 Oct 2023 11:06:14 -0700 Subject: [PATCH 009/111] Use Content Model to handle Delete/Backspace key in more cases (#2162) --- .../lib/publicApi/editing/keyboardDelete.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts index 2634fe6c161..d22a9b8ee50 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts @@ -89,16 +89,12 @@ function shouldDeleteWithContentModel(range: Range | null, rawEvent: KeyboardEve } function canDeleteBefore(rawEvent: KeyboardEvent, range: Range) { - return ( - rawEvent.key == 'Backspace' && - (range.startOffset > 1 || range.startContainer.previousSibling) - ); + return rawEvent.key == 'Backspace' && range.startOffset > 1; } function canDeleteAfter(rawEvent: KeyboardEvent, range: Range) { return ( rawEvent.key == 'Delete' && - (range.startOffset < (range.startContainer.nodeValue?.length ?? 0) - 1 || - range.startContainer.nextSibling) + range.startOffset < (range.startContainer.nodeValue?.length ?? 0) - 1 ); } From d1ea6eb8063f13d6b9083b6b6fbdcbf1731b43d0 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 24 Oct 2023 11:31:18 -0700 Subject: [PATCH 010/111] Do not set focus when quite shadow edit (#2163) --- .../lib/editor/coreApi/setContentModel.ts | 7 +++- .../lib/editor/coreApi/switchShadowEdit.ts | 8 ++++- .../editor/coreApi/switchShadowEditTest.ts | 7 +++- .../lib/context/ModelToDomOption.ts | 5 +++ .../lib/coreApi/switchShadowEdit.ts | 33 +++---------------- 5 files changed, 28 insertions(+), 32 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts index b274ca09278..96a4290e5fa 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts @@ -17,6 +17,7 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea const modelToDomContext = option ? createModelToDomContext(editorContext, ...(core.defaultModelToDomOptions || []), option) : createModelToDomContextWithConfig(core.defaultModelToDomConfig, editorContext); + const selection = contentModelToDom( core.contentDiv.ownerDocument, core.contentDiv, @@ -29,7 +30,11 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea core.cache.cachedSelection = selection || undefined; if (selection) { - core.api.setDOMSelection(core, selection); + if (!option?.ignoreSelection) { + core.api.setDOMSelection(core, selection); + } else if (selection.type == 'range') { + core.domEvent.selectionRange = selection.range; + } } core.cache.cachedModel = model; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts index 93425f02088..7ab5578d6b9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts @@ -1,4 +1,5 @@ import { getSelectionPath } from 'roosterjs-editor-dom'; +import { iterateSelections } from '../../modelApi/selection/iterateSelections'; import { PluginEventType } from 'roosterjs-editor-types'; import type { ContentModelEditorCore } from '../../publicTypes/ContentModelEditorCore'; import type { SwitchShadowEdit } from 'roosterjs-editor-types'; @@ -54,7 +55,12 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { ); if (core.cache.cachedModel) { - core.api.setContentModel(core, core.cache.cachedModel); + // Force clear cached element from selected block + iterateSelections([core.cache.cachedModel], () => {}); + + core.api.setContentModel(core, core.cache.cachedModel, { + ignoreSelection: true, // Do not set focus and selection when quit shadow edit, focus may remain in UI control (picker, ...) + }); } } } diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts index 0991c08f96d..4d679d7b833 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts @@ -1,3 +1,4 @@ +import * as iterateSelections from '../../../lib/modelApi/selection/iterateSelections'; import { ContentModelEditorCore } from '../../../lib/publicTypes/ContentModelEditorCore'; import { PluginEventType } from 'roosterjs-editor-types'; import { switchShadowEdit } from '../../../lib/editor/coreApi/switchShadowEdit'; @@ -142,10 +143,14 @@ describe('switchShadowEdit', () => { it('with cache, isOff', () => { core.cache.cachedModel = mockedCachedModel; + spyOn(iterateSelections, 'iterateSelections'); + switchShadowEdit(core, false); expect(createContentModel).not.toHaveBeenCalled(); - expect(setContentModel).toHaveBeenCalledWith(core, mockedCachedModel); + expect(setContentModel).toHaveBeenCalledWith(core, mockedCachedModel, { + ignoreSelection: true, + }); expect(core.cache.cachedModel).toBe(mockedCachedModel); expect(triggerEvent).toHaveBeenCalledTimes(1); diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomOption.ts b/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomOption.ts index 92af52f93f7..3ca91ef31db 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomOption.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/ModelToDomOption.ts @@ -28,4 +28,9 @@ export interface ModelToDomOption { * Overrides default metadata appliers */ metadataAppliers?: Partial; + + /** + * When set to true, selection from content model will not be applied + */ + ignoreSelection?: boolean; } diff --git a/packages/roosterjs-editor-core/lib/coreApi/switchShadowEdit.ts b/packages/roosterjs-editor-core/lib/coreApi/switchShadowEdit.ts index 95eb4b16965..5d3dd632aaf 100644 --- a/packages/roosterjs-editor-core/lib/coreApi/switchShadowEdit.ts +++ b/packages/roosterjs-editor-core/lib/coreApi/switchShadowEdit.ts @@ -98,39 +98,14 @@ export const switchShadowEdit: SwitchShadowEdit = (core: EditorCore, isOn: boole shadowEditEntities ); } - core.api.focus(core); if (shadowEditSelectionPath) { - core.api.selectRange( - core, - createRange( - contentDiv, - shadowEditSelectionPath.start, - shadowEditSelectionPath.end - ) + core.domEvent.selectionRange = createRange( + contentDiv, + shadowEditSelectionPath.start, + shadowEditSelectionPath.end ); } - - if (core.domEvent.imageSelectionRange) { - const { image } = core.domEvent.imageSelectionRange; - const imageElement = core.contentDiv.querySelector('#' + image.id); - if (imageElement) { - core.api.selectImage(core, image); - } - } - - if (core.domEvent.tableSelectionRange) { - const { table, coordinates } = core.domEvent.tableSelectionRange; - const tableId = table.id; - const tableElement = core.contentDiv.querySelector('#' + tableId); - if (table) { - core.domEvent.tableSelectionRange = core.api.selectTable( - core, - tableElement as HTMLTableElement, - coordinates - ); - } - } } } }; From 36b72b466d1203e6529e4c79389a7e4724b0f19d Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 24 Oct 2023 11:50:22 -0700 Subject: [PATCH 011/111] Fix #237217 (#2164) --- .../lib/modelApi/selection/setSelection.ts | 7 +------ .../modelApi/selection/setSelectionTest.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/setSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/setSelection.ts index 6587d3b1349..06a3371f7f8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/setSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/setSelection.ts @@ -115,12 +115,7 @@ function setSelectionToTable( setIsSelected(currentCell, isSelected); if (!isSelected) { - setSelectionToBlockGroup( - currentCell, - false /*isInSelection*/, - null /*start*/, - null /*end*/ - ); + setSelectionToBlockGroup(currentCell, false /*isInSelection*/, start, end); } } } diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/setSelectionTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/setSelectionTest.ts index f6bcf753a2f..c7c5d7fcefe 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/setSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/setSelectionTest.ts @@ -821,4 +821,22 @@ describe('setSelection', () => { expect(cell.isSelected).toBeFalsy(); expect(segment.isSelected).toBeFalsy(); }); + + it('Set selection under a table', () => { + const model = createContentModelDocument(); + const table = createTable(1); + const cell = createTableCell(); + const para = createParagraph(); + const segment = createBr(); + + para.segments.push(segment); + cell.blocks.push(para); + table.rows[0].cells.push(cell); + model.blocks.push(table); + + setSelection(model, segment); + + expect(cell.isSelected).toBeFalsy(); + expect(segment.isSelected).toBeTrue(); + }); }); From dd3d6910e6763dc0e22124185c336d1b57878fe3 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 24 Oct 2023 12:14:54 -0700 Subject: [PATCH 012/111] Fix #237735 (#2165) --- .../lib/publicApi/table/editTable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts index 02e14bea43b..443b2cf6cb0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts @@ -114,7 +114,7 @@ export default function editTable(editor: IContentModelEditor, operation: TableO } } - normalizeTable(tableModel); + normalizeTable(tableModel, model.format); if (hasMetadata(tableModel)) { applyTableFormat(tableModel, undefined /*newFormat*/, true /*keepCellShade*/); From 41233183836d08a3e38a444640285e7477b41ac7 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 25 Oct 2023 09:40:11 -0700 Subject: [PATCH 013/111] Fix #236416 (#2166) --- .../processors/delimiterProcessor.ts | 29 +++++++------ .../processors/delimiterProcessorTest.ts | 42 ++++++++++++++++++- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/delimiterProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/delimiterProcessor.ts index fd6052e09b3..5f330cccb2a 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/delimiterProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/delimiterProcessor.ts @@ -1,23 +1,28 @@ -import { getRegularSelectionOffsets } from '../utils/getRegularSelectionOffsets'; -import { handleRegularSelection } from './childProcessor'; +import { addSelectionMarker } from '../utils/addSelectionMarker'; import type { ElementProcessor } from 'roosterjs-content-model-types'; /** * @internal * @param group - * @param element + * @param node * @param context */ -export const delimiterProcessor: ElementProcessor = (group, element, context) => { - let index = 0; - const [nodeStartOffset, nodeEndOffset] = getRegularSelectionOffsets(context, element); +export const delimiterProcessor: ElementProcessor = (group, node, context) => { + const range = context.selection?.type == 'range' ? context.selection.range : null; - for (let child = element.firstChild; child; child = child.nextSibling) { - handleRegularSelection(index, context, group, nodeStartOffset, nodeEndOffset); + if (range) { + if (node.contains(range.startContainer)) { + context.isInSelection = true; - delimiterProcessor(group, child, context); - index++; - } + addSelectionMarker(group, context); + } + + if (context.selection?.type == 'range' && node.contains(range.endContainer)) { + if (!context.selection.range.collapsed) { + addSelectionMarker(group, context); + } - handleRegularSelection(index, context, group, nodeStartOffset, nodeEndOffset); + context.isInSelection = false; + } + } }; diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts index d63a35989fd..e2312476e6f 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/delimiterProcessorTest.ts @@ -24,7 +24,6 @@ describe('delimiterProcessor', () => { blockGroupType: 'Document', blocks: [], }); - expect(delimiterProcessorFile.handleRegularSelection).toHaveBeenCalledTimes(3); }); it('Delimiter with selection', () => { @@ -65,4 +64,45 @@ describe('delimiterProcessor', () => { }); expect(context.isInSelection).toBeTrue(); }); + + it('Delimiter with selection end', () => { + const doc = createContentModelDocument(); + const text1 = document.createTextNode('test1'); + const text2 = document.createTextNode('test2'); + const span = document.createElement('span'); + const span2 = document.createElement('span'); + const div = document.createElement('div'); + + span.appendChild(text2); + + div.appendChild(text1); + div.appendChild(span); + div.appendChild(span2); + + context.selection = { + type: 'range', + range: createRange(text1, 2, text2, 3), + }; + + delimiterProcessor(doc, span, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + isImplicit: true, + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + }, + ], + }); + expect(context.isInSelection).toBeFalse(); + }); }); From 384fce08ff75610c6f3a4fad558d0813a6e341d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 25 Oct 2023 16:39:34 -0300 Subject: [PATCH 014/111] wip --- .../table/applyTableBorderFormatTest.ts | 1569 +++++++++++++++++ .../lib/utils/toggleListType.ts | 1 + .../roosterjs-editor-dom/lib/list/VList.ts | 12 +- .../lib/list/VListItem.ts | 8 + 4 files changed, 1585 insertions(+), 5 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts new file mode 100644 index 00000000000..387c3477a70 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts @@ -0,0 +1,1569 @@ +import * as normalizeTable from '../../../lib/modelApi/table/normalizeTable'; +import applyTableBorderFormat from '../../../lib/publicApi/table/applyTableBorderFormat'; +import { Border } from '../../../lib/publicTypes/interface/Border'; +import { BorderOperations } from '../../../lib/publicTypes/enum/BorderOperations'; +import { ContentModelTable, ContentModelTableCell } from 'roosterjs-content-model-types'; +import { createContentModelDocument } from 'roosterjs-content-model-dom'; +import { createTable, createTableCell } from 'roosterjs-content-model-dom'; +import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; + +describe('applyTableBorderFormat', () => { + let editor: IContentModelEditor; + let setContentModel: jasmine.Spy; + let createContentModel: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; + let getVisibleViewport: jasmine.Spy; + const width = '3px'; + const style = 'double'; + const color = '#AABBCC'; + const testBorder: Border = { width: width, style: style, color: color }; + const testBorderString = `${width} ${style} ${color}`; + + function createTestTable( + rows: number, + columns: number, + format?: ContentModelTableCell['format'] + ) { + // Create a table with all cells selected except the first and last row and column + const table = createTable(rows); + for (let i = 0; i < rows; i++) { + const row = table.rows[i]; + for (let j = 0; j < columns; j++) { + const cell = createTableCell(false, false, false, format); + if (i != 0 && j != 0 && i != rows - 1 && j != columns - 1) { + cell.isSelected = true; + } + row.cells.push(cell); + } + } + return table; + } + + beforeEach(() => { + setContentModel = jasmine.createSpy('setContentModel'); + createContentModel = jasmine.createSpy('createContentModel'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + getVisibleViewport = jasmine.createSpy('getVisibleViewport'); + + spyOn(normalizeTable, 'normalizeTable'); + + editor = ({ + focus: () => {}, + addUndoSnapshot: (callback: Function) => callback(), + setContentModel, + createContentModel, + isDarkMode: () => false, + triggerPluginEvent, + getVisibleViewport, + } as any) as IContentModelEditor; + }); + + function runTest( + table: ContentModelTable, + expectedTable: ContentModelTable | null, + border: Border, + operation: BorderOperations + ) { + const model = createContentModelDocument(); + model.blocks.push(table); + + createContentModel.and.returnValue(model); + + applyTableBorderFormat(editor, border, operation); + + if (expectedTable) { + expect(setContentModel).toHaveBeenCalledTimes(1); + expect(setContentModel).toHaveBeenCalledWith( + { + blockGroupType: 'Document', + blocks: [expectedTable], + }, + undefined, + undefined + ); + } else { + expect(setContentModel).not.toHaveBeenCalled(); + } + } + it('All Borders', () => { + runTest( + createTestTable(4, 4), + { + blockType: 'Table', + dataset: {}, + format: {}, + rows: [ + { + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + dataset: {}, + format: {}, + isHeader: false, + spanAbove: false, + spanLeft: false, + }, + { + blockGroupType: 'TableCell', + blocks: [], + dataset: { + editingInfo: '{"borderOverride":true}', + }, + format: { + borderBottom: testBorderString, + }, + isHeader: false, + spanAbove: false, + spanLeft: false, + }, + { + blockGroupType: 'TableCell', + blocks: [], + dataset: { + editingInfo: '{"borderOverride":true}', + }, + format: { + borderBottom: testBorderString, + }, + isHeader: false, + spanAbove: false, + spanLeft: false, + }, + { + blockGroupType: 'TableCell', + blocks: [], + dataset: {}, + format: {}, + isHeader: false, + spanAbove: false, + spanLeft: false, + }, + ], + format: {}, + height: 0, + }, + { + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + dataset: { + editingInfo: '{"borderOverride":true}', + }, + format: { + borderRight: testBorderString, + }, + isHeader: false, + spanAbove: false, + spanLeft: false, + }, + { + blockGroupType: 'TableCell', + blocks: [], + dataset: { + editingInfo: '{"borderOverride":true}', + }, + format: { + borderBottom: testBorderString, + borderLeft: testBorderString, + borderRight: testBorderString, + borderTop: testBorderString, + }, + isHeader: false, + isSelected: true, + spanAbove: false, + spanLeft: false, + }, + { + blockGroupType: 'TableCell', + blocks: [], + dataset: { + editingInfo: '{"borderOverride":true}', + }, + format: { + borderBottom: testBorderString, + borderLeft: testBorderString, + borderRight: testBorderString, + borderTop: testBorderString, + }, + isHeader: false, + isSelected: true, + spanAbove: false, + spanLeft: false, + }, + { + blockGroupType: 'TableCell', + blocks: [], + dataset: { + editingInfo: '{"borderOverride":true}', + }, + format: { + borderLeft: testBorderString, + }, + isHeader: false, + spanAbove: false, + spanLeft: false, + }, + ], + format: {}, + height: 0, + }, + { + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + dataset: { + editingInfo: '{"borderOverride":true}', + }, + format: { + borderRight: testBorderString, + }, + isHeader: false, + spanAbove: false, + spanLeft: false, + }, + { + blockGroupType: 'TableCell', + blocks: [], + dataset: { + editingInfo: '{"borderOverride":true}', + }, + format: { + borderBottom: testBorderString, + borderLeft: testBorderString, + borderRight: testBorderString, + borderTop: testBorderString, + }, + isHeader: false, + isSelected: true, + spanAbove: false, + spanLeft: false, + }, + { + blockGroupType: 'TableCell', + blocks: [], + dataset: { + editingInfo: '{"borderOverride":true}', + }, + format: { + borderBottom: testBorderString, + borderLeft: testBorderString, + borderRight: testBorderString, + borderTop: testBorderString, + }, + isHeader: false, + isSelected: true, + spanAbove: false, + spanLeft: false, + }, + { + blockGroupType: 'TableCell', + blocks: [], + dataset: { + editingInfo: '{"borderOverride":true}', + }, + format: { + borderLeft: testBorderString, + }, + isHeader: false, + spanAbove: false, + spanLeft: false, + }, + ], + format: {}, + height: 0, + }, + { + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + dataset: {}, + format: {}, + isHeader: false, + spanAbove: false, + spanLeft: false, + }, + { + blockGroupType: 'TableCell', + blocks: [], + dataset: { + editingInfo: '{"borderOverride":true}', + }, + format: { + borderTop: testBorderString, + }, + isHeader: false, + spanAbove: false, + spanLeft: false, + }, + { + blockGroupType: 'TableCell', + blocks: [], + dataset: { + editingInfo: '{"borderOverride":true}', + }, + format: { + borderTop: testBorderString, + }, + isHeader: false, + spanAbove: false, + spanLeft: false, + }, + { + blockGroupType: 'TableCell', + blocks: [], + dataset: {}, + format: {}, + isHeader: false, + spanAbove: false, + spanLeft: false, + }, + ], + format: {}, + height: 0, + }, + ], + widths: [], + }, + testBorder, + 'allBorders' + ); + }); + it('No Borders', () => { + runTest( + createTestTable(4, 4, { + borderTop: testBorderString, + borderBottom: testBorderString, + borderLeft: testBorderString, + borderRight: testBorderString, + }), + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + borderBottom: testBorderString, + borderLeft: testBorderString, + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + borderBottom: '', + borderLeft: testBorderString, + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + borderBottom: '', + borderLeft: testBorderString, + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + borderBottom: testBorderString, + borderLeft: testBorderString, + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + borderBottom: testBorderString, + borderLeft: testBorderString, + borderRight: '', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: '', + borderBottom: '', + borderLeft: '', + borderRight: '', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: '', + borderBottom: '', + borderLeft: '', + borderRight: '', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + borderBottom: testBorderString, + borderLeft: '', + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + borderBottom: testBorderString, + borderLeft: testBorderString, + borderRight: '', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: '', + borderBottom: '', + borderLeft: '', + borderRight: '', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: '', + borderBottom: '', + borderLeft: '', + borderRight: '', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + borderBottom: testBorderString, + borderLeft: '', + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + borderBottom: testBorderString, + borderLeft: testBorderString, + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: '', + borderBottom: testBorderString, + borderLeft: testBorderString, + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: '', + borderBottom: testBorderString, + borderLeft: testBorderString, + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + borderBottom: testBorderString, + borderLeft: testBorderString, + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + testBorder, + 'noBorders' + ); + }); + it('Top Borders', () => { + runTest( + createTestTable(3, 3), + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderBottom: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + testBorder, + 'topBorders' + ); + }); + it('Bottom Borders', () => { + runTest( + createTestTable(3, 3), + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderBottom: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + testBorder, + 'bottomBorders' + ); + }); + it('Left Borders', () => { + runTest( + createTestTable(3, 3), + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderLeft: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + testBorder, + 'leftBorders' + ); + }); + it('Right Borders', () => { + runTest( + createTestTable(3, 3), + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderLeft: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + testBorder, + 'rightBorders' + ); + }); + it('Outside Borders', () => { + runTest( + createTestTable(4, 4), + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderBottom: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderBottom: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderLeft: testBorderString, + borderTop: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderRight: testBorderString, + borderTop: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderLeft: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderLeft: testBorderString, + borderBottom: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderRight: testBorderString, + borderBottom: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderLeft: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + testBorder, + 'outsideBorders' + ); + }); + it('Inside Borders', () => { + runTest( + createTestTable(4, 4), + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderBottom: testBorderString, + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderBottom: testBorderString, + borderLeft: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + borderRight: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + borderTop: testBorderString, + borderLeft: testBorderString, + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: { + editingInfo: '{"borderOverride":true}', + }, + isSelected: true, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + testBorder, + 'insideBorders' + ); + }); +}); diff --git a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts index a502bc769c8..efe5df32774 100644 --- a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts +++ b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts @@ -60,6 +60,7 @@ export default function toggleListType( if (vList && start && end) { vList.changeListType(start, end, listType); vList.setListStyleType(orderedStyle, unorderedStyle); + vList.removeMargins(); vList.writeBack( editor.isFeatureEnabled(ExperimentalFeatures.ReuseAllAncestorListElements), editor.isFeatureEnabled(ExperimentalFeatures.DisableListChain) diff --git a/packages/roosterjs-editor-dom/lib/list/VList.ts b/packages/roosterjs-editor-dom/lib/list/VList.ts index dfcfc6cafa5..da87a345451 100644 --- a/packages/roosterjs-editor-dom/lib/list/VList.ts +++ b/packages/roosterjs-editor-dom/lib/list/VList.ts @@ -206,12 +206,7 @@ export default class VList { item.writeBack(listStack, this.rootList, shouldReuseAllAncestorListElements); - const isNewList = this.rootList.childElementCount === 0; // If the root list is empty, is a new list const topList = listStack[1] as HTMLElement; - if (isNewList && topList) { - topList.style.marginBlockEnd = '0px'; - topList.style.marginBlockStart = '0px'; - } item.applyListStyle(this.rootList, start); @@ -333,6 +328,13 @@ export default class VList { }); } + removeMargins() { + if (!this.rootList.style.marginTop && !this.rootList.style.marginBottom) { + this.rootList.style.marginBlockStart = '0px'; + this.rootList.style.marginBlockEnd = '0px'; + } + } + /** * Change list type of the given range of this list. * If some of the items are not real list item yet, this will make them to be list item with given type diff --git a/packages/roosterjs-editor-dom/lib/list/VListItem.ts b/packages/roosterjs-editor-dom/lib/list/VListItem.ts index 38bd1b241f4..cb165d0a060 100644 --- a/packages/roosterjs-editor-dom/lib/list/VListItem.ts +++ b/packages/roosterjs-editor-dom/lib/list/VListItem.ts @@ -473,6 +473,14 @@ function createListElement( result = doc.createElement(listType == ListType.Ordered ? 'ol' : 'ul'); } + if ( + originalRoot?.style.marginBlockStart == '0px' && + originalRoot?.style.marginBlockEnd == '0px' + ) { + result.style.marginBlockStart = '0px'; + result.style.marginBlockEnd = '0px'; + } + // Always maintain the metadata saved in the list if (originalRoot && nextLevel == 1 && listType != getListTypeFromNode(originalRoot)) { const style = getMetadata(originalRoot, ListStyleDefinitionMetadata); From a38a8392a27f05ab05ae6bde41e93f59caa49e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 25 Oct 2023 17:35:51 -0300 Subject: [PATCH 015/111] fixes --- .../test/utils/toggleListTypeTest.ts | 8 ++--- .../test/list/VListTest.ts | 36 ++++++++++++++++++- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts b/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts index db43cb22ac7..5cbdeb0d77d 100644 --- a/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts +++ b/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts @@ -32,7 +32,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
                            default format

                            • test
                            ' + '
                            default format

                            • test
                            ' ); }); @@ -53,7 +53,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
                            default format

                            • test
                            • test
                            ' + '
                            default format

                            • test
                            • test
                            ' ); }); @@ -74,7 +74,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
                            default format

                            • test
                            • test

                            ' + '
                            default format

                            • test
                            • test

                            ' ); }); @@ -95,7 +95,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
                            default format

                            • test

                            • test
                            ' + '
                            default format

                            • test

                            • test
                            ' ); }); }); diff --git a/packages/roosterjs-editor-dom/test/list/VListTest.ts b/packages/roosterjs-editor-dom/test/list/VListTest.ts index f60dec48c51..c1126be56f4 100644 --- a/packages/roosterjs-editor-dom/test/list/VListTest.ts +++ b/packages/roosterjs-editor-dom/test/list/VListTest.ts @@ -509,7 +509,7 @@ describe('VList.writeBack', () => { it('Write back with Lists with list item types', () => { const styledList = - '
                            1. 123
                              1. 123
                                1. 123

                            '; + '
                            1. 123
                              1. 123
                                1. 123

                            '; const div = document.createElement('div'); document.body.append(div); div.innerHTML = styledList; @@ -1518,3 +1518,37 @@ describe('VList.setListStyleType', () => { ); }); }); + +describe('VList.removeMargins', () => { + const testId = 'VList_changeListType'; + const ListRoot = 'listRoot'; + + afterEach(() => { + DomTestHelper.removeElement(testId); + }); + + function runTest(source: string) { + DomTestHelper.createElementFromContent(testId, source); + const list = document.getElementById(ListRoot) as HTMLOListElement; + + if (!list) { + throw new Error('No root node'); + } + const vList = new VList(list); + + // Act + vList.removeMargins(); + expect(list.style.marginBlock).toEqual('0px'); + DomTestHelper.removeElement(testId); + } + + it('remove list margins OL list', () => { + const list = `
                              `; + runTest(list); + }); + + it('remove list margins UL list', () => { + const list = `
                                `; + runTest(list); + }); +}); From 95bdb1c38f9cba948471ea2fb59250c400c49973 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 27 Oct 2023 09:17:53 -0700 Subject: [PATCH 016/111] Fix #2160 (#2167) * Fix #2160 * fix build --- .../lib/modelApi/common/mergeModel.ts | 23 +- .../lib/publicApi/utils/paste.ts | 51 +- .../event/ContentModelBeforePasteEvent.ts | 6 +- .../test/modelApi/common/mergeModelTest.ts | 2079 +++++++++-------- .../test/publicApi/utils/pasteTest.ts | 57 +- 5 files changed, 1249 insertions(+), 967 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts index 1abe26a9994..bd43e655cd6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts @@ -58,13 +58,19 @@ export interface MergeModelOption { /** * @internal + * Merge source model into target mode + * @param target Target Content Model that will merge content into + * @param source Source Content Model will be merged to target model + * @param context Format context. When call this function inside formatWithContentModel, provide this context so that formatWithContentModel will do extra handling to the result + * @param options More options, see MergeModelOption + * @returns Insert point after merge, or null if there is no insert point */ export function mergeModel( target: ContentModelDocument, source: ContentModelDocument, context?: FormatWithContentModelContext, options?: MergeModelOption -) { +): InsertPoint | null { const insertPosition = options?.insertPosition ?? deleteSelection(target, [], context).insertPoint; @@ -119,6 +125,8 @@ export function mergeModel( } normalizeContentModel(target); + + return insertPosition; } function mergeParagraph( @@ -186,7 +194,7 @@ function mergeTable( newTable: ContentModelTable, source: ContentModelDocument ) { - const { tableContext } = markerPosition; + const { tableContext, marker } = markerPosition; if (tableContext && source.blocks.length == 1 && source.blocks[0] == newTable) { const { table, colIndex, rowIndex } = tableContext; @@ -226,10 +234,19 @@ function mergeTable( } } + const oldCell = table.rows[rowIndex + i].cells[colIndex + j]; table.rows[rowIndex + i].cells[colIndex + j] = newCell; if (i == 0 && j == 0) { - addSegment(newCell, createSelectionMarker()); + const newMarker = createSelectionMarker(marker.format); + const newPara = addSegment(newCell, newMarker); + + if (markerPosition.path[0] == oldCell) { + // Update insert point to match the change result + markerPosition.path[0] = newCell; + markerPosition.marker = newMarker; + markerPosition.paragraph = newPara; + } } } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts index e5562272409..2fc03bfe8f0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts @@ -3,6 +3,8 @@ import { ChangeSource } from '../../publicTypes/event/ContentModelContentChanged import { formatWithContentModel } from './formatWithContentModel'; import { GetContentMode, PasteType as OldPasteType, PluginEventType } from 'roosterjs-editor-types'; import { mergeModel } from '../../modelApi/common/mergeModel'; +import { setPendingFormat } from '../../modelApi/format/pendingFormat'; +import type { InsertPoint } from '../../publicTypes/selection/InsertPoint'; import type { ContentModelDocument, ContentModelSegmentFormat, @@ -35,6 +37,19 @@ const PasteTypeMap: Record = { mergeFormat: OldPasteType.MergeFormat, normal: OldPasteType.Normal, }; +const EmptySegmentFormat: Required = { + backgroundColor: '', + fontFamily: '', + fontSize: '', + fontWeight: '', + italic: false, + letterSpacing: '', + lineHeight: '', + strikethrough: false, + superOrSubScriptSequence: '', + textColor: '', + underline: false, +}; /** * Paste into editor using a clipboardData object @@ -55,6 +70,7 @@ export default function paste( } editor.focus(); + let originalFormat: ContentModelSegmentFormat | undefined; formatWithContentModel( editor, @@ -81,7 +97,7 @@ export default function paste( createDomToModelContext(undefined /*editorContext*/, domToModelOption) ); - mergePasteContent( + const insertPoint = mergePasteContent( model, context, pasteModel, @@ -89,6 +105,10 @@ export default function paste( customizedMerge ); + if (insertPoint) { + originalFormat = insertPoint.marker.format; + } + return true; }, @@ -97,6 +117,17 @@ export default function paste( getChangeData: () => clipboardData, } ); + + const pos = editor.getFocusedPosition(); + + if (originalFormat && pos) { + setPendingFormat( + editor, + { ...EmptySegmentFormat, ...originalFormat }, // Use empty format as initial value to clear any other format inherits from pasted content + pos.node, + pos.offset + ); + } } /** @@ -110,16 +141,14 @@ export function mergePasteContent( applyCurrentFormat: boolean, customizedMerge: | undefined - | ((source: ContentModelDocument, target: ContentModelDocument) => void) -) { - if (customizedMerge) { - customizedMerge(model, pasteModel); - } else { - mergeModel(model, pasteModel, context, { - mergeFormat: applyCurrentFormat ? 'keepSourceEmphasisFormat' : 'none', - mergeTable: shouldMergeTable(pasteModel), - }); - } + | ((source: ContentModelDocument, target: ContentModelDocument) => InsertPoint | null) +): InsertPoint | null { + return customizedMerge + ? customizedMerge(model, pasteModel) + : mergeModel(model, pasteModel, context, { + mergeFormat: applyCurrentFormat ? 'keepSourceEmphasisFormat' : 'none', + mergeTable: shouldMergeTable(pasteModel), + }); } function shouldMergeTable(pasteModel: ContentModelDocument): boolean | undefined { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelBeforePasteEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelBeforePasteEvent.ts index 3c6a526740d..742850f1489 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelBeforePasteEvent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelBeforePasteEvent.ts @@ -1,3 +1,4 @@ +import type { InsertPoint } from '../selection/InsertPoint'; import type { ContentModelDocument, DomToModelOption } from 'roosterjs-content-model-types'; import type { BeforePasteEvent, @@ -16,7 +17,10 @@ export interface ContentModelBeforePasteEventData extends BeforePasteEventData { /** * customizedMerge Customized merge function to use when merging the paste fragment into the editor */ - customizedMerge?: (target: ContentModelDocument, source: ContentModelDocument) => void; + customizedMerge?: ( + target: ContentModelDocument, + source: ContentModelDocument + ) => InsertPoint | null; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts index 4496f205a82..b83ad88b109 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts @@ -1,8 +1,16 @@ import * as applyTableFormat from '../../../lib/modelApi/table/applyTableFormat'; import * as normalizeTable from '../../../lib/modelApi/table/normalizeTable'; -import { ContentModelDocument, ContentModelImage } from 'roosterjs-content-model-types'; import { FormatWithContentModelContext } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { mergeModel } from '../../../lib/modelApi/common/mergeModel'; +import { + ContentModelDocument, + ContentModelImage, + ContentModelListItem, + ContentModelParagraph, + ContentModelSelectionMarker, + ContentModelTable, + ContentModelTableCell, +} from 'roosterjs-content-model-types'; import { createBr, createContentModelDocument, @@ -27,31 +35,38 @@ describe('mergeModel', () => { para.segments.push(marker); majorModel.blocks.push(para); - mergeModel(majorModel, sourceModel, { + const result = mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [], }); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [ { - blockType: 'Paragraph', + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Br', format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Br', - format: {}, - }, - ], }, ], + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, }); }); @@ -71,37 +86,43 @@ describe('mergeModel', () => { para2.segments.push(text1, text2); sourceModel.blocks.push(para2); - mergeModel(majorModel, sourceModel, { + const result = mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [], }); - - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'test2', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, format: {}, }, ], + format: {}, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, }); }); @@ -125,50 +146,56 @@ describe('mergeModel', () => { majorModel.blocks.push(para1); sourceModel.blocks.push(para2); - mergeModel(majorModel, sourceModel, { + const result = mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [], }); - - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + textColor: 'green', + }, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test1', - format: { - textColor: 'red', - }, - }, - { - segmentType: 'Text', - text: 'test3', - format: { - textColor: 'blue', - }, - }, - { - segmentType: 'Text', - text: 'test4', - format: { - textColor: 'yellow', - }, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - textColor: 'green', - }, - }, - ], - format: {}, + segmentType: 'Text', + text: 'test1', + format: { + textColor: 'red', + }, + }, + { + segmentType: 'Text', + text: 'test3', + format: { + textColor: 'blue', + }, + }, + { + segmentType: 'Text', + text: 'test4', + format: { + textColor: 'yellow', + }, }, + marker, ], + format: {}, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, }); }); @@ -208,11 +235,37 @@ describe('mergeModel', () => { sourceModel.blocks.push(newPara1); sourceModel.blocks.push(newPara2); - mergeModel(majorModel, sourceModel, { + const result = mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [], }); + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + textColor: 'green', + }, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'newText2', + format: {}, + }, + marker, + { + segmentType: 'Text', + text: 'test4', + format: { + textColor: 'yellow', + }, + }, + ], + format: {}, + }; expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -235,33 +288,15 @@ describe('mergeModel', () => { ], format: {}, }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'newText2', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - textColor: 'green', - }, - }, - { - segmentType: 'Text', - text: 'test4', - format: { - textColor: 'yellow', - }, - }, - ], - format: {}, - }, + paragraph, ], }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('text to list', () => { @@ -307,11 +342,51 @@ describe('mergeModel', () => { sourceModel.blocks.push(newPara2); sourceModel.blocks.push(newPara3); - mergeModel(majorModel, sourceModel, { + const result = mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [], }); + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'newText3', + format: {}, + }, + marker, + { + segmentType: 'Text', + text: 'test21', + format: {}, + }, + ], + format: {}, + }; + const listItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [paragraph], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }; expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -381,48 +456,15 @@ describe('mergeModel', () => { }, format: {}, }, - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'newText3', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { - segmentType: 'Text', - text: 'test21', - format: {}, - }, - ], - format: {}, - }, - ], - levels: [ - { - listType: 'OL', - format: {}, - dataset: {}, - }, - ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - format: {}, - }, + listItem, ], }); + expect(result).toEqual({ + marker, + paragraph, + path: [listItem, majorModel], + tableContext: undefined, + }); }); it('list to text', () => { @@ -458,11 +500,27 @@ describe('mergeModel', () => { sourceModel.blocks.push(newList1); sourceModel.blocks.push(newList2); - mergeModel(majorModel, sourceModel, { + const result = mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [], }); + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + marker, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }; expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -538,23 +596,15 @@ describe('mergeModel', () => { }, format: {}, }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - format: {}, - }, + paragraph, ], }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('list to list', () => { @@ -628,50 +678,56 @@ describe('mergeModel', () => { sourceModel.blocks.push(newList1); sourceModel.blocks.push(newList2); - mergeModel(majorModel, sourceModel, { + const result = mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [], }); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + marker, { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test11', - format: {}, - }, - ], - format: {}, - }, - ], - levels: [ - { - listType: 'OL', - dataset: { - editingInfo: JSON.stringify({ - startNumberOverride: 1, - unorderedStyleType: 2, - }), - }, - format: {}, - }, - ], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, + segmentType: 'Text', + text: 'test22', + format: {}, + }, + ], + format: {}, + }; + const listItem: ContentModelListItem = { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [paragraph], + levels: [ + { + listType: 'OL', + dataset: { + editingInfo: JSON.stringify({ + startNumberOverride: 1, + unorderedStyleType: 2, + }), }, format: {}, }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [ { blockType: 'BlockGroup', blockGroupType: 'ListItem', @@ -681,7 +737,7 @@ describe('mergeModel', () => { segments: [ { segmentType: 'Text', - text: 'newText1', + text: 'test11', format: {}, }, ], @@ -716,7 +772,7 @@ describe('mergeModel', () => { segments: [ { segmentType: 'Text', - text: 'newText2', + text: 'newText1', format: {}, }, ], @@ -734,16 +790,6 @@ describe('mergeModel', () => { }, format: {}, }, - { - listType: 'UL', - dataset: { - editingInfo: JSON.stringify({ - startNumberOverride: 5, - unorderedStyleType: 6, - }), - }, - format: {}, - }, ], formatHolder: { segmentType: 'SelectionMarker', @@ -759,14 +805,9 @@ describe('mergeModel', () => { { blockType: 'Paragraph', segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, { segmentType: 'Text', - text: 'test22', + text: 'newText2', format: {}, }, ], @@ -784,6 +825,16 @@ describe('mergeModel', () => { }, format: {}, }, + { + listType: 'UL', + dataset: { + editingInfo: JSON.stringify({ + startNumberOverride: 5, + unorderedStyleType: 6, + }), + }, + format: {}, + }, ], formatHolder: { segmentType: 'SelectionMarker', @@ -792,8 +843,15 @@ describe('mergeModel', () => { }, format: {}, }, + listItem, ], }); + expect(result).toEqual({ + marker, + paragraph, + path: [listItem, majorModel], + tableContext: undefined, + }); }); it('table to text', () => { @@ -820,12 +878,29 @@ describe('mergeModel', () => { sourceModel.blocks.push(newTable1); - mergeModel(majorModel, sourceModel, { + const result = mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [], }); + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + marker, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }; + expect(majorModel).toEqual({ blockGroupType: 'Document', blocks: [ @@ -864,23 +939,15 @@ describe('mergeModel', () => { widths: [], dataset: {}, }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - format: {}, - }, + paragraph, ], }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('table to table, no merge table', () => { @@ -925,76 +992,87 @@ describe('mergeModel', () => { spyOn(applyTableFormat, 'applyTableFormat'); spyOn(normalizeTable, 'normalizeTable'); - mergeModel(majorModel, sourceModel, { + const result = mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [], }); - expect(normalizeTable.normalizeTable).not.toHaveBeenCalled(); - expect(majorModel).toEqual({ - blockGroupType: 'Document', + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + marker, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }; + const tableCell: ContentModelTableCell = { + blockGroupType: 'TableCell', blocks: [ { blockType: 'Table', + format: {}, + widths: [], + dataset: {}, rows: [ - { format: {}, height: 0, cells: [cell11, cell12] }, { format: {}, height: 0, - cells: [ - cell21, - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Table', - format: {}, - widths: [], - dataset: {}, - rows: [ - { - format: {}, - height: 0, - cells: [newCell11, newCell12], - }, - { - format: {}, - height: 0, - cells: [newCell21, newCell22], - }, - ], - }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - format: {}, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], + cells: [newCell11, newCell12], + }, + { + format: {}, + height: 0, + cells: [newCell21, newCell22], }, ], + }, + paragraph, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }; + const table: ContentModelTable = { + blockType: 'Table', + rows: [ + { format: {}, height: 0, cells: [cell11, cell12] }, + { format: {}, - widths: [], - dataset: {}, + height: 0, + cells: [cell21, tableCell], }, ], + format: {}, + widths: [], + dataset: {}, + }; + + expect(normalizeTable.normalizeTable).not.toHaveBeenCalled(); + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [table], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [tableCell, majorModel], + tableContext: { + table, + rowIndex: 1, + colIndex: 1, + isWholeTableSelected: false, + }, }); }); @@ -1046,7 +1124,7 @@ describe('mergeModel', () => { spyOn(applyTableFormat, 'applyTableFormat'); spyOn(normalizeTable, 'normalizeTable'); - mergeModel( + const result = mergeModel( majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [] }, @@ -1055,90 +1133,96 @@ describe('mergeModel', () => { } ); - expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [marker], + format: {}, + isImplicit: true, + }; + const tableCell: ContentModelTableCell = { + blockGroupType: 'TableCell', + blocks: [paragraph], + format: { + backgroundColor: 'n11', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }; + const table: ContentModelTable = { + blockType: 'Table', + rows: [ { - blockType: 'Table', - rows: [ - { - format: {}, - height: 0, - cells: [ - cell01, - cell02, - { - blockGroupType: 'TableCell', - blocks: [], - format: { - backgroundColor: '02', - }, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], - }, + format: {}, + height: 0, + cells: [ + cell01, + cell02, { - format: {}, - height: 0, - cells: [ - cell11, - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: { - backgroundColor: 'n11', - }, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - newCell12, - ], + blockGroupType: 'TableCell', + blocks: [], + format: { + backgroundColor: '02', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, }, - { format: {}, height: 0, cells: [cell21, newCell21, newCell22] }, + ], + }, + { + format: {}, + height: 0, + cells: [cell11, tableCell, newCell12], + }, + { format: {}, height: 0, cells: [cell21, newCell21, newCell22] }, + { + format: {}, + height: 0, + cells: [ + cell31, + cell32, { - format: {}, - height: 0, - cells: [ - cell31, - cell32, - { - blockGroupType: 'TableCell', - blocks: [], - format: { - backgroundColor: '32', - }, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], + blockGroupType: 'TableCell', + blocks: [], + format: { + backgroundColor: '32', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, }, ], - format: {}, - widths: [], - dataset: {}, }, ], + format: {}, + widths: [], + dataset: {}, + }; + + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [table], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [tableCell, majorModel], + tableContext: { + table, + rowIndex: 1, + colIndex: 1, + isWholeTableSelected: false, + }, }); }); @@ -1188,7 +1272,7 @@ describe('mergeModel', () => { spyOn(applyTableFormat, 'applyTableFormat'); spyOn(normalizeTable, 'normalizeTable'); - mergeModel( + const result = mergeModel( majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [] }, @@ -1196,84 +1280,88 @@ describe('mergeModel', () => { mergeTable: true, } ); - - expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [marker], + format: {}, + isImplicit: true, + }; + const tableCell: ContentModelTableCell = { + blockGroupType: 'TableCell', + blocks: [paragraph], + format: { + backgroundColor: 'n11', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }; + const table: ContentModelTable = { + blockType: 'Table', + rows: [ + { format: {}, height: 0, cells: [cell01, cell02, cell03, cell04] }, { - blockType: 'Table', - rows: [ - { format: {}, height: 0, cells: [cell01, cell02, cell03, cell04] }, + format: {}, + height: 0, + cells: [cell11, tableCell, newCell12, cell14], + }, + { + format: {}, + height: 0, + cells: [ { - format: {}, - height: 0, - cells: [ - cell11, - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: { - backgroundColor: 'n11', - }, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - newCell12, - cell14, - ], + blockGroupType: 'TableCell', + blocks: [], + format: { + backgroundColor: '11', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, }, + newCell21, + newCell22, { - format: {}, - height: 0, - cells: [ - { - blockGroupType: 'TableCell', - blocks: [], - format: { - backgroundColor: '11', - }, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - newCell21, - newCell22, - { - blockGroupType: 'TableCell', - blocks: [], - format: { - backgroundColor: '14', - }, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], + blockGroupType: 'TableCell', + blocks: [], + format: { + backgroundColor: '14', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, }, ], - format: {}, - widths: [], - dataset: {}, }, ], + format: {}, + widths: [], + dataset: {}, + }; + + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [table], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [tableCell, majorModel], + tableContext: { + table, + rowIndex: 1, + colIndex: 1, + isWholeTableSelected: false, + }, }); }); @@ -1319,7 +1407,7 @@ describe('mergeModel', () => { spyOn(applyTableFormat, 'applyTableFormat'); spyOn(normalizeTable, 'normalizeTable'); - mergeModel( + const result = mergeModel( majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [] }, @@ -1328,89 +1416,94 @@ describe('mergeModel', () => { } ); - expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [marker], + format: {}, + isImplicit: true, + }; + const tableCell: ContentModelTableCell = { + blockGroupType: 'TableCell', + blocks: [paragraph], + format: { + backgroundColor: 'n11', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }; + const table: ContentModelTable = { + blockType: 'Table', + rows: [ { - blockType: 'Table', - rows: [ - { - format: {}, - height: 0, - cells: [ - cell01, - cell02, - { - blockGroupType: 'TableCell', - blocks: [], - format: { - backgroundColor: '02', - }, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], - }, + format: {}, + height: 0, + cells: [ + cell01, + cell02, { - format: {}, - height: 0, - cells: [ - cell11, - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: { - backgroundColor: 'n11', - }, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - newCell12, - ], + blockGroupType: 'TableCell', + blocks: [], + format: { + backgroundColor: '02', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, }, + ], + }, + { + format: {}, + height: 0, + cells: [cell11, tableCell, newCell12], + }, + { + format: {}, + height: 0, + cells: [ { - format: {}, - height: 0, - cells: [ - { - blockGroupType: 'TableCell', - blocks: [], - format: { - backgroundColor: '11', - }, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - newCell21, - newCell22, - ], + blockGroupType: 'TableCell', + blocks: [], + format: { + backgroundColor: '11', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, }, + newCell21, + newCell22, ], - format: {}, - widths: [], - dataset: {}, }, ], + format: {}, + widths: [], + dataset: {}, + }; + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [table], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [tableCell, majorModel], + tableContext: { + table, + rowIndex: 1, + colIndex: 1, + isWholeTableSelected: false, + }, }); }); @@ -1421,7 +1514,7 @@ describe('mergeModel', () => { const text1 = createText('test1'); const text2 = createText('test2'); const marker1 = createSelectionMarker(); - const marker2 = createSelectionMarker(); + const marker2 = createSelectionMarker({ fontSize: '10pt' }); const marker3 = createSelectionMarker(); para1.segments.push(marker1, text1, marker2, text2, marker3); @@ -1433,7 +1526,7 @@ describe('mergeModel', () => { newPara.segments.push(newText); sourceModel.blocks.push(newPara); - mergeModel( + const result = mergeModel( majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [] }, @@ -1446,46 +1539,47 @@ describe('mergeModel', () => { } ); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { - segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: 'new text', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { - segmentType: 'Text', - text: 'test2', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: 'new text', + format: {}, + }, + marker2, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, format: {}, }, ], + format: {}, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + expect(result).toEqual({ + marker: marker2, + paragraph, + path: [majorModel], }); }); @@ -1516,7 +1610,7 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel( + const result = mergeModel( majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [] }, @@ -1525,28 +1619,30 @@ describe('mergeModel', () => { } ); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test', - format: MockedFormat, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - format: {}, + segmentType: 'Text', + text: 'test', + format: MockedFormat, }, + marker, ], + format: {}, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], format: MockedFormat, }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Merge with default format', () => { @@ -1578,7 +1674,7 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel( + const result = mergeModel( majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [] }, @@ -1587,28 +1683,30 @@ describe('mergeModel', () => { } ); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test', - format: MockedFormat, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - format: {}, + segmentType: 'Text', + text: 'test', + format: MockedFormat, }, + marker, ], + format: {}, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], format: MockedFormat, }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Merge with default format, keep the source bold, italic and underline', () => { @@ -1631,8 +1729,8 @@ describe('mergeModel', () => { format: { formatName: 'ToBeRemoved', fontWeight: 'sourceFontWeight', - italic: 'sourceItalic', - underline: 'sourceUnderline', + italic: true, + underline: true, } as any, }, ], @@ -1646,7 +1744,7 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel( + const result = mergeModel( majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [] }, @@ -1655,33 +1753,35 @@ describe('mergeModel', () => { } ); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test', - format: { - formatName: 'mocked', - fontWeight: 'sourceFontWeight', - italic: 'sourceItalic', - underline: 'sourceUnderline', - } as any, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - format: {}, + segmentType: 'Text', + text: 'test', + format: { + formatName: 'mocked', + fontWeight: 'sourceFontWeight', + italic: true, + underline: true, + } as any, }, + marker, ], + format: {}, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], format: MockedFormat, }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Merge model with List Item with default format, keep the source bold, italic and underline', () => { @@ -1724,8 +1824,8 @@ describe('mergeModel', () => { format: { formatName: 'ToBeRemoved', fontWeight: 'sourceFontWeight', - italic: 'sourceItalic', - underline: 'sourceUnderline', + italic: true, + underline: true, } as any, }, ], @@ -1741,7 +1841,7 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel( + const result = mergeModel( majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [] }, @@ -1750,6 +1850,12 @@ describe('mergeModel', () => { } ); + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [marker, { segmentType: 'Br', format: {} }], + format: {}, + }; + expect(majorModel).toEqual({ blockGroupType: 'Document', blocks: [ @@ -1779,8 +1885,8 @@ describe('mergeModel', () => { backgroundColor: 'rgb(0,0,0)', color: 'rgb(255,255,255)', fontWeight: 'sourceFontWeight', - italic: 'sourceItalic', - underline: 'sourceUnderline', + italic: true, + underline: true, } as any, }, ], @@ -1788,21 +1894,16 @@ describe('mergeModel', () => { }, ], }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { segmentType: 'Br', format: {} }, - ], - format: {}, - }, + paragraph, ], format: MockedFormat, }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Divider to single selected paragraph with inline format', () => { @@ -1820,11 +1921,17 @@ describe('mergeModel', () => { sourceModel.blocks.push(divider); - mergeModel(majorModel, sourceModel, { + const result = mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [], }); + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [marker, { segmentType: 'Text', text: 'test2', format: {} }], + format: {}, + segmentFormat: { fontFamily: 'Arial' }, + }; expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1836,17 +1943,15 @@ describe('mergeModel', () => { segmentFormat: { fontFamily: 'Arial' }, }, { blockType: 'Divider', tagName: 'hr', format: {} }, - { - blockType: 'Paragraph', - segments: [ - { segmentType: 'SelectionMarker', isSelected: true, format: {} }, - { segmentType: 'Text', text: 'test2', format: {} }, - ], - format: {}, - segmentFormat: { fontFamily: 'Arial' }, - }, + paragraph, ], }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Keep Paragraph format of source on merge', () => { @@ -1891,12 +1996,41 @@ describe('mergeModel', () => { sourceModel.blocks.push(newPara1); sourceModel.blocks.push(newPara2); - mergeModel(majorModel, sourceModel, { + const result = mergeModel(majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [], }); + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + textColor: 'green', + }, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'newText2', + format: {}, + }, + marker, + { + segmentType: 'Text', + text: 'test4', + format: { + textColor: 'yellow', + }, + }, + ], + format: { + backgroundColor: 'red', + }, + }; + expect(majorModel).toEqual({ blockGroupType: 'Document', blocks: [ @@ -1918,35 +2052,15 @@ describe('mergeModel', () => { ], format: {}, }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'newText2', - format: {}, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: { - textColor: 'green', - }, - }, - { - segmentType: 'Text', - text: 'test4', - format: { - textColor: 'yellow', - }, - }, - ], - format: { - backgroundColor: 'red', - }, - }, + paragraph, ], }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Merge with default format paragraph and paragraph with decorator, keepSourceEmphasisFormat', () => { @@ -1969,8 +2083,8 @@ describe('mergeModel', () => { format: { formatName: 'ToBeRemoved', fontWeight: 'sourceFontWeight', - italic: 'sourceItalic', - underline: 'sourceUnderline', + italic: true, + underline: true, } as any, }, ], @@ -1992,7 +2106,7 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel( + const result = mergeModel( majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [] }, @@ -2001,33 +2115,35 @@ describe('mergeModel', () => { } ); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test', - format: { - formatName: 'mocked', - fontWeight: 'sourceFontWeight', - italic: 'sourceItalic', - underline: 'sourceUnderline', - } as any, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - format: {}, + segmentType: 'Text', + text: 'test', + format: { + formatName: 'mocked', + fontWeight: 'sourceFontWeight', + italic: true, + underline: true, + } as any, }, + marker, ], + format: {}, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], format: MockedFormat, }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Merge with default format paragraph and paragraph with decorator, mergeAll', () => { @@ -2049,10 +2165,10 @@ describe('mergeModel', () => { text: 'test', format: { fontFamily: 'sourceFontFamily', - italic: 'sourceItalic', - underline: 'sourceUnderline', + italic: true, + underline: true, fontSize: 'sourcefontSize', - } as any, + }, }, ], format: {}, @@ -2073,7 +2189,7 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel( + const result = mergeModel( majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [] }, @@ -2082,49 +2198,51 @@ describe('mergeModel', () => { } ); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test', - format: { - fontWeight: 'sourceDecoratorFontWeight', - italic: 'sourceItalic', - underline: 'sourceUnderline', - fontFamily: 'sourceFontFamily', - fontSize: 'sourcefontSize', - } as any, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - format: {}, - decorator: { - tagName: 'h1', - format: { - fontWeight: 'sourceDecoratorFontWeight', - fontSize: 'sourceDecoratorFontSize', - fontFamily: 'sourceDecoratorFontName', - }, + segmentType: 'Text', + text: 'test', + format: { + fontWeight: 'sourceDecoratorFontWeight', + italic: true, + underline: true, + fontFamily: 'sourceFontFamily', + fontSize: 'sourcefontSize', }, }, + marker, ], + format: {}, + decorator: { + tagName: 'h1', + format: { + fontWeight: 'sourceDecoratorFontWeight', + fontSize: 'sourceDecoratorFontSize', + fontFamily: 'sourceDecoratorFontName', + }, + }, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], format: MockedFormat, }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Merge with default format paragraph and paragraph, mergeFormat: none', () => { const MockedFormat = { fontFamily: 'sourceSegmentFormatFontFamily', - italic: 'sourceSegmentFormatItalic', - underline: 'sourceSegmentFormatUnderline', + italic: true, + underline: true, fontSize: 'sourceSegmentFormatFontSize', } as any; const majorModel = createContentModelDocument(MockedFormat); @@ -2154,10 +2272,10 @@ describe('mergeModel', () => { text: 'test', format: { fontFamily: 'sourceFontFamily', - italic: 'sourceItalic', - underline: 'sourceUnderline', + italic: true, + underline: true, fontSize: 'sourcefontSize', - } as any, + }, }, ], format: {}, @@ -2170,7 +2288,7 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel( + const result = mergeModel( majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [] }, @@ -2179,48 +2297,55 @@ describe('mergeModel', () => { } ); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const resultMarker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + fontFamily: 'sourceSegmentFormatFontFamily', + italic: true, + underline: true, + fontSize: 'sourceSegmentFormatFontSize', + }, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { + fontFamily: 'sourceFontFamily', + italic: true, + underline: true, + fontSize: 'sourceSegmentFormatFontSize', + }, + }, { - blockType: 'Paragraph', - segments: [ - Object({ - segmentType: 'Text', - text: 'test', - format: Object({ - fontFamily: 'sourceFontFamily', - italic: 'sourceSegmentFormatItalic', - underline: 'sourceSegmentFormatUnderline', - fontSize: 'sourceSegmentFormatFontSize', - }), - }), - Object({ - segmentType: 'Text', - text: 'test', - format: Object({ - fontFamily: 'sourceFontFamily', - italic: 'sourceItalic', - underline: 'sourceUnderline', - fontSize: 'sourcefontSize', - }), - }), - Object({ - segmentType: 'SelectionMarker', - isSelected: true, - format: Object({ - fontFamily: 'sourceSegmentFormatFontFamily', - italic: 'sourceSegmentFormatItalic', - underline: 'sourceSegmentFormatUnderline', - fontSize: 'sourceSegmentFormatFontSize', - }), - }), - ], - format: {}, + segmentType: 'Text', + text: 'test', + format: { + fontFamily: 'sourceFontFamily', + italic: true, + underline: true, + fontSize: 'sourcefontSize', + }, }, + resultMarker, ], + format: {}, + }; + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], format: MockedFormat, }); + expect(result).toEqual({ + marker: resultMarker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Merge Table + Paragraph', () => { @@ -2360,7 +2485,7 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel( + const result = mergeModel( majorModel, sourceModel, { newEntities: [], deletedEntities: [], newImages: [] }, @@ -2369,6 +2494,24 @@ describe('mergeModel', () => { } ); + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'Test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'black', + fontWeight: 'bold', + }, + }, + marker, + ], + format: {}, + }; + expect(majorModel).toEqual({ blockGroupType: 'Document', blocks: [ @@ -2479,29 +2622,15 @@ describe('mergeModel', () => { '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0}', }, }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'Test', - format: { - fontFamily: 'Calibri', - fontSize: '11pt', - textColor: 'black', - fontWeight: 'bold', - }, - }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], - format: {}, - }, + paragraph, ], }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Merge Paragraph with default format and a Heading element', () => { @@ -2535,37 +2664,42 @@ describe('mergeModel', () => { sourceModel.blocks.push(heading); - mergeModel(majorModel, sourceModel); + const result = mergeModel(majorModel, sourceModel); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { segmentType: 'Text', text: 'test1', format: {} }, + { segmentType: 'Text', text: 'sourceTest1', format: {} }, + { segmentType: 'Text', text: 'sourceTest2', format: {} }, { - blockType: 'Paragraph', - segments: [ - { segmentType: 'Text', text: 'test1', format: {} }, - { segmentType: 'Text', text: 'sourceTest1', format: {} }, - { segmentType: 'Text', text: 'sourceTest2', format: {} }, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { segmentType: 'Text', text: 'test2', format: {} }, - ], + segmentType: 'SelectionMarker', + isSelected: true, format: {}, - decorator: { - tagName: 'h1', - format: { - fontFamily: 'Calibri', - fontSize: '16pt', - textColor: 'aliceblue', - italic: true, - }, - }, - segmentFormat: { backgroundColor: 'red' }, }, + { segmentType: 'Text', text: 'test2', format: {} }, ], + format: {}, + decorator: { + tagName: 'h1', + format: { + fontFamily: 'Calibri', + fontSize: '16pt', + textColor: 'aliceblue', + italic: true, + }, + }, + segmentFormat: { backgroundColor: 'red' }, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, }); }); @@ -2606,8 +2740,27 @@ describe('mergeModel', () => { newTable1.rows[0].cells.push(newCell1); sourceModel.blocks.push(newTable1); - mergeModel(majorModel, sourceModel); + const result = mergeModel(majorModel, sourceModel); + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Text', text: 'test2', format: {} }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }; expect(majorModel).toEqual({ blockGroupType: 'Document', blocks: [ @@ -2668,27 +2821,15 @@ describe('mergeModel', () => { widths: [], dataset: {}, }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { segmentType: 'Text', text: 'test2', format: {} }, - ], - format: {}, - segmentFormat: { - fontFamily: 'Arial', - fontSize: '15px', - backgroundColor: 'red', - textColor: 'blue', - italic: false, - }, - }, + paragraph, ], }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Merge Divider with styles into paragraph, paragraph after table should not inherit styles from Divider', () => { @@ -2719,8 +2860,27 @@ describe('mergeModel', () => { }); sourceModel.blocks.push(newDiv); - mergeModel(majorModel, sourceModel); + const result = mergeModel(majorModel, sourceModel); + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Text', text: 'test2', format: {} }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }; expect(majorModel).toEqual({ blockGroupType: 'Document', blocks: [ @@ -2749,27 +2909,15 @@ describe('mergeModel', () => { backgroundColor: 'rgb(255, 255, 255)', }, }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { segmentType: 'Text', text: 'test2', format: {} }, - ], - format: {}, - segmentFormat: { - fontFamily: 'Arial', - fontSize: '15px', - backgroundColor: 'red', - textColor: 'blue', - italic: false, - }, - }, + paragraph, ], }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Merge ListItem with styles into paragraph, paragraph after table should not inherit styles from ListItem', () => { @@ -2806,7 +2954,27 @@ describe('mergeModel', () => { para2.segments.push(text3); sourceModel.blocks.push(newList); - mergeModel(majorModel, sourceModel); + const result = mergeModel(majorModel, sourceModel); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Text', text: 'test2', format: {} }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }; expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -2854,27 +3022,15 @@ describe('mergeModel', () => { }, format: {}, }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { segmentType: 'Text', text: 'test2', format: {} }, - ], - format: {}, - segmentFormat: { - fontFamily: 'Arial', - fontSize: '15px', - backgroundColor: 'red', - textColor: 'blue', - italic: false, - }, - }, + paragraph, ], }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Merge Entity with styles into paragraph, paragraph after table should not inherit styles from Entity', () => { @@ -2908,7 +3064,27 @@ describe('mergeModel', () => { }; sourceModel.blocks.push(newEntity); - mergeModel(majorModel, sourceModel, context); + const result = mergeModel(majorModel, sourceModel, context); + + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Text', text: 'test2', format: {} }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }; expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -2942,25 +3118,7 @@ describe('mergeModel', () => { }, wrapper: newEntity.wrapper, }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - { segmentType: 'Text', text: 'test2', format: {} }, - ], - format: {}, - segmentFormat: { - fontFamily: 'Arial', - fontSize: '15px', - backgroundColor: 'red', - textColor: 'blue', - italic: false, - }, - }, + paragraph, ], }); expect(context).toEqual({ @@ -2968,6 +3126,12 @@ describe('mergeModel', () => { deletedEntities: [], newImages: [], }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Merge and replace inline entities', () => { @@ -2994,26 +3158,22 @@ describe('mergeModel', () => { newImages: [], newEntities: [], }; - mergeModel(majorModel, sourceModel, context); + const result = mergeModel(majorModel, sourceModel, context); + + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + format: {}, + segments: [newEntity1, text, newEntity2, marker], + }; expect(majorModel).toEqual({ blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - newEntity1, - text, - newEntity2, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], + blocks: [paragraph], }); expect(context).toEqual({ newEntities: [newEntity1, newEntity2], @@ -3025,6 +3185,12 @@ describe('mergeModel', () => { ], newImages: [], }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Merge Image', () => { @@ -3058,26 +3224,25 @@ describe('mergeModel', () => { newEntities: [], }; - mergeModel(majorModel, sourceModel, context, { + const result = mergeModel(majorModel, sourceModel, context, { mergeFormat: 'mergeAll', }); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + newImage, { - blockType: 'Paragraph', - segments: [ - newImage, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], + segmentType: 'SelectionMarker', + isSelected: true, format: {}, }, ], + format: {}, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], }); expect(context).toEqual({ @@ -3085,6 +3250,12 @@ describe('mergeModel', () => { newEntities: [], newImages: [newImage], }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Merge two Images', () => { @@ -3124,27 +3295,26 @@ describe('mergeModel', () => { newEntities: [], }; - mergeModel(majorModel, sourceModel, context, { + const result = mergeModel(majorModel, sourceModel, context, { mergeFormat: 'mergeAll', }); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + newImage, + newImage1, { - blockType: 'Paragraph', - segments: [ - newImage, - newImage1, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], + segmentType: 'SelectionMarker', + isSelected: true, format: {}, }, ], + format: {}, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], }); expect(context).toEqual({ @@ -3152,6 +3322,12 @@ describe('mergeModel', () => { newEntities: [], newImages: [newImage, newImage1], }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); it('Merge into a paragraph with image', () => { @@ -3191,27 +3367,26 @@ describe('mergeModel', () => { newImages: [image], }; - mergeModel(majorModel, sourceModel, context, { + const result = mergeModel(majorModel, sourceModel, context, { mergeFormat: 'mergeAll', }); - expect(majorModel).toEqual({ - blockGroupType: 'Document', - blocks: [ + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [ + image, + newImage, { - blockType: 'Paragraph', - segments: [ - image, - newImage, - { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - ], + segmentType: 'SelectionMarker', + isSelected: true, format: {}, }, ], + format: {}, + }; + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [paragraph], }); expect(context).toEqual({ @@ -3219,5 +3394,11 @@ describe('mergeModel', () => { newEntities: [], newImages: [image, newImage], }); + expect(result).toEqual({ + marker, + paragraph, + path: [majorModel], + tableContext: undefined, + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts index 094c961f8a4..8277f055433 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts @@ -4,6 +4,7 @@ import * as ExcelF from '../../../lib/editor/plugins/PastePlugin/Excel/processPa import * as getPasteSourceF from '../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/getPasteSource'; import * as getSelectedSegmentsF from '../../../lib/publicApi/selection/getSelectedSegments'; import * as mergeModelFile from '../../../lib/modelApi/common/mergeModel'; +import * as pendingFormatF from '../../../lib/modelApi/format/pendingFormat'; import * as PPT from '../../../lib/editor/plugins/PastePlugin/PowerPoint/processPastedContentFromPowerPoint'; import * as setProcessorF from '../../../lib/editor/plugins/PastePlugin/utils/setProcessor'; import * as WacComponents from '../../../lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents'; @@ -43,6 +44,8 @@ describe('Paste ', () => { let getTrustedHTMLHandler: jasmine.Spy; let triggerPluginEvent: jasmine.Spy; let getVisibleViewport: jasmine.Spy; + let mergeModelSpy: jasmine.Spy; + let setPendingFormatSpy: jasmine.Spy; const mockedPos = 'POS' as any; @@ -70,6 +73,7 @@ describe('Paste ', () => { getFocusedPosition = jasmine.createSpy('getFocusedPosition').and.returnValue(mockedPos); getContent = jasmine.createSpy('getContent'); getDocument = jasmine.createSpy('getDocument').and.returnValue(document); + setPendingFormatSpy = spyOn(pendingFormatF, 'setPendingFormat'); triggerPluginEvent = jasmine.createSpy('triggerPluginEvent').and.returnValue({ clipboardData, fragment: document.createDocumentFragment(), @@ -97,7 +101,10 @@ describe('Paste ', () => { .and.returnValue((html: string) => html); getVisibleViewport = jasmine.createSpy('getVisibleViewport'); - spyOn(mergeModelFile, 'mergeModel').and.callFake(() => (mockedModel = mockedMergeModel)); + mergeModelSpy = spyOn(mergeModelFile, 'mergeModel').and.callFake(() => { + mockedModel = mockedMergeModel; + return null; + }); spyOn(getSelectedSegmentsF, 'default').and.returnValue([ { format: { @@ -134,7 +141,6 @@ describe('Paste ', () => { expect(setContentModel).toHaveBeenCalled(); expect(focus).toHaveBeenCalled(); expect(addUndoSnapshot).toHaveBeenCalled(); - expect(getFocusedPosition).not.toHaveBeenCalled(); expect(getContent).toHaveBeenCalled(); expect(triggerPluginEvent).toHaveBeenCalled(); expect(getDocument).toHaveBeenCalled(); @@ -148,7 +154,6 @@ describe('Paste ', () => { expect(setContentModel).toHaveBeenCalled(); expect(focus).toHaveBeenCalled(); expect(addUndoSnapshot).toHaveBeenCalled(); - expect(getFocusedPosition).not.toHaveBeenCalled(); expect(getContent).toHaveBeenCalled(); expect(triggerPluginEvent).toHaveBeenCalledTimes(1); expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.ContentChanged, { @@ -164,6 +169,52 @@ describe('Paste ', () => { expect(getTrustedHTMLHandler).toHaveBeenCalled(); expect(mockedModel).toEqual(mockedMergeModel); }); + + it('Preserve segment format after paste', () => { + const mockedNode = 'Node' as any; + const mockedOffset = 'Offset' as any; + const mockedFormat = { + fontFamily: 'Arial', + }; + clipboardData.rawHtml = + 'test'; + getFocusedPosition.and.returnValue({ + node: mockedNode, + offset: mockedOffset, + }); + mergeModelSpy.and.returnValue({ + marker: { + format: mockedFormat, + }, + }); + + paste(editor, clipboardData); + + editor.createContentModel({ + processorOverride: { + table: tableProcessor, + }, + }); + + expect(setPendingFormatSpy).toHaveBeenCalledWith( + editor, + { + backgroundColor: '', + fontFamily: 'Arial', + fontSize: '', + fontWeight: '', + italic: false, + letterSpacing: '', + lineHeight: '', + strikethrough: false, + superOrSubScriptSequence: '', + textColor: '', + underline: false, + }, + mockedNode, + mockedOffset + ); + }); }); describe('paste with content model & paste plugin', () => { From e0f4a2903b8dd15b0c6424e1e06799bce405485b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 27 Oct 2023 17:01:55 -0300 Subject: [PATCH 017/111] test --- .../lib/utils/toggleListType.ts | 7 ++++- .../test/format/setIndentationTest.ts | 26 +++++++++++++++++ .../test/utils/toggleListTypeTest.ts | 28 ++++++++++++++++--- .../test/list/VListTest.ts | 22 +++++++++++++-- 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts index efe5df32774..a71c4025aac 100644 --- a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts +++ b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts @@ -49,6 +49,7 @@ export default function toggleListType( if (!block) { return; } + const vList = chain && end && start?.equalTo(end) ? chain.createVListAtBlock(block, startNumber) @@ -57,10 +58,14 @@ export default function toggleListType( startNumber === 1 ? false : includeSiblingLists ); + const isNewList = chains.length === 0 && block.tagName != 'LI'; + if (vList && start && end) { vList.changeListType(start, end, listType); vList.setListStyleType(orderedStyle, unorderedStyle); - vList.removeMargins(); + if (isNewList) { + vList.removeMargins(); + } vList.writeBack( editor.isFeatureEnabled(ExperimentalFeatures.ReuseAllAncestorListElements), editor.isFeatureEnabled(ExperimentalFeatures.DisableListChain) diff --git a/packages/roosterjs-editor-api/test/format/setIndentationTest.ts b/packages/roosterjs-editor-api/test/format/setIndentationTest.ts index db2c21de36f..ef245fa8be1 100644 --- a/packages/roosterjs-editor-api/test/format/setIndentationTest.ts +++ b/packages/roosterjs-editor-api/test/format/setIndentationTest.ts @@ -45,6 +45,19 @@ describe('setIndentation()', () => { ); }); + it('Indent the first list item in a list with margin-block', () => { + runTest( + '
                                1. Text
                                ', + () => { + const range = new Range(); + range.setStart(editor.getDocument().getElementById('test'), 0); + editor.select(range); + }, + Indentation.Increase, + '
                                1. Text
                                ' + ); + }); + it('Outdent the first list item in a list', () => { runTest( '
                                1. Text
                                ', @@ -58,6 +71,19 @@ describe('setIndentation()', () => { ); }); + it('Outdent the first list item in a list with margin-block', () => { + runTest( + '
                                1. Text
                                ', + () => { + const range = new Range(); + range.setStart(editor.getDocument().getElementById('test'), 0); + editor.select(range); + }, + Indentation.Decrease, + '
                                1. Text
                                ' + ); + }); + it('Outdent whole table selected, when no Blockquote wraping table', () => { runTest( '




                                ', diff --git a/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts b/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts index 5cbdeb0d77d..74373a682e8 100644 --- a/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts +++ b/packages/roosterjs-editor-api/test/utils/toggleListTypeTest.ts @@ -32,7 +32,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
                                default format

                                • test
                                ' + '
                                default format

                                • test
                                ' ); }); @@ -53,7 +53,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
                                default format

                                • test
                                • test
                                ' + '
                                default format

                                • test
                                • test
                                ' ); }); @@ -74,7 +74,7 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
                                default format

                                • test
                                • test

                                ' + '
                                default format

                                • test
                                • test

                                ' ); }); @@ -95,7 +95,27 @@ describe('toggleListTypeTest()', () => { // Assert expect(editor.getContent()).toBe( - '
                                default format

                                • test

                                • test
                                ' + '
                                default format

                                • test

                                • test
                                ' ); }); + + it('Do not set margin-block when in the middle', () => { + // Arrange + const originalContent = + '
                                default format
                                ' + + '

                                ' + + '
                                ' + + '
                                • test

                                • test
                                ' + + '
                                '; + editor.setContent(originalContent); + editor.focus(); + editor.select(document.getElementById('focusNode'), PositionType.Begin); + + // Act + toggleListType(editor, ListType.Unordered); + + // Assert + const ul = editor.getDocument().getElementById('list'); + expect(ul?.style.marginBlock).toBe(''); + }); }); diff --git a/packages/roosterjs-editor-dom/test/list/VListTest.ts b/packages/roosterjs-editor-dom/test/list/VListTest.ts index c1126be56f4..c5fc1b640a3 100644 --- a/packages/roosterjs-editor-dom/test/list/VListTest.ts +++ b/packages/roosterjs-editor-dom/test/list/VListTest.ts @@ -1289,6 +1289,14 @@ describe('VList.split', () => { 9 ); }); + + it('split List 4 with margin-block', () => { + runTest( + `
                                1. 1
                                  1. 1
                                  2. 2
                                  3. 3
                                2. 3
                                3. 4
                                `, + '
                                1. 1
                                  1. 1
                                  2. 2
                                  3. 3
                                2. 3
                                3. 4
                                ', + 9 + ); + }); }); describe('VList.setListStyleType', () => { @@ -1527,7 +1535,7 @@ describe('VList.removeMargins', () => { DomTestHelper.removeElement(testId); }); - function runTest(source: string) { + function runTest(source: string, shouldNotRemoveMargin: boolean = false) { DomTestHelper.createElementFromContent(testId, source); const list = document.getElementById(ListRoot) as HTMLOListElement; @@ -1538,7 +1546,12 @@ describe('VList.removeMargins', () => { // Act vList.removeMargins(); - expect(list.style.marginBlock).toEqual('0px'); + if (shouldNotRemoveMargin) { + expect(list.style.marginBlock).toEqual(''); + } else { + expect(list.style.marginBlock).toEqual('0px'); + } + DomTestHelper.removeElement(testId); } @@ -1551,4 +1564,9 @@ describe('VList.removeMargins', () => { const list = `
                                  `; runTest(list); }); + + it('do not remove list margins UL list', () => { + const list = `
                                  • test
                                  `; + runTest(list, true /** shouldNotRemoveMargin */); + }); }); From dbf8fc282d41d03478905eb495bea05f8825cc16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 27 Oct 2023 17:16:00 -0300 Subject: [PATCH 018/111] remove test --- .../roosterjs-editor-dom/lib/list/VList.ts | 6 ++- .../test/list/VListTest.ts | 41 +++++++++---------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/packages/roosterjs-editor-dom/lib/list/VList.ts b/packages/roosterjs-editor-dom/lib/list/VList.ts index da87a345451..a8022ba1662 100644 --- a/packages/roosterjs-editor-dom/lib/list/VList.ts +++ b/packages/roosterjs-editor-dom/lib/list/VList.ts @@ -205,8 +205,7 @@ export default class VList { } item.writeBack(listStack, this.rootList, shouldReuseAllAncestorListElements); - - const topList = listStack[1] as HTMLElement; + const topList = listStack[1]; item.applyListStyle(this.rootList, start); @@ -328,6 +327,9 @@ export default class VList { }); } + /** + * Remove margins of a new list + */ removeMargins() { if (!this.rootList.style.marginTop && !this.rootList.style.marginBottom) { this.rootList.style.marginBlockStart = '0px'; diff --git a/packages/roosterjs-editor-dom/test/list/VListTest.ts b/packages/roosterjs-editor-dom/test/list/VListTest.ts index c5fc1b640a3..8a8687f6d37 100644 --- a/packages/roosterjs-editor-dom/test/list/VListTest.ts +++ b/packages/roosterjs-editor-dom/test/list/VListTest.ts @@ -53,19 +53,16 @@ describe('VList.ctor', () => { }); it('nested UL in OL', () => { - runTest( - `
                                  1. line1
                                    • line2
                                  `, - [ - { - listTypes: [ListType.None, ListType.Ordered], - outerHTML: '
                                • line1
                                • ', - }, - { - listTypes: [ListType.None, ListType.Ordered, ListType.Unordered], - outerHTML: '
                                • line2
                                • ', - }, - ] - ); + runTest(`
                                  1. line1
                                    • line2
                                  `, [ + { + listTypes: [ListType.None, ListType.Ordered], + outerHTML: '
                                • line1
                                • ', + }, + { + listTypes: [ListType.None, ListType.Ordered, ListType.Unordered], + outerHTML: '
                                • line2
                                • ', + }, + ]); }); it('orphan item that will be merged', () => { @@ -602,7 +599,7 @@ describe('VList.setIndentation', () => { it('deep list', () => { runTest( - `
                                  1. line1
                                    • line2
                                  `, + `
                                  1. line1
                                    • line2
                                  `, [ { listTypes: [ListType.None, ListType.Ordered, ListType.Ordered], @@ -873,7 +870,7 @@ describe('VList.changeListType', () => { `
                                    ` + '
                                  1. line1
                                  2. ' + `
                                  3. line2
                                  4. ` + - '
                                      ' + + '
                                        ' + `
                                      • line3
                                      • ` + '
                                      • line4
                                      • ' + '
                                      ' + @@ -939,9 +936,9 @@ describe('VList.changeListType', () => { runTest( `
                                        ` + `
                                      1. line1
                                      2. ` + - '
                                          ' + + '
                                            ' + '
                                          1. line2
                                          2. ' + - '
                                              ' + + '
                                                ' + `
                                              1. line3
                                              2. ` + '
                                              ' + '
                                            ' + @@ -1198,7 +1195,7 @@ describe('VList.mergeVList', () => { it('List 2 has deep orphan item that cannot be merged', () => { runTest( `
                                            1. line1
                                            ` + - `
                                                line2
                                              • line3
                                            `, + `
                                                line2
                                              • line3
                                            `, [ { listTypes: [ListType.None, ListType.Ordered], @@ -1218,8 +1215,8 @@ describe('VList.mergeVList', () => { it('List 2 has deep orphan item that can be merged', () => { runTest( - `
                                              • line1
                                            ` + - `
                                                line2
                                              • line3
                                            `, + `
                                              • line1
                                            ` + + `
                                                line2
                                              • line3
                                            `, [ { listTypes: [ListType.None, ListType.Ordered, ListType.Unordered], @@ -1276,7 +1273,7 @@ describe('VList.split', () => { it('split List 3', () => { runTest( - `
                                            1. 1
                                              1. 1
                                              2. 2
                                              3. 3
                                            2. 3
                                            3. 4
                                            `, + `
                                            1. 1
                                              1. 1
                                              2. 2
                                              3. 3
                                            2. 3
                                            3. 4
                                            `, '
                                            1. 1
                                              1. 1
                                              1. 2
                                              2. 3
                                            1. 3
                                            2. 4
                                            ', 1 ); @@ -1284,7 +1281,7 @@ describe('VList.split', () => { it('split List 4', () => { runTest( - `
                                            1. 1
                                              1. 1
                                              2. 2
                                              3. 3
                                            2. 3
                                            3. 4
                                            `, + `
                                            1. 1
                                              1. 1
                                              2. 2
                                              3. 3
                                            2. 3
                                            3. 4
                                            `, '
                                            1. 1
                                              1. 1
                                              2. 2
                                              3. 3
                                            2. 3
                                            3. 4
                                            ', 9 ); From 3b3f9bf482db1f4a447c9cea902d4f3272d5f2fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 27 Oct 2023 17:21:59 -0300 Subject: [PATCH 019/111] remove test --- .../test/list/VListTest.ts | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/roosterjs-editor-dom/test/list/VListTest.ts b/packages/roosterjs-editor-dom/test/list/VListTest.ts index 8a8687f6d37..32d787e4ee8 100644 --- a/packages/roosterjs-editor-dom/test/list/VListTest.ts +++ b/packages/roosterjs-editor-dom/test/list/VListTest.ts @@ -109,7 +109,7 @@ describe('VList.ctor', () => { runTest( `
                                              ` + '
                                            1. line0
                                            2. ' + - '
                                                ' + + '
                                                  ' + '
                                                  line1
                                                  ' + '
                                                • line2
                                                • ' + '
                                                  line3
                                                  ' + @@ -197,14 +197,14 @@ describe('VList.ctor', () => { it('disconnected nested list', () => { runTest( `
                                                    ` + - '
                                                  1. line1
                                                    • line2
                                                    • line3
                                                    line4
                                                  2. ' + + '
                                                  3. line1
                                                    • line2
                                                    • line3
                                                    line4
                                                  4. ' + '
                                                  5. line5
                                                  6. ' + '
                                                  ', [ { listTypes: [ListType.None, ListType.Ordered], outerHTML: - '
                                                • line1
                                                  • line2
                                                  • line3
                                                  line4
                                                • ', + '
                                                • line1
                                                  • line2
                                                  • line3
                                                  line4
                                                • ', }, { listTypes: [ListType.None, ListType.Ordered], @@ -276,12 +276,11 @@ describe('VList.writeBack', () => { const vList = new VList(list); const items = (vList).items as VListItem[]; - newItems.forEach(newItem => { - list.append(DomTestHelper.htmlToDom(newItem.html)[0]); + newItems.forEach(newItem => items.push( new VListItem(DomTestHelper.htmlToDom(newItem.html)[0], ...newItem.listTypes) - ); - }); + ) + ); vList.writeBack(); expect(div.innerHTML).toBe(expectedHtml); @@ -506,7 +505,7 @@ describe('VList.writeBack', () => { it('Write back with Lists with list item types', () => { const styledList = - '
                                                  1. 123
                                                    1. 123
                                                      1. 123

                                                  '; + '
                                                  1. 123
                                                    1. 123
                                                      1. 123

                                                  '; const div = document.createElement('div'); document.body.append(div); div.innerHTML = styledList; @@ -633,7 +632,7 @@ describe('VList.setIndentation', () => { `
                                                    ` + '
                                                  1. line1
                                                  2. ' + `
                                                  3. line2
                                                  4. ` + - '
                                                      ' + + '
                                                        ' + `
                                                      • line3
                                                      • ` + '
                                                      • line4
                                                      • ' + '
                                                      ' + @@ -687,7 +686,7 @@ describe('VList.setIndentation', () => { `
                                                        ` + '
                                                      1. line1
                                                      2. ' + `
                                                      3. line2
                                                      4. ` + - '
                                                          ' + + '
                                                            ' + `
                                                          • line3
                                                          • ` + '
                                                          • line4
                                                          • ' + '
                                                          ' + @@ -831,7 +830,7 @@ describe('VList.changeListType', () => { it('deep list', () => { runTest( - `
                                                          1. line1
                                                            • line2
                                                          `, + `
                                                          1. line1
                                                            • line2
                                                          `, [ { listTypes: [ListType.None], @@ -1273,7 +1272,7 @@ describe('VList.split', () => { it('split List 3', () => { runTest( - `
                                                          1. 1
                                                            1. 1
                                                            2. 2
                                                            3. 3
                                                          2. 3
                                                          3. 4
                                                          `, + `
                                                          1. 1
                                                            1. 1
                                                            2. 2
                                                            3. 3
                                                          2. 3
                                                          3. 4
                                                          `, '
                                                          1. 1
                                                            1. 1
                                                            1. 2
                                                            2. 3
                                                          1. 3
                                                          2. 4
                                                          ', 1 ); From 84b70fdf178f22e535d42ea71d9401c936645644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 27 Oct 2023 17:23:25 -0300 Subject: [PATCH 020/111] remove test --- packages/roosterjs-editor-dom/test/list/VListTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/roosterjs-editor-dom/test/list/VListTest.ts b/packages/roosterjs-editor-dom/test/list/VListTest.ts index 32d787e4ee8..0090990035e 100644 --- a/packages/roosterjs-editor-dom/test/list/VListTest.ts +++ b/packages/roosterjs-editor-dom/test/list/VListTest.ts @@ -505,7 +505,7 @@ describe('VList.writeBack', () => { it('Write back with Lists with list item types', () => { const styledList = - '
                                                          1. 123
                                                            1. 123
                                                              1. 123

                                                          '; + '
                                                          1. 123
                                                            1. 123
                                                              1. 123

                                                          '; const div = document.createElement('div'); document.body.append(div); div.innerHTML = styledList; From 7994d637daad3af27201c889e570d0515aa59aa4 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 30 Oct 2023 14:58:55 -0700 Subject: [PATCH 021/111] Rearrange parameters of iterateSelections (#2180) * Rearrange parameters of iterateSelections * improve --- .../lib/editor/coreApi/switchShadowEdit.ts | 2 +- .../ContentModelCopyPastePlugin.ts | 2 +- .../lib/modelApi/common/clearModelFormat.ts | 2 +- .../common/retrieveModelFormatState.ts | 2 +- .../edit/utils/deleteExpandedSelection.ts | 2 +- .../modelApi/selection/adjustWordSelection.ts | 2 +- .../modelApi/selection/collectSelections.ts | 2 +- .../modelApi/selection/iterateSelections.ts | 21 +- .../publicApi/format/applyPendingFormat.ts | 2 +- .../common/retrieveModelFormatStateTest.ts | 49 ++-- .../selection/iterateSelectionsTest.ts | 264 +++++------------- 11 files changed, 115 insertions(+), 235 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts index 7ab5578d6b9..6d4d56fc95f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts @@ -56,7 +56,7 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { if (core.cache.cachedModel) { // Force clear cached element from selected block - iterateSelections([core.cache.cachedModel], () => {}); + iterateSelections(core.cache.cachedModel, () => {}); core.api.setContentModel(core, core.cache.cachedModel, { ignoreSelection: true, // Do not set focus and selection when quit shadow edit, focus may remain in UI control (picker, ...) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts index 87e79986880..46d20818822 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts @@ -112,7 +112,7 @@ export default class ContentModelCopyPastePlugin implements PluginWithState { + iterateSelections(pasteModel, (_, tableContext) => { if (tableContext?.table) { const table = tableContext?.table; table.rows = table.rows diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts index 7228bf4ccf4..34f12aa4d77 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts @@ -28,7 +28,7 @@ export function clearModelFormat( tablesToClear: [ContentModelTable, boolean][] ) { iterateSelections( - [model], + model, (path, tableContext, block, segments) => { if (segments) { segmentsToClear.push(...segments); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts index f9eb9a7c448..5e7a33e7c80 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts @@ -31,7 +31,7 @@ export function retrieveModelFormatState( let isFirstSegment = true; iterateSelections( - [model], + model, (path, tableContext, block, segments) => { // Structure formats retrieveStructureFormat(formatState, path, isFirst); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts index bf7bcf69abd..809e80d35fc 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts @@ -36,7 +36,7 @@ export function deleteExpandedSelection( }; iterateSelections( - [model], + model, (path, tableContext, block, segments) => { // Set paragraph, format and index for default position where we will put cursor to. // Later we can overwrite these info when process the selections diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts index 0a2035f99b8..9d4d3e2f8cf 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts @@ -17,7 +17,7 @@ export function adjustWordSelection( ): ContentModelSegment[] { let markerBlock: ContentModelParagraph | undefined; - iterateSelections([model], (path, tableContext, block, segments) => { + iterateSelections(model, (_, __, block, segments) => { //Find the block with the selection marker if (block?.blockType == 'Paragraph' && segments?.length == 1 && segments[0] == marker) { markerBlock = block; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts index 1089c46eeb4..186dc27b562 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts @@ -172,7 +172,7 @@ function collectSelections( const selections: SelectionInfo[] = []; iterateSelections( - [model], + model, (path, tableContext, block, segments) => { selections.push({ path, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/iterateSelections.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/iterateSelections.ts index 454832349e4..551eeb8bcb5 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/iterateSelections.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/iterateSelections.ts @@ -8,6 +8,7 @@ import type { /** * @internal + * Options for iterateSelections API */ export interface IterateSelectionsOption { /** @@ -42,6 +43,11 @@ export interface IterateSelectionsOption { /** * @internal + * The callback function type for iterateSelections + * @param path The block group path of current selection + * @param tableContext Table context of current selection + * @param block Block of current selection + * @param segments Segments of current selection * @returns True to stop iterating, otherwise keep going */ export type IterateSelectionsCallback = ( @@ -53,15 +59,16 @@ export type IterateSelectionsCallback = ( /** * @internal - * @returns True to stop iterating, otherwise keep going + * Iterate all selected elements in a given model + * @param group The given Content Model to iterate selection from + * @param callback The callback function to access the selected element + * @param option Option to determine how to iterate */ export function iterateSelections( - path: ContentModelBlockGroup[], + group: ContentModelBlockGroup, callback: IterateSelectionsCallback, - option?: IterateSelectionsOption, - table?: TableSelectionContext, - treatAllAsSelect?: boolean -) { + option?: IterateSelectionsOption +): void { const internalCallback: IterateSelectionsCallback = (path, tableContext, block, segments) => { if (!!(block as ContentModelBlockWithCache)?.cachedElement) { // TODO: This is a temporary solution. A better solution would be making all results from iterationSelection() to be readonly, @@ -72,7 +79,7 @@ export function iterateSelections( return callback(path, tableContext, block, segments); }; - internalIterateSelections(path, internalCallback, option, table, treatAllAsSelect); + internalIterateSelections([group], internalCallback, option); } function internalIterateSelections( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts index 7a892435298..432da79a18d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts @@ -23,7 +23,7 @@ export default function applyPendingFormat(editor: IContentModelEditor, data: st let isChanged = false; formatWithContentModel(editor, 'applyPendingFormat', (model, context) => { - iterateSelections([model], (_, __, block, segments) => { + iterateSelections(model, (_, __, block, segments) => { if ( block?.blockType == 'Paragraph' && segments?.length == 1 && diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts index 296db0ff587..21dabcc1371 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts @@ -64,7 +64,7 @@ describe('retrieveModelFormatState', () => { const marker = createSelectionMarker(segmentFormat); spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, para, [marker]); + callback([path], undefined, para, [marker]); return false; }); @@ -82,7 +82,7 @@ describe('retrieveModelFormatState', () => { addCode(marker, { format: { fontFamily: 'monospace' } }); spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, para, [marker]); + callback([path], undefined, para, [marker]); return false; }); @@ -150,7 +150,7 @@ describe('retrieveModelFormatState', () => { const marker = createSelectionMarker(segmentFormat); spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, para, [marker]); + callback([path], undefined, para, [marker]); return false; }); @@ -175,7 +175,7 @@ describe('retrieveModelFormatState', () => { const marker = createSelectionMarker(segmentFormat); spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, para, [marker]); + callback([path], undefined, para, [marker]); return false; }); @@ -201,7 +201,7 @@ describe('retrieveModelFormatState', () => { spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { callback( - path, + [path], { table: table, colIndex: 0, @@ -238,7 +238,7 @@ describe('retrieveModelFormatState', () => { spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { callback( - path, + [path], { table: table, colIndex: 0, @@ -287,7 +287,12 @@ describe('retrieveModelFormatState', () => { model.blocks.push(table); spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, { table: table, rowIndex: 0, colIndex: 0, isWholeTableSelected: false }); + callback([path], { + table: table, + rowIndex: 0, + colIndex: 0, + isWholeTableSelected: false, + }); return false; }); @@ -309,8 +314,8 @@ describe('retrieveModelFormatState', () => { const marker2 = createSelectionMarker(); spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, para1, [marker1]); - callback(path, undefined, para2, [marker2]); + callback([path], undefined, para1, [marker1]); + callback([path], undefined, para2, [marker2]); return false; }); @@ -344,7 +349,7 @@ describe('retrieveModelFormatState', () => { const result: ContentModelFormatState = {}; spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, para, [text1, text2]); + callback([path], undefined, para, [text1, text2]); return false; }); @@ -370,7 +375,7 @@ describe('retrieveModelFormatState', () => { const result: ContentModelFormatState = {}; spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, para, [text1, text2]); + callback([path], undefined, para, [text1, text2]); return false; }); @@ -395,7 +400,7 @@ describe('retrieveModelFormatState', () => { const result: ContentModelFormatState = {}; spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, para, [text, marker]); + callback([path], undefined, para, [text, marker]); return false; }); @@ -421,8 +426,8 @@ describe('retrieveModelFormatState', () => { const marker1 = createSelectionMarker(segmentFormat); spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, para1, [marker1]); - callback(path, undefined, divider); + callback([path], undefined, para1, [marker1]); + callback([path], undefined, divider); return false; }); @@ -444,8 +449,8 @@ describe('retrieveModelFormatState', () => { const marker1 = createSelectionMarker(segmentFormat); spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, divider); - callback(path, undefined, para1, [marker1]); + callback([path], undefined, divider); + callback([path], undefined, para1, [marker1]); return false; }); @@ -471,7 +476,7 @@ describe('retrieveModelFormatState', () => { }; spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, para1, [marker1]); + callback([path], undefined, para1, [marker1]); return false; }); @@ -622,7 +627,7 @@ describe('retrieveModelFormatState', () => { para.segments.push(image); spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, para, [image]); + callback([path], undefined, para, [image]); return false; }); @@ -662,7 +667,7 @@ describe('retrieveModelFormatState', () => { para.segments.push(image2); spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, para, [image, image2]); + callback([path], undefined, para, [image, image2]); return false; }); @@ -691,7 +696,7 @@ describe('retrieveModelFormatState', () => { para.segments.push(marker); spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, para, [marker]); + callback([path], undefined, para, [marker]); return false; }); @@ -726,7 +731,7 @@ describe('retrieveModelFormatState', () => { text1.isSelected = true; spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, para, [text1]); + callback([path], undefined, para, [text1]); return false; }); @@ -760,7 +765,7 @@ describe('retrieveModelFormatState', () => { text2.isSelected = true; spyOn(iterateSelections, 'iterateSelections').and.callFake((path, callback) => { - callback(path, undefined, para, [text1, text2]); + callback([path], undefined, para, [text1, text2]); return false; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/iterateSelectionsTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/iterateSelectionsTest.ts index fba405cde0f..a790c394993 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/iterateSelectionsTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/iterateSelectionsTest.ts @@ -28,7 +28,7 @@ describe('iterateSelections', () => { it('empty group', () => { const group = createContentModelDocument(); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).not.toHaveBeenCalled(); }); @@ -45,7 +45,7 @@ describe('iterateSelections', () => { group.blocks.push(para1); group.blocks.push(para2); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).not.toHaveBeenCalled(); }); @@ -64,7 +64,7 @@ describe('iterateSelections', () => { group.blocks.push(para1); group.blocks.push(para2); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([group], undefined, para1, [text1]); @@ -85,7 +85,7 @@ describe('iterateSelections', () => { group.blocks.push(para1); group.blocks.push(para2); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(2); expect(callback).toHaveBeenCalledWith([group], undefined, para1, [text1]); @@ -110,7 +110,7 @@ describe('iterateSelections', () => { group.blocks.push(listItem); group.blocks.push(para2); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(2); expect(callback).toHaveBeenCalledWith([listItem, group], undefined, para1, [text1]); @@ -137,7 +137,7 @@ describe('iterateSelections', () => { group.blocks.push(quote); group.blocks.push(para2); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([quote, group], undefined, para1, [text1]); @@ -163,7 +163,7 @@ describe('iterateSelections', () => { group.blocks.push(table); group.blocks.push(para2); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith( @@ -202,7 +202,7 @@ describe('iterateSelections', () => { group.blocks.push(table); group.blocks.push(para2); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(2); expect(callback).toHaveBeenCalledWith( @@ -250,7 +250,7 @@ describe('iterateSelections', () => { group.blocks.push(table); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(3); expect(callback).toHaveBeenCalledWith( @@ -309,7 +309,7 @@ describe('iterateSelections', () => { group.blocks.push(table); - iterateSelections([group], callback, { + iterateSelections(group, callback, { contentUnderSelectedTableCell: 'ignoreForTableOrCell', }); @@ -348,7 +348,7 @@ describe('iterateSelections', () => { group.blocks.push(table); - iterateSelections([group], callback, { + iterateSelections(group, callback, { contentUnderSelectedTableCell: 'ignoreForTable', }); @@ -410,7 +410,7 @@ describe('iterateSelections', () => { group.blocks.push(table); - iterateSelections([group], callback, { contentUnderSelectedTableCell: 'ignoreForTable' }); + iterateSelections(group, callback, { contentUnderSelectedTableCell: 'ignoreForTable' }); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([group], undefined, table, undefined); @@ -429,7 +429,7 @@ describe('iterateSelections', () => { group.blocks.push(para1); group.blocks.push(para2); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(2); expect(callback).toHaveBeenCalledWith([group], undefined, para1, [marker]); @@ -449,7 +449,7 @@ describe('iterateSelections', () => { group.blocks.push(para1); group.blocks.push(para2); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(2); expect(callback).toHaveBeenCalledWith([group], undefined, para1, [text]); @@ -469,7 +469,7 @@ describe('iterateSelections', () => { group.blocks.push(para1); group.blocks.push(para2); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(2); expect(callback).toHaveBeenCalledWith([group], undefined, para1, [marker]); @@ -493,7 +493,7 @@ describe('iterateSelections', () => { group.blocks.push(para2); group.blocks.push(para3); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(3); expect(callback).toHaveBeenCalledWith([group], undefined, para1, [marker1]); @@ -524,7 +524,7 @@ describe('iterateSelections', () => { group.blocks.push(para2); group.blocks.push(para3); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(3); expect(callback).toHaveBeenCalledWith([group], undefined, para1, [marker1, text1]); @@ -543,7 +543,7 @@ describe('iterateSelections', () => { listItem.blocks.push(para); group.blocks.push(listItem); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(2); expect(callback).toHaveBeenCalledWith([listItem, group], undefined, para, [text]); @@ -565,7 +565,7 @@ describe('iterateSelections', () => { listItem.blocks.push(para); group.blocks.push(listItem); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([listItem, group], undefined, para, [text1]); @@ -584,7 +584,7 @@ describe('iterateSelections', () => { generalSpan.blocks.push(para); group.blocks.push(generalSpan); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([generalSpan, group], undefined, para, [text1]); @@ -604,7 +604,7 @@ describe('iterateSelections', () => { para2.segments.push(text1, text2); group.blocks.push(para1); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([generalSpan, group], undefined, para2, [text1]); @@ -622,14 +622,14 @@ describe('iterateSelections', () => { const callback2 = jasmine.createSpy('callback2'); const callback3 = jasmine.createSpy('callback3'); - iterateSelections([group], callback); - iterateSelections([group], callback1, { + iterateSelections(group, callback); + iterateSelections(group, callback1, { contentUnderSelectedGeneralElement: 'contentOnly', }); - iterateSelections([group], callback2, { + iterateSelections(group, callback2, { contentUnderSelectedGeneralElement: 'generalElementOnly', }); - iterateSelections([group], callback3, { contentUnderSelectedGeneralElement: 'both' }); + iterateSelections(group, callback3, { contentUnderSelectedGeneralElement: 'both' }); expect(callback).toHaveBeenCalledTimes(0); expect(callback1).toHaveBeenCalledTimes(0); @@ -656,14 +656,14 @@ describe('iterateSelections', () => { const callback2 = jasmine.createSpy('callback2'); const callback3 = jasmine.createSpy('callback3'); - iterateSelections([group], callback); - iterateSelections([group], callback1, { + iterateSelections(group, callback); + iterateSelections(group, callback1, { contentUnderSelectedGeneralElement: 'contentOnly', }); - iterateSelections([group], callback2, { + iterateSelections(group, callback2, { contentUnderSelectedGeneralElement: 'generalElementOnly', }); - iterateSelections([group], callback3, { contentUnderSelectedGeneralElement: 'both' }); + iterateSelections(group, callback3, { contentUnderSelectedGeneralElement: 'both' }); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([generalSpan, group], undefined, para2, [text2]); @@ -688,14 +688,14 @@ describe('iterateSelections', () => { const callback2 = jasmine.createSpy('callback2'); const callback3 = jasmine.createSpy('callback3'); - iterateSelections([group], callback); - iterateSelections([group], callback1, { + iterateSelections(group, callback); + iterateSelections(group, callback1, { contentUnderSelectedGeneralElement: 'contentOnly', }); - iterateSelections([group], callback2, { + iterateSelections(group, callback2, { contentUnderSelectedGeneralElement: 'generalElementOnly', }); - iterateSelections([group], callback3, { contentUnderSelectedGeneralElement: 'both' }); + iterateSelections(group, callback3, { contentUnderSelectedGeneralElement: 'both' }); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([group], undefined, para1, [generalSpan]); @@ -728,81 +728,14 @@ describe('iterateSelections', () => { const callback2 = jasmine.createSpy('callback2'); const callback3 = jasmine.createSpy('callback3'); - iterateSelections([group], callback); - iterateSelections([group], callback1, { + iterateSelections(group, callback); + iterateSelections(group, callback1, { contentUnderSelectedGeneralElement: 'contentOnly', }); - iterateSelections([group], callback2, { + iterateSelections(group, callback2, { contentUnderSelectedGeneralElement: 'generalElementOnly', }); - iterateSelections([group], callback3, { contentUnderSelectedGeneralElement: 'both' }); - - expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith([generalSpan, group], undefined, para2, [ - text1, - text2, - ]); - - expect(callback1).toHaveBeenCalledTimes(1); - expect(callback1).toHaveBeenCalledWith([generalSpan, group], undefined, para2, [ - text1, - text2, - ]); - - expect(callback2).toHaveBeenCalledTimes(1); - expect(callback2).toHaveBeenCalledWith([group], undefined, para1, [generalSpan]); - - expect(callback3).toHaveBeenCalledTimes(2); - expect(callback3).toHaveBeenCalledWith([generalSpan, group], undefined, para2, [ - text1, - text2, - ]); - expect(callback3).toHaveBeenCalledWith([group], undefined, para1, [generalSpan]); - }); - - it('Get Selection from model that contains general segment, treat all as selected', () => { - const group = createContentModelDocument(); - const generalSpan = createGeneralSegment(document.createElement('span')); - const para1 = createParagraph(true /*implicit*/); - const para2 = createParagraph(true /*implicit*/); - const text1 = createText('test1'); - const text2 = createText('test1'); - - para1.segments.push(generalSpan); - generalSpan.blocks.push(para2); - para2.segments.push(text1, text2); - group.blocks.push(para1); - - const callback1 = jasmine.createSpy('callback1'); - const callback2 = jasmine.createSpy('callback2'); - const callback3 = jasmine.createSpy('callback3'); - - iterateSelections([group], callback, undefined, undefined, true); - iterateSelections( - [group], - callback1, - { - contentUnderSelectedGeneralElement: 'contentOnly', - }, - undefined, - true - ); - iterateSelections( - [group], - callback2, - { - contentUnderSelectedGeneralElement: 'generalElementOnly', - }, - undefined, - true - ); - iterateSelections( - [group], - callback3, - { contentUnderSelectedGeneralElement: 'both' }, - undefined, - true - ); + iterateSelections(group, callback3, { contentUnderSelectedGeneralElement: 'both' }); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([generalSpan, group], undefined, para2, [ @@ -839,7 +772,7 @@ describe('iterateSelections', () => { para2.segments.push(text1, text2); group.blocks.push(generalDiv); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([generalDiv, group], undefined, para2, [text1]); @@ -855,14 +788,14 @@ describe('iterateSelections', () => { const callback2 = jasmine.createSpy('callback2'); const callback3 = jasmine.createSpy('callback3'); - iterateSelections([group], callback); - iterateSelections([group], callback1, { + iterateSelections(group, callback); + iterateSelections(group, callback1, { contentUnderSelectedGeneralElement: 'contentOnly', }); - iterateSelections([group], callback2, { + iterateSelections(group, callback2, { contentUnderSelectedGeneralElement: 'generalElementOnly', }); - iterateSelections([group], callback3, { contentUnderSelectedGeneralElement: 'both' }); + iterateSelections(group, callback3, { contentUnderSelectedGeneralElement: 'both' }); expect(callback).toHaveBeenCalledTimes(0); expect(callback1).toHaveBeenCalledTimes(0); @@ -887,14 +820,14 @@ describe('iterateSelections', () => { const callback2 = jasmine.createSpy('callback2'); const callback3 = jasmine.createSpy('callback3'); - iterateSelections([group], callback); - iterateSelections([group], callback1, { + iterateSelections(group, callback); + iterateSelections(group, callback1, { contentUnderSelectedGeneralElement: 'contentOnly', }); - iterateSelections([group], callback2, { + iterateSelections(group, callback2, { contentUnderSelectedGeneralElement: 'generalElementOnly', }); - iterateSelections([group], callback3, { contentUnderSelectedGeneralElement: 'both' }); + iterateSelections(group, callback3, { contentUnderSelectedGeneralElement: 'both' }); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([generalDiv, group], undefined, para2, [text2]); @@ -917,14 +850,14 @@ describe('iterateSelections', () => { const callback2 = jasmine.createSpy('callback2'); const callback3 = jasmine.createSpy('callback3'); - iterateSelections([group], callback); - iterateSelections([group], callback1, { + iterateSelections(group, callback); + iterateSelections(group, callback1, { contentUnderSelectedGeneralElement: 'contentOnly', }); - iterateSelections([group], callback2, { + iterateSelections(group, callback2, { contentUnderSelectedGeneralElement: 'generalElementOnly', }); - iterateSelections([group], callback3, { contentUnderSelectedGeneralElement: 'both' }); + iterateSelections(group, callback3, { contentUnderSelectedGeneralElement: 'both' }); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([group], undefined, generalDiv, undefined); @@ -955,79 +888,14 @@ describe('iterateSelections', () => { const callback2 = jasmine.createSpy('callback2'); const callback3 = jasmine.createSpy('callback3'); - iterateSelections([group], callback); - iterateSelections([group], callback1, { + iterateSelections(group, callback); + iterateSelections(group, callback1, { contentUnderSelectedGeneralElement: 'contentOnly', }); - iterateSelections([group], callback2, { + iterateSelections(group, callback2, { contentUnderSelectedGeneralElement: 'generalElementOnly', }); - iterateSelections([group], callback3, { contentUnderSelectedGeneralElement: 'both' }); - - expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith([generalDiv, group], undefined, para2, [ - text1, - text2, - ]); - - expect(callback1).toHaveBeenCalledTimes(1); - expect(callback1).toHaveBeenCalledWith([generalDiv, group], undefined, para2, [ - text1, - text2, - ]); - - expect(callback2).toHaveBeenCalledTimes(1); - expect(callback2).toHaveBeenCalledWith([group], undefined, generalDiv, undefined); - - expect(callback3).toHaveBeenCalledTimes(2); - expect(callback3).toHaveBeenCalledWith([generalDiv, group], undefined, para2, [ - text1, - text2, - ]); - expect(callback3).toHaveBeenCalledWith([group], undefined, generalDiv, undefined); - }); - - it('Get Selection from model that contains general block, treat all as selected', () => { - const group = createContentModelDocument(); - const generalDiv = createGeneralBlock(document.createElement('div')); - const para2 = createParagraph(true /*implicit*/); - const text1 = createText('test1'); - const text2 = createText('test1'); - - generalDiv.blocks.push(para2); - para2.segments.push(text1, text2); - group.blocks.push(generalDiv); - - const callback1 = jasmine.createSpy('callback1'); - const callback2 = jasmine.createSpy('callback2'); - const callback3 = jasmine.createSpy('callback3'); - - iterateSelections([group], callback, undefined, undefined, true); - iterateSelections( - [group], - callback1, - { - contentUnderSelectedGeneralElement: 'contentOnly', - }, - undefined, - true - ); - iterateSelections( - [group], - callback2, - { - contentUnderSelectedGeneralElement: 'generalElementOnly', - }, - undefined, - true - ); - iterateSelections( - [group], - callback3, - { contentUnderSelectedGeneralElement: 'both' }, - undefined, - true - ); + iterateSelections(group, callback3, { contentUnderSelectedGeneralElement: 'both' }); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([generalDiv, group], undefined, para2, [ @@ -1059,7 +927,7 @@ describe('iterateSelections', () => { divider.isSelected = true; group.blocks.push(divider); - iterateSelections([group], callback); + iterateSelections(group, callback); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([group], undefined, divider, undefined); @@ -1086,7 +954,7 @@ describe('iterateSelections', () => { return block == para1; }); - iterateSelections([group], newCallback); + iterateSelections(group, newCallback); expect(newCallback).toHaveBeenCalledTimes(1); expect(newCallback).toHaveBeenCalledWith([group], undefined, para1, [text1]); @@ -1114,7 +982,7 @@ describe('iterateSelections', () => { return block == divider; }); - iterateSelections([group], newCallback); + iterateSelections(group, newCallback); expect(newCallback).toHaveBeenCalledTimes(2); expect(newCallback).toHaveBeenCalledWith([group], undefined, para1, [text1]); @@ -1146,7 +1014,7 @@ describe('iterateSelections', () => { return block == para1; }); - iterateSelections([group], newCallback); + iterateSelections(group, newCallback); expect(newCallback).toHaveBeenCalledTimes(1); expect(newCallback).toHaveBeenCalledWith([quote1, group], undefined, para1, [text1]); @@ -1173,7 +1041,7 @@ describe('iterateSelections', () => { return block == table; }); - iterateSelections([group], newCallback, { + iterateSelections(group, newCallback, { contentUnderSelectedTableCell: 'ignoreForTable', }); @@ -1207,7 +1075,7 @@ describe('iterateSelections', () => { } }); - iterateSelections([group], newCallback); + iterateSelections(group, newCallback); expect(newCallback).toHaveBeenCalledTimes(2); expect(newCallback).toHaveBeenCalledWith( @@ -1247,7 +1115,7 @@ describe('iterateSelections', () => { list.blocks.push(para); doc.blocks.push(list); - iterateSelections([doc], callback, { includeListFormatHolder: 'anySegment' }); + iterateSelections(doc, callback, { includeListFormatHolder: 'anySegment' }); expect(callback).toHaveBeenCalledTimes(2); expect(callback).toHaveBeenCalledWith([list, doc], undefined, para, [text2]); @@ -1273,7 +1141,7 @@ describe('iterateSelections', () => { list.blocks.push(para); doc.blocks.push(list); - iterateSelections([doc], callback, { includeListFormatHolder: 'allSegments' }); + iterateSelections(doc, callback, { includeListFormatHolder: 'allSegments' }); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([list, doc], undefined, para, [text2]); @@ -1293,7 +1161,7 @@ describe('iterateSelections', () => { list.blocks.push(para); doc.blocks.push(list); - iterateSelections([doc], callback, { includeListFormatHolder: 'allSegments' }); + iterateSelections(doc, callback, { includeListFormatHolder: 'allSegments' }); expect(callback).toHaveBeenCalledTimes(2); expect(callback).toHaveBeenCalledWith([list, doc], undefined, para, [text1, text2]); @@ -1320,7 +1188,7 @@ describe('iterateSelections', () => { list.blocks.push(para); doc.blocks.push(list); - iterateSelections([doc], callback, { includeListFormatHolder: 'never' }); + iterateSelections(doc, callback, { includeListFormatHolder: 'never' }); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([list, doc], undefined, para, [text1, text2]); @@ -1336,7 +1204,7 @@ describe('iterateSelections', () => { para.segments.push(entity); doc.blocks.push(para); - iterateSelections([doc], callback, { includeListFormatHolder: 'never' }); + iterateSelections(doc, callback, { includeListFormatHolder: 'never' }); expect(callback).toHaveBeenCalledTimes(1); expect(callback).toHaveBeenCalledWith([doc], undefined, para, [entity]); @@ -1368,7 +1236,7 @@ describe('iterateSelections', () => { doc.blocks.push(quote1, quote2, para1, para2, divider1, divider2); - iterateSelections([doc], callback); + iterateSelections(doc, callback); expect(doc).toEqual({ blockGroupType: 'Document', From 66e6d957ec4045737ba59184d35c33a8c3924d18 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 30 Oct 2023 15:50:06 -0700 Subject: [PATCH 022/111] Standalone editor step 0: Create a copy of Editor class (#2175) * Standalone editor step 0: Create a copy of Editor class * remove unnecessary change * improve --- .../lib/coreApi/addUndoSnapshot.ts | 137 +++ .../lib/coreApi/attachDomEvent.ts | 64 + .../lib/coreApi/coreApiMap.ts | 49 + .../lib/coreApi/createPasteFragment.ts | 152 +++ .../lib/coreApi/ensureTypeInContainer.ts | 88 ++ .../lib/coreApi/focus.ts | 44 + .../lib/coreApi/getContent.ts | 91 ++ .../lib/coreApi/getPendableFormatState.ts | 101 ++ .../lib/coreApi/getSelectionRange.ts | 44 + .../lib/coreApi/getSelectionRangeEx.ts | 101 ++ .../lib/coreApi/getStyleBasedFormatState.ts | 96 ++ .../lib/coreApi/hasFocus.ts | 15 + .../lib/coreApi/insertNode.ts | 235 ++++ .../lib/coreApi/restoreUndoSnapshot.ts | 69 ++ .../lib/coreApi/select.ts | 179 +++ .../lib/coreApi/selectImage.ts | 65 ++ .../lib/coreApi/selectRange.ts | 74 ++ .../lib/coreApi/selectTable.ts | 268 +++++ .../lib/coreApi/setContent.ts | 120 ++ .../lib/coreApi/switchShadowEdit.ts | 111 ++ .../lib/coreApi/transformColor.ts | 69 ++ .../lib/coreApi/triggerEvent.ts | 44 + .../lib/coreApi/utils/addUniqueId.ts | 31 + .../lib/corePlugins/CopyPastePlugin.ts | 296 +++++ .../lib/corePlugins/DOMEventPlugin.ts | 259 +++++ .../lib/corePlugins/EditPlugin.ts | 96 ++ .../lib/corePlugins/EntityPlugin.ts | 390 +++++++ .../lib/corePlugins/ImageSelection.ts | 104 ++ .../lib/corePlugins/LifecyclePlugin.ts | 188 +++ .../lib/corePlugins/MouseUpPlugin.ts | 72 ++ .../lib/corePlugins/NormalizeTablePlugin.ts | 180 +++ .../corePlugins/PendingFormatStatePlugin.ts | 184 +++ .../lib/corePlugins/TypeInContainerPlugin.ts | 99 ++ .../lib/corePlugins/UndoPlugin.ts | 279 +++++ .../lib/corePlugins/createCorePlugins.ts | 66 ++ .../corePlugins/utils/forEachSelectedCell.ts | 22 + .../utils/inlineEntityOnPluginEvent.ts | 291 +++++ .../utils/removeCellsOutsideSelection.ts | 37 + .../lib/editor/AdapterEditor.ts | 1035 +++++++++++++++++ .../lib/editor/DarkColorHandlerImpl.ts | 173 +++ .../lib/editor/createEditorCore.ts | 61 + .../lib/editor/isFeatureEnabled.ts | 16 + .../lib/index.ts | 2 + .../package.json | 14 + 44 files changed, 6111 insertions(+) create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/addUndoSnapshot.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/attachDomEvent.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/coreApiMap.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/createPasteFragment.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/ensureTypeInContainer.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/focus.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getContent.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getPendableFormatState.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRange.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRangeEx.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getStyleBasedFormatState.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/hasFocus.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/insertNode.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/restoreUndoSnapshot.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/select.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectImage.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectRange.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectTable.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/setContent.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/switchShadowEdit.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/transformColor.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/triggerEvent.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/utils/addUniqueId.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/CopyPastePlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/DOMEventPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EditPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EntityPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/ImageSelection.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/LifecyclePlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/MouseUpPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/NormalizeTablePlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/PendingFormatStatePlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/TypeInContainerPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/UndoPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/createCorePlugins.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/forEachSelectedCell.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/removeCellsOutsideSelection.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/editor/AdapterEditor.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/editor/DarkColorHandlerImpl.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/editor/createEditorCore.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/editor/isFeatureEnabled.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/index.ts create mode 100644 packages-content-model/roosterjs-content-model-adapter/package.json diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/addUndoSnapshot.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/addUndoSnapshot.ts new file mode 100644 index 00000000000..aa7919c803f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/addUndoSnapshot.ts @@ -0,0 +1,137 @@ +import { getSelectionPath, Position } from 'roosterjs-editor-dom'; +import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import type { + EntityState, + AddUndoSnapshot, + ChangeSource, + ContentChangedData, + ContentChangedEvent, + ContentMetadata, + EditorCore, + NodePosition, + SelectionRangeEx, +} from 'roosterjs-editor-types'; +import type { CompatibleChangeSource } from 'roosterjs-editor-types/lib/compatibleTypes'; + +/** + * @internal + * Call an editing callback with adding undo snapshots around, and trigger a ContentChanged event if change source is specified. + * Undo snapshot will not be added if this call is nested inside another addUndoSnapshot() call. + * @param core The EditorCore object + * @param callback The editing callback, accepting current selection start and end position, returns an optional object used as the data field of ContentChangedEvent. + * @param changeSource The ChangeSource string of ContentChangedEvent. @default ChangeSource.Format. Set to null to avoid triggering ContentChangedEvent + * @param canUndoByBackspace True if this action can be undone when user press Backspace key (aka Auto Complete). + * @param additionalData @optional parameter to provide additional data related to the ContentChanged Event. + */ +export const addUndoSnapshot: AddUndoSnapshot = ( + core: EditorCore, + callback: ((start: NodePosition | null, end: NodePosition | null) => any) | null, + changeSource: ChangeSource | CompatibleChangeSource | string | null, + canUndoByBackspace: boolean, + additionalData?: ContentChangedData +) => { + const undoState = core.undo; + const isNested = undoState.isNested; + let data: any; + + if (!isNested) { + undoState.isNested = true; + + // When there is getEntityState, it means this is triggered by an entity change. + // So if HTML content is not changed (hasNewContent is false), no need to add another snapshot before change + if (core.undo.hasNewContent || !additionalData?.getEntityState || !callback) { + addUndoSnapshotInternal(core, canUndoByBackspace, additionalData?.getEntityState?.()); + } + } + + try { + if (callback) { + const range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); + data = callback( + range && Position.getStart(range).normalize(), + range && Position.getEnd(range).normalize() + ); + + if (!isNested) { + const entityStates = additionalData?.getEntityState?.(); + addUndoSnapshotInternal(core, false /*isAutoCompleteSnapshot*/, entityStates); + } + } + } finally { + if (!isNested) { + undoState.isNested = false; + } + } + + if (callback && changeSource) { + const event: ContentChangedEvent = { + eventType: PluginEventType.ContentChanged, + source: changeSource, + data: data, + additionalData, + }; + core.api.triggerEvent(core, event, true /*broadcast*/); + } + + if (canUndoByBackspace) { + const range = core.api.getSelectionRange(core, false /*tryGetFromCache*/); + + if (range) { + core.undo.hasNewContent = false; + core.undo.autoCompletePosition = Position.getStart(range); + } + } +}; + +function addUndoSnapshotInternal( + core: EditorCore, + canUndoByBackspace: boolean, + entityStates?: EntityState[] +) { + if (!core.lifecycle.shadowEditFragment) { + const rangeEx = core.api.getSelectionRangeEx(core); + const isDarkMode = core.lifecycle.isDarkMode; + const metadata = createContentMetadata(core.contentDiv, rangeEx, isDarkMode) || null; + + core.undo.snapshotsService.addSnapshot( + { + html: core.contentDiv.innerHTML, + metadata, + knownColors: core.darkColorHandler?.getKnownColorsCopy() || [], + entityStates, + }, + canUndoByBackspace + ); + core.undo.hasNewContent = false; + } +} + +function createContentMetadata( + root: HTMLElement, + rangeEx: SelectionRangeEx, + isDarkMode: boolean +): ContentMetadata | undefined { + switch (rangeEx?.type) { + case SelectionRangeTypes.TableSelection: + return { + type: SelectionRangeTypes.TableSelection, + tableId: rangeEx.table.id, + isDarkMode: !!isDarkMode, + ...rangeEx.coordinates!, + }; + case SelectionRangeTypes.ImageSelection: + return { + type: SelectionRangeTypes.ImageSelection, + imageId: rangeEx.image.id, + isDarkMode: !!isDarkMode, + }; + case SelectionRangeTypes.Normal: + return { + type: SelectionRangeTypes.Normal, + isDarkMode: !!isDarkMode, + start: [], + end: [], + ...(getSelectionPath(root, rangeEx.ranges[0]) || {}), + }; + } +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/attachDomEvent.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/attachDomEvent.ts new file mode 100644 index 00000000000..0ca6916e4e6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/attachDomEvent.ts @@ -0,0 +1,64 @@ +import { getObjectKeys } from 'roosterjs-editor-dom'; +import type { + AttachDomEvent, + DOMEventHandler, + DOMEventHandlerObject, + EditorCore, + PluginDomEvent, +} from 'roosterjs-editor-types'; + +/** + * @internal + * Attach a DOM event to the editor content DIV + * @param core The EditorCore object + * @param eventName The DOM event name + * @param pluginEventType Optional event type. When specified, editor will trigger a plugin event with this name when the DOM event is triggered + * @param beforeDispatch Optional callback function to be invoked when the DOM event is triggered before trigger plugin event + */ +export const attachDomEvent: AttachDomEvent = ( + core: EditorCore, + eventMap: Record +) => { + const disposers = getObjectKeys(eventMap || {}).map(key => { + const { pluginEventType, beforeDispatch } = extractHandler(eventMap[key]); + const eventName = key as keyof HTMLElementEventMap; + const onEvent = (event: HTMLElementEventMap[typeof eventName]) => { + if (beforeDispatch) { + beforeDispatch(event); + } + if (pluginEventType != null) { + core.api.triggerEvent( + core, + { + eventType: pluginEventType, + rawEvent: event, + }, + false /*broadcast*/ + ); + } + }; + + core.contentDiv.addEventListener(eventName, onEvent); + + return () => { + core.contentDiv.removeEventListener(eventName, onEvent); + }; + }); + return () => disposers.forEach(disposers => disposers()); +}; + +function extractHandler(handlerObj: DOMEventHandler): DOMEventHandlerObject { + let result: DOMEventHandlerObject = { + pluginEventType: null, + beforeDispatch: null, + }; + + if (typeof handlerObj === 'number') { + result.pluginEventType = handlerObj; + } else if (typeof handlerObj === 'function') { + result.beforeDispatch = handlerObj; + } else if (typeof handlerObj === 'object') { + result = handlerObj; + } + return result; +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/coreApiMap.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/coreApiMap.ts new file mode 100644 index 00000000000..dd12197703f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/coreApiMap.ts @@ -0,0 +1,49 @@ +import { addUndoSnapshot } from './addUndoSnapshot'; +import { attachDomEvent } from './attachDomEvent'; +import { createPasteFragment } from './createPasteFragment'; +import { ensureTypeInContainer } from './ensureTypeInContainer'; +import { focus } from './focus'; +import { getContent } from './getContent'; +import { getPendableFormatState } from './getPendableFormatState'; +import { getSelectionRange } from './getSelectionRange'; +import { getSelectionRangeEx } from './getSelectionRangeEx'; +import { getStyleBasedFormatState } from './getStyleBasedFormatState'; +import { hasFocus } from './hasFocus'; +import { insertNode } from './insertNode'; +import { restoreUndoSnapshot } from './restoreUndoSnapshot'; +import { select } from './select'; +import { selectImage } from './selectImage'; +import { selectRange } from './selectRange'; +import { selectTable } from './selectTable'; +import { setContent } from './setContent'; +import { switchShadowEdit } from './switchShadowEdit'; +import { transformColor } from './transformColor'; +import { triggerEvent } from './triggerEvent'; +import type { CoreApiMap } from 'roosterjs-editor-types'; + +/** + * @internal + */ +export const coreApiMap: CoreApiMap = { + attachDomEvent, + addUndoSnapshot, + createPasteFragment, + ensureTypeInContainer, + focus, + getContent, + getSelectionRange, + getSelectionRangeEx, + getStyleBasedFormatState, + getPendableFormatState, + hasFocus, + insertNode, + restoreUndoSnapshot, + select, + selectRange, + setContent, + switchShadowEdit, + transformColor, + triggerEvent, + selectTable, + selectImage, +}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/createPasteFragment.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/createPasteFragment.ts new file mode 100644 index 00000000000..5dcc65d82a7 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/createPasteFragment.ts @@ -0,0 +1,152 @@ +import { PasteType, PluginEventType } from 'roosterjs-editor-types'; +import { + applyFormat, + applyTextStyle, + createDefaultHtmlSanitizerOptions, + getPasteType, + handleImagePaste, + handleTextPaste, + moveChildNodes, + retrieveMetadataFromClipboard, + sanitizePasteContent, +} from 'roosterjs-editor-dom'; +import type { + BeforePasteEvent, + ClipboardData, + CreatePasteFragment, + EditorCore, + NodePosition, + DefaultFormat, +} from 'roosterjs-editor-types'; + +/** + * @internal + * Create a DocumentFragment for paste from a ClipboardData + * @param core The EditorCore object. + * @param clipboardData Clipboard data retrieved from clipboard + * @param position The position to paste to + * @param pasteAsText True to force use plain text as the content to paste, false to choose HTML or Image if any + * @param applyCurrentStyle True if apply format of current selection to the pasted content, + * false to keep original format + * @param pasteAsImage True if the image should be pasted as image + */ +export const createPasteFragment: CreatePasteFragment = ( + core: EditorCore, + clipboardData: ClipboardData, + position: NodePosition | null, + pasteAsText: boolean, + applyCurrentStyle: boolean, + pasteAsImage: boolean = false +) => { + if (!clipboardData) { + return null; + } + + const pasteType = getPasteType(pasteAsText, applyCurrentStyle, pasteAsImage); + + // Step 1: Prepare BeforePasteEvent object + const event = createBeforePasteEvent(core, clipboardData, pasteType); + return createFragmentFromClipboardData( + core, + clipboardData, + position, + pasteAsText, + applyCurrentStyle, + pasteAsImage, + event + ); +}; + +function createBeforePasteEvent( + core: EditorCore, + clipboardData: ClipboardData, + pasteType: PasteType +): BeforePasteEvent { + const options = createDefaultHtmlSanitizerOptions(); + + // Remove "caret-color" style generated by Safari to make sure caret shows in right color after paste + options.cssStyleCallbacks['caret-color'] = () => false; + + return { + eventType: PluginEventType.BeforePaste, + clipboardData, + fragment: core.contentDiv.ownerDocument.createDocumentFragment(), + sanitizingOption: options, + htmlBefore: '', + htmlAfter: '', + htmlAttributes: {}, + pasteType: pasteType, + }; +} + +/** + * Create a DocumentFragment for paste from a ClipboardData + * @param core The EditorCore object. + * @param clipboardData Clipboard data retrieved from clipboard + * @param position The position to paste to + * @param pasteAsText True to force use plain text as the content to paste, false to choose HTML or Image if any + * @param applyCurrentStyle True if apply format of current selection to the pasted content, + * @param pasteAsImage Whether to force paste as image + * @param event Event to trigger. + * false to keep original format + */ +function createFragmentFromClipboardData( + core: EditorCore, + clipboardData: ClipboardData, + position: NodePosition | null, + pasteAsText: boolean, + applyCurrentStyle: boolean, + pasteAsImage: boolean, + event: BeforePasteEvent +) { + const { fragment } = event; + const { rawHtml, text, imageDataUri } = clipboardData; + const doc: Document | undefined = rawHtml + ? new DOMParser().parseFromString(core.trustedHTMLHandler(rawHtml), 'text/html') + : undefined; + + // Step 2: Retrieve Metadata from Html and the Html that was copied. + retrieveMetadataFromClipboard(doc, event, core.trustedHTMLHandler); + + // Step 3: Fill the BeforePasteEvent object, especially the fragment for paste + if ((pasteAsImage && imageDataUri) || (!pasteAsText && !text && imageDataUri)) { + // Paste image + handleImagePaste(imageDataUri, fragment); + } else if (!pasteAsText && rawHtml && doc ? doc.body : false) { + moveChildNodes(fragment, doc?.body); + + if (applyCurrentStyle && position) { + const format = getCurrentFormat(core, position.node); + applyTextStyle(fragment, node => applyFormat(node, format)); + } + } else if (text) { + // Paste text + handleTextPaste(text, position, fragment); + } + + // Step 4: Trigger BeforePasteEvent so that plugins can do proper change before paste, when the type of paste is different than Plain Text + if (event.pasteType !== PasteType.AsPlainText) { + core.api.triggerEvent(core, event, true /*broadcast*/); + } + + // Step 5. Sanitize the fragment before paste to make sure the content is safe + sanitizePasteContent(event, position); + + return fragment; +} + +function getCurrentFormat(core: EditorCore, node: Node): DefaultFormat { + const pendableFormat = core.api.getPendableFormatState(core, true /** forceGetStateFromDOM*/); + const styleBasedFormat = core.api.getStyleBasedFormatState(core, node); + return { + fontFamily: styleBasedFormat.fontName, + fontSize: styleBasedFormat.fontSize, + textColor: styleBasedFormat.textColor, + backgroundColor: styleBasedFormat.backgroundColor, + textColors: styleBasedFormat.textColors, + backgroundColors: styleBasedFormat.backgroundColors, + bold: pendableFormat.isBold, + italic: pendableFormat.isItalic, + underline: pendableFormat.isUnderline, + }; +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/ensureTypeInContainer.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/ensureTypeInContainer.ts new file mode 100644 index 00000000000..3860ac2a4d7 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/ensureTypeInContainer.ts @@ -0,0 +1,88 @@ +import { ContentPosition, KnownCreateElementDataIndex, PositionType } from 'roosterjs-editor-types'; +import type { EditorCore, EnsureTypeInContainer, NodePosition } from 'roosterjs-editor-types'; +import { + applyFormat, + createElement, + createRange, + findClosestElementAncestor, + getBlockElementAtNode, + isNodeEmpty, + Position, + safeInstanceOf, +} from 'roosterjs-editor-dom'; + +/** + * @internal + * When typing goes directly under content div, many things can go wrong + * We fix it by wrapping it with a div and reposition cursor within the div + */ +export const ensureTypeInContainer: EnsureTypeInContainer = ( + core: EditorCore, + position: NodePosition, + keyboardEvent?: KeyboardEvent +) => { + const table = findClosestElementAncestor(position.node, core.contentDiv, 'table'); + let td: HTMLElement | null; + + if (table && (td = table.querySelector('td,th'))) { + position = new Position(td, PositionType.Begin); + } + position = position.normalize(); + + const block = getBlockElementAtNode(core.contentDiv, position.node); + let formatNode: HTMLElement | null; + + if (block) { + formatNode = block.collapseToSingleElement(); + if (isNodeEmpty(formatNode, false /* trimContent */, true /* shouldCountBrAsVisible */)) { + const brEl = formatNode.ownerDocument.createElement('br'); + formatNode.append(brEl); + } + // if the block is empty, apply default format + // Otherwise, leave it as it is as we don't want to change the style for existing data + // unless the block was just created by the keyboard event (e.g. ctrl+a & start typing) + const shouldSetNodeStyles = + isNodeEmpty(formatNode) || + (keyboardEvent && wasNodeJustCreatedByKeyboardEvent(keyboardEvent, formatNode)); + formatNode = formatNode && shouldSetNodeStyles ? formatNode : null; + } else { + // Only reason we don't get the selection block is that we have an empty content div + // which can happen when users removes everything (i.e. select all and DEL, or backspace from very end to begin) + // The fix is to add a DIV wrapping, apply default format and move cursor over + formatNode = createElement( + KnownCreateElementDataIndex.EmptyLine, + core.contentDiv.ownerDocument + ) as HTMLElement; + core.api.insertNode(core, formatNode, { + position: ContentPosition.End, + updateCursor: false, + replaceSelection: false, + insertOnNewLine: false, + }); + + // element points to a wrapping node we added "

                                                          ". We should move the selection left to
                                                          + position = new Position(formatNode, PositionType.Begin); + } + + if (formatNode && core.lifecycle.defaultFormat) { + applyFormat( + formatNode, + core.lifecycle.defaultFormat, + core.lifecycle.isDarkMode, + core.darkColorHandler + ); + } + + // If this is triggered by a keyboard event, let's select the new position + if (keyboardEvent) { + core.api.selectRange(core, createRange(new Position(position))); + } +}; + +function wasNodeJustCreatedByKeyboardEvent(event: KeyboardEvent, formatNode: HTMLElement) { + return ( + safeInstanceOf(event.target, 'Node') && + event.target.contains(formatNode) && + event.key === formatNode.innerText + ); +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/focus.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/focus.ts new file mode 100644 index 00000000000..4490f5a24a0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/focus.ts @@ -0,0 +1,44 @@ +import { createRange, getFirstLeafNode } from 'roosterjs-editor-dom'; +import { PositionType } from 'roosterjs-editor-types'; +import type { EditorCore, Focus } from 'roosterjs-editor-types'; + +/** + * @internal + * Focus to editor. If there is a cached selection range, use it as current selection + * @param core The EditorCore object + */ +export const focus: Focus = (core: EditorCore) => { + if (!core.lifecycle.shadowEditFragment) { + if ( + !core.api.hasFocus(core) || + !core.api.getSelectionRange(core, false /*tryGetFromCache*/) + ) { + // Focus (document.activeElement indicates) and selection are mostly in sync, but could be out of sync in some extreme cases. + // i.e. if you programmatically change window selection to point to a non-focusable DOM element (i.e. tabindex=-1 etc.). + // On Chrome/Firefox, it does not change document.activeElement. On Edge/IE, it change document.activeElement to be body + // Although on Chrome/Firefox, document.activeElement points to editor, you cannot really type which we don't want (no cursor). + // So here we always do a live selection pull on DOM and make it point in Editor. The pitfall is, the cursor could be reset + // to very begin to of editor since we don't really have last saved selection (created on blur which does not fire in this case). + // It should be better than the case you cannot type + if ( + !core.domEvent.selectionRange || + !core.api.selectRange(core, core.domEvent.selectionRange, true /*skipSameRange*/) + ) { + const node = getFirstLeafNode(core.contentDiv) || core.contentDiv; + core.api.selectRange( + core, + createRange(node, PositionType.Begin), + true /*skipSameRange*/ + ); + } + } + + // remember to clear cached selection range + core.domEvent.selectionRange = null; + + // This is more a fallback to ensure editor gets focus if it didn't manage to move focus to editor + if (!core.api.hasFocus(core)) { + core.contentDiv.focus(); + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getContent.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getContent.ts new file mode 100644 index 00000000000..8135e2866e3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getContent.ts @@ -0,0 +1,91 @@ +import { ColorTransformDirection, GetContentMode, PluginEventType } from 'roosterjs-editor-types'; +import type { EditorCore, GetContent } from 'roosterjs-editor-types'; +import { + createRange, + getHtmlWithSelectionPath, + getSelectionPath, + getTextContent, + safeInstanceOf, +} from 'roosterjs-editor-dom'; +import type { CompatibleGetContentMode } from 'roosterjs-editor-types/lib/compatibleTypes'; + +/** + * @internal + * Get current editor content as HTML string + * @param core The EditorCore object + * @param mode specify what kind of HTML content to retrieve + * @returns HTML string representing current editor content + */ +export const getContent: GetContent = ( + core: EditorCore, + mode: GetContentMode | CompatibleGetContentMode +): string => { + let content: string | null = ''; + const triggerExtractContentEvent = mode == GetContentMode.CleanHTML; + const includeSelectionMarker = mode == GetContentMode.RawHTMLWithSelection; + + // When there is fragment for shadow edit, always use the cached fragment as document since HTML node in editor + // has been changed by uncommitted shadow edit which should be ignored. + const root = core.lifecycle.shadowEditFragment || core.contentDiv; + + if (mode == GetContentMode.PlainTextFast) { + content = root.textContent; + } else if (mode == GetContentMode.PlainText) { + content = getTextContent(root); + } else { + const clonedRoot = cloneNode(root); + clonedRoot.normalize(); + + const originalRange = core.api.getSelectionRange(core, true /*tryGetFromCache*/); + const path = !includeSelectionMarker + ? null + : core.lifecycle.shadowEditFragment + ? core.lifecycle.shadowEditSelectionPath + : originalRange + ? getSelectionPath(core.contentDiv, originalRange) + : null; + const range = path && createRange(clonedRoot, path.start, path.end); + + core.api.transformColor( + core, + clonedRoot, + false /*includeSelf*/, + null /*callback*/, + ColorTransformDirection.DarkToLight, + true /*forceTransform*/, + core.lifecycle.isDarkMode + ); + + if (triggerExtractContentEvent) { + core.api.triggerEvent( + core, + { + eventType: PluginEventType.ExtractContentWithDom, + clonedRoot, + }, + true /*broadcast*/ + ); + + content = clonedRoot.innerHTML; + } else if (range) { + // range is not null, which means we want to include a selection path in the content + content = getHtmlWithSelectionPath(clonedRoot, range); + } else { + content = clonedRoot.innerHTML; + } + } + + return content ?? ''; +}; + +function cloneNode(node: HTMLElement | DocumentFragment): HTMLElement { + let clonedNode: HTMLElement; + if (safeInstanceOf(node, 'DocumentFragment')) { + clonedNode = node.ownerDocument.createElement('div'); + clonedNode.appendChild(node.cloneNode(true /*deep*/)); + } else { + clonedNode = node.cloneNode(true /*deep*/) as HTMLElement; + } + + return clonedNode; +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getPendableFormatState.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getPendableFormatState.ts new file mode 100644 index 00000000000..f68e1a1ad5b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getPendableFormatState.ts @@ -0,0 +1,101 @@ +import { contains, getObjectKeys, getTagOfNode, Position } from 'roosterjs-editor-dom'; +import { NodeType } from 'roosterjs-editor-types'; +import type { PendableFormatNames } from 'roosterjs-editor-dom'; +import type { + EditorCore, + GetPendableFormatState, + NodePosition, + PendableFormatState, +} from 'roosterjs-editor-types'; + +/** + * @internal + * @param core The EditorCore object + * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. + * @returns The cached format state if it exists. If the cached position do not exist, search for pendable elements in the DOM tree and return the pendable format state. + */ +export const getPendableFormatState: GetPendableFormatState = ( + core: EditorCore, + forceGetStateFromDOM: boolean +): PendableFormatState => { + const range = core.api.getSelectionRange(core, true /* tryGetFromCache*/); + const cachedPendableFormatState = core.pendingFormatState.pendableFormatState; + const cachedPosition = core.pendingFormatState.pendableFormatPosition?.normalize(); + const currentPosition = range && Position.getStart(range).normalize(); + const isSamePosition = + currentPosition && + cachedPosition && + range.collapsed && + currentPosition.equalTo(cachedPosition); + + if (range && cachedPendableFormatState && isSamePosition && !forceGetStateFromDOM) { + return cachedPendableFormatState; + } else { + return currentPosition ? queryCommandStateFromDOM(core, currentPosition) : {}; + } +}; + +const PendableStyleCheckers: Record< + PendableFormatNames, + (tagName: string, style: CSSStyleDeclaration) => boolean +> = { + isBold: (tag, style) => + tag == 'B' || + tag == 'STRONG' || + tag == 'H1' || + tag == 'H2' || + tag == 'H3' || + tag == 'H4' || + tag == 'H5' || + tag == 'H6' || + parseInt(style.fontWeight) >= 700 || + ['bold', 'bolder'].indexOf(style.fontWeight) >= 0, + isUnderline: (tag, style) => tag == 'U' || style.textDecoration.indexOf('underline') >= 0, + isItalic: (tag, style) => tag == 'I' || tag == 'EM' || style.fontStyle === 'italic', + isSubscript: (tag, style) => tag == 'SUB' || style.verticalAlign === 'sub', + isSuperscript: (tag, style) => tag == 'SUP' || style.verticalAlign === 'super', + isStrikeThrough: (tag, style) => + tag == 'S' || tag == 'STRIKE' || style.textDecoration.indexOf('line-through') >= 0, +}; + +/** + * CssFalsyCheckers checks for non pendable format that might overlay a pendable format, then it can prevent getPendableFormatState return falsy pendable format states. + */ + +const CssFalsyCheckers: Record boolean> = { + isBold: style => + (style.fontWeight !== '' && parseInt(style.fontWeight) < 700) || + style.fontWeight === 'normal', + isUnderline: style => + style.textDecoration !== '' && style.textDecoration.indexOf('underline') < 0, + isItalic: style => style.fontStyle !== '' && style.fontStyle !== 'italic', + isSubscript: style => style.verticalAlign !== '' && style.verticalAlign !== 'sub', + isSuperscript: style => style.verticalAlign !== '' && style.verticalAlign !== 'super', + isStrikeThrough: style => + style.textDecoration !== '' && style.textDecoration.indexOf('line-through') < 0, +}; + +function queryCommandStateFromDOM( + core: EditorCore, + currentPosition: NodePosition +): PendableFormatState { + let node: Node | null = currentPosition.node; + const formatState: PendableFormatState = {}; + const pendableKeys: PendableFormatNames[] = []; + while (node && contains(core.contentDiv, node)) { + const tag = getTagOfNode(node); + const style = node.nodeType == NodeType.Element && (node as HTMLElement).style; + if (tag && style) { + getObjectKeys(PendableStyleCheckers).forEach(key => { + if (!(pendableKeys.indexOf(key) >= 0)) { + formatState[key] = formatState[key] || PendableStyleCheckers[key](tag, style); + if (CssFalsyCheckers[key](style)) { + pendableKeys.push(key); + } + } + }); + } + node = node.parentNode; + } + return formatState; +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRange.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRange.ts new file mode 100644 index 00000000000..9e85478152c --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRange.ts @@ -0,0 +1,44 @@ +import { contains, createRange } from 'roosterjs-editor-dom'; +import type { EditorCore, GetSelectionRange } from 'roosterjs-editor-types'; + +/** + * @internal + * Get current or cached selection range + * @param core The EditorCore object + * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now + * @returns A Range object of the selection range + */ +export const getSelectionRange: GetSelectionRange = ( + core: EditorCore, + tryGetFromCache: boolean +) => { + let result: Range | null = null; + + if (core.lifecycle.shadowEditFragment) { + result = + core.lifecycle.shadowEditSelectionPath && + createRange( + core.contentDiv, + core.lifecycle.shadowEditSelectionPath.start, + core.lifecycle.shadowEditSelectionPath.end + ); + + return result; + } else { + if (!tryGetFromCache || core.api.hasFocus(core)) { + const selection = core.contentDiv.ownerDocument.defaultView?.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + if (contains(core.contentDiv, range)) { + result = range; + } + } + } + + if (!result && tryGetFromCache) { + result = core.domEvent.selectionRange; + } + + return result; + } +}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRangeEx.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRangeEx.ts new file mode 100644 index 00000000000..049546a1813 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRangeEx.ts @@ -0,0 +1,101 @@ +import { contains, createRange, findClosestElementAncestor } from 'roosterjs-editor-dom'; +import { SelectionRangeTypes } from 'roosterjs-editor-types'; +import type { EditorCore, GetSelectionRangeEx, SelectionRangeEx } from 'roosterjs-editor-types'; + +/** + * @internal + * Get current or cached selection range + * @param core The EditorCore object + * @returns A Range object of the selection range + */ +export const getSelectionRangeEx: GetSelectionRangeEx = (core: EditorCore) => { + const result: SelectionRangeEx | null = null; + if (core.lifecycle.shadowEditFragment) { + const { + shadowEditTableSelectionPath, + shadowEditSelectionPath, + shadowEditImageSelectionPath, + } = core.lifecycle; + + if ((shadowEditTableSelectionPath?.length || 0) > 0) { + const ranges = core.lifecycle.shadowEditTableSelectionPath!.map(path => + createRange(core.contentDiv, path.start, path.end) + ); + + return { + type: SelectionRangeTypes.TableSelection, + ranges, + areAllCollapsed: checkAllCollapsed(ranges), + table: findClosestElementAncestor( + ranges[0].startContainer, + core.contentDiv, + 'table' + ) as HTMLTableElement, + coordinates: undefined, + }; + } else if ((shadowEditImageSelectionPath?.length || 0) > 0) { + const ranges = core.lifecycle.shadowEditImageSelectionPath!.map(path => + createRange(core.contentDiv, path.start, path.end) + ); + return { + type: SelectionRangeTypes.ImageSelection, + ranges, + areAllCollapsed: checkAllCollapsed(ranges), + image: findClosestElementAncestor( + ranges[0].startContainer, + core.contentDiv, + 'img' + ) as HTMLImageElement, + imageId: undefined, + }; + } else { + const shadowRange = + shadowEditSelectionPath && + createRange( + core.contentDiv, + shadowEditSelectionPath.start, + shadowEditSelectionPath.end + ); + + return createNormalSelectionEx(shadowRange ? [shadowRange] : []); + } + } else { + if (core.api.hasFocus(core)) { + if (core.domEvent.tableSelectionRange) { + return core.domEvent.tableSelectionRange; + } + + if (core.domEvent.imageSelectionRange) { + return core.domEvent.imageSelectionRange; + } + + const selection = core.contentDiv.ownerDocument.defaultView?.getSelection(); + if (!result && selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + if (contains(core.contentDiv, range)) { + return createNormalSelectionEx([range]); + } + } + } + + return ( + core.domEvent.tableSelectionRange ?? + core.domEvent.imageSelectionRange ?? + createNormalSelectionEx( + core.domEvent.selectionRange ? [core.domEvent.selectionRange] : [] + ) + ); + } +}; + +function createNormalSelectionEx(ranges: Range[]): SelectionRangeEx { + return { + type: SelectionRangeTypes.Normal, + ranges: ranges, + areAllCollapsed: checkAllCollapsed(ranges), + }; +} + +function checkAllCollapsed(ranges: Range[]): boolean { + return ranges.filter(range => range?.collapsed).length == ranges.length; +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getStyleBasedFormatState.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getStyleBasedFormatState.ts new file mode 100644 index 00000000000..0be0504b38e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getStyleBasedFormatState.ts @@ -0,0 +1,96 @@ +import { contains, getComputedStyles } from 'roosterjs-editor-dom'; +import { NodeType } from 'roosterjs-editor-types'; +import type { EditorCore, GetStyleBasedFormatState } from 'roosterjs-editor-types'; + +/** + * @internal + * Get style based format state from current selection, including font name/size and colors + * @param core The EditorCore objects + * @param node The node to get style from + */ +export const getStyleBasedFormatState: GetStyleBasedFormatState = ( + core: EditorCore, + node: Node | null +) => { + if (!node) { + return {}; + } + + let override: string[] = []; + const pendableFormatSpan = core.pendingFormatState.pendableFormatSpan; + + if (pendableFormatSpan) { + override = [ + pendableFormatSpan.style.fontFamily, + pendableFormatSpan.style.fontSize, + pendableFormatSpan.style.color, + pendableFormatSpan.style.backgroundColor, + ]; + } + + const styles = node + ? getComputedStyles(node, [ + 'font-family', + 'font-size', + 'color', + 'background-color', + 'line-height', + 'margin-top', + 'margin-bottom', + 'text-align', + 'direction', + 'font-weight', + ]) + : []; + const { contentDiv, darkColorHandler } = core; + + let styleTextColor: string | undefined; + let styleBackColor: string | undefined; + + while ( + node && + contains(contentDiv, node, true /*treatSameNodeAsContain*/) && + !(styleTextColor && styleBackColor) + ) { + if (node.nodeType == NodeType.Element) { + const element = node as HTMLElement; + + styleTextColor = styleTextColor || element.style.getPropertyValue('color'); + styleBackColor = styleBackColor || element.style.getPropertyValue('background-color'); + } + node = node.parentNode; + } + + if (!core.lifecycle.isDarkMode && node == core.contentDiv) { + styleTextColor = styleTextColor || styles[2]; + styleBackColor = styleBackColor || styles[3]; + } + + const textColor = darkColorHandler.parseColorValue(override[2] || styleTextColor); + const backColor = darkColorHandler.parseColorValue(override[3] || styleBackColor); + + return { + fontName: override[0] || styles[0], + fontSize: override[1] || styles[1], + textColor: textColor.lightModeColor, + backgroundColor: backColor.lightModeColor, + textColors: textColor.darkModeColor + ? { + lightModeColor: textColor.lightModeColor, + darkModeColor: textColor.darkModeColor, + } + : undefined, + backgroundColors: backColor.darkModeColor + ? { + lightModeColor: backColor.lightModeColor, + darkModeColor: backColor.darkModeColor, + } + : undefined, + lineHeight: styles[4], + marginTop: styles[5], + marginBottom: styles[6], + textAlign: styles[7], + direction: styles[8], + fontWeight: styles[9], + }; +}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/hasFocus.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/hasFocus.ts new file mode 100644 index 00000000000..35ad6eb49a8 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/hasFocus.ts @@ -0,0 +1,15 @@ +import { contains } from 'roosterjs-editor-dom'; +import type { EditorCore, HasFocus } from 'roosterjs-editor-types'; + +/** + * @internal + * Check if the editor has focus now + * @param core The EditorCore object + * @returns True if the editor has focus, otherwise false + */ +export const hasFocus: HasFocus = (core: EditorCore) => { + const activeElement = core.contentDiv.ownerDocument.activeElement; + return !!( + activeElement && contains(core.contentDiv, activeElement, true /*treatSameNodeAsContain*/) + ); +}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/insertNode.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/insertNode.ts new file mode 100644 index 00000000000..8176d491d6a --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/insertNode.ts @@ -0,0 +1,235 @@ +import type { + BlockElement, + EditorCore, + InsertNode, + InsertOption, + NodePosition, +} from 'roosterjs-editor-types'; +import { + ContentPosition, + ColorTransformDirection, + NodeType, + PositionType, + RegionType, +} from 'roosterjs-editor-types'; +import { + createRange, + getBlockElementAtNode, + getFirstLastBlockElement, + isBlockElement, + isVoidHtmlElement, + Position, + safeInstanceOf, + toArray, + wrap, + adjustInsertPosition, + getRegionsFromRange, + splitTextNode, + splitParentNode, +} from 'roosterjs-editor-dom'; + +function getInitialRange( + core: EditorCore, + option: InsertOption +): { range: Range | null; rangeToRestore: Range | null } { + // Selection start replaces based on the current selection. + // Range inserts based on a provided range. + // Both have the potential to use the current selection to restore cursor position + // So in both cases we need to store the selection state. + let range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); + let rangeToRestore = null; + if (option.position == ContentPosition.Range) { + rangeToRestore = range; + range = option.range; + } else if (range) { + rangeToRestore = range.cloneRange(); + } + + return { range, rangeToRestore }; +} + +/** + * @internal + * Insert a DOM node into editor content + * @param core The EditorCore object. No op if null. + * @param option An insert option object to specify how to insert the node + */ +export const insertNode: InsertNode = ( + core: EditorCore, + node: Node, + option: InsertOption | null +) => { + option = option || { + position: ContentPosition.SelectionStart, + insertOnNewLine: false, + updateCursor: true, + replaceSelection: true, + insertToRegionRoot: false, + }; + const contentDiv = core.contentDiv; + + if (option.updateCursor) { + core.api.focus(core); + } + + if (option.position == ContentPosition.Outside) { + contentDiv.parentNode?.insertBefore(node, contentDiv.nextSibling); + return true; + } + + core.api.transformColor( + core, + node, + true /*includeSelf*/, + () => { + if (!option) { + return; + } + switch (option.position) { + case ContentPosition.Begin: + case ContentPosition.End: { + const isBegin = option.position == ContentPosition.Begin; + const block = getFirstLastBlockElement(contentDiv, isBegin); + let insertedNode: Node | Node[] | undefined; + if (block) { + const refNode = isBegin ? block.getStartNode() : block.getEndNode(); + if ( + option.insertOnNewLine || + refNode.nodeType == NodeType.Text || + isVoidHtmlElement(refNode) + ) { + // For insert on new line, or refNode is text or void html element (HR, BR etc.) + // which cannot have children, i.e.
                                                          hello
                                                          world
                                                          . 'hello', 'world' are the + // first and last node. Insert before 'hello' or after 'world', but still inside DIV + if (safeInstanceOf(node, 'DocumentFragment')) { + // if the node to be inserted is DocumentFragment, use its childNodes as insertedNode + // because insertBefore() returns an empty DocumentFragment + insertedNode = toArray(node.childNodes); + refNode.parentNode?.insertBefore( + node, + isBegin ? refNode : refNode.nextSibling + ); + } else { + insertedNode = refNode.parentNode?.insertBefore( + node, + isBegin ? refNode : refNode.nextSibling + ); + } + } else { + // if the refNode can have child, use appendChild (which is like to insert as first/last child) + // i.e.
                                                          hello
                                                          , the content will be inserted before/after hello + insertedNode = refNode.insertBefore( + node, + isBegin ? refNode.firstChild : null + ); + } + } else { + // No first block, this can happen when editor is empty. Use appendChild to insert the content in contentDiv + insertedNode = contentDiv.appendChild(node); + } + + // Final check to see if the inserted node is a block. If not block and the ask is to insert on new line, + // add a DIV wrapping + if (insertedNode && option.insertOnNewLine) { + const nodes = Array.isArray(insertedNode) ? insertedNode : [insertedNode]; + if (!isBlockElement(nodes[0]) || !isBlockElement(nodes[nodes.length - 1])) { + wrap(nodes); + } + } + + break; + } + case ContentPosition.DomEnd: + // Use appendChild to insert the node at the end of the content div. + const insertedNode = contentDiv.appendChild(node); + // Final check to see if the inserted node is a block. If not block and the ask is to insert on new line, + // add a DIV wrapping + if (insertedNode && option.insertOnNewLine && !isBlockElement(insertedNode)) { + wrap(insertedNode); + } + break; + case ContentPosition.Range: + case ContentPosition.SelectionStart: + let { range, rangeToRestore } = getInitialRange(core, option); + if (!range) { + return; + } + + // if to replace the selection and the selection is not collapsed, remove the the content at selection first + if (option.replaceSelection && !range.collapsed) { + range.deleteContents(); + } + + let pos: NodePosition = Position.getStart(range); + let blockElement: BlockElement | null; + + if (option.insertOnNewLine && option.insertToRegionRoot) { + pos = adjustInsertPositionRegionRoot(core, range, pos); + } else if ( + option.insertOnNewLine && + (blockElement = getBlockElementAtNode(contentDiv, pos.normalize().node)) + ) { + pos = adjustInsertPositionNewLine(blockElement, core, pos); + } else { + pos = adjustInsertPosition(contentDiv, node, pos, range); + } + + const nodeForCursor = + node.nodeType == NodeType.DocumentFragment ? node.lastChild : node; + + range = createRange(pos); + range.insertNode(node); + + if (option.updateCursor && nodeForCursor) { + rangeToRestore = createRange( + new Position(nodeForCursor, PositionType.After).normalize() + ); + } + + if (rangeToRestore) { + core.api.selectRange(core, rangeToRestore); + } + + break; + } + }, + ColorTransformDirection.LightToDark + ); + + return true; +}; + +function adjustInsertPositionRegionRoot(core: EditorCore, range: Range, position: NodePosition) { + const region = getRegionsFromRange(core.contentDiv, range, RegionType.Table)[0]; + let node: Node | null = position.node; + + if (region) { + if (node.nodeType == NodeType.Text && !position.isAtEnd) { + node = splitTextNode(node as Text, position.offset, true /*returnFirstPart*/); + } + + if (node != region.rootNode) { + while (node && node.parentNode != region.rootNode) { + splitParentNode(node, false /*splitBefore*/); + node = node.parentNode; + } + } + + if (node) { + position = new Position(node, PositionType.After); + } + } + + return position; +} + +function adjustInsertPositionNewLine(blockElement: BlockElement, core: EditorCore, pos: Position) { + let tempPos = new Position(blockElement.getEndNode(), PositionType.After); + if (safeInstanceOf(tempPos.node, 'HTMLTableRowElement')) { + const div = core.contentDiv.ownerDocument.createElement('div'); + const range = createRange(pos); + range.insertNode(div); + tempPos = new Position(div, PositionType.Begin); + } + return tempPos; +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/restoreUndoSnapshot.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/restoreUndoSnapshot.ts new file mode 100644 index 00000000000..4433ebc3b7c --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/restoreUndoSnapshot.ts @@ -0,0 +1,69 @@ +import { EntityOperation, PluginEventType } from 'roosterjs-editor-types'; +import { getEntityFromElement, getEntitySelector, queryElements } from 'roosterjs-editor-dom'; +import type { EditorCore, RestoreUndoSnapshot } from 'roosterjs-editor-types'; + +/** + * @internal + * Restore an undo snapshot into editor + * @param core The editor core object + * @param step Steps to move, can be 0, positive or negative + */ +export const restoreUndoSnapshot: RestoreUndoSnapshot = (core: EditorCore, step: number) => { + if (core.undo.hasNewContent && step < 0) { + core.api.addUndoSnapshot( + core, + null /*callback*/, + null /*changeSource*/, + false /*canUndoByBackspace*/ + ); + } + + const snapshot = core.undo.snapshotsService.move(step); + + if (snapshot && snapshot.html != null) { + try { + core.undo.isRestoring = true; + core.api.setContent( + core, + snapshot.html, + true /*triggerContentChangedEvent*/, + snapshot.metadata ?? undefined + ); + + const darkColorHandler = core.darkColorHandler; + const isDarkModel = core.lifecycle.isDarkMode; + + snapshot.knownColors.forEach(color => { + darkColorHandler.registerColor( + color.lightModeColor, + isDarkModel, + color.darkModeColor + ); + }); + + snapshot.entityStates?.forEach(entityState => { + const { type, id, state } = entityState; + const wrapper = queryElements( + core.contentDiv, + getEntitySelector(type, id) + )[0] as HTMLElement; + const entity = wrapper && getEntityFromElement(wrapper); + + if (entity) { + core.api.triggerEvent( + core, + { + eventType: PluginEventType.EntityOperation, + operation: EntityOperation.UpdateEntityState, + entity: entity, + state, + }, + false + ); + } + }); + } finally { + core.undo.isRestoring = false; + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/select.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/select.ts new file mode 100644 index 00000000000..a3179bfea8e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/select.ts @@ -0,0 +1,179 @@ +import { contains, createRange, safeInstanceOf } from 'roosterjs-editor-dom'; +import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import type { + EditorCore, + NodePosition, + PositionType, + Select, + SelectionPath, + SelectionRangeEx, + TableSelection, +} from 'roosterjs-editor-types'; + +/** + * @internal + * Select content according to the given information. + * There are a bunch of allowed combination of parameters. See IEditor.select for more details + * @param core The editor core object + * @param arg1 A DOM Range, or SelectionRangeEx, or NodePosition, or Node, or Selection Path + * @param arg2 (optional) A NodePosition, or an offset number, or a PositionType, or a TableSelection + * @param arg3 (optional) A Node + * @param arg4 (optional) An offset number, or a PositionType + */ +export const select: Select = (core, arg1, arg2, arg3, arg4) => { + const rangeEx = buildRangeEx(core, arg1, arg2, arg3, arg4); + + if (rangeEx) { + const skipReselectOnFocus = core.domEvent.skipReselectOnFocus; + + // We are applying a new selection, so we don't need to apply cached selection in DOMEventPlugin. + // Set skipReselectOnFocus to skip this behavior + core.domEvent.skipReselectOnFocus = true; + + try { + applyRangeEx(core, rangeEx); + } finally { + core.domEvent.skipReselectOnFocus = skipReselectOnFocus; + } + } else { + core.domEvent.tableSelectionRange = core.api.selectTable(core, null); + core.domEvent.imageSelectionRange = core.api.selectImage(core, null); + } + + return !!rangeEx; +}; + +function buildRangeEx( + core: EditorCore, + arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, + arg2?: NodePosition | number | PositionType | TableSelection | null, + arg3?: Node, + arg4?: number | PositionType +) { + let rangeEx: SelectionRangeEx | null = null; + + if (isSelectionRangeEx(arg1)) { + rangeEx = arg1; + } else if (safeInstanceOf(arg1, 'HTMLTableElement') && isTableSelectionOrNull(arg2)) { + rangeEx = { + type: SelectionRangeTypes.TableSelection, + ranges: [], + areAllCollapsed: false, + table: arg1, + coordinates: arg2 ?? undefined, + }; + } else if (safeInstanceOf(arg1, 'HTMLImageElement') && typeof arg2 == 'undefined') { + rangeEx = { + type: SelectionRangeTypes.ImageSelection, + ranges: [], + areAllCollapsed: false, + image: arg1, + }; + } else { + const range = !arg1 + ? null + : safeInstanceOf(arg1, 'Range') + ? arg1 + : isSelectionPath(arg1) + ? createRange(core.contentDiv, arg1.start, arg1.end) + : isNodePosition(arg1) || safeInstanceOf(arg1, 'Node') + ? createRange( + arg1, + arg2, + arg3, + arg4 + ) + : null; + + rangeEx = range + ? { + type: SelectionRangeTypes.Normal, + ranges: [range], + areAllCollapsed: range.collapsed, + } + : null; + } + + return rangeEx; +} + +function applyRangeEx(core: EditorCore, rangeEx: SelectionRangeEx | null) { + switch (rangeEx?.type) { + case SelectionRangeTypes.TableSelection: + if (contains(core.contentDiv, rangeEx.table)) { + core.domEvent.imageSelectionRange = core.api.selectImage(core, null); + core.domEvent.tableSelectionRange = core.api.selectTable( + core, + rangeEx.table, + rangeEx.coordinates + ); + rangeEx = core.domEvent.tableSelectionRange; + } + break; + case SelectionRangeTypes.ImageSelection: + if (contains(core.contentDiv, rangeEx.image)) { + core.domEvent.tableSelectionRange = core.api.selectTable(core, null); + core.domEvent.imageSelectionRange = core.api.selectImage(core, rangeEx.image); + rangeEx = core.domEvent.imageSelectionRange; + } + break; + case SelectionRangeTypes.Normal: + core.domEvent.tableSelectionRange = core.api.selectTable(core, null); + core.domEvent.imageSelectionRange = core.api.selectImage(core, null); + + if (contains(core.contentDiv, rangeEx.ranges[0])) { + core.api.selectRange(core, rangeEx.ranges[0]); + } else { + rangeEx = null; + } + break; + } + + core.api.triggerEvent( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: rangeEx, + }, + true /** broadcast **/ + ); +} + +function isSelectionRangeEx(obj: any): obj is SelectionRangeEx { + const rangeEx = obj as SelectionRangeEx; + return ( + rangeEx && + typeof rangeEx == 'object' && + typeof rangeEx.type == 'number' && + Array.isArray(rangeEx.ranges) + ); +} + +function isTableSelectionOrNull(obj: any): obj is TableSelection | null { + const selection = obj as TableSelection | null; + + return ( + selection === null || + (selection && + typeof selection == 'object' && + typeof selection.firstCell == 'object' && + typeof selection.lastCell == 'object') + ); +} + +function isSelectionPath(obj: any): obj is SelectionPath { + const path = obj as SelectionPath; + + return path && typeof path == 'object' && Array.isArray(path.start) && Array.isArray(path.end); +} + +function isNodePosition(obj: any): obj is NodePosition { + const pos = obj as NodePosition; + + return ( + pos && + typeof pos == 'object' && + typeof pos.node == 'object' && + typeof pos.offset == 'number' + ); +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectImage.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectImage.ts new file mode 100644 index 00000000000..44bcfb974e6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectImage.ts @@ -0,0 +1,65 @@ +import addUniqueId from './utils/addUniqueId'; +import { PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { + createRange, + Position, + removeGlobalCssStyle, + removeImportantStyleRule, + setGlobalCssStyles, +} from 'roosterjs-editor-dom'; +import type { EditorCore, ImageSelectionRange, SelectImage } from 'roosterjs-editor-types'; + +const IMAGE_ID = 'imageSelected'; +const CONTENT_DIV_ID = 'contentDiv_'; +const STYLE_ID = 'imageStyle'; +const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; + +/** + * @internal + * Select a image and save data of the selected range + * @param image Image to select + * @returns Selected image information + */ +export const selectImage: SelectImage = (core: EditorCore, image: HTMLImageElement | null) => { + unselect(core); + + let selection: ImageSelectionRange | null = null; + + if (image) { + const range = createRange(image); + + addUniqueId(image, IMAGE_ID); + addUniqueId(core.contentDiv, CONTENT_DIV_ID); + + core.api.selectRange(core, createRange(new Position(image, PositionType.After))); + + select(core, image); + + selection = { + type: SelectionRangeTypes.ImageSelection, + ranges: [range], + image: image, + areAllCollapsed: range.collapsed, + }; + } + + return selection; +}; + +const select = (core: EditorCore, image: HTMLImageElement) => { + removeImportantStyleRule(image, ['border', 'margin']); + const borderCSS = buildBorderCSS(core, image.id); + setGlobalCssStyles(core.contentDiv.ownerDocument, borderCSS, STYLE_ID + core.contentDiv.id); +}; + +const buildBorderCSS = (core: EditorCore, imageId: string): string => { + const divId = core.contentDiv.id; + const color = core.imageSelectionBorderColor || DEFAULT_SELECTION_BORDER_COLOR; + + return `#${divId} #${imageId} {outline-style: auto!important;outline-color: ${color}!important;caret-color: transparent!important;}`; +}; + +const unselect = (core: EditorCore) => { + const doc = core.contentDiv.ownerDocument; + removeGlobalCssStyle(doc, STYLE_ID + core.contentDiv.id); +}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectRange.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectRange.ts new file mode 100644 index 00000000000..d4816eb43fc --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectRange.ts @@ -0,0 +1,74 @@ +import { hasFocus } from './hasFocus'; +import type { EditorCore, SelectRange } from 'roosterjs-editor-types'; +import { + contains, + getPendableFormatState, + Position, + PendableFormatCommandMap, + addRangeToSelection, + getObjectKeys, +} from 'roosterjs-editor-dom'; + +/** + * @internal + * Change the editor selection to the given range + * @param core The EditorCore object + * @param range The range to select + * @param skipSameRange When set to true, do nothing if the given range is the same with current selection + * in editor, otherwise it will always remove current selection range and set to the given one. + * This parameter is always treat as true in Edge to avoid some weird runtime exception. + */ +export const selectRange: SelectRange = ( + core: EditorCore, + range: Range, + skipSameRange?: boolean +) => { + if (!core.lifecycle.shadowEditSelectionPath && contains(core.contentDiv, range)) { + addRangeToSelection(range, skipSameRange); + + if (!hasFocus(core)) { + core.domEvent.selectionRange = range; + } + + if (range.collapsed) { + // If selected, and current selection is collapsed, + // need to restore pending format state if exists. + restorePendingFormatState(core); + } + + return true; + } else { + return false; + } +}; + +/** + * Restore cached pending format state (if exist) to current selection + */ +function restorePendingFormatState(core: EditorCore) { + const { + contentDiv, + pendingFormatState, + api: { getSelectionRange }, + } = core; + + if (pendingFormatState.pendableFormatState) { + const document = contentDiv.ownerDocument; + const formatState = getPendableFormatState(document); + getObjectKeys(PendableFormatCommandMap).forEach(key => { + if (!!pendingFormatState.pendableFormatState?.[key] != formatState[key]) { + document.execCommand( + PendableFormatCommandMap[key], + false /* showUI */, + undefined /* value */ + ); + } + }); + + const range = getSelectionRange(core, true /*tryGetFromCache*/); + const position: Position | null = range && Position.getStart(range); + if (position) { + pendingFormatState.pendableFormatPosition = position; + } + } +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectTable.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectTable.ts new file mode 100644 index 00000000000..3d74d61b6bd --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectTable.ts @@ -0,0 +1,268 @@ +import addUniqueId from './utils/addUniqueId'; +import { PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { + createRange, + getTagOfNode, + isWholeTableSelected, + Position, + removeGlobalCssStyle, + removeImportantStyleRule, + setGlobalCssStyles, + toArray, + VTable, +} from 'roosterjs-editor-dom'; +import type { EditorCore, TableSelection, SelectTable, Coordinates } from 'roosterjs-editor-types'; + +const TABLE_ID = 'tableSelected'; +const CONTENT_DIV_ID = 'contentDiv_'; +const STYLE_ID = 'tableStyle'; +const SELECTED_CSS_RULE = + '{background-color: rgb(198,198,198) !important; caret-color: transparent}'; +const MAX_RULE_SELECTOR_LENGTH = 9000; + +/** + * @internal + * Select a table and save data of the selected range + * @param core The EditorCore object + * @param table table to select + * @param coordinates first and last cell of the selection, if this parameter is null, instead of + * selecting, will unselect the table. + * @returns true if successful + */ +export const selectTable: SelectTable = ( + core: EditorCore, + table: HTMLTableElement | null, + coordinates?: TableSelection +) => { + unselect(core); + + if (areValidCoordinates(coordinates) && table) { + addUniqueId(table, TABLE_ID); + addUniqueId(core.contentDiv, CONTENT_DIV_ID); + + const { ranges, isWholeTableSelected } = select(core, table, coordinates); + if (!isMergedCell(table, coordinates)) { + const cellToSelect = table.rows + .item(coordinates.firstCell.y) + ?.cells.item(coordinates.firstCell.x); + + if (cellToSelect) { + core.api.selectRange( + core, + createRange(new Position(cellToSelect, PositionType.Begin)) + ); + } + } + + return { + type: SelectionRangeTypes.TableSelection, + ranges, + table, + areAllCollapsed: ranges.filter(range => range?.collapsed).length == ranges.length, + coordinates, + isWholeTableSelected, + }; + } + + return null; +}; + +function buildCss( + table: HTMLTableElement, + coordinates: TableSelection, + contentDivSelector: string +): { cssRules: string[]; ranges: Range[]; isWholeTableSelected: boolean } { + const ranges: Range[] = []; + const selectors: string[] = []; + + const vTable = new VTable(table); + const isAllTableSelected = isWholeTableSelected(vTable, coordinates); + if (isAllTableSelected) { + handleAllTableSelected(contentDivSelector, vTable, selectors, ranges); + } else { + handleTableSelected(coordinates, vTable, contentDivSelector, selectors, ranges); + } + + const cssRules: string[] = []; + let currentRules: string = ''; + while (selectors.length > 0) { + currentRules += (currentRules.length > 0 ? ',' : '') + selectors.shift() || ''; + if ( + currentRules.length + (selectors[0]?.length || 0) > MAX_RULE_SELECTOR_LENGTH || + selectors.length == 0 + ) { + cssRules.push(currentRules + ' ' + SELECTED_CSS_RULE); + currentRules = ''; + } + } + + return { cssRules, ranges, isWholeTableSelected: isAllTableSelected }; +} + +function handleAllTableSelected( + contentDivSelector: string, + vTable: VTable, + selectors: string[], + ranges: Range[] +) { + const table = vTable.table; + const tableSelector = contentDivSelector + ' #' + table.id; + selectors.push(tableSelector, `${tableSelector} *`); + + const tableRange = new Range(); + tableRange.selectNode(table); + ranges.push(tableRange); +} + +function handleTableSelected( + coordinates: TableSelection, + vTable: VTable, + contentDivSelector: string, + selectors: string[], + ranges: Range[] +) { + const tr1 = coordinates.firstCell.y; + const td1 = coordinates.firstCell.x; + const tr2 = coordinates.lastCell.y; + const td2 = coordinates.lastCell.x; + const table = vTable.table; + + let firstSelected: HTMLTableCellElement | null = null; + let lastSelected: HTMLTableCellElement | null = null; + // Get whether table has thead, tbody or tfoot. + const tableChildren = toArray(table.childNodes).filter( + node => ['THEAD', 'TBODY', 'TFOOT'].indexOf(getTagOfNode(node)) > -1 + ); + // Set the start and end of each of the table children, so we can build the selector according the element between the table and the row. + let cont = 0; + const indexes = tableChildren.map(node => { + const result = { + el: getTagOfNode(node), + start: cont, + end: node.childNodes.length + cont, + }; + + cont = result.end; + return result; + }); + + vTable.cells?.forEach((row, rowIndex) => { + let tdCount = 0; + firstSelected = null; + lastSelected = null; + + //Get current TBODY/THEAD/TFOOT + const midElement = indexes.filter(ind => ind.start <= rowIndex && ind.end > rowIndex)[0]; + + const middleElSelector = midElement ? '>' + midElement.el + '>' : '>'; + const currentRow = + midElement && rowIndex + 1 >= midElement.start + ? rowIndex + 1 - midElement.start + : rowIndex + 1; + + for (let cellIndex = 0; cellIndex < row.length; cellIndex++) { + const cell = row[cellIndex].td; + if (cell) { + tdCount++; + if (rowIndex >= tr1 && rowIndex <= tr2 && cellIndex >= td1 && cellIndex <= td2) { + removeImportant(cell); + + const selector = generateCssFromCell( + contentDivSelector, + table.id, + middleElSelector, + currentRow, + getTagOfNode(cell), + tdCount + ); + const elementsSelector = selector + ' *'; + + selectors.push(selector, elementsSelector); + firstSelected = firstSelected || table.querySelector(selector); + lastSelected = table.querySelector(selector); + } + } + } + + if (firstSelected && lastSelected) { + const rowRange = new Range(); + rowRange.setStartBefore(firstSelected); + rowRange.setEndAfter(lastSelected); + ranges.push(rowRange); + } + }); +} + +function select( + core: EditorCore, + table: HTMLTableElement, + coordinates: TableSelection +): { ranges: Range[]; isWholeTableSelected: boolean } { + const contentDivSelector = '#' + core.contentDiv.id; + const { cssRules, ranges, isWholeTableSelected } = buildCss( + table, + coordinates, + contentDivSelector + ); + cssRules.forEach(css => + setGlobalCssStyles(core.contentDiv.ownerDocument, css, STYLE_ID + core.contentDiv.id) + ); + + return { ranges, isWholeTableSelected }; +} + +const unselect = (core: EditorCore) => { + const doc = core.contentDiv.ownerDocument; + removeGlobalCssStyle(doc, STYLE_ID + core.contentDiv.id); +}; + +function generateCssFromCell( + contentDivSelector: string, + tableId: string, + middleElSelector: string, + rowIndex: number, + cellTag: string, + index: number +): string { + return ( + contentDivSelector + + ' #' + + tableId + + middleElSelector + + ' tr:nth-child(' + + rowIndex + + ')>' + + cellTag + + ':nth-child(' + + index + + ')' + ); +} + +function removeImportant(cell: HTMLTableCellElement) { + if (cell) { + removeImportantStyleRule(cell, ['background-color', 'background']); + } +} + +function areValidCoordinates(input?: TableSelection): input is TableSelection { + if (input) { + const { firstCell, lastCell } = input || {}; + if (firstCell && lastCell) { + const handler = (coordinate: Coordinates) => + isValidCoordinate(coordinate.x) && isValidCoordinate(coordinate.y); + return handler(firstCell) && handler(lastCell); + } + } + + return false; +} + +function isValidCoordinate(input: number): boolean { + return (!!input || input == 0) && input > -1; +} + +function isMergedCell(table: HTMLTableElement, coordinates: TableSelection): boolean { + const { firstCell } = coordinates; + return !(table.rows.item(firstCell.y) && table.rows.item(firstCell.y)?.cells.item(firstCell.x)); +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/setContent.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/setContent.ts new file mode 100644 index 00000000000..58e8eb86c72 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/setContent.ts @@ -0,0 +1,120 @@ +import { + ChangeSource, + ColorTransformDirection, + PluginEventType, + SelectionRangeTypes, +} from 'roosterjs-editor-types'; +import { + createRange, + extractContentMetadata, + queryElements, + restoreContentWithEntityPlaceholder, +} from 'roosterjs-editor-dom'; +import type { ContentMetadata, EditorCore, SetContent } from 'roosterjs-editor-types'; + +/** + * @internal + * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered + * if triggerContentChangedEvent is set to true + * @param core The EditorCore object + * @param content HTML content to set in + * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true + * @param metadata @optional Metadata of the content that helps editor know the selection and color mode. + * If not passed, we will treat content as in light mode without selection + */ +export const setContent: SetContent = ( + core: EditorCore, + content: string, + triggerContentChangedEvent: boolean, + metadata?: ContentMetadata +) => { + let contentChanged = false; + if (core.contentDiv.innerHTML != content) { + core.api.triggerEvent( + core, + { + eventType: PluginEventType.BeforeSetContent, + newContent: content, + }, + true /*broadcast*/ + ); + + const entities = core.entity.entityMap; + const html = content || ''; + const body = new DOMParser().parseFromString( + core.trustedHTMLHandler?.(html) ?? html, + 'text/html' + ).body; + + restoreContentWithEntityPlaceholder(body, core.contentDiv, entities); + + const metadataFromContent = extractContentMetadata(core.contentDiv); + metadata = metadata || metadataFromContent; + selectContentMetadata(core, metadata); + contentChanged = true; + } + + const isDarkMode = core.lifecycle.isDarkMode; + + if ((!metadata && isDarkMode) || (metadata && !!metadata.isDarkMode != !!isDarkMode)) { + core.api.transformColor( + core, + core.contentDiv, + false /*includeSelf*/, + null /*callback*/, + isDarkMode ? ColorTransformDirection.LightToDark : ColorTransformDirection.DarkToLight, + true /*forceTransform*/, + metadata?.isDarkMode + ); + contentChanged = true; + } + + if (triggerContentChangedEvent && contentChanged) { + core.api.triggerEvent( + core, + { + eventType: PluginEventType.ContentChanged, + source: ChangeSource.SetContent, + }, + false /*broadcast*/ + ); + } +}; + +function selectContentMetadata(core: EditorCore, metadata: ContentMetadata | undefined) { + if (!core.lifecycle.shadowEditSelectionPath && metadata) { + core.domEvent.tableSelectionRange = null; + core.domEvent.imageSelectionRange = null; + core.domEvent.selectionRange = null; + + switch (metadata.type) { + case SelectionRangeTypes.Normal: + core.api.selectTable(core, null); + core.api.selectImage(core, null); + + const range = createRange(core.contentDiv, metadata.start, metadata.end); + core.api.selectRange(core, range); + break; + case SelectionRangeTypes.TableSelection: + const table = queryElements( + core.contentDiv, + '#' + metadata.tableId + )[0] as HTMLTableElement; + + if (table) { + core.domEvent.tableSelectionRange = core.api.selectTable(core, table, metadata); + } + break; + case SelectionRangeTypes.ImageSelection: + const image = queryElements( + core.contentDiv, + '#' + metadata.imageId + )[0] as HTMLImageElement; + + if (image) { + core.domEvent.imageSelectionRange = core.api.selectImage(core, image); + } + break; + } + } +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/switchShadowEdit.ts new file mode 100644 index 00000000000..5d3dd632aaf --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/switchShadowEdit.ts @@ -0,0 +1,111 @@ +import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { + createRange, + getSelectionPath, + moveContentWithEntityPlaceholders, + restoreContentWithEntityPlaceholder, +} from 'roosterjs-editor-dom'; +import type { EditorCore, SelectionRangeEx, SwitchShadowEdit } from 'roosterjs-editor-types'; + +/** + * @internal + */ +export const switchShadowEdit: SwitchShadowEdit = (core: EditorCore, isOn: boolean): void => { + const { lifecycle, contentDiv } = core; + let { + shadowEditEntities, + shadowEditFragment, + shadowEditSelectionPath, + shadowEditTableSelectionPath, + shadowEditImageSelectionPath, + } = lifecycle; + const wasInShadowEdit = !!shadowEditFragment; + + const getShadowEditSelectionPath = ( + selectionType: SelectionRangeTypes, + shadowEditSelection?: SelectionRangeEx + ) => { + return ( + (shadowEditSelection?.type == selectionType && + shadowEditSelection.ranges + .map(range => getSelectionPath(contentDiv, range)) + .map(w => w!!)) || + null + ); + }; + + if (isOn) { + if (!wasInShadowEdit) { + const selection = core.api.getSelectionRangeEx(core); + const range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); + + shadowEditSelectionPath = range && getSelectionPath(contentDiv, range); + shadowEditTableSelectionPath = getShadowEditSelectionPath( + SelectionRangeTypes.TableSelection, + selection + ); + shadowEditImageSelectionPath = getShadowEditSelectionPath( + SelectionRangeTypes.ImageSelection, + selection + ); + + shadowEditEntities = {}; + shadowEditFragment = moveContentWithEntityPlaceholders(contentDiv, shadowEditEntities); + + core.api.triggerEvent( + core, + { + eventType: PluginEventType.EnteredShadowEdit, + fragment: shadowEditFragment, + selectionPath: shadowEditSelectionPath, + }, + false /*broadcast*/ + ); + + lifecycle.shadowEditFragment = shadowEditFragment; + lifecycle.shadowEditSelectionPath = shadowEditSelectionPath; + lifecycle.shadowEditTableSelectionPath = shadowEditTableSelectionPath; + lifecycle.shadowEditImageSelectionPath = shadowEditImageSelectionPath; + lifecycle.shadowEditEntities = shadowEditEntities; + } + + if (lifecycle.shadowEditFragment) { + restoreContentWithEntityPlaceholder( + lifecycle.shadowEditFragment, + contentDiv, + lifecycle.shadowEditEntities, + true /*insertClonedNode*/ + ); + } + } else { + lifecycle.shadowEditFragment = null; + lifecycle.shadowEditSelectionPath = null; + lifecycle.shadowEditEntities = null; + + if (wasInShadowEdit) { + core.api.triggerEvent( + core, + { + eventType: PluginEventType.LeavingShadowEdit, + }, + false /*broadcast*/ + ); + + if (shadowEditFragment) { + restoreContentWithEntityPlaceholder( + shadowEditFragment, + contentDiv, + shadowEditEntities + ); + } + + if (shadowEditSelectionPath) { + core.domEvent.selectionRange = createRange( + contentDiv, + shadowEditSelectionPath.start, + shadowEditSelectionPath.end + ); + } + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/transformColor.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/transformColor.ts new file mode 100644 index 00000000000..c0b89cc38c1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/transformColor.ts @@ -0,0 +1,69 @@ +import { ColorTransformDirection } from 'roosterjs-editor-types'; +import type { EditorCore, TransformColor } from 'roosterjs-editor-types'; +import type { CompatibleColorTransformDirection } from 'roosterjs-editor-types/lib/compatibleTypes'; + +/** + * @internal + * Edit and transform color of elements between light mode and dark mode + * @param core The EditorCore object + * @param rootNode The root HTML elements to transform + * @param includeSelf True to transform the root node as well, otherwise false + * @param callback The callback function to invoke before do color transformation + * @param direction To specify the transform direction, light to dark, or dark to light + * @param forceTransform By default this function will only work when editor core is in dark mode. + * Pass true to this value to force do color transformation even editor core is in light mode + */ +export const transformColor: TransformColor = ( + core: EditorCore, + rootNode: Node | null, + includeSelf: boolean, + callback: (() => void) | null, + direction: ColorTransformDirection | CompatibleColorTransformDirection, + forceTransform?: boolean, + fromDarkMode: boolean = false +) => { + const { + darkColorHandler, + lifecycle: { onExternalContentTransform }, + } = core; + const toDarkMode = direction == ColorTransformDirection.LightToDark; + if (rootNode && (forceTransform || core.lifecycle.isDarkMode)) { + const transformer = onExternalContentTransform + ? (element: HTMLElement) => { + onExternalContentTransform(element, fromDarkMode, toDarkMode, darkColorHandler); + } + : (element: HTMLElement) => { + darkColorHandler.transformElementColor(element, fromDarkMode, toDarkMode); + }; + + iterateElements(rootNode, transformer, includeSelf); + } + + callback?.(); +}; + +function iterateElements( + root: Node, + transformer: (element: HTMLElement) => void, + includeSelf?: boolean +) { + if (includeSelf && isHTMLElement(root)) { + transformer(root); + } + + for (let child = root.firstChild; child; child = child.nextSibling) { + if (isHTMLElement(child)) { + transformer(child); + } + + iterateElements(child, transformer); + } +} + +// This is not a strict check, we just need to make sure this element has style so that we can set style to it +// We don't use safeInstanceOf() here since this function will be called very frequently when extract html content +// in dark mode, so we need to make sure this check is fast enough +function isHTMLElement(node: Node): node is HTMLElement { + const htmlElement = node; + return node.nodeType == Node.ELEMENT_NODE && !!htmlElement.style; +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/triggerEvent.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/triggerEvent.ts new file mode 100644 index 00000000000..7fc7272bf7d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/triggerEvent.ts @@ -0,0 +1,44 @@ +import { PluginEventType } from 'roosterjs-editor-types'; +import type { EditorCore, EditorPlugin, PluginEvent, TriggerEvent } from 'roosterjs-editor-types'; +import type { CompatiblePluginEventType } from 'roosterjs-editor-types/lib/compatibleTypes'; + +const allowedEventsInShadowEdit: (PluginEventType | CompatiblePluginEventType)[] = [ + PluginEventType.EditorReady, + PluginEventType.BeforeDispose, + PluginEventType.ExtractContentWithDom, + PluginEventType.ZoomChanged, +]; + +/** + * @internal + * Trigger a plugin event + * @param core The EditorCore object + * @param pluginEvent The event object to trigger + * @param broadcast Set to true to skip the shouldHandleEventExclusively check + */ +export const triggerEvent: TriggerEvent = ( + core: EditorCore, + pluginEvent: PluginEvent, + broadcast: boolean +) => { + if ( + (!core.lifecycle.shadowEditFragment || + allowedEventsInShadowEdit.indexOf(pluginEvent.eventType) >= 0) && + (broadcast || !core.plugins.some(plugin => handledExclusively(pluginEvent, plugin))) + ) { + core.plugins.forEach(plugin => { + if (plugin.onPluginEvent) { + plugin.onPluginEvent(pluginEvent); + } + }); + } +}; + +function handledExclusively(event: PluginEvent, plugin: EditorPlugin): boolean { + if (plugin.onPluginEvent && plugin.willHandleEventExclusively?.(event)) { + plugin.onPluginEvent(event); + return true; + } + + return false; +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/utils/addUniqueId.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/utils/addUniqueId.ts new file mode 100644 index 00000000000..9d3897bc5a3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/utils/addUniqueId.ts @@ -0,0 +1,31 @@ +/** + * @internal + * Add an unique id to element and ensure that is unique + * @param el The HTMLElement that will receive the id + * @param idPrefix The prefix that will antecede the id (Ex: tableSelected01) + */ +export default function addUniqueId(el: HTMLElement, idPrefix: string) { + const doc = el.ownerDocument; + if (!el.id) { + applyId(el, idPrefix, doc); + } else { + const elements = doc.querySelectorAll(`#${el.id}`); + if (elements.length > 1) { + el.removeAttribute('id'); + applyId(el, idPrefix, doc); + } + } +} + +function applyId(el: HTMLElement, idPrefix: string, doc: Document) { + let cont = 0; + const getElement = () => doc.getElementById(idPrefix + cont); + //Ensure that there are no elements with the same ID + let element = getElement(); + while (element) { + cont++; + element = getElement(); + } + + el.id = idPrefix + cont; +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/CopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/CopyPastePlugin.ts new file mode 100644 index 00000000000..b968d9a12cb --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/CopyPastePlugin.ts @@ -0,0 +1,296 @@ +import { forEachSelectedCell } from './utils/forEachSelectedCell'; +import { removeCellsOutsideSelection } from './utils/removeCellsOutsideSelection'; +import { + addRangeToSelection, + createElement, + extractClipboardEvent, + moveChildNodes, + Browser, + setHtmlWithMetadata, + createRange, + VTable, + isWholeTableSelected, +} from 'roosterjs-editor-dom'; +import type { + CopyPastePluginState, + EditorOptions, + IEditor, + PluginWithState, + SelectionRangeEx, + TableSelection, +} from 'roosterjs-editor-types'; +import { + ChangeSource, + GetContentMode, + PluginEventType, + KnownCreateElementDataIndex, + SelectionRangeTypes, + TableOperation, +} from 'roosterjs-editor-types'; + +/** + * @internal + * Copy and paste plugin for handling onCopy and onPaste event + */ +export default class CopyPastePlugin implements PluginWithState { + private editor: IEditor | null = null; + private disposer: (() => void) | null = null; + private state: CopyPastePluginState; + + /** + * Construct a new instance of CopyPastePlugin + * @param options The editor options + */ + constructor(options: EditorOptions) { + this.state = { + allowedCustomPasteType: options.allowedCustomPasteType || [], + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'CopyPaste'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + this.disposer = this.editor.addDomEventHandler({ + paste: e => this.onPaste(e), + copy: e => this.onCutCopy(e, false /*isCut*/), + cut: e => this.onCutCopy(e, true /*isCut*/), + }); + } + + /** + * Dispose this plugin + */ + dispose() { + if (this.disposer) { + this.disposer(); + } + this.disposer = null; + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + private onCutCopy(event: Event, isCut: boolean) { + if (this.editor) { + const selection = this.editor.getSelectionRangeEx(); + if (selection && !selection.areAllCollapsed) { + const html = this.editor.getContent(GetContentMode.RawHTMLWithSelection); + const tempDiv = this.getTempDiv(this.editor, true /*forceInLightMode*/); + const metadata = setHtmlWithMetadata( + tempDiv, + html, + this.editor.getTrustedHTMLHandler() + ); + let newRange: Range | null = null; + + if ( + selection.type === SelectionRangeTypes.TableSelection && + selection.coordinates + ) { + const table = tempDiv.querySelector( + `#${selection.table.id}` + ) as HTMLTableElement; + newRange = this.createTableRange(table, selection.coordinates); + if (isCut) { + this.deleteTableContent( + this.editor, + selection.table, + selection.coordinates + ); + } + } else if (selection.type === SelectionRangeTypes.ImageSelection) { + const image = tempDiv.querySelector('#' + selection.image.id); + + if (image) { + newRange = createRange(image); + if (isCut) { + this.deleteImage(this.editor, selection.image.id); + } + } + } else { + newRange = + metadata?.type === SelectionRangeTypes.Normal + ? createRange(tempDiv, metadata.start, metadata.end) + : null; + } + if (newRange) { + const cutCopyEvent = this.editor.triggerPluginEvent( + PluginEventType.BeforeCutCopy, + { + clonedRoot: tempDiv, + range: newRange, + rawEvent: event as ClipboardEvent, + isCut, + } + ); + + if (cutCopyEvent.range) { + addRangeToSelection(newRange); + } + + this.editor.runAsync(editor => { + this.cleanUpAndRestoreSelection(tempDiv, selection, !isCut /* isCopy */); + + if (isCut) { + editor.addUndoSnapshot(() => { + const position = editor.deleteSelectedContent(); + editor.focus(); + editor.select(position); + }, ChangeSource.Cut); + } + }); + } + } + } + } + + private onPaste = (event: Event) => { + let range: Range | null = null; + if (this.editor) { + const editor = this.editor; + extractClipboardEvent( + event as ClipboardEvent, + clipboardData => { + if (editor && !editor.isDisposed()) { + editor.paste(clipboardData); + } + }, + { + allowedCustomPasteType: this.state.allowedCustomPasteType, + getTempDiv: () => { + range = editor.getSelectionRange() ?? null; + return this.getTempDiv(editor); + }, + removeTempDiv: div => { + if (range) { + this.cleanUpAndRestoreSelection(div, range, false /* isCopy */); + } + }, + }, + this.editor.getSelectionRange() ?? undefined + ); + } + }; + + private getTempDiv(editor: IEditor, forceInLightMode?: boolean) { + const div = editor.getCustomData( + 'CopyPasteTempDiv', + () => { + const tempDiv = createElement( + KnownCreateElementDataIndex.CopyPasteTempDiv, + editor.getDocument() + ) as HTMLDivElement; + + editor.getDocument().body.appendChild(tempDiv); + + return tempDiv; + }, + tempDiv => tempDiv.parentNode?.removeChild(tempDiv) + ); + + if (forceInLightMode) { + div.style.backgroundColor = 'white'; + div.style.color = 'black'; + } + + div.style.display = ''; + div.focus(); + + return div; + } + + private cleanUpAndRestoreSelection( + tempDiv: HTMLDivElement, + range: Range | SelectionRangeEx, + isCopy: boolean + ) { + if (!!(range)?.type || (range).type == 0) { + const selection = range; + switch (selection.type) { + case SelectionRangeTypes.TableSelection: + case SelectionRangeTypes.ImageSelection: + this.editor?.select(selection); + break; + case SelectionRangeTypes.Normal: + const range = selection.ranges?.[0]; + this.restoreRange(range, isCopy); + break; + } + } else { + this.restoreRange(range, isCopy); + } + + tempDiv.style.backgroundColor = ''; + tempDiv.style.color = ''; + tempDiv.style.display = 'none'; + moveChildNodes(tempDiv); + } + + private restoreRange(range: Range, isCopy: boolean) { + if (range && this.editor) { + if (isCopy && Browser.isAndroid) { + range.collapse(); + } + this.editor.select(range); + } + } + + private createTableRange(table: HTMLTableElement, selection: TableSelection) { + const clonedVTable = new VTable(table as HTMLTableElement); + clonedVTable.selection = selection; + removeCellsOutsideSelection(clonedVTable); + clonedVTable.writeBack(); + return createRange(clonedVTable.table); + } + + private deleteTableContent( + editor: IEditor, + table: HTMLTableElement, + selection: TableSelection + ) { + const selectedVTable = new VTable(table); + selectedVTable.selection = selection; + + forEachSelectedCell(selectedVTable, cell => { + if (cell?.td) { + cell.td.innerHTML = editor.getTrustedHTMLHandler()('
                                                          '); + } + }); + + const wholeTableSelected = isWholeTableSelected(selectedVTable, selection); + const isWholeColumnSelected = + table.rows.length - 1 === selection.lastCell.y && selection.firstCell.y === 0; + if (wholeTableSelected) { + selectedVTable.edit(TableOperation.DeleteTable); + selectedVTable.writeBack(); + } else if (isWholeColumnSelected) { + selectedVTable.edit(TableOperation.DeleteColumn); + selectedVTable.writeBack(); + } + if (wholeTableSelected || isWholeColumnSelected) { + table.style.removeProperty('width'); + table.style.removeProperty('height'); + } + } + + private deleteImage(editor: IEditor, imageId: string) { + editor.queryElements('#' + imageId, node => { + editor.deleteNode(node); + }); + } +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/DOMEventPlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/DOMEventPlugin.ts new file mode 100644 index 00000000000..206ab44193e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/DOMEventPlugin.ts @@ -0,0 +1,259 @@ +import { arrayPush, Browser, isCharacterValue } from 'roosterjs-editor-dom'; +import { ChangeSource, Keys, PluginEventType } from 'roosterjs-editor-types'; +import type { + ContextMenuProvider, + DOMEventHandler, + DOMEventPluginState, + EditorOptions, + EditorPlugin, + IEditor, + PluginWithState, +} from 'roosterjs-editor-types'; + +/** + * @internal + * DOMEventPlugin handles customized DOM events, including: + * 1. Keyboard event + * 2. Mouse event + * 3. IME state + * 4. Drop event + * 5. Focus and blur event + * 6. Input event + * 7. Scroll event + * It contains special handling for Safari since Safari cannot get correct selection when onBlur event is triggered in editor. + */ +export default class DOMEventPlugin implements PluginWithState { + private editor: IEditor | null = null; + private disposer: (() => void) | null = null; + private state: DOMEventPluginState; + + /** + * Construct a new instance of DOMEventPlugin + * @param options The editor options + * @param contentDiv The editor content DIV + */ + constructor(options: EditorOptions, contentDiv: HTMLDivElement) { + this.state = { + isInIME: false, + scrollContainer: options.scrollContainer || contentDiv, + selectionRange: null, + stopPrintableKeyboardEventPropagation: !options.allowKeyboardEventPropagation, + contextMenuProviders: + options.plugins?.filter>(isContextMenuProvider) || [], + tableSelectionRange: null, + imageSelectionRange: null, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'DOMEvent'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + + const document = this.editor.getDocument(); + //Record + const eventHandlers: Partial< + { [P in keyof HTMLElementEventMap]: DOMEventHandler } + > = { + // 1. Keyboard event + keypress: this.getEventHandler(PluginEventType.KeyPress), + keydown: this.getEventHandler(PluginEventType.KeyDown), + keyup: this.getEventHandler(PluginEventType.KeyUp), + + // 2. Mouse event + mousedown: PluginEventType.MouseDown, + contextmenu: this.onContextMenuEvent, + + // 3. IME state management + compositionstart: () => (this.state.isInIME = true), + compositionend: (rawEvent: CompositionEvent) => { + this.state.isInIME = false; + editor.triggerPluginEvent(PluginEventType.CompositionEnd, { + rawEvent, + }); + }, + + // 4. Drag and Drop event + dragstart: this.onDragStart, + drop: this.onDrop, + + // 5. Focus management + focus: this.onFocus, + + // 6. Input event + [Browser.isIE ? 'textinput' : 'input']: this.getEventHandler(PluginEventType.Input), + }; + + // 7. onBlur handlers + if (Browser.isSafari) { + document.addEventListener('mousedown', this.onMouseDownDocument, true /*useCapture*/); + document.addEventListener('keydown', this.onKeyDownDocument); + document.defaultView?.addEventListener('blur', this.cacheSelection); + } else if (Browser.isIEOrEdge) { + type EventHandlersIE = { + beforedeactivate: DOMEventHandler; + }; + (eventHandlers as EventHandlersIE).beforedeactivate = this.cacheSelection; + } else { + eventHandlers.blur = this.cacheSelection; + } + + this.disposer = editor.addDomEventHandler(>eventHandlers); + + // 8. Scroll event + this.state.scrollContainer.addEventListener('scroll', this.onScroll); + document.defaultView?.addEventListener('scroll', this.onScroll); + document.defaultView?.addEventListener('resize', this.onScroll); + } + + /** + * Dispose this plugin + */ + dispose() { + const document = this.editor?.getDocument(); + if (document && Browser.isSafari) { + document.removeEventListener( + 'mousedown', + this.onMouseDownDocument, + true /*useCapture*/ + ); + document.removeEventListener('keydown', this.onKeyDownDocument); + document.defaultView?.removeEventListener('blur', this.cacheSelection); + } + + document?.defaultView?.removeEventListener('resize', this.onScroll); + document?.defaultView?.removeEventListener('scroll', this.onScroll); + this.state.scrollContainer.removeEventListener('scroll', this.onScroll); + this.disposer?.(); + this.disposer = null; + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + private onDragStart = (e: Event) => { + const dragEvent = e as DragEvent; + const element = this.editor?.getElementAtCursor('*', dragEvent.target as Node); + + if (element && !element.isContentEditable) { + dragEvent.preventDefault(); + } + }; + private onDrop = () => { + this.editor?.runAsync(editor => { + editor.addUndoSnapshot(() => {}, ChangeSource.Drop); + }); + }; + + private onFocus = () => { + if (!this.state.skipReselectOnFocus) { + const { table, coordinates } = this.state.tableSelectionRange || {}; + const { image } = this.state.imageSelectionRange || {}; + + if (table && coordinates) { + this.editor?.select(table, coordinates); + } else if (image) { + this.editor?.select(image); + } else if (this.state.selectionRange) { + this.editor?.select(this.state.selectionRange); + } + } + + this.state.selectionRange = null; + }; + private onKeyDownDocument = (event: KeyboardEvent) => { + if (event.which == Keys.TAB && !event.defaultPrevented) { + this.cacheSelection(); + } + }; + + private onMouseDownDocument = (event: MouseEvent) => { + if ( + this.editor && + !this.state.selectionRange && + !this.editor.contains(event.target as Node) + ) { + this.cacheSelection(); + } + }; + + private cacheSelection = () => { + if (!this.state.selectionRange && this.editor) { + this.state.selectionRange = this.editor.getSelectionRange(false /*tryGetFromCache*/); + } + }; + private onScroll = (e: Event) => { + this.editor?.triggerPluginEvent(PluginEventType.Scroll, { + rawEvent: e, + scrollContainer: this.state.scrollContainer, + }); + }; + + private getEventHandler(eventType: PluginEventType): DOMEventHandler { + const beforeDispatch = (event: Event) => + eventType == PluginEventType.Input + ? this.onInputEvent(event) + : this.onKeyboardEvent(event); + + return this.state.stopPrintableKeyboardEventPropagation + ? { + pluginEventType: eventType, + beforeDispatch, + } + : eventType; + } + + private onKeyboardEvent = (event: KeyboardEvent) => { + if (isCharacterValue(event) || (event.which >= Keys.PAGEUP && event.which <= Keys.DOWN)) { + // Stop propagation for Character keys and Up/Down/Left/Right/Home/End/PageUp/PageDown + // since editor already handles these keys and no need to propagate to parents + event.stopPropagation(); + } + }; + + private onInputEvent = (event: InputEvent) => { + event.stopPropagation(); + }; + + private onContextMenuEvent = (event: MouseEvent) => { + const allItems: any[] = []; + const searcher = this.editor?.getContentSearcherOfCursor(); + const elementBeforeCursor = searcher?.getInlineElementBefore(); + + let eventTargetNode = event.target as Node; + if (event.button != 2 && elementBeforeCursor) { + eventTargetNode = elementBeforeCursor.getContainerNode(); + } + this.state.contextMenuProviders.forEach(provider => { + const items = provider.getContextMenuItems(eventTargetNode) ?? []; + if (items?.length > 0) { + if (allItems.length > 0) { + allItems.push(null); + } + arrayPush(allItems, items); + } + }); + this.editor?.triggerPluginEvent(PluginEventType.ContextMenu, { + rawEvent: event, + items: allItems, + }); + }; +} + +function isContextMenuProvider(source: EditorPlugin): source is ContextMenuProvider { + return !!(>source)?.getContextMenuItems; +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EditPlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EditPlugin.ts new file mode 100644 index 00000000000..ea627186823 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EditPlugin.ts @@ -0,0 +1,96 @@ +import { isCtrlOrMetaPressed } from 'roosterjs-editor-dom'; +import { Keys, PluginEventType } from 'roosterjs-editor-types'; +import type { + EditPluginState, + GenericContentEditFeature, + IEditor, + PluginEvent, + PluginWithState, +} from 'roosterjs-editor-types'; + +/** + * @internal + * Edit Component helps handle Content edit features + */ +export default class EditPlugin implements PluginWithState { + private editor: IEditor | null = null; + private state: EditPluginState; + + /** + * Construct a new instance of EditPlugin + * @param options The editor options + */ + constructor() { + this.state = { + features: {}, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Edit'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + let hasFunctionKey = false; + let features: GenericContentEditFeature[] | null = null; + let ctrlOrMeta = false; + const isKeyDownEvent = event.eventType == PluginEventType.KeyDown; + + if (isKeyDownEvent) { + const rawEvent = event.rawEvent; + const range = this.editor?.getSelectionRange(); + + ctrlOrMeta = isCtrlOrMetaPressed(rawEvent); + hasFunctionKey = ctrlOrMeta || rawEvent.altKey; + features = + this.state.features[rawEvent.which] || + (range && !range.collapsed && this.state.features[Keys.RANGE]); + } else if (event.eventType == PluginEventType.ContentChanged) { + features = this.state.features[Keys.CONTENTCHANGED]; + } + + for (let i = 0; features && i < features?.length; i++) { + const feature = features[i]; + if ( + (feature.allowFunctionKeys || !hasFunctionKey) && + this.editor && + feature.shouldHandleEvent(event, this.editor, ctrlOrMeta) + ) { + feature.handleEvent(event, this.editor); + if (isKeyDownEvent) { + event.handledByEditFeature = true; + } + break; + } + } + } +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EntityPlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EntityPlugin.ts new file mode 100644 index 00000000000..df8d8198e33 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EntityPlugin.ts @@ -0,0 +1,390 @@ +import { + inlineEntityOnPluginEvent, + normalizeDelimitersInEditor, +} from './utils/inlineEntityOnPluginEvent'; +import { + Browser, + commitEntity, + getEntityFromElement, + getEntitySelector, + isCharacterValue, + toArray, + arrayPush, + createElement, + addRangeToSelection, + createRange, + isBlockElement, + getObjectKeys, +} from 'roosterjs-editor-dom'; +import type { + ContentChangedEvent, + Entity, + EntityOperationEvent, + EntityPluginState, + KnownEntityItem, + HtmlSanitizerOptions, + IEditor, + PluginEvent, + PluginMouseUpEvent, + PluginWithState, +} from 'roosterjs-editor-types'; +import { + ChangeSource, + ContentPosition, + EntityClasses, + EntityOperation, + Keys, + PluginEventType, + QueryScope, +} from 'roosterjs-editor-types'; +import type { CompatibleEntityOperation } from 'roosterjs-editor-types/lib/compatibleTypes'; + +const ENTITY_ID_REGEX = /_(\d{1,8})$/; + +const ENTITY_CSS_REGEX = '^' + EntityClasses.ENTITY_INFO_NAME + '$'; +const ENTITY_ID_CSS_REGEX = '^' + EntityClasses.ENTITY_ID_PREFIX; +const ENTITY_TYPE_CSS_REGEX = '^' + EntityClasses.ENTITY_TYPE_PREFIX; +const ENTITY_READONLY_CSS_REGEX = '^' + EntityClasses.ENTITY_READONLY_PREFIX; +const ALLOWED_CSS_CLASSES = [ + ENTITY_CSS_REGEX, + ENTITY_ID_CSS_REGEX, + ENTITY_TYPE_CSS_REGEX, + ENTITY_READONLY_CSS_REGEX, +]; +const REMOVE_ENTITY_OPERATIONS: (EntityOperation | CompatibleEntityOperation)[] = [ + EntityOperation.Overwrite, + EntityOperation.PartialOverwrite, + EntityOperation.RemoveFromStart, + EntityOperation.RemoveFromEnd, +]; + +/** + * @internal + * Entity Plugin helps handle all operations related to an entity and generate entity specified events + */ +export default class EntityPlugin implements PluginWithState { + private editor: IEditor | null = null; + private state: EntityPluginState; + + /** + * Construct a new instance of EntityPlugin + */ + constructor() { + this.state = { + entityMap: {}, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Entity'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + this.state.entityMap = {}; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + switch (event.eventType) { + case PluginEventType.MouseUp: + this.handleMouseUpEvent(event); + break; + case PluginEventType.KeyDown: + this.handleKeyDownEvent(event.rawEvent); + break; + case PluginEventType.BeforeCutCopy: + if (event.isCut) { + this.handleCutEvent(event.rawEvent); + } + break; + case PluginEventType.BeforePaste: + this.handleBeforePasteEvent(event.sanitizingOption); + break; + case PluginEventType.ContentChanged: + this.handleContentChangedEvent(event); + break; + case PluginEventType.EditorReady: + this.handleContentChangedEvent(); + break; + case PluginEventType.ExtractContentWithDom: + this.handleExtractContentWithDomEvent(event.clonedRoot); + break; + case PluginEventType.ContextMenu: + this.handleContextMenuEvent(event.rawEvent); + break; + case PluginEventType.EntityOperation: + this.handleEntityOperationEvent(event); + break; + } + + if (this.editor) { + inlineEntityOnPluginEvent(event, this.editor); + } + } + + private handleContextMenuEvent(event: UIEvent) { + const node = event.target as Node; + const entityElement = node && this.editor?.getElementAtCursor(getEntitySelector(), node); + + if (entityElement) { + event.preventDefault(); + this.triggerEvent(entityElement, EntityOperation.ContextMenu, event); + } + } + + private handleCutEvent = (event: ClipboardEvent) => { + const range = this.editor?.getSelectionRange(); + if (range && !range.collapsed) { + this.checkRemoveEntityForRange(event); + } + }; + + private handleMouseUpEvent(event: PluginMouseUpEvent) { + const { rawEvent, isClicking } = event; + const node = rawEvent.target as Node; + let entityElement: HTMLElement | null; + + if ( + this.editor && + isClicking && + node && + !!(entityElement = this.editor.getElementAtCursor(getEntitySelector(), node)) + ) { + this.triggerEvent(entityElement, EntityOperation.Click, rawEvent); + + workaroundSelectionIssueForIE(this.editor); + } + } + + private handleKeyDownEvent(event: KeyboardEvent) { + if ( + isCharacterValue(event) || + event.which == Keys.BACKSPACE || + event.which == Keys.DELETE || + event.which == Keys.ENTER + ) { + const range = this.editor?.getSelectionRange(); + if (range && !range.collapsed) { + this.checkRemoveEntityForRange(event); + } + } + } + + private handleBeforePasteEvent(sanitizingOption: HtmlSanitizerOptions) { + const range = this.editor?.getSelectionRange(); + + if (range && !range.collapsed) { + this.checkRemoveEntityForRange(null! /*rawEvent*/); + } + + if (sanitizingOption.additionalAllowedCssClasses) { + arrayPush(sanitizingOption.additionalAllowedCssClasses, ALLOWED_CSS_CLASSES); + } + } + + private handleContentChangedEvent(event?: ContentChangedEvent) { + let shouldNormalizeDelimiters: boolean = false; + // 1. find removed entities + getObjectKeys(this.state.entityMap).forEach(id => { + const item = this.state.entityMap[id]; + const element = item.element; + + if (this.editor && !item.isDeleted && !this.editor.contains(element)) { + item.isDeleted = true; + + this.triggerEvent(element, EntityOperation.Overwrite); + + if ( + !shouldNormalizeDelimiters && + !element.isContentEditable && + !isBlockElement(element) + ) { + shouldNormalizeDelimiters = true; + } + } + }); + + // 2. collect all new entities + const newEntities = + event?.source == ChangeSource.InsertEntity && event.data + ? [event.data as Entity] + : this.getExistingEntities().filter(entity => { + const item = this.state.entityMap[entity.id]; + + return !item || item.element != entity.wrapper || item.isDeleted; + }); + + // 3. Add new entities to known entity list, and hydrate + newEntities.forEach(entity => { + const { wrapper, type, id, isReadonly } = entity; + + entity.id = this.ensureUniqueId(type, id, wrapper); + commitEntity(wrapper, type, isReadonly, entity.id); // Use entity.id here because it is newly updated + this.handleNewEntity(entity); + }); + + if (shouldNormalizeDelimiters && this.editor) { + normalizeDelimitersInEditor(this.editor); + } + } + + private handleEntityOperationEvent(event: EntityOperationEvent) { + if (this.editor && REMOVE_ENTITY_OPERATIONS.indexOf(event.operation) >= 0) { + const item = this.state.entityMap[event.entity.id]; + + if (item) { + item.isDeleted = true; + } + } + } + + private handleExtractContentWithDomEvent(root: HTMLElement) { + toArray(root.querySelectorAll(getEntitySelector())).forEach(element => { + element.removeAttribute('contentEditable'); + + this.triggerEvent(element as HTMLElement, EntityOperation.ReplaceTemporaryContent); + }); + } + + private checkRemoveEntityForRange(event: Event) { + const editableEntityElements: HTMLElement[] = []; + const selector = getEntitySelector(); + this.editor?.queryElements(selector, QueryScope.OnSelection, element => { + if (element.isContentEditable) { + editableEntityElements.push(element); + } else { + this.triggerEvent(element, EntityOperation.Overwrite, event); + } + }); + + // For editable entities, we need to check if it is fully or partially covered by current selection, + // and trigger different events; + if (this.editor && editableEntityElements.length > 0) { + const inSelectionEntityElements = this.editor.queryElements( + selector, + QueryScope.InSelection + ); + editableEntityElements.forEach(element => { + const isFullyCovered = inSelectionEntityElements.indexOf(element) >= 0; + this.triggerEvent( + element, + isFullyCovered ? EntityOperation.Overwrite : EntityOperation.PartialOverwrite, + event + ); + }); + } + } + + private triggerEvent(element: HTMLElement, operation: EntityOperation, rawEvent?: Event) { + const entity = element && getEntityFromElement(element); + + return entity + ? this.editor?.triggerPluginEvent(PluginEventType.EntityOperation, { + operation, + rawEvent, + entity, + }) + : null; + } + + private handleNewEntity(entity: Entity) { + const { wrapper } = entity; + const event = this.triggerEvent(wrapper, EntityOperation.NewEntity); + + const newItem: KnownEntityItem = { + element: entity.wrapper, + }; + + if (event?.shouldPersist) { + newItem.canPersist = true; + } + + this.state.entityMap[entity.id] = newItem; + } + + private getExistingEntities(): Entity[] { + return ( + this.editor + ?.queryElements(getEntitySelector()) + .map(getEntityFromElement) + .filter((x): x is Entity => !!x) ?? [] + ); + } + + private ensureUniqueId(type: string, id: string, wrapper: HTMLElement) { + const match = ENTITY_ID_REGEX.exec(id); + const baseId = (match ? id.substr(0, id.length - match[0].length) : id) || type; + + // Make sure entity id is unique + let newId = ''; + + for (let num = (match && parseInt(match[1])) || 0; ; num++) { + newId = num > 0 ? `${baseId}_${num}` : baseId; + + const item = this.state.entityMap[newId]; + + if (!item || item.element == wrapper) { + break; + } + } + + return newId; + } +} + +/** + * IE will show a resize border around the readonly content within content editable DIV + * This is a workaround to remove it by temporarily move focus out of editor + */ +const workaroundSelectionIssueForIE = Browser.isIE + ? (editor: IEditor) => { + editor.runAsync(editor => { + const workaroundButton = editor.getCustomData('ENTITY_IE_FOCUS_BUTTON', () => { + const button = createElement( + { + tag: 'button', + style: 'overflow:hidden;position:fixed;width:0;height:0;top:-1000px', + }, + editor.getDocument() + ) as HTMLElement; + button.onblur = () => { + button.style.display = 'none'; + }; + + editor.insertNode(button, { + position: ContentPosition.Outside, + }); + + return button; + }); + + workaroundButton.style.display = ''; + addRangeToSelection(createRange(workaroundButton, 0)); + }); + } + : () => {}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/ImageSelection.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/ImageSelection.ts new file mode 100644 index 00000000000..bad0268be0a --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/ImageSelection.ts @@ -0,0 +1,104 @@ +import { PluginEventType, PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { Position, safeInstanceOf } from 'roosterjs-editor-dom'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; + +const Escape = 'Escape'; +const Delete = 'Delete'; +const mouseLeftButton = 0; + +/** + * @internal + * Detect image selection and help highlight the image + */ +export default class ImageSelection implements EditorPlugin { + private editor: IEditor | null = null; + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'ImageSelection'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor?.select(null); + this.editor = null; + } + + onPluginEvent(event: PluginEvent) { + if (this.editor) { + switch (event.eventType) { + case PluginEventType.MouseUp: + const target = event.rawEvent.target; + if ( + safeInstanceOf(target, 'HTMLImageElement') && + target.isContentEditable && + event.rawEvent.button === mouseLeftButton + ) { + this.editor.select(target); + } + break; + case PluginEventType.MouseDown: + const mouseTarget = event.rawEvent.target; + const mouseSelection = this.editor.getSelectionRangeEx(); + if ( + mouseSelection && + mouseSelection.type === SelectionRangeTypes.ImageSelection && + mouseSelection.image !== mouseTarget + ) { + this.editor.select(null); + } + break; + case PluginEventType.KeyDown: + const rawEvent = event.rawEvent; + const key = rawEvent.key; + const keyDownSelection = this.editor.getSelectionRangeEx(); + if ( + !rawEvent.ctrlKey && + !rawEvent.altKey && + !rawEvent.shiftKey && + !rawEvent.metaKey && + keyDownSelection.type === SelectionRangeTypes.ImageSelection + ) { + if (key === Escape) { + this.editor.select(keyDownSelection.image, PositionType.Before); + this.editor.getSelectionRange()?.collapse(); + event.rawEvent.stopPropagation(); + } else if (key === Delete) { + this.editor.deleteNode(keyDownSelection.image); + event.rawEvent.preventDefault(); + } else { + const position = new Position( + keyDownSelection.image, + PositionType.Before + ); + + this.editor.select(position); + } + } + break; + case PluginEventType.ContextMenu: + const contextMenuTarget = event.rawEvent.target; + const actualSelection = this.editor.getSelectionRangeEx(); + if ( + safeInstanceOf(contextMenuTarget, 'HTMLImageElement') && + (actualSelection.type !== SelectionRangeTypes.ImageSelection || + actualSelection.image !== contextMenuTarget) + ) { + this.editor.select(contextMenuTarget); + } + } + } + } +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/LifecyclePlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/LifecyclePlugin.ts new file mode 100644 index 00000000000..cd17471380c --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/LifecyclePlugin.ts @@ -0,0 +1,188 @@ +import { ChangeSource, PluginEventType } from 'roosterjs-editor-types'; +import { getObjectKeys, setColor } from 'roosterjs-editor-dom'; +import type { + EditorOptions, + IEditor, + LifecyclePluginState, + PluginWithState, + PluginEvent, +} from 'roosterjs-editor-types'; + +const CONTENT_EDITABLE_ATTRIBUTE_NAME = 'contenteditable'; + +const DARK_MODE_DEFAULT_FORMAT = { + backgroundColors: { + darkModeColor: 'rgb(51,51,51)', + lightModeColor: 'rgb(255,255,255)', + }, + textColors: { + darkModeColor: 'rgb(255,255,255)', + lightModeColor: 'rgb(0,0,0)', + }, +}; + +/** + * @internal + * Lifecycle plugin handles editor initialization and disposing + */ +export default class LifecyclePlugin implements PluginWithState { + private editor: IEditor | null = null; + private state: LifecyclePluginState; + private initialContent: string; + private initializer: (() => void) | null = null; + private disposer: (() => void) | null = null; + private adjustColor: () => void; + + /** + * Construct a new instance of LifecyclePlugin + * @param options The editor options + * @param contentDiv The editor content DIV + */ + constructor(options: EditorOptions, contentDiv: HTMLDivElement) { + this.initialContent = options.initialContent || contentDiv.innerHTML || ''; + + // Make the container editable and set its selection styles + if (contentDiv.getAttribute(CONTENT_EDITABLE_ATTRIBUTE_NAME) === null) { + this.initializer = () => { + contentDiv.contentEditable = 'true'; + contentDiv.style.userSelect = 'text'; + }; + this.disposer = () => { + contentDiv.style.userSelect = ''; + contentDiv.removeAttribute(CONTENT_EDITABLE_ATTRIBUTE_NAME); + }; + } + this.adjustColor = options.doNotAdjustEditorColor + ? () => {} + : () => { + const { textColors, backgroundColors } = DARK_MODE_DEFAULT_FORMAT; + const { isDarkMode } = this.state; + const darkColorHandler = this.editor?.getDarkColorHandler(); + setColor( + contentDiv, + textColors, + false /*isBackground*/, + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler + ); + setColor( + contentDiv, + backgroundColors, + true /*isBackground*/, + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler + ); + }; + + const getDarkColor = options.getDarkColor ?? ((color: string) => color); + const defaultFormat = options.defaultFormat ? { ...options.defaultFormat } : null; + + if (defaultFormat) { + if (defaultFormat.textColor && !defaultFormat.textColors) { + defaultFormat.textColors = { + lightModeColor: defaultFormat.textColor, + darkModeColor: getDarkColor(defaultFormat.textColor), + }; + delete defaultFormat.textColor; + } + + if (defaultFormat.backgroundColor && !defaultFormat.backgroundColors) { + defaultFormat.backgroundColors = { + lightModeColor: defaultFormat.backgroundColor, + darkModeColor: getDarkColor(defaultFormat.backgroundColor), + }; + delete defaultFormat.backgroundColor; + } + } + + this.state = { + customData: {}, + defaultFormat, + isDarkMode: !!options.inDarkMode, + getDarkColor, + onExternalContentTransform: options.onExternalContentTransform ?? null, + experimentalFeatures: options.experimentalFeatures || [], + shadowEditFragment: null, + shadowEditEntities: null, + shadowEditSelectionPath: null, + shadowEditTableSelectionPath: null, + shadowEditImageSelectionPath: null, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Lifecycle'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + + // Ensure initial content and its format + this.editor.setContent(this.initialContent, false /*triggerContentChangedEvent*/); + + // Set content DIV to be editable + this.initializer?.(); + + // Set editor background color for dark mode + this.adjustColor(); + + // Let other plugins know that we are ready + this.editor.triggerPluginEvent(PluginEventType.EditorReady, {}, true /*broadcast*/); + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor?.triggerPluginEvent(PluginEventType.BeforeDispose, {}, true /*broadcast*/); + + getObjectKeys(this.state.customData).forEach(key => { + const data = this.state.customData[key]; + + if (data && data.disposer) { + data.disposer(data.value); + } + + delete this.state.customData[key]; + }); + + if (this.disposer) { + this.disposer(); + this.disposer = null; + this.initializer = null; + } + + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + if ( + event.eventType == PluginEventType.ContentChanged && + (event.source == ChangeSource.SwitchToDarkMode || + event.source == ChangeSource.SwitchToLightMode) + ) { + this.state.isDarkMode = event.source == ChangeSource.SwitchToDarkMode; + this.adjustColor(); + } + } +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/MouseUpPlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/MouseUpPlugin.ts new file mode 100644 index 00000000000..2f072e3b10d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/MouseUpPlugin.ts @@ -0,0 +1,72 @@ +import { PluginEventType } from 'roosterjs-editor-types'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; + +/** + * @internal + * MouseUpPlugin help trigger MouseUp event even when mouse up happens outside editor + * as long as the mouse was pressed within Editor before + */ +export default class MouseUpPlugin implements EditorPlugin { + private editor: IEditor | null = null; + private mouseUpEventListerAdded: boolean = false; + private mouseDownX: number | null = null; + private mouseDownY: number | null = null; + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'MouseUp'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.removeMouseUpEventListener(); + this.editor = null; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + if ( + this.editor && + event.eventType == PluginEventType.MouseDown && + !this.mouseUpEventListerAdded + ) { + this.editor + .getDocument() + .addEventListener('mouseup', this.onMouseUp, true /*setCapture*/); + this.mouseUpEventListerAdded = true; + this.mouseDownX = event.rawEvent.pageX; + this.mouseDownY = event.rawEvent.pageY; + } + } + private removeMouseUpEventListener() { + if (this.editor && this.mouseUpEventListerAdded) { + this.mouseUpEventListerAdded = false; + this.editor.getDocument().removeEventListener('mouseup', this.onMouseUp, true); + } + } + + private onMouseUp = (rawEvent: MouseEvent) => { + if (this.editor) { + this.removeMouseUpEventListener(); + this.editor.triggerPluginEvent(PluginEventType.MouseUp, { + rawEvent, + isClicking: this.mouseDownX == rawEvent.pageX && this.mouseDownY == rawEvent.pageY, + }); + } + }; +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/NormalizeTablePlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/NormalizeTablePlugin.ts new file mode 100644 index 00000000000..79ce990da14 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/NormalizeTablePlugin.ts @@ -0,0 +1,180 @@ +import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { + changeElementTag, + getTagOfNode, + moveChildNodes, + safeInstanceOf, + toArray, +} from 'roosterjs-editor-dom'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; + +/** + * @internal + * TODO: Rename this plugin since it is not only for table now + * + * NormalizeTable plugin makes sure each table in editor has TBODY/THEAD/TFOOT tag around TR tags + * + * When we retrieve HTML content using innerHTML, browser will always add TBODY around TR nodes if there is not. + * This causes some issue when we restore the HTML content with selection path since the selection path is + * deeply coupled with DOM structure. So we need to always make sure there is already TBODY tag whenever + * new table is inserted, to make sure the selection path we created is correct. + */ +export default class NormalizeTablePlugin implements EditorPlugin { + private editor: IEditor | null = null; + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'NormalizeTable'; + } + + /** + * The first method that editor will call to a plugin when editor is initializing. + * It will pass in the editor instance, plugin should take this chance to save the + * editor reference so that it can call to any editor method or format API later. + * @param editor The editor object + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * The last method that editor will call to a plugin before it is disposed. + * Plugin can take this chance to clear the reference to editor. After this method is + * called, plugin should not call to any editor method since it will result in error. + */ + dispose() { + this.editor = null; + } + + /** + * Core method for a plugin. Once an event happens in editor, editor will call this + * method of each plugin to handle the event as long as the event is not handled + * exclusively by another plugin. + * @param event The event to handle: + */ + onPluginEvent(event: PluginEvent) { + switch (event.eventType) { + case PluginEventType.EditorReady: + case PluginEventType.ContentChanged: + if (this.editor) { + this.normalizeTables(this.editor.queryElements('table')); + } + break; + + case PluginEventType.BeforePaste: + this.normalizeTables(toArray(event.fragment.querySelectorAll('table'))); + break; + + case PluginEventType.MouseDown: + this.normalizeTableFromEvent(event.rawEvent); + break; + + case PluginEventType.KeyDown: + if (event.rawEvent.shiftKey) { + this.normalizeTableFromEvent(event.rawEvent); + } + break; + + case PluginEventType.ExtractContentWithDom: + normalizeListsForExport(event.clonedRoot); + break; + } + } + + private normalizeTableFromEvent(event: KeyboardEvent | MouseEvent) { + const table = this.editor?.getElementAtCursor('table', event.target as Node); + + if (table) { + this.normalizeTables([table]); + } + } + + private normalizeTables(tables: HTMLTableElement[]) { + if (this.editor && tables.length > 0) { + const rangeEx = this.editor.getSelectionRangeEx(); + const { startContainer, endContainer, startOffset, endOffset } = + (rangeEx?.type == SelectionRangeTypes.Normal && rangeEx.ranges[0]) || {}; + + const isChanged = normalizeTables(tables); + + if (isChanged) { + if ( + startContainer && + endContainer && + typeof startOffset === 'number' && + typeof endOffset === 'number' + ) { + this.editor.select(startContainer, startOffset, endContainer, endOffset); + } else if ( + rangeEx?.type == SelectionRangeTypes.TableSelection && + rangeEx.coordinates + ) { + this.editor.select(rangeEx.table, rangeEx.coordinates); + } + } + } + } +} + +function normalizeTables(tables: HTMLTableElement[]) { + let isDOMChanged = false; + tables.forEach(table => { + let tbody: HTMLTableSectionElement | null = null; + + for (let child = table.firstChild; child; child = child.nextSibling) { + const tag = getTagOfNode(child); + switch (tag) { + case 'TR': + if (!tbody) { + tbody = table.ownerDocument.createElement('tbody'); + table.insertBefore(tbody, child); + } + + tbody.appendChild(child); + child = tbody; + isDOMChanged = true; + + break; + case 'TBODY': + if (tbody) { + moveChildNodes(tbody, child, true /*keepExistingChildren*/); + child.parentNode?.removeChild(child); + child = tbody; + isDOMChanged = true; + } else { + tbody = child as HTMLTableSectionElement; + } + break; + default: + tbody = null; + break; + } + } + + const colgroups = table.querySelectorAll('colgroup'); + const thead = table.querySelector('thead'); + if (thead) { + colgroups.forEach(colgroup => { + if (!thead.contains(colgroup)) { + thead.appendChild(colgroup); + } + }); + } + }); + + return isDOMChanged; +} + +function normalizeListsForExport(root: ParentNode) { + toArray(root.querySelectorAll('li')).forEach(li => { + const prevElement = li.previousSibling; + + if (li.style.display == 'block' && safeInstanceOf(prevElement, 'HTMLLIElement')) { + li.style.removeProperty('display'); + + prevElement.appendChild(changeElementTag(li, 'div')); + } + }); +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/PendingFormatStatePlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/PendingFormatStatePlugin.ts new file mode 100644 index 00000000000..4a726f9f504 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/PendingFormatStatePlugin.ts @@ -0,0 +1,184 @@ +import { ChangeSource, Keys, PluginEventType, PositionType } from 'roosterjs-editor-types'; +import { isCharacterValue, Position, setColor } from 'roosterjs-editor-dom'; +import type { + IEditor, + NodePosition, + PendingFormatStatePluginState, + PluginEvent, + PluginWithState, +} from 'roosterjs-editor-types'; + +const ZERO_WIDTH_SPACE = '\u200B'; + +/** + * @internal + * PendingFormatStatePlugin handles pending format state management + */ +export default class PendingFormatStatePlugin + implements PluginWithState { + private editor: IEditor | null = null; + private state: PendingFormatStatePluginState; + + /** + * Construct a new instance of PendingFormatStatePlugin + * @param options The editor options + * @param contentDiv The editor content DIV + */ + constructor() { + this.state = { + pendableFormatPosition: null, + pendableFormatState: null, + pendableFormatSpan: null, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'PendingFormatState'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + this.clear(); + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + switch (event.eventType) { + case PluginEventType.PendingFormatStateChanged: + // Got PendingFormatStateChanged event, cache current position and pending format if a format is passed in + // otherwise clear existing pending format. + if (event.formatState) { + this.state.pendableFormatPosition = this.getCurrentPosition(); + this.state.pendableFormatState = event.formatState; + this.state.pendableFormatSpan = event.formatCallback + ? this.createPendingFormatSpan(event.formatCallback) + : null; + } else { + this.clear(); + } + + break; + case PluginEventType.KeyDown: + case PluginEventType.MouseDown: + case PluginEventType.ContentChanged: + let currentPosition: NodePosition | null = null; + if ( + this.editor && + event.eventType == PluginEventType.KeyDown && + isCharacterValue(event.rawEvent) && + this.state.pendableFormatSpan + ) { + this.state.pendableFormatSpan.removeAttribute('contentEditable'); + this.editor.insertNode(this.state.pendableFormatSpan); + this.editor.select( + this.state.pendableFormatSpan, + PositionType.Begin, + this.state.pendableFormatSpan, + PositionType.End + ); + this.clear(); + } else if ( + (event.eventType == PluginEventType.KeyDown && + event.rawEvent.which >= Keys.PAGEUP && + event.rawEvent.which <= Keys.DOWN) || + (this.state.pendableFormatPosition && + (currentPosition = this.getCurrentPosition()) && + !this.state.pendableFormatPosition.equalTo(currentPosition)) || + (event.eventType == PluginEventType.ContentChanged && + (event.source == ChangeSource.SwitchToDarkMode || + event.source == ChangeSource.SwitchToLightMode)) + ) { + // If content or position is changed (by keyboard, mouse, or code), + // check if current position is still the same with the cached one (if exist), + // and clear cached format if position is changed since it is out-of-date now + this.clear(); + } + + break; + } + } + + private clear() { + this.state.pendableFormatPosition = null; + this.state.pendableFormatState = null; + this.state.pendableFormatSpan = null; + } + + private getCurrentPosition() { + const range = this.editor?.getSelectionRange(); + return (range && Position.getStart(range).normalize()) ?? null; + } + + private createPendingFormatSpan( + callback: (element: HTMLElement, isInnerNode?: boolean) => any + ) { + let span = this.state.pendableFormatSpan; + + if (!span && this.editor) { + const currentStyle = this.editor.getStyleBasedFormatState(); + const doc = this.editor.getDocument(); + const isDarkMode = this.editor.isDarkMode(); + + span = doc.createElement('span'); + span.contentEditable = 'true'; + span.appendChild(doc.createTextNode(ZERO_WIDTH_SPACE)); + + span.style.setProperty('font-family', currentStyle.fontName ?? null); + span.style.setProperty('font-size', currentStyle.fontSize ?? null); + + const darkColorHandler = this.editor.getDarkColorHandler(); + + if (currentStyle.textColors || currentStyle.textColor) { + setColor( + span, + (currentStyle.textColors || currentStyle.textColor)!, + false /*isBackground*/, + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler + ); + } + + if (currentStyle.backgroundColors || currentStyle.backgroundColor) { + setColor( + span, + (currentStyle.backgroundColors || currentStyle.backgroundColor)!, + true /*isBackground*/, + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler + ); + } + } + + if (span) { + callback(span); + } + + return span; + } +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/TypeInContainerPlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/TypeInContainerPlugin.ts new file mode 100644 index 00000000000..227c1f2f3f4 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/TypeInContainerPlugin.ts @@ -0,0 +1,99 @@ +import { PluginEventType } from 'roosterjs-editor-types'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; +import { + Browser, + findClosestElementAncestor, + getTagOfNode, + isCtrlOrMetaPressed, + Position, +} from 'roosterjs-editor-dom'; + +/** + * @internal + * Typing Component helps to ensure typing is always happening under a DOM container + */ +export default class TypeInContainerPlugin implements EditorPlugin { + private editor: IEditor | null = null; + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'TypeInContainer'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + } + + private isRangeEmpty(range: Range) { + if ( + range.collapsed && + range.startContainer.nodeType === Node.ELEMENT_NODE && + getTagOfNode(range.startContainer) == 'DIV' && + !range.startContainer.firstChild + ) { + return true; + } + return false; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + // We need to check if the ctrl key or the meta key is pressed, + // browsers like Safari fire the "keypress" event when the meta key is pressed. + if ( + event.eventType == PluginEventType.KeyPress && + this.editor && + !(event.rawEvent && isCtrlOrMetaPressed(event.rawEvent)) + ) { + // If normalization was not possible before the keypress, + // check again after the keyboard event has been processed by browser native behavior. + // + // This handles the case where the keyboard event that first inserts content happens when + // there is already content under the selection (e.g. Ctrl+a -> type new content). + // + // Only schedule when the range is not collapsed to catch this edge case. + const range = this.editor.getSelectionRange(); + + const styledAncestor = + range && + findClosestElementAncestor(range.startContainer, undefined /* root */, '[style]'); + + if (!range || (!this.isRangeEmpty(range) && this.editor.contains(styledAncestor))) { + return; + } + + if (range.collapsed) { + this.editor.ensureTypeInContainer(Position.getStart(range), event.rawEvent); + } else { + const callback = () => { + const focusedPosition = this.editor?.getFocusedPosition(); + if (focusedPosition) { + this.editor?.ensureTypeInContainer(focusedPosition, event.rawEvent); + } + }; + + if (Browser.isMobileOrTablet) { + this.editor.getDocument().defaultView?.setTimeout(callback, 100); + } else { + this.editor.runAsync(callback); + } + } + } + } +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/UndoPlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/UndoPlugin.ts new file mode 100644 index 00000000000..4cd9c12de9b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/UndoPlugin.ts @@ -0,0 +1,279 @@ +import { ChangeSource, Keys, PluginEventType } from 'roosterjs-editor-types'; +import type { + ContentChangedEvent, + EditorOptions, + IEditor, + PluginEvent, + PluginWithState, + Snapshot, + UndoPluginState, + UndoSnapshotsService, +} from 'roosterjs-editor-types'; +import { + addSnapshotV2, + canMoveCurrentSnapshot, + clearProceedingSnapshotsV2, + createSnapshots, + isCtrlOrMetaPressed, + moveCurrentSnapshot, + canUndoAutoComplete, +} from 'roosterjs-editor-dom'; + +// Max stack size that cannot be exceeded. When exceeded, old undo history will be dropped +// to keep size under limit. This is kept at 10MB +const MAX_SIZE_LIMIT = 1e7; + +/** + * @internal + * Provides snapshot based undo service for Editor + */ +export default class UndoPlugin implements PluginWithState { + private editor: IEditor | null = null; + private lastKeyPress: number | null = null; + private state: UndoPluginState; + + /** + * Construct a new instance of UndoPlugin + * @param options The wrapper of the state object + */ + constructor(options: EditorOptions) { + this.state = { + snapshotsService: + options.undoMetadataSnapshotService || + createUndoSnapshotServiceBridge(options.undoSnapshotService) || + createUndoSnapshots(), + isRestoring: false, + hasNewContent: false, + isNested: false, + autoCompletePosition: null, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Undo'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor): void { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Check if the plugin should handle the given event exclusively. + * @param event The event to check + */ + willHandleEventExclusively(event: PluginEvent) { + return ( + event.eventType == PluginEventType.KeyDown && + event.rawEvent.which == Keys.BACKSPACE && + !event.rawEvent.ctrlKey && + this.canUndoAutoComplete() + ); + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent): void { + // if editor is in IME, don't do anything + if (!this.editor || this.editor.isInIME()) { + return; + } + + switch (event.eventType) { + case PluginEventType.EditorReady: + const undoState = this.editor.getUndoState(); + if (!undoState.canUndo && !undoState.canRedo) { + // Only add initial snapshot when there is no existing snapshot + // Otherwise preserved undo/redo state may be ruined + this.addUndoSnapshot(); + } + break; + case PluginEventType.KeyDown: + this.onKeyDown(event.rawEvent); + break; + case PluginEventType.KeyPress: + this.onKeyPress(event.rawEvent); + break; + case PluginEventType.CompositionEnd: + this.clearRedoForInput(); + this.addUndoSnapshot(); + break; + case PluginEventType.ContentChanged: + this.onContentChanged(event); + break; + case PluginEventType.BeforeKeyboardEditing: + this.onBeforeKeyboardEditing(event.rawEvent); + break; + } + } + + private onKeyDown(evt: KeyboardEvent): void { + // Handle backspace/delete when there is a selection to take a snapshot + // since we want the state prior to deletion restorable + // Ignore if keycombo is ALT+BACKSPACE + if ((evt.which == Keys.BACKSPACE && !evt.altKey) || evt.which == Keys.DELETE) { + if (evt.which == Keys.BACKSPACE && !evt.ctrlKey && this.canUndoAutoComplete()) { + evt.preventDefault(); + this.editor?.undo(); + this.state.autoCompletePosition = null; + this.lastKeyPress = evt.which; + } else if (!evt.defaultPrevented) { + const selectionRange = this.editor?.getSelectionRange(); + + // Add snapshot when + // 1. Something has been selected (not collapsed), or + // 2. It has a different key code from the last keyDown event (to prevent adding too many snapshot when keeping press the same key), or + // 3. Ctrl/Meta key is pressed so that a whole word will be deleted + if ( + selectionRange && + (!selectionRange.collapsed || + this.lastKeyPress != evt.which || + isCtrlOrMetaPressed(evt)) + ) { + this.addUndoSnapshot(); + } + + // Since some content is deleted, always set hasNewContent to true so that we will take undo snapshot next time + this.state.hasNewContent = true; + this.lastKeyPress = evt.which; + } + } else if (evt.which >= Keys.PAGEUP && evt.which <= Keys.DOWN) { + // PageUp, PageDown, Home, End, Left, Right, Up, Down + if (this.state.hasNewContent) { + this.addUndoSnapshot(); + } + this.lastKeyPress = 0; + } else if (this.lastKeyPress == Keys.BACKSPACE || this.lastKeyPress == Keys.DELETE) { + if (this.state.hasNewContent) { + this.addUndoSnapshot(); + } + } + } + + private onKeyPress(evt: KeyboardEvent): void { + if (evt.metaKey) { + // if metaKey is pressed, simply return since no actual effect will be taken on the editor. + // this is to prevent changing hasNewContent to true when meta + v to paste on Safari. + return; + } + + const range = this.editor?.getSelectionRange(); + if ( + (range && !range.collapsed) || + (evt.which == Keys.SPACE && this.lastKeyPress != Keys.SPACE) || + evt.which == Keys.ENTER + ) { + this.addUndoSnapshot(); + if (evt.which == Keys.ENTER) { + // Treat ENTER as new content so if there is no input after ENTER and undo, + // we restore the snapshot before ENTER + this.state.hasNewContent = true; + } + } else { + this.clearRedoForInput(); + } + + this.lastKeyPress = evt.which; + } + + private onBeforeKeyboardEditing(event: KeyboardEvent) { + // For keyboard event (triggered from Content Model), we can get its keycode from event.data + // And when user is keep pressing the same key, mark editor with "hasNewContent" so that next time user + // do some other action or press a different key, we will add undo snapshot + if (event.which != this.lastKeyPress) { + this.addUndoSnapshot(); + } + + this.lastKeyPress = event.which; + this.state.hasNewContent = true; + } + + private onContentChanged(event: ContentChangedEvent) { + if ( + !( + this.state.isRestoring || + event.source == ChangeSource.SwitchToDarkMode || + event.source == ChangeSource.SwitchToLightMode || + event.source == ChangeSource.Keyboard + ) + ) { + this.clearRedoForInput(); + } + } + + private clearRedoForInput() { + this.state.snapshotsService.clearRedo(); + this.lastKeyPress = 0; + this.state.hasNewContent = true; + } + + private canUndoAutoComplete() { + const focusedPosition = this.editor?.getFocusedPosition(); + return ( + this.state.snapshotsService.canUndoAutoComplete() && + !!focusedPosition && + !!this.state.autoCompletePosition?.equalTo(focusedPosition) + ); + } + + private addUndoSnapshot() { + this.editor?.addUndoSnapshot(); + this.state.autoCompletePosition = null; + } +} + +function createUndoSnapshots(): UndoSnapshotsService { + const snapshots = createSnapshots(MAX_SIZE_LIMIT); + + return { + canMove: (delta: number): boolean => canMoveCurrentSnapshot(snapshots, delta), + move: (delta: number): Snapshot | null => moveCurrentSnapshot(snapshots, delta), + addSnapshot: (snapshot: Snapshot, isAutoCompleteSnapshot: boolean) => + addSnapshotV2(snapshots, snapshot, isAutoCompleteSnapshot), + clearRedo: () => clearProceedingSnapshotsV2(snapshots), + canUndoAutoComplete: () => canUndoAutoComplete(snapshots), + }; +} + +function createUndoSnapshotServiceBridge( + service: UndoSnapshotsService | undefined +): UndoSnapshotsService | undefined { + let html: string | null; + return service + ? { + canMove: (delta: number) => service.canMove(delta), + move: (delta: number): Snapshot | null => + (html = service.move(delta)) ? { html, metadata: null, knownColors: [] } : null, + addSnapshot: (snapshot: Snapshot, isAutoCompleteSnapshot: boolean) => + service.addSnapshot( + snapshot.html + + (snapshot.metadata ? `` : ''), + isAutoCompleteSnapshot + ), + clearRedo: () => service.clearRedo(), + canUndoAutoComplete: () => service.canUndoAutoComplete(), + } + : undefined; +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/createCorePlugins.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/createCorePlugins.ts new file mode 100644 index 00000000000..f538a066bd6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/createCorePlugins.ts @@ -0,0 +1,66 @@ +import CopyPastePlugin from './CopyPastePlugin'; +import DOMEventPlugin from './DOMEventPlugin'; +import EditPlugin from './EditPlugin'; +import EntityPlugin from './EntityPlugin'; +import ImageSelection from './ImageSelection'; +import LifecyclePlugin from './LifecyclePlugin'; +import MouseUpPlugin from './MouseUpPlugin'; +import NormalizeTablePlugin from './NormalizeTablePlugin'; +import PendingFormatStatePlugin from './PendingFormatStatePlugin'; +import TypeInContainerPlugin from './TypeInContainerPlugin'; +import UndoPlugin from './UndoPlugin'; +import type { CorePlugins, EditorOptions, PluginState } from 'roosterjs-editor-types'; + +/** + * @internal + */ +export interface CreateCorePluginResponse extends CorePlugins { + _placeholder: null; +} + +/** + * @internal + * Create Core Plugins + * @param contentDiv Content DIV of editor + * @param options Editor options + */ +export default function createCorePlugins( + contentDiv: HTMLDivElement, + options: EditorOptions +): CreateCorePluginResponse { + const map = options.corePluginOverride || {}; + // The order matters, some plugin needs to be put before/after others to make sure event + // can be handled in right order + return { + typeInContainer: map.typeInContainer || new TypeInContainerPlugin(), + edit: map.edit || new EditPlugin(), + pendingFormatState: map.pendingFormatState || new PendingFormatStatePlugin(), + _placeholder: null, + typeAfterLink: null!, //deprecated after firefox update + undo: map.undo || new UndoPlugin(options), + domEvent: map.domEvent || new DOMEventPlugin(options, contentDiv), + mouseUp: map.mouseUp || new MouseUpPlugin(), + copyPaste: map.copyPaste || new CopyPastePlugin(options), + entity: map.entity || new EntityPlugin(), + imageSelection: map.imageSelection || new ImageSelection(), + normalizeTable: map.normalizeTable || new NormalizeTablePlugin(), + lifecycle: map.lifecycle || new LifecyclePlugin(options, contentDiv), + }; +} + +/** + * @internal + * Get plugin state of core plugins + * @param corePlugins CorePlugins object + */ +export function getPluginState(corePlugins: CorePlugins): PluginState { + return { + domEvent: corePlugins.domEvent.getState(), + pendingFormatState: corePlugins.pendingFormatState.getState(), + edit: corePlugins.edit.getState(), + lifecycle: corePlugins.lifecycle.getState(), + undo: corePlugins.undo.getState(), + entity: corePlugins.entity.getState(), + copyPaste: corePlugins.copyPaste.getState(), + }; +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/forEachSelectedCell.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/forEachSelectedCell.ts new file mode 100644 index 00000000000..edd97016f3c --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/forEachSelectedCell.ts @@ -0,0 +1,22 @@ +import type { VCell } from 'roosterjs-editor-types'; +import type { VTable } from 'roosterjs-editor-dom'; + +/** + * @internal + * Executes an action to all the cells within the selection range. + * @param callback action to apply on each selected cell + * @returns the amount of cells modified + */ +export const forEachSelectedCell = (vTable: VTable, callback: (cell: VCell) => void): void => { + if (vTable.selection) { + const { lastCell, firstCell } = vTable.selection; + + for (let y = firstCell.y; y <= lastCell.y; y++) { + for (let x = firstCell.x; x <= lastCell.x; x++) { + if (vTable.cells && vTable.cells[y][x]?.td) { + callback(vTable.cells[y][x]); + } + } + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts new file mode 100644 index 00000000000..e077b6cc5ce --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts @@ -0,0 +1,291 @@ +import { + addDelimiters, + arrayPush, + createRange, + getDelimiterFromElement, + getEntityFromElement, + getEntitySelector, + isBlockElement, + isCharacterValue, + matchesSelector, + Position, + safeInstanceOf, + splitTextNode, +} from 'roosterjs-editor-dom'; +import type { Entity, IEditor, PluginEvent, PluginKeyDownEvent } from 'roosterjs-editor-types'; +import { + ChangeSource, + DelimiterClasses, + Keys, + NodeType, + PluginEventType, + PositionType, + SelectionRangeTypes, +} from 'roosterjs-editor-types'; + +const DELIMITER_SELECTOR = + '.' + DelimiterClasses.DELIMITER_AFTER + ',.' + DelimiterClasses.DELIMITER_BEFORE; +const ZERO_WIDTH_SPACE = '\u200B'; +const INLINE_ENTITY_SELECTOR = 'span' + getEntitySelector(); + +/** + * @internal + */ +export function inlineEntityOnPluginEvent(event: PluginEvent, editor: IEditor) { + switch (event.eventType) { + case PluginEventType.ContentChanged: + if (event.source === ChangeSource.SetContent) { + normalizeDelimitersInEditor(editor); + } + break; + case PluginEventType.EditorReady: + normalizeDelimitersInEditor(editor); + break; + + case PluginEventType.BeforePaste: + const { fragment, sanitizingOption } = event; + addDelimitersIfNeeded(fragment.querySelectorAll(INLINE_ENTITY_SELECTOR)); + + if (sanitizingOption.additionalAllowedCssClasses) { + arrayPush(sanitizingOption.additionalAllowedCssClasses, [ + DelimiterClasses.DELIMITER_AFTER, + DelimiterClasses.DELIMITER_BEFORE, + ]); + } + break; + + case PluginEventType.ExtractContentWithDom: + case PluginEventType.BeforeCutCopy: + event.clonedRoot.querySelectorAll(DELIMITER_SELECTOR).forEach(node => { + if (getDelimiterFromElement(node)) { + removeNode(node); + } else { + removeDelimiterAttr(node); + } + }); + break; + + case PluginEventType.KeyDown: + handleKeyDownEvent(editor, event); + break; + } +} + +function preventTypeInDelimiter(delimiter: HTMLElement) { + delimiter.normalize(); + const textNode = delimiter.firstChild as Node; + const index = textNode.nodeValue?.indexOf(ZERO_WIDTH_SPACE) ?? -1; + if (index >= 0) { + splitTextNode(textNode, index == 0 ? 1 : index, false /* returnFirstPart */); + let nodeToMove: Node | undefined; + delimiter.childNodes.forEach(node => { + if (node.nodeValue !== ZERO_WIDTH_SPACE) { + nodeToMove = node; + } + }); + if (nodeToMove) { + delimiter.parentElement?.insertBefore( + nodeToMove, + delimiter.className == DelimiterClasses.DELIMITER_BEFORE + ? delimiter + : delimiter.nextSibling + ); + const selection = nodeToMove.ownerDocument?.getSelection(); + + if (selection) { + selection.setPosition( + nodeToMove, + new Position(nodeToMove, PositionType.End).offset + ); + } + } + } +} + +/** + * @internal + */ +export function normalizeDelimitersInEditor(editor: IEditor) { + removeInvalidDelimiters(editor.queryElements(DELIMITER_SELECTOR)); + addDelimitersIfNeeded(editor.queryElements(INLINE_ENTITY_SELECTOR)); +} + +function addDelimitersIfNeeded(nodes: Element[] | NodeListOf) { + nodes.forEach(node => { + if (isEntityElement(node)) { + addDelimiters(node); + } + }); +} + +function isEntityElement(node: Node | null): node is HTMLElement { + return !!( + node && + safeInstanceOf(node, 'HTMLElement') && + isReadOnly(getEntityFromElement(node)) + ); +} + +function removeNode(el: Node | undefined | null) { + el?.parentElement?.removeChild(el); +} + +function isReadOnly(entity: Entity | null) { + return ( + entity?.isReadonly && + !isBlockElement(entity.wrapper) && + safeInstanceOf(entity.wrapper, 'HTMLElement') + ); +} + +function removeInvalidDelimiters(nodes: Element[] | NodeListOf) { + nodes.forEach(node => { + if (getDelimiterFromElement(node)) { + const sibling = node.classList.contains(DelimiterClasses.DELIMITER_BEFORE) + ? node.nextElementSibling + : node.previousElementSibling; + if (!(safeInstanceOf(sibling, 'HTMLElement') && getEntityFromElement(sibling))) { + removeNode(node); + } + } else { + removeDelimiterAttr(node); + } + }); +} + +function removeDelimiterAttr(node: Element | undefined | null, checkEntity: boolean = true) { + if (!node) { + return; + } + + const isAfter = node.classList.contains(DelimiterClasses.DELIMITER_AFTER); + const entitySibling = isAfter ? node.previousElementSibling : node.nextElementSibling; + if (checkEntity && entitySibling && isEntityElement(entitySibling)) { + return; + } + + node.classList.remove(DelimiterClasses.DELIMITER_AFTER, DelimiterClasses.DELIMITER_BEFORE); + + node.normalize(); + node.childNodes.forEach(cn => { + const index = cn.textContent?.indexOf(ZERO_WIDTH_SPACE) ?? -1; + if (index >= 0) { + createRange(cn, index, cn, index + 1)?.deleteContents(); + } + }); +} + +function handleCollapsedEnter(editor: IEditor, delimiter: HTMLElement) { + const isAfter = delimiter.classList.contains(DelimiterClasses.DELIMITER_AFTER); + const entity = !isAfter ? delimiter.nextSibling : delimiter.previousSibling; + const block = getBlock(editor, delimiter); + + editor.runAsync(() => { + if (!block) { + return; + } + const blockToCheck = isAfter ? block.nextSibling : block.previousSibling; + if (blockToCheck && safeInstanceOf(blockToCheck, 'HTMLElement')) { + const delimiters = blockToCheck.querySelectorAll(DELIMITER_SELECTOR); + // Check if the last or first delimiter still contain the delimiter class and remove it. + const delimiterToCheck = delimiters.item(isAfter ? 0 : delimiters.length - 1); + removeDelimiterAttr(delimiterToCheck); + } + + if (isEntityElement(entity)) { + const { nextElementSibling, previousElementSibling } = entity; + [nextElementSibling, previousElementSibling].forEach(el => { + // Check if after Enter the ZWS got removed but we still have a element with the class + // Remove the attributes of the element if it is invalid now. + if (el && matchesSelector(el, DELIMITER_SELECTOR) && !getDelimiterFromElement(el)) { + removeDelimiterAttr(el, false /* checkEntity */); + } + }); + // Add delimiters to the entity if needed because on Enter we can sometimes lose the ZWS of the element. + addDelimiters(entity); + } + }); +} + +const getPosition = (container: HTMLElement | null) => { + if (container && getDelimiterFromElement(container)) { + const isAfter = container.classList.contains(DelimiterClasses.DELIMITER_AFTER); + return new Position(container, isAfter ? PositionType.After : PositionType.Before); + } + return undefined; +}; + +function getBlock(editor: IEditor, element: Node | undefined) { + if (!element) { + return undefined; + } + + let block = editor.getBlockElementAtNode(element)?.getStartNode(); + + while (block && !isBlockElement(block)) { + block = editor.contains(block.parentElement) ? block.parentElement! : undefined; + } + + return block; +} + +function handleSelectionNotCollapsed(editor: IEditor, range: Range, event: KeyboardEvent) { + const { startContainer, endContainer, startOffset, endOffset } = range; + + const startElement = editor.getElementAtCursor(DELIMITER_SELECTOR, startContainer); + const endElement = editor.getElementAtCursor(DELIMITER_SELECTOR, endContainer); + + const startUpdate = getPosition(startElement); + const endUpdate = getPosition(endElement); + + if (startUpdate || endUpdate) { + editor.select( + startUpdate ?? new Position(startContainer, startOffset), + endUpdate ?? new Position(endContainer, endOffset) + ); + } + editor.runAsync(aEditor => { + const delimiter = aEditor.getElementAtCursor(DELIMITER_SELECTOR); + if (delimiter) { + preventTypeInDelimiter(delimiter); + if (event.which === Keys.ENTER) { + removeDelimiterAttr(delimiter); + } + } + }); +} + +function handleKeyDownEvent(editor: IEditor, event: PluginKeyDownEvent) { + const range = editor.getSelectionRangeEx(); + const { rawEvent } = event; + if (range.type != SelectionRangeTypes.Normal) { + return; + } + + if (range.areAllCollapsed && (isCharacterValue(rawEvent) || rawEvent.which === Keys.ENTER)) { + const position = editor.getFocusedPosition()?.normalize(); + if (!position) { + return; + } + + const { element, node } = position; + const refNode = element == node ? element.childNodes.item(position.offset) : element; + + const delimiter = editor.getElementAtCursor(DELIMITER_SELECTOR, refNode); + if (!delimiter) { + return; + } + + if (rawEvent.which === Keys.ENTER) { + handleCollapsedEnter(editor, delimiter); + } else if (delimiter.firstChild?.nodeType == NodeType.Text) { + editor.runAsync(() => preventTypeInDelimiter(delimiter)); + } + } else if (!range.areAllCollapsed && !rawEvent.shiftKey && rawEvent.which != Keys.SHIFT) { + const currentRange = range.ranges[0]; + if (!currentRange) { + return; + } + handleSelectionNotCollapsed(editor, currentRange, rawEvent); + } +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/removeCellsOutsideSelection.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/removeCellsOutsideSelection.ts new file mode 100644 index 00000000000..e2c75d2f55b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/removeCellsOutsideSelection.ts @@ -0,0 +1,37 @@ +import { isWholeTableSelected } from 'roosterjs-editor-dom'; +import type { VTable } from 'roosterjs-editor-dom'; +import type { VCell } from 'roosterjs-editor-types'; + +/** + * @internal + * Remove the cells outside of the selection. + * @param vTable VTable to remove selection + */ +export const removeCellsOutsideSelection = (vTable: VTable) => { + if (vTable.selection) { + if (isWholeTableSelected(vTable, vTable.selection)) { + return; + } + + vTable.table.style.removeProperty('width'); + vTable.table.style.removeProperty('height'); + + const { firstCell, lastCell } = vTable.selection; + const resultCells: VCell[][] = []; + + const firstX = firstCell.x; + const firstY = firstCell.y; + const lastX = lastCell.x; + const lastY = lastCell.y; + + if (vTable.cells) { + vTable.cells.forEach((row, y) => { + row = row.filter((_, x) => y >= firstY && y <= lastY && x >= firstX && x <= lastX); + if (row.length > 0) { + resultCells.push(row); + } + }); + vTable.cells = resultCells; + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/editor/AdapterEditor.ts b/packages-content-model/roosterjs-content-model-adapter/lib/editor/AdapterEditor.ts new file mode 100644 index 00000000000..7bd1ee208a5 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/editor/AdapterEditor.ts @@ -0,0 +1,1035 @@ +import { createEditorCore } from './createEditorCore'; +import { isFeatureEnabled } from './isFeatureEnabled'; +import { + ChangeSource, + ColorTransformDirection, + ContentPosition, + GetContentMode, + PluginEventType, + PositionType, + QueryScope, + RegionType, +} from 'roosterjs-editor-types'; +import type { + BlockElement, + ClipboardData, + ContentChangedData, + DarkColorHandler, + DefaultFormat, + DOMEventHandler, + EditorCore, + EditorOptions, + EditorUndoState, + ExperimentalFeatures, + GenericContentEditFeature, + IContentTraverser, + IEditor, + InsertOption, + IPositionContentSearcher, + NodePosition, + PendableFormatState, + PluginEvent, + PluginEventData, + PluginEventFromType, + Rect, + Region, + SelectionPath, + SelectionRangeEx, + SizeTransformer, + StyleBasedFormatState, + TableSelection, + TrustedHTMLHandler, +} from 'roosterjs-editor-types'; +import { + cacheGetEventData, + collapseNodes, + contains, + ContentTraverser, + deleteSelectedContent, + getRegionsFromRange, + findClosestElementAncestor, + getBlockElementAtNode, + getSelectionPath, + getTagOfNode, + isNodeEmpty, + Position, + PositionContentSearcher, + queryElements, + wrap, + isPositionAtBeginningOf, + toArray, +} from 'roosterjs-editor-dom'; +import type { + CompatibleChangeSource, + CompatibleColorTransformDirection, + CompatibleContentPosition, + CompatibleExperimentalFeatures, + CompatibleGetContentMode, + CompatiblePluginEventType, + CompatibleQueryScope, + CompatibleRegionType, +} from 'roosterjs-editor-types/lib/compatibleTypes'; + +/** + * RoosterJs adapter editor that supports Content Model and can be used by legacy roosterjs plugin + * (This class is still under development, temporarily do internal export for now.) + */ +export class AdapterEditor implements IEditor { + private core: EditorCore | null = null; + + /** + * Creates an instance of EditorBase + * @param contentDiv The DIV HTML element which will be the container element of editor + * @param options An optional options object to customize the editor + */ + constructor(contentDiv: HTMLDivElement, options: EditorOptions) { + // 1. Make sure all parameters are valid + if (getTagOfNode(contentDiv) != 'DIV') { + throw new Error('contentDiv must be an HTML DIV element'); + } + + // 2. Create editor core + this.core = createEditorCore(contentDiv, options); + + // 3. Initialize plugins + this.core.plugins.forEach(plugin => plugin.initialize(this)); + + // 4. Ensure user will type in a container node, not the editor content DIV + this.ensureTypeInContainer( + new Position(this.core.contentDiv, PositionType.Begin).normalize() + ); + } + + /** + * Dispose this editor, dispose all plugins and custom data + */ + public dispose(): void { + const core = this.getCore(); + + for (let i = core.plugins.length - 1; i >= 0; i--) { + const plugin = core.plugins[i]; + + try { + plugin.dispose(); + } catch (e) { + // Cache the error and pass it out, then keep going since dispose should always succeed + core.disposeErrorHandler?.(plugin, e as Error); + } + } + + core.darkColorHandler.reset(); + + this.core = null; + } + + /** + * Get whether this editor is disposed + * @returns True if editor is disposed, otherwise false + */ + public isDisposed(): boolean { + return !this.core; + } + + //#endregion + + //#region Node API + + /** + * Insert node into editor + * @param node The node to insert + * @param option Insert options. Default value is: + * position: ContentPosition.SelectionStart + * updateCursor: true + * replaceSelection: true + * insertOnNewLine: false + * @returns true if node is inserted. Otherwise false + */ + public insertNode(node: Node, option?: InsertOption): boolean { + const core = this.getCore(); + return node ? core.api.insertNode(core, node, option ?? null) : false; + } + + /** + * Delete a node from editor content + * @param node The node to delete + * @returns true if node is deleted. Otherwise false + */ + public deleteNode(node: Node): boolean { + // Only remove the node when it falls within editor + if (node && this.contains(node) && node.parentNode) { + node.parentNode.removeChild(node); + return true; + } + + return false; + } + + /** + * Replace a node in editor content with another node + * @param existingNode The existing node to be replaced + * @param toNode node to replace to + * @param transformColorForDarkMode (optional) Whether to transform new node to dark mode. Default is false + * @returns true if node is replaced. Otherwise false + */ + public replaceNode( + existingNode: Node, + toNode: Node, + transformColorForDarkMode?: boolean + ): boolean { + const core = this.getCore(); + // Only replace the node when it falls within editor + if (this.contains(existingNode) && toNode) { + core.api.transformColor( + core, + transformColorForDarkMode ? toNode : null, + true /*includeSelf*/, + () => existingNode.parentNode?.replaceChild(toNode, existingNode), + ColorTransformDirection.LightToDark + ); + + return true; + } + + return false; + } + + /** + * Get BlockElement at given node + * @param node The node to create InlineElement + * @returns The BlockElement result + */ + public getBlockElementAtNode(node: Node): BlockElement | null { + return getBlockElementAtNode(this.getCore().contentDiv, node); + } + + public contains(arg: Node | Range | null): boolean { + if (!arg) { + return false; + } + return contains(this.getCore().contentDiv, arg); + } + + public queryElements( + selector: string, + scopeOrCallback: + | QueryScope + | CompatibleQueryScope + | ((node: Node) => any) = QueryScope.Body, + callback?: (node: Node) => any + ) { + const core = this.getCore(); + const result: HTMLElement[] = []; + const scope = scopeOrCallback instanceof Function ? QueryScope.Body : scopeOrCallback; + callback = scopeOrCallback instanceof Function ? scopeOrCallback : callback; + + const selectionEx = scope == QueryScope.Body ? null : this.getSelectionRangeEx(); + if (selectionEx) { + selectionEx.ranges.forEach(range => { + result.push(...queryElements(core.contentDiv, selector, callback, scope, range)); + }); + } else { + return queryElements(core.contentDiv, selector, callback, scope, undefined /* range */); + } + + return result; + } + + /** + * Collapse nodes within the given start and end nodes to their common ancestor node, + * split parent nodes if necessary + * @param start The start node + * @param end The end node + * @param canSplitParent True to allow split parent node there are nodes before start or after end under the same parent + * and the returned nodes will be all nodes from start through end after splitting + * False to disallow split parent + * @returns When canSplitParent is true, returns all node from start through end after splitting, + * otherwise just return start and end + */ + public collapseNodes(start: Node, end: Node, canSplitParent: boolean): Node[] { + return collapseNodes(this.getCore().contentDiv, start, end, canSplitParent); + } + + //#endregion + + //#region Content API + + /** + * Check whether the editor contains any visible content + * @param trim Whether trim the content string before check. Default is false + * @returns True if there's no visible content, otherwise false + */ + public isEmpty(trim?: boolean): boolean { + return isNodeEmpty(this.getCore().contentDiv, trim); + } + + /** + * Get current editor content as HTML string + * @param mode specify what kind of HTML content to retrieve + * @returns HTML string representing current editor content + */ + public getContent( + mode: GetContentMode | CompatibleGetContentMode = GetContentMode.CleanHTML + ): string { + const core = this.getCore(); + return core.api.getContent(core, mode); + } + + /** + * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered + * @param content HTML content to set in + * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true + */ + public setContent(content: string, triggerContentChangedEvent: boolean = true) { + const core = this.getCore(); + core.api.setContent(core, content, triggerContentChangedEvent); + } + + /** + * Insert HTML content into editor + * @param HTML content to insert + * @param option Insert options. Default value is: + * position: ContentPosition.SelectionStart + * updateCursor: true + * replaceSelection: true + * insertOnNewLine: false + */ + public insertContent(content: string, option?: InsertOption) { + if (content) { + const doc = this.getDocument(); + const body = new DOMParser().parseFromString( + this.getCore().trustedHTMLHandler(content), + 'text/html' + )?.body; + let allNodes = body?.childNodes ? toArray(body.childNodes) : []; + + // If it is to insert on new line, and there are more than one node in the collection, wrap all nodes with + // a parent DIV before calling insertNode on each top level sub node. Otherwise, every sub node may get wrapped + // separately to show up on its own line + if (option && option.insertOnNewLine && allNodes.length > 1) { + allNodes = [wrap(allNodes)]; + } + + const fragment = doc.createDocumentFragment(); + allNodes.forEach(node => fragment.appendChild(node)); + + this.insertNode(fragment, option); + } + } + + /** + * Delete selected content + */ + public deleteSelectedContent(): NodePosition | null { + const range = this.getSelectionRange(); + if (range && !range.collapsed) { + return deleteSelectedContent(this.getCore().contentDiv, range); + } + return null; + } + + /** + * Paste into editor using a clipboardData object + * @param clipboardData Clipboard data retrieved from clipboard + * @param pasteAsText Force pasting as plain text. Default value is false + * @param applyCurrentStyle True if apply format of current selection to the pasted content, + * false to keep original format. Default value is false. When pasteAsText is true, this parameter is ignored + * @param pasteAsImage: When set to true, if the clipboardData contains a imageDataUri will paste the image to the editor + */ + public paste( + clipboardData: ClipboardData, + pasteAsText: boolean = false, + applyCurrentFormat: boolean = false, + pasteAsImage: boolean = false + ) { + const core = this.getCore(); + if (!clipboardData) { + return; + } + + if (clipboardData.snapshotBeforePaste) { + // Restore original content before paste a new one + this.setContent(clipboardData.snapshotBeforePaste); + } else { + clipboardData.snapshotBeforePaste = this.getContent( + GetContentMode.RawHTMLWithSelection + ); + } + + const range = this.getSelectionRange(); + const pos = range && Position.getStart(range); + const fragment = core.api.createPasteFragment( + core, + clipboardData, + pos, + pasteAsText, + applyCurrentFormat, + pasteAsImage + ); + if (fragment) { + this.addUndoSnapshot(() => { + this.insertNode(fragment); + return clipboardData; + }, ChangeSource.Paste); + } + } + + //#endregion + + //#region Focus and Selection + + /** + * Get current selection range from Editor. + * It does a live pull on the selection, if nothing retrieved, return whatever we have in cache. + * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now. + * Default value is true + * @returns current selection range, or null if editor never got focus before + */ + public getSelectionRange(tryGetFromCache: boolean = true): Range | null { + const core = this.getCore(); + return core.api.getSelectionRange(core, tryGetFromCache); + } + + /** + * Get current selection range from Editor. + * It does a live pull on the selection, if nothing retrieved, return whatever we have in cache. + * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now. + * Default value is true + * @returns current selection range, or null if editor never got focus before + */ + public getSelectionRangeEx(): SelectionRangeEx { + const core = this.getCore(); + return core.api.getSelectionRangeEx(core); + } + + /** + * Get current selection in a serializable format + * It does a live pull on the selection, if nothing retrieved, return whatever we have in cache. + * @returns current selection path, or null if editor never got focus before + */ + public getSelectionPath(): SelectionPath | null { + const range = this.getSelectionRange(); + return range && getSelectionPath(this.getCore().contentDiv, range); + } + + /** + * Check if focus is in editor now + * @returns true if focus is in editor, otherwise false + */ + public hasFocus(): boolean { + const core = this.getCore(); + return core.api.hasFocus(core); + } + + /** + * Focus to this editor, the selection was restored to where it was before, no unexpected scroll. + */ + public focus() { + const core = this.getCore(); + core.api.focus(core); + } + + public select( + arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, + arg2?: NodePosition | number | PositionType | TableSelection | null, + arg3?: Node, + arg4?: number | PositionType + ): boolean { + const core = this.getCore(); + + return core.api.select(core, arg1, arg2, arg3, arg4); + } + + /** + * Get current focused position. Return null if editor doesn't have focus at this time. + */ + public getFocusedPosition(): NodePosition | null { + const sel = this.getDocument().defaultView?.getSelection(); + if (sel?.focusNode && this.contains(sel.focusNode)) { + return new Position(sel.focusNode, sel.focusOffset); + } + + const range = this.getSelectionRange(); + if (range) { + return Position.getStart(range); + } + + return null; + } + + /** + * Get an HTML element from current cursor position. + * When expectedTags is not specified, return value is the current node (if it is HTML element) + * or its parent node (if current node is a Text node). + * When expectedTags is specified, return value is the first ancestor of current node which has + * one of the expected tags. + * If no element found within editor by the given tag, return null. + * @param selector Optional, an HTML selector to find HTML element with. + * @param startFrom Start search from this node. If not specified, start from current focused position + * @param event Optional, if specified, editor will try to get cached result from the event object first. + * If it is not cached before, query from DOM and cache the result into the event object + */ + public getElementAtCursor( + selector?: string, + startFrom?: Node, + event?: PluginEvent + ): HTMLElement | null { + event = startFrom ? undefined : event; // Only use cache when startFrom is not specified, for different start position can have different result + + return ( + cacheGetEventData(event ?? null, 'GET_ELEMENT_AT_CURSOR_' + selector, () => { + if (!startFrom) { + const position = this.getFocusedPosition(); + startFrom = position?.node; + } + return ( + startFrom && + findClosestElementAncestor(startFrom, this.getCore().contentDiv, selector) + ); + }) ?? null + ); + } + + /** + * Check if this position is at beginning of the editor. + * This will return true if all nodes between the beginning of target node and the position are empty. + * @param position The position to check + * @returns True if position is at beginning of the editor, otherwise false + */ + public isPositionAtBeginning(position: NodePosition): boolean { + return isPositionAtBeginningOf(position, this.getCore().contentDiv); + } + + /** + * Get impacted regions from selection + */ + public getSelectedRegions( + type: RegionType | CompatibleRegionType = RegionType.Table + ): Region[] { + const selection = this.getSelectionRangeEx(); + const result: Region[] = []; + const contentDiv = this.getCore().contentDiv; + selection.ranges.forEach(range => { + result.push(...(range ? getRegionsFromRange(contentDiv, range, type) : [])); + }); + return result.filter((value, index, self) => { + return self.indexOf(value) === index; + }); + } + + //#endregion + + //#region EVENT API + + public addDomEventHandler( + nameOrMap: string | Record, + handler?: DOMEventHandler + ): () => void { + const eventsToMap = typeof nameOrMap == 'string' ? { [nameOrMap]: handler! } : nameOrMap; + const core = this.getCore(); + return core.api.attachDomEvent(core, eventsToMap); + } + + /** + * Trigger an event to be dispatched to all plugins + * @param eventType Type of the event + * @param data data of the event with given type, this is the rest part of PluginEvent with the given type + * @param broadcast indicates if the event needs to be dispatched to all plugins + * True means to all, false means to allow exclusive handling from one plugin unless no one wants that + * @returns the event object which is really passed into plugins. Some plugin may modify the event object so + * the result of this function provides a chance to read the modified result + */ + public triggerPluginEvent( + eventType: T, + data: PluginEventData, + broadcast: boolean = false + ): PluginEventFromType { + const core = this.getCore(); + const event = ({ + eventType, + ...data, + } as any) as PluginEventFromType; + core.api.triggerEvent(core, event, broadcast); + + return event; + } + + /** + * Trigger a ContentChangedEvent + * @param source Source of this event, by default is 'SetContent' + * @param data additional data for this event + */ + public triggerContentChangedEvent( + source: ChangeSource | CompatibleChangeSource | string = ChangeSource.SetContent, + data?: any + ) { + this.triggerPluginEvent(PluginEventType.ContentChanged, { + source, + data, + }); + } + + //#endregion + + //#region Undo API + + /** + * Undo last edit operation + */ + public undo() { + this.focus(); + const core = this.getCore(); + core.api.restoreUndoSnapshot(core, -1 /*step*/); + } + + /** + * Redo next edit operation + */ + public redo() { + this.focus(); + const core = this.getCore(); + core.api.restoreUndoSnapshot(core, 1 /*step*/); + } + + /** + * Add undo snapshot, and execute a format callback function, then add another undo snapshot, then trigger + * ContentChangedEvent with given change source. + * If this function is called nested, undo snapshot will only be added in the outside one + * @param callback The callback function to perform formatting, returns a data object which will be used as + * the data field in ContentChangedEvent if changeSource is not null. + * @param changeSource The change source to use when fire ContentChangedEvent. When the value is not null, + * a ContentChangedEvent will be fired with change source equal to this value + * @param canUndoByBackspace True if this action can be undone when user press Backspace key (aka Auto Complete). + */ + public addUndoSnapshot( + callback?: (start: NodePosition | null, end: NodePosition | null) => any, + changeSource?: ChangeSource | CompatibleChangeSource | string, + canUndoByBackspace?: boolean, + additionalData?: ContentChangedData + ) { + const core = this.getCore(); + core.api.addUndoSnapshot( + core, + callback ?? null, + changeSource ?? null, + canUndoByBackspace ?? false, + additionalData + ); + } + + /** + * Whether there is an available undo/redo snapshot + */ + public getUndoState(): EditorUndoState { + const { hasNewContent, snapshotsService } = this.getCore().undo; + return { + canUndo: hasNewContent || snapshotsService.canMove(-1 /*previousSnapshot*/), + canRedo: snapshotsService.canMove(1 /*nextSnapshot*/), + }; + } + + //#endregion + + //#region Misc + + /** + * Get document which contains this editor + * @returns The HTML document which contains this editor + */ + public getDocument(): Document { + return this.getCore().contentDiv.ownerDocument; + } + + /** + * Get the scroll container of the editor + */ + public getScrollContainer(): HTMLElement { + return this.getCore().domEvent.scrollContainer; + } + + /** + * Get custom data related to this editor + * @param key Key of the custom data + * @param getter Getter function. If custom data for the given key doesn't exist, + * call this function to get one and store it if it is specified. Otherwise return undefined + * @param disposer An optional disposer function to dispose this custom data when + * dispose editor. + */ + public getCustomData(key: string, getter?: () => T, disposer?: (value: T) => void): T { + const core = this.getCore(); + return (core.lifecycle.customData[key] = core.lifecycle.customData[key] || { + value: getter ? getter() : undefined, + disposer, + }).value as T; + } + + /** + * Check if editor is in IME input sequence + * @returns True if editor is in IME input sequence, otherwise false + */ + public isInIME(): boolean { + return this.getCore().domEvent.isInIME; + } + + /** + * Get default format of this editor + * @returns Default format object of this editor + */ + public getDefaultFormat(): DefaultFormat { + return this.getCore().lifecycle.defaultFormat ?? {}; + } + + /** + * Get a content traverser for the whole editor + * @param startNode The node to start from. If not passed, it will start from the beginning of the body + */ + public getBodyTraverser(startNode?: Node): IContentTraverser { + return ContentTraverser.createBodyTraverser(this.getCore().contentDiv, startNode); + } + + /** + * Get a content traverser for current selection + * @returns A content traverser, or null if editor never got focus before + */ + public getSelectionTraverser(range?: Range): IContentTraverser | null { + range = range ?? this.getSelectionRange() ?? undefined; + return range + ? ContentTraverser.createSelectionTraverser(this.getCore().contentDiv, range) + : null; + } + + /** + * Get a content traverser for current block element start from specified position + * @param startFrom Start position of the traverser. Default value is ContentPosition.SelectionStart + * @returns A content traverser, or null if editor never got focus before + */ + public getBlockTraverser( + startFrom: ContentPosition | CompatibleContentPosition = ContentPosition.SelectionStart + ): IContentTraverser | null { + const range = this.getSelectionRange(); + return range + ? ContentTraverser.createBlockTraverser(this.getCore().contentDiv, range, startFrom) + : null; + } + + /** + * Get a text traverser of current selection + * @param event Optional, if specified, editor will try to get cached result from the event object first. + * If it is not cached before, query from DOM and cache the result into the event object + * @returns A content traverser, or null if editor never got focus before + */ + public getContentSearcherOfCursor(event?: PluginEvent): IPositionContentSearcher | null { + return cacheGetEventData(event ?? null, 'ContentSearcher', () => { + const range = this.getSelectionRange(); + return ( + range && + new PositionContentSearcher(this.getCore().contentDiv, Position.getStart(range)) + ); + }); + } + + /** + * Run a callback function asynchronously + * @param callback The callback function to run + * @returns a function to cancel this async run + */ + public runAsync(callback: (editor: IEditor) => void) { + const win = this.getCore().contentDiv.ownerDocument.defaultView || window; + const handle = win.requestAnimationFrame(() => { + if (!this.isDisposed() && callback) { + callback(this); + } + }); + + return () => { + win.cancelAnimationFrame(handle); + }; + } + + /** + * Set DOM attribute of editor content DIV + * @param name Name of the attribute + * @param value Value of the attribute + */ + public setEditorDomAttribute(name: string, value: string | null) { + if (value === null) { + this.getCore().contentDiv.removeAttribute(name); + } else { + this.getCore().contentDiv.setAttribute(name, value); + } + } + + /** + * Get DOM attribute of editor content DIV, null if there is no such attribute. + * @param name Name of the attribute + */ + public getEditorDomAttribute(name: string): string | null { + return this.getCore().contentDiv.getAttribute(name); + } + + /** + * @deprecated Use getVisibleViewport() instead. + * + * Get current relative distance from top-left corner of the given element to top-left corner of editor content DIV. + * @param element The element to calculate from. If the given element is not in editor, return value will be null + * @param addScroll When pass true, The return value will also add scrollLeft and scrollTop if any. So the value + * may be different than what user is seeing from the view. When pass false, scroll position will be ignored. + * @returns An [x, y] array which contains the left and top distances, or null if the given element is not in editor. + */ + getRelativeDistanceToEditor(element: HTMLElement, addScroll?: boolean): number[] | null { + if (this.contains(element)) { + const contentDiv = this.getCore().contentDiv; + const editorRect = contentDiv.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + if (editorRect && elementRect) { + let x = elementRect.left - editorRect?.left; + let y = elementRect.top - editorRect?.top; + + if (addScroll) { + x += contentDiv.scrollLeft; + y += contentDiv.scrollTop; + } + + return [x, y]; + } + } + + return null; + } + + /** + * Add a Content Edit feature. + * @param feature The feature to add + */ + public addContentEditFeature(feature: GenericContentEditFeature) { + const core = this.getCore(); + feature?.keys.forEach(key => { + const array = core.edit.features[key] || []; + array.push(feature); + core.edit.features[key] = array; + }); + } + + /** + * Remove a Content Edit feature. + * @param feature The feature to remove + */ + public removeContentEditFeature(feature: GenericContentEditFeature) { + const core = this.getCore(); + feature?.keys.forEach(key => { + const featureSet = core.edit.features[key]; + const index = featureSet?.indexOf(feature) ?? -1; + if (index >= 0) { + core.edit.features[key].splice(index, 1); + if (core.edit.features[key].length < 1) { + delete core.edit.features[key]; + } + } + }); + } + + /** + * Get style based format state from current selection, including font name/size and colors + */ + public getStyleBasedFormatState(node?: Node): StyleBasedFormatState { + if (!node) { + const range = this.getSelectionRange(); + node = (range && Position.getStart(range).normalize().node) ?? undefined; + } + const core = this.getCore(); + return core.api.getStyleBasedFormatState(core, node ?? null); + } + + /** + * Get the pendable format such as underline and bold + * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. + * @returns The pending format state + */ + public getPendableFormatState(forceGetStateFromDOM: boolean = false): PendableFormatState { + const core = this.getCore(); + return core.api.getPendableFormatState(core, forceGetStateFromDOM); + } + + /** + * Ensure user will type into a container element rather than into the editor content DIV directly + * @param position The position that user is about to type to + * @param keyboardEvent Optional keyboard event object + */ + public ensureTypeInContainer(position: NodePosition, keyboardEvent?: KeyboardEvent) { + const core = this.getCore(); + core.api.ensureTypeInContainer(core, position, keyboardEvent); + } + + //#endregion + + //#region Dark mode APIs + + /** + * Set the dark mode state and transforms the content to match the new state. + * @param nextDarkMode The next status of dark mode. True if the editor should be in dark mode, false if not. + */ + public setDarkModeState(nextDarkMode?: boolean) { + const isDarkMode = this.isDarkMode(); + + if (isDarkMode == !!nextDarkMode) { + return; + } + const core = this.getCore(); + + core.api.transformColor( + core, + core.contentDiv, + false /*includeSelf*/, + null /*callback*/, + nextDarkMode + ? ColorTransformDirection.LightToDark + : ColorTransformDirection.DarkToLight, + true /*forceTransform*/, + isDarkMode + ); + + this.triggerContentChangedEvent( + nextDarkMode ? ChangeSource.SwitchToDarkMode : ChangeSource.SwitchToLightMode + ); + } + + /** + * Check if the editor is in dark mode + * @returns True if the editor is in dark mode, otherwise false + */ + public isDarkMode(): boolean { + return this.getCore().lifecycle.isDarkMode; + } + + /** + * Transform the given node and all its child nodes to dark mode color if editor is in dark mode + * @param node The node to transform + * @param direction The transform direction. @default ColorTransformDirection.LightToDark + */ + public transformToDarkColor( + node: Node, + direction: + | ColorTransformDirection + | CompatibleColorTransformDirection = ColorTransformDirection.LightToDark + ) { + const core = this.getCore(); + core.api.transformColor(core, node, true /*includeSelf*/, null /*callback*/, direction); + } + + /** + * Get a darkColorHandler object for this editor. + */ + public getDarkColorHandler(): DarkColorHandler { + return this.getCore().darkColorHandler; + } + + /** + * Make the editor in "Shadow Edit" mode. + * In Shadow Edit mode, all format change will finally be ignored. + * This can be used for building a live preview feature for format button, to allow user + * see format result without really apply it. + * This function can be called repeated. If editor is already in shadow edit mode, we can still + * use this function to do more shadow edit operation. + */ + public startShadowEdit() { + const core = this.getCore(); + core.api.switchShadowEdit(core, true /*isOn*/); + } + + /** + * Leave "Shadow Edit" mode, all changes made during shadow edit will be discarded + */ + public stopShadowEdit() { + const core = this.getCore(); + core.api.switchShadowEdit(core, false /*isOn*/); + } + + /** + * Check if editor is in Shadow Edit mode + */ + public isInShadowEdit() { + return !!this.getCore().lifecycle.shadowEditFragment; + } + + /** + * Check if the given experimental feature is enabled + * @param feature The feature to check + */ + public isFeatureEnabled( + feature: ExperimentalFeatures | CompatibleExperimentalFeatures + ): boolean { + return isFeatureEnabled(this.getCore().lifecycle.experimentalFeatures, feature); + } + + /** + * Get a function to convert HTML string to trusted HTML string. + * By default it will just return the input HTML directly. To override this behavior, + * pass your own trusted HTML handler to EditorOptions.trustedHTMLHandler + * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types + */ + getTrustedHTMLHandler(): TrustedHTMLHandler { + return this.getCore().trustedHTMLHandler; + } + + /** + * @deprecated Use getZoomScale() instead + */ + getSizeTransformer(): SizeTransformer { + return this.getCore().sizeTransformer; + } + + /** + * Get current zoom scale, default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale + * to let editor behave correctly especially for those mouse drag/drop behaviors + * @returns current zoom scale number + */ + getZoomScale(): number { + return this.getCore().zoomScale; + } + + /** + * Set current zoom scale, default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale + * to let editor behave correctly especially for those mouse drag/drop behaviors + * @param scale The new scale number to set. It should be positive number and no greater than 10, otherwise it will be ignored. + */ + setZoomScale(scale: number): void { + const core = this.getCore(); + if (scale > 0 && scale <= 10) { + const oldValue = core.zoomScale; + core.zoomScale = scale; + + if (oldValue != scale) { + this.triggerPluginEvent( + PluginEventType.ZoomChanged, + { + oldZoomScale: oldValue, + newZoomScale: scale, + }, + true /*broadcast*/ + ); + } + } + } + + /** + * Retrieves the rect of the visible viewport of the editor. + */ + getVisibleViewport(): Rect | null { + return this.getCore().getVisibleViewport(); + } + + /** + * @returns the current EditorCore object + * @throws a standard Error if there's no core object + */ + protected getCore(): EditorCore { + if (!this.core) { + throw new Error('Editor is already disposed'); + } + return this.core; + } + + //#endregion +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/editor/DarkColorHandlerImpl.ts b/packages-content-model/roosterjs-content-model-adapter/lib/editor/DarkColorHandlerImpl.ts new file mode 100644 index 00000000000..f87a42cb7f0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/editor/DarkColorHandlerImpl.ts @@ -0,0 +1,173 @@ +import { getObjectKeys, parseColor, setColor } from 'roosterjs-editor-dom'; +import type { + ColorKeyAndValue, + DarkColorHandler, + ModeIndependentColor, +} from 'roosterjs-editor-types'; + +const VARIABLE_REGEX = /^\s*var\(\s*(\-\-[a-zA-Z0-9\-_]+)\s*(?:,\s*(.*))?\)\s*$/; +const VARIABLE_PREFIX = 'var('; +const COLOR_VAR_PREFIX = 'darkColor'; +const enum ColorAttributeEnum { + CssColor = 0, + HtmlColor = 1, +} +const ColorAttributeName: { [key in ColorAttributeEnum]: string }[] = [ + { + [ColorAttributeEnum.CssColor]: 'color', + [ColorAttributeEnum.HtmlColor]: 'color', + }, + { + [ColorAttributeEnum.CssColor]: 'background-color', + [ColorAttributeEnum.HtmlColor]: 'bgcolor', + }, +]; + +/** + * @internal + */ +export default class DarkColorHandlerImpl implements DarkColorHandler { + private knownColors: Record> = {}; + + constructor(private contentDiv: HTMLElement, private getDarkColor: (color: string) => string) {} + + /** + * Get a copy of known colors + * @returns + */ + getKnownColorsCopy() { + return Object.values(this.knownColors); + } + + /** + * Given a light mode color value and an optional dark mode color value, register this color + * so that editor can handle it, then return the CSS color value for current color mode. + * @param lightModeColor Light mode color value + * @param isDarkMode Whether current color mode is dark mode + * @param darkModeColor Optional dark mode color value. If not passed, we will calculate one. + */ + registerColor(lightModeColor: string, isDarkMode: boolean, darkModeColor?: string): string { + const parsedColor = this.parseColorValue(lightModeColor); + let colorKey: string | undefined; + + if (parsedColor) { + lightModeColor = parsedColor.lightModeColor; + darkModeColor = parsedColor.darkModeColor || darkModeColor; + colorKey = parsedColor.key; + } + + if (isDarkMode && lightModeColor) { + colorKey = + colorKey || `--${COLOR_VAR_PREFIX}_${lightModeColor.replace(/[^\d\w]/g, '_')}`; + + if (!this.knownColors[colorKey]) { + darkModeColor = darkModeColor || this.getDarkColor(lightModeColor); + + this.knownColors[colorKey] = { lightModeColor, darkModeColor }; + this.contentDiv.style.setProperty(colorKey, darkModeColor); + } + + return `var(${colorKey}, ${lightModeColor})`; + } else { + return lightModeColor; + } + } + + /** + * Reset known color record, clean up registered color variables. + */ + reset(): void { + getObjectKeys(this.knownColors).forEach(key => this.contentDiv.style.removeProperty(key)); + this.knownColors = {}; + } + + /** + * Parse an existing color value, if it is in variable-based color format, extract color key, + * light color and query related dark color if any + * @param color The color string to parse + * @param isInDarkMode Whether current content is in dark mode. When set to true, if the color value is not in dark var format, + * we will treat is as a dark mode color and try to find a matched dark mode color. + */ + parseColorValue(color: string | undefined | null, isInDarkMode?: boolean): ColorKeyAndValue { + let key: string | undefined; + let lightModeColor = ''; + let darkModeColor: string | undefined; + + if (color) { + const match = color.startsWith(VARIABLE_PREFIX) ? VARIABLE_REGEX.exec(color) : null; + + if (match) { + if (match[2]) { + key = match[1]; + lightModeColor = match[2]; + darkModeColor = this.knownColors[key]?.darkModeColor; + } else { + lightModeColor = ''; + } + } else if (isInDarkMode) { + // If editor is in dark mode but the color is not in dark color format, it is possible the color was inserted from external code + // without any light color info. So we first try to see if there is a known dark color can match this color, and use its related + // light color as light mode color. Otherwise we need to drop this color to avoid show "white on white" content. + lightModeColor = this.findLightColorFromDarkColor(color) || ''; + + if (lightModeColor) { + darkModeColor = color; + } + } else { + lightModeColor = color; + } + } + + return { key, lightModeColor, darkModeColor }; + } + + /** + * Find related light mode color from dark mode color. + * @param darkColor The existing dark color + */ + findLightColorFromDarkColor(darkColor: string): string | null { + const rgbSearch = parseColor(darkColor); + + if (rgbSearch) { + const key = getObjectKeys(this.knownColors).find(key => { + const rgbCurrent = parseColor(this.knownColors[key].darkModeColor); + + return ( + rgbCurrent && + rgbCurrent[0] == rgbSearch[0] && + rgbCurrent[1] == rgbSearch[1] && + rgbCurrent[2] == rgbSearch[2] + ); + }); + + if (key) { + return this.knownColors[key].lightModeColor; + } + } + + return null; + } + + /** + * Transform element color, from dark to light or from light to dark + * @param element The element to transform color + * @param fromDarkMode Whether this is transforming color from dark mode + * @param toDarkMode Whether this is transforming color to dark mode + */ + transformElementColor(element: HTMLElement, fromDarkMode: boolean, toDarkMode: boolean): void { + ColorAttributeName.forEach((names, i) => { + const color = this.parseColorValue( + element.style.getPropertyValue(names[ColorAttributeEnum.CssColor]) || + element.getAttribute(names[ColorAttributeEnum.HtmlColor]), + !!fromDarkMode + ).lightModeColor; + + element.style.setProperty(names[ColorAttributeEnum.CssColor], null); + element.removeAttribute(names[ColorAttributeEnum.HtmlColor]); + + if (color && color != 'inherit') { + setColor(element, color, i != 0, toDarkMode, false /*shouldAdaptFontColor*/, this); + } + }); + } +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-adapter/lib/editor/createEditorCore.ts new file mode 100644 index 00000000000..813e69288ea --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/editor/createEditorCore.ts @@ -0,0 +1,61 @@ +import createCorePlugins, { getPluginState } from '../corePlugins/createCorePlugins'; +import DarkColorHandlerImpl from './DarkColorHandlerImpl'; +import { arrayPush, getIntersectedRect } from 'roosterjs-editor-dom'; +import { coreApiMap } from '../coreApi/coreApiMap'; +import { getObjectKeys } from 'roosterjs-content-model-dom'; +import type { CoreCreator, EditorCore, EditorOptions, EditorPlugin } from 'roosterjs-editor-types'; + +/** + * @internal + * Create a new instance of Editor Core + * @param contentDiv The DIV HTML element which will be the container element of editor + * @param options An optional options object to customize the editor + */ +export const createEditorCore: CoreCreator = (contentDiv, options) => { + const corePlugins = createCorePlugins(contentDiv, options); + const plugins: EditorPlugin[] = []; + + getObjectKeys(corePlugins).forEach(name => { + if (name == '_placeholder') { + if (options.plugins) { + arrayPush(plugins, options.plugins); + } + } else { + plugins.push(corePlugins[name]); + } + }); + + const pluginState = getPluginState(corePlugins); + const zoomScale: number = (options.zoomScale ?? -1) > 0 ? options.zoomScale! : 1; + const getVisibleViewport = + options.getVisibleViewport || + (() => { + const scrollContainer = pluginState.domEvent.scrollContainer; + + return getIntersectedRect( + scrollContainer == core.contentDiv + ? [scrollContainer] + : [scrollContainer, core.contentDiv] + ); + }); + + const core: EditorCore = { + contentDiv, + api: { + ...coreApiMap, + ...(options.coreApiOverride || {}), + }, + originalApi: coreApiMap, + plugins: plugins.filter(x => !!x), + ...pluginState, + trustedHTMLHandler: options.trustedHTMLHandler || ((html: string) => html), + zoomScale: zoomScale, + sizeTransformer: options.sizeTransformer || ((size: number) => size / zoomScale), + getVisibleViewport, + imageSelectionBorderColor: options.imageSelectionBorderColor, + darkColorHandler: new DarkColorHandlerImpl(contentDiv, pluginState.lifecycle.getDarkColor), + disposeErrorHandler: options.disposeErrorHandler, + }; + + return core; +}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/editor/isFeatureEnabled.ts b/packages-content-model/roosterjs-content-model-adapter/lib/editor/isFeatureEnabled.ts new file mode 100644 index 00000000000..17f1a552c44 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/editor/isFeatureEnabled.ts @@ -0,0 +1,16 @@ +import type { ExperimentalFeatures } from 'roosterjs-editor-types'; +import type { CompatibleExperimentalFeatures } from 'roosterjs-editor-types/lib/compatibleTypes'; + +/** + * @internal + * Check if the given experimental feature is enabled + * @param featureSet All enabled features + * @param feature The feature to check + * @returns True if the given feature is enabled, otherwise false + */ +export function isFeatureEnabled( + featureSet: (ExperimentalFeatures | CompatibleExperimentalFeatures)[] | undefined, + feature: ExperimentalFeatures | CompatibleExperimentalFeatures +) { + return (featureSet || []).indexOf(feature) >= 0; +} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/index.ts b/packages-content-model/roosterjs-content-model-adapter/lib/index.ts new file mode 100644 index 00000000000..c3d93c07c6d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/lib/index.ts @@ -0,0 +1,2 @@ +// Classes +export { AdapterEditor } from './editor/AdapterEditor'; diff --git a/packages-content-model/roosterjs-content-model-adapter/package.json b/packages-content-model/roosterjs-content-model-adapter/package.json new file mode 100644 index 00000000000..430e9ebd5b7 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-adapter/package.json @@ -0,0 +1,14 @@ +{ + "name": "roosterjs-content-model-adapter", + "description": "Content Model for roosterjs (Under development)", + "dependencies": { + "tslib": "^2.3.1", + "roosterjs-editor-types": "", + "roosterjs-editor-dom": "", + "roosterjs-content-model-editor": "", + "roosterjs-content-model-dom": "", + "roosterjs-content-model-types": "" + }, + "version": "0.0.0", + "main": "./lib/index.ts" +} From 8f6365f8d3a93917e5fe965ea05b631dd035857d Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 30 Oct 2023 20:34:30 -0700 Subject: [PATCH 023/111] Fix #2061, apply pending format on Android (#2172) * Fix #2061 * Fix for Android --- .../lib/editor/corePlugins/ContentModelFormatPlugin.ts | 6 +++++- .../lib/editor/createContentModelEditorCore.ts | 2 ++ .../lib/publicTypes/IContentModelEditor.ts | 5 +++++ .../editor/corePlugins/ContentModelFormatPluginTest.ts | 5 +++++ .../test/editor/createContentModelEditorCoreTest.ts | 10 +++++----- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts index a33376c502e..72d62a29148 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts @@ -90,8 +90,12 @@ export default class ContentModelFormatPlugin switch (event.eventType) { case PluginEventType.Input: + const env = this.editor.getEnvironment(); + // In Safari, isComposing will be undefined but isInIME() works - if (!event.rawEvent.isComposing && !this.editor.isInIME()) { + // For Android, we can skip checking isComposing since this property is not always reliable in all IME, + // and we have tested without this check it can still work correctly + if (env.isAndroid || (!event.rawEvent.isComposing && !this.editor.isInIME())) { this.checkAndApplyPendingFormat(event.rawEvent.data); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts index b3eba12c47f..e05e3a71bf6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts @@ -119,7 +119,9 @@ function promoteCoreApi(cmCore: ContentModelEditorCore) { } function promoteEnvironment(cmCore: ContentModelEditorCore) { + // It is ok to use global window here since the environment should always be the same for all windows in one session cmCore.environment.isMac = window.navigator.appVersion.indexOf('Mac') != -1; + cmCore.environment.isAndroid = /android/i.test(window.navigator.userAgent); } function getPluginState(options: ContentModelEditorOptions): ContentModelPluginState { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index 95777221d44..bb56a3f9a04 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -15,6 +15,11 @@ export interface EditorEnvironment { * Whether editor is running on Mac */ isMac?: boolean; + + /** + * Whether editor is running on Android + */ + isAndroid?: boolean; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts index 9e42c3e47be..8d22dad703a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts @@ -51,6 +51,7 @@ describe('ContentModelFormatPlugin', () => { setContentModel, isInIME: () => false, cacheContentModel: () => {}, + getEnvironment: () => ({}), } as any) as IContentModelEditor; const state = { defaultFormat: {}, @@ -87,6 +88,7 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, setContentModel, cacheContentModel: () => {}, + getEnvironment: () => ({}), } as any) as IContentModelEditor; const state = { defaultFormat: {}, @@ -121,6 +123,7 @@ describe('ContentModelFormatPlugin', () => { setContentModel, isInIME: () => false, cacheContentModel: () => {}, + getEnvironment: () => ({}), } as any) as IContentModelEditor; const state = { defaultFormat: {}, @@ -165,6 +168,7 @@ describe('ContentModelFormatPlugin', () => { isDarkMode: () => false, triggerPluginEvent: jasmine.createSpy('triggerPluginEvent'), getVisibleViewport: jasmine.createSpy('getVisibleViewport'), + getEnvironment: () => ({}), } as any) as IContentModelEditor; const state = { defaultFormat: {}, @@ -405,6 +409,7 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, setContentModel, cacheContentModel: () => {}, + getEnvironment: () => ({}), } as any) as IContentModelEditor; const state = { defaultFormat: {}, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts index b76d619f47a..c7e433dd2a3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts @@ -143,7 +143,7 @@ describe('createContentModelEditorCore', () => { }, cache: { domIndexer: undefined }, copyPaste: { allowedCustomPasteType: [] }, - environment: { isMac: false }, + environment: { isMac: false, isAndroid: false }, } as any); }); @@ -223,7 +223,7 @@ describe('createContentModelEditorCore', () => { domIndexer: undefined, }, copyPaste: { allowedCustomPasteType: [] }, - environment: { isMac: false }, + environment: { isMac: false, isAndroid: false }, } as any); }); @@ -312,7 +312,7 @@ describe('createContentModelEditorCore', () => { }, cache: { domIndexer: undefined }, copyPaste: { allowedCustomPasteType: [] }, - environment: { isMac: false }, + environment: { isMac: false, isAndroid: false }, } as any); }); @@ -384,7 +384,7 @@ describe('createContentModelEditorCore', () => { }, cache: { domIndexer: undefined }, copyPaste: { allowedCustomPasteType: [] }, - environment: { isMac: false }, + environment: { isMac: false, isAndroid: false }, } as any); }); @@ -457,7 +457,7 @@ describe('createContentModelEditorCore', () => { }, cache: { domIndexer: contentModelDomIndexer }, copyPaste: { allowedCustomPasteType: [] }, - environment: { isMac: false }, + environment: { isMac: false, isAndroid: false }, } as any); }); }); From 4acb67d322cbdb504e27765847c6980cbfebcdb4 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 31 Oct 2023 10:26:28 -0700 Subject: [PATCH 024/111] Fix #2080 (#2173) --- .../lib/plugins/Picker/PickerPlugin.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/Picker/PickerPlugin.ts b/packages/roosterjs-editor-plugins/lib/plugins/Picker/PickerPlugin.ts index 41a7966f87e..0650005599b 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/Picker/PickerPlugin.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/Picker/PickerPlugin.ts @@ -260,12 +260,14 @@ export default class PickerPlugin { + if (currentNode) { + this.editor?.deleteNode(currentNode); + } + if (replacementNode) { + this.editor?.insertNode(replacementNode); + } + }, ChangeSource.Keyboard); } private getRangeUntilAt(event: PluginKeyboardEvent | null): Range | null { @@ -497,7 +499,7 @@ export default class PickerPlugin Date: Tue, 31 Oct 2023 11:03:00 -0700 Subject: [PATCH 025/111] Standalone editor Step 0.5: Create test page for adapter editor (#2176) * Standalone editor step 0: Create a copy of Editor class * Standalone editor Step 0.5: Create test page for adapter editor * Add Content Model functionality to AdapterEditor * remove unnecessary change * remove unnecessary change * improve --- .../controls/AdapterEditorMainPane.tsx | 237 ++++++++++++++++++ .../controls/ContentModelEditorMainPane.tsx | 12 +- demo/scripts/controls/MainPane.tsx | 2 +- demo/scripts/controls/titleBar/TitleBar.tsx | 34 +-- demo/scripts/index.ts | 3 + demo/scripts/tsconfig.json | 6 + .../lib/editor/AdapterEditor.ts | 81 +++++- 7 files changed, 343 insertions(+), 32 deletions(-) create mode 100644 demo/scripts/controls/AdapterEditorMainPane.tsx diff --git a/demo/scripts/controls/AdapterEditorMainPane.tsx b/demo/scripts/controls/AdapterEditorMainPane.tsx new file mode 100644 index 00000000000..9aa52e55073 --- /dev/null +++ b/demo/scripts/controls/AdapterEditorMainPane.tsx @@ -0,0 +1,237 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import ApiPlaygroundPlugin from './sidePane/apiPlayground/ApiPlaygroundPlugin'; +import ContentModelPanePlugin from './sidePane/contentModel/ContentModelPanePlugin'; +import ContentModelRibbon from './ribbonButtons/contentModel/ContentModelRibbon'; +import EditorOptionsPlugin from './sidePane/editorOptions/EditorOptionsPlugin'; +import EventViewPlugin from './sidePane/eventViewer/EventViewPlugin'; +import FormatStatePlugin from './sidePane/formatState/FormatStatePlugin'; +import getToggleablePlugins from './getToggleablePlugins'; +import MainPaneBase from './MainPaneBase'; +import SampleEntityPlugin from './sampleEntity/SampleEntityPlugin'; +import SidePane from './sidePane/SidePane'; +import SnapshotPlugin from './sidePane/snapshot/SnapshotPlugin'; +import TitleBar from './titleBar/TitleBar'; +import { AdapterEditor } from 'roosterjs-content-model-adapter'; +import { arrayPush } from 'roosterjs-editor-dom'; +import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; +import { darkMode, DarkModeButtonStringKey } from './ribbonButtons/darkMode'; +import { EditorOptions, EditorPlugin } from 'roosterjs-editor-types'; +import { ExportButtonStringKey, exportContent } from './ribbonButtons/export'; +import { PartialTheme } from '@fluentui/react/lib/Theme'; +import { popout, PopoutButtonStringKey } from './ribbonButtons/popout'; +import { zoom, ZoomButtonStringKey } from './ribbonButtons/zoom'; +import { + createRibbonPlugin, + RibbonPlugin, + createPasteOptionPlugin, + createEmojiPlugin, + Ribbon, + RibbonButton, + AllButtonStringKeys, + getButtons, + AllButtonKeys, +} from 'roosterjs-react'; + +const styles = require('./MainPane.scss'); +type RibbonStringKeys = + | AllButtonStringKeys + | DarkModeButtonStringKey + | ZoomButtonStringKey + | ExportButtonStringKey + | PopoutButtonStringKey; + +const LightTheme: PartialTheme = { + palette: { + themePrimary: '#0099aa', + themeLighterAlt: '#f2fbfc', + themeLighter: '#cbeef2', + themeLight: '#a1dfe6', + themeTertiary: '#52c0cd', + themeSecondary: '#16a5b5', + themeDarkAlt: '#008a9a', + themeDark: '#007582', + themeDarker: '#005660', + neutralLighterAlt: '#faf9f8', + neutralLighter: '#f3f2f1', + neutralLight: '#edebe9', + neutralQuaternaryAlt: '#e1dfdd', + neutralQuaternary: '#d0d0d0', + neutralTertiaryAlt: '#c8c6c4', + neutralTertiary: '#a19f9d', + neutralSecondary: '#605e5c', + neutralPrimaryAlt: '#3b3a39', + neutralPrimary: '#323130', + neutralDark: '#201f1e', + black: '#000000', + white: '#ffffff', + }, +}; + +const DarkTheme: PartialTheme = { + palette: { + themePrimary: '#0091A1', + themeLighterAlt: '#f1fafb', + themeLighter: '#caecf0', + themeLight: '#9fdce3', + themeTertiary: '#4fbac6', + themeSecondary: '#159dac', + themeDarkAlt: '#008291', + themeDark: '#006e7a', + themeDarker: '#00515a', + neutralLighterAlt: '#3c3c3c', + neutralLighter: '#444444', + neutralLight: '#515151', + neutralQuaternaryAlt: '#595959', + neutralQuaternary: '#5f5f5f', + neutralTertiaryAlt: '#7a7a7a', + neutralTertiary: '#c8c8c8', + neutralSecondary: '#d0d0d0', + neutralPrimaryAlt: '#dadada', + neutralPrimary: '#ffffff', + neutralDark: '#f4f4f4', + black: '#f8f8f8', + white: '#333333', + }, +}; + +class MainPane extends MainPaneBase { + private formatStatePlugin: FormatStatePlugin; + private editorOptionPlugin: EditorOptionsPlugin; + private eventViewPlugin: EventViewPlugin; + private apiPlaygroundPlugin: ApiPlaygroundPlugin; + private contentModelPanePlugin: ContentModelPanePlugin; + private ribbonPlugin: RibbonPlugin; + private contentModelRibbonPlugin: RibbonPlugin; + private pasteOptionPlugin: EditorPlugin; + private emojiPlugin: EditorPlugin; + private toggleablePlugins: EditorPlugin[] | null = null; + private sampleEntityPlugin: SampleEntityPlugin; + private mainWindowButtons: RibbonButton[]; + private popoutWindowButtons: RibbonButton[]; + + constructor(props: {}) { + super(props); + + this.formatStatePlugin = new FormatStatePlugin(); + this.editorOptionPlugin = new EditorOptionsPlugin(); + this.eventViewPlugin = new EventViewPlugin(); + this.apiPlaygroundPlugin = new ApiPlaygroundPlugin(); + this.snapshotPlugin = new SnapshotPlugin(); + this.ribbonPlugin = createRibbonPlugin(); + this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); + this.contentModelPanePlugin = new ContentModelPanePlugin(); + this.pasteOptionPlugin = createPasteOptionPlugin(); + this.emojiPlugin = createEmojiPlugin(); + this.sampleEntityPlugin = new SampleEntityPlugin(); + + this.mainWindowButtons = getButtons([ + ...AllButtonKeys, + darkMode, + zoom, + exportContent, + popout, + ]); + this.popoutWindowButtons = getButtons([...AllButtonKeys, darkMode, zoom, exportContent]); + + this.state = { + showSidePane: window.location.hash != '', + popoutWindow: null, + initState: this.editorOptionPlugin.getBuildInPluginState(), + scale: 1, + isDarkMode: this.themeMatch?.matches || false, + editorCreator: null, + isRtl: false, + }; + } + + getStyles(): Record { + return styles; + } + + renderTitleBar() { + return ; + } + + renderRibbon(isPopout: boolean) { + return ( + <> + + + + ); + } + + renderSidePane(fullWidth: boolean) { + const styles = this.getStyles(); + + return ( + + ); + } + + getPlugins() { + this.toggleablePlugins = + this.toggleablePlugins || getToggleablePlugins(this.state.initState); + + const plugins = [ + ...this.toggleablePlugins, + this.ribbonPlugin, + this.contentModelRibbonPlugin, + this.contentModelPanePlugin.getInnerRibbonPlugin(), + this.pasteOptionPlugin, + this.emojiPlugin, + this.sampleEntityPlugin, + ]; + + if (this.state.showSidePane || this.state.popoutWindow) { + arrayPush(plugins, this.getSidePanePlugins()); + } + + plugins.push(this.updateContentPlugin); + + return plugins; + } + + resetEditor() { + this.toggleablePlugins = null; + this.setState({ + editorCreator: (div: HTMLDivElement, options: EditorOptions) => + new AdapterEditor(div, options), + }); + } + + getTheme(isDark: boolean): PartialTheme { + return isDark ? DarkTheme : LightTheme; + } + + private getSidePanePlugins() { + return [ + this.formatStatePlugin, + this.editorOptionPlugin, + this.eventViewPlugin, + this.apiPlaygroundPlugin, + this.snapshotPlugin, + this.contentModelPanePlugin, + ]; + } +} + +export function mount(parent: HTMLElement) { + ReactDOM.render(, parent); +} diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index d57e1da3d26..080a36074cd 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -18,12 +18,7 @@ import { ContentModelEditor } from 'roosterjs-content-model-editor'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; import { EditorOptions, EditorPlugin } from 'roosterjs-editor-types'; import { PartialTheme } from '@fluentui/react/lib/Theme'; -import { - createRibbonPlugin, - RibbonPlugin, - createPasteOptionPlugin, - createEmojiPlugin, -} from 'roosterjs-react'; +import { RibbonPlugin, createPasteOptionPlugin, createEmojiPlugin } from 'roosterjs-react'; const styles = require('./ContentModelEditorMainPane.scss'); @@ -87,7 +82,6 @@ class ContentModelEditorMainPane extends MainPaneBase { private eventViewPlugin: ContentModelEventViewPlugin; private apiPlaygroundPlugin: ApiPlaygroundPlugin; private ContentModelPanePlugin: ContentModelPanePlugin; - private ribbonPlugin: RibbonPlugin; private contentModelRibbonPlugin: RibbonPlugin; private pasteOptionPlugin: EditorPlugin; private emojiPlugin: EditorPlugin; @@ -104,7 +98,6 @@ class ContentModelEditorMainPane extends MainPaneBase { this.apiPlaygroundPlugin = new ApiPlaygroundPlugin(); this.snapshotPlugin = new SnapshotPlugin(); this.ContentModelPanePlugin = new ContentModelPanePlugin(); - this.ribbonPlugin = createRibbonPlugin(); this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.pasteOptionPlugin = createPasteOptionPlugin(); this.emojiPlugin = createEmojiPlugin(); @@ -131,7 +124,7 @@ class ContentModelEditorMainPane extends MainPaneBase { } renderTitleBar() { - return ; + return ; } renderRibbon(isPopout: boolean) { @@ -165,7 +158,6 @@ class ContentModelEditorMainPane extends MainPaneBase { const plugins = [ ...this.toggleablePlugins, - this.ribbonPlugin, this.contentModelRibbonPlugin, this.ContentModelPanePlugin.getInnerRibbonPlugin(), this.pasteOptionPlugin, diff --git a/demo/scripts/controls/MainPane.tsx b/demo/scripts/controls/MainPane.tsx index 6b0eada1569..ec18675e5b2 100644 --- a/demo/scripts/controls/MainPane.tsx +++ b/demo/scripts/controls/MainPane.tsx @@ -143,7 +143,7 @@ class MainPane extends MainPaneBase { } renderTitleBar() { - return ; + return ; } renderRibbon(isPopout: boolean) { diff --git a/demo/scripts/controls/titleBar/TitleBar.tsx b/demo/scripts/controls/titleBar/TitleBar.tsx index bf7437018c6..47108e88cdc 100644 --- a/demo/scripts/controls/titleBar/TitleBar.tsx +++ b/demo/scripts/controls/titleBar/TitleBar.tsx @@ -7,26 +7,30 @@ const github = require('./iconmonstr-github-1.svg'); export interface TitleBarProps { className?: string; - isContentModelPane: boolean; + mode: 'classical' | 'contentModel' | 'adapter'; } export default class TitleBar extends React.Component { render() { - const { isContentModelPane, className: baseClassName } = this.props; - const styles = isContentModelPane ? contentModelStyles : classicalStyles; + const { mode, className: baseClassName } = this.props; + const styles = mode == 'contentModel' ? contentModelStyles : classicalStyles; const className = styles.titleBar + ' ' + (baseClassName || ''); - const titleText = isContentModelPane - ? 'RoosterJs Content Model Demo Site' - : 'RoosterJs Demo Site'; - const switchLink = isContentModelPane ? ( - - Switch to classical demo - - ) : ( - - Switch to Content Model demo - - ); + const titleText = + mode == 'contentModel' + ? 'RoosterJs Content Model Demo Site' + : mode == 'classical' + ? 'RoosterJs Demo Site' + : 'RoosterJs Adapter Demo Site'; + const switchLink = + mode == 'contentModel' ? ( + + Switch to classical demo + + ) : ( + + Switch to Content Model demo + + ); return (
                                                          diff --git a/demo/scripts/index.ts b/demo/scripts/index.ts index 269728b04f8..722f9329dcc 100644 --- a/demo/scripts/index.ts +++ b/demo/scripts/index.ts @@ -1,10 +1,13 @@ import { mount as mountClassicalEditorMainPane } from './controls/MainPane'; +import { mount as mountAdapterEditorMainPane } from './controls/AdapterEditorMainPane'; import { mount as mountContentModelEditorMainPane } from './controls/ContentModelEditorMainPane'; const search = document.location.search.substring(1).split('&'); if (search.some(x => x == 'cm=1')) { mountContentModelEditorMainPane(document.getElementById('mainPane')); +} else if (search.some(x => x == 'cm=2')) { + mountAdapterEditorMainPane(document.getElementById('mainPane')); } else { mountClassicalEditorMainPane(document.getElementById('mainPane')); } diff --git a/demo/scripts/tsconfig.json b/demo/scripts/tsconfig.json index 8fe669a06a5..f7754dfecc3 100644 --- a/demo/scripts/tsconfig.json +++ b/demo/scripts/tsconfig.json @@ -43,6 +43,12 @@ "roosterjs-content-model-editor/lib/*": [ "packages-content-model/roosterjs-content-model-editor/lib/*" ], + "roosterjs-content-model-adapter": [ + "packages-content-model/roosterjs-content-model-adapter/lib/index" + ], + "roosterjs-content-model-adapter/lib/*": [ + "packages-content-model/roosterjs-content-model-adapter/lib/*" + ], "roosterjs-react": ["packages-ui/roosterjs-react/lib/index"], "roosterjs-react/lib/*": ["packages-ui/roosterjs-react/lib/*"] } diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/editor/AdapterEditor.ts b/packages-content-model/roosterjs-content-model-adapter/lib/editor/AdapterEditor.ts index 7bd1ee208a5..59c35fe73d2 100644 --- a/packages-content-model/roosterjs-content-model-adapter/lib/editor/AdapterEditor.ts +++ b/packages-content-model/roosterjs-content-model-adapter/lib/editor/AdapterEditor.ts @@ -1,5 +1,17 @@ -import { createEditorCore } from './createEditorCore'; +import { createContentModelEditorCore } from 'roosterjs-content-model-editor'; import { isFeatureEnabled } from './isFeatureEnabled'; +import type { + ContentModelEditorCore, + EditorEnvironment, + IContentModelEditor, +} from 'roosterjs-content-model-editor'; +import type { + ContentModelDocument, + DOMSelection, + DomToModelOption, + ModelToDomOption, + OnNodeCreated, +} from 'roosterjs-content-model-types'; import { ChangeSource, ColorTransformDirection, @@ -17,7 +29,6 @@ import type { DarkColorHandler, DefaultFormat, DOMEventHandler, - EditorCore, EditorOptions, EditorUndoState, ExperimentalFeatures, @@ -74,8 +85,8 @@ import type { * RoosterJs adapter editor that supports Content Model and can be used by legacy roosterjs plugin * (This class is still under development, temporarily do internal export for now.) */ -export class AdapterEditor implements IEditor { - private core: EditorCore | null = null; +export class AdapterEditor implements IEditor, IContentModelEditor { + private core: ContentModelEditorCore | null = null; /** * Creates an instance of EditorBase @@ -89,7 +100,7 @@ export class AdapterEditor implements IEditor { } // 2. Create editor core - this.core = createEditorCore(contentDiv, options); + this.core = createContentModelEditorCore(contentDiv, options); // 3. Initialize plugins this.core.plugins.forEach(plugin => plugin.initialize(this)); @@ -130,6 +141,64 @@ export class AdapterEditor implements IEditor { return !this.core; } + //#region Content Model Editor members + + /** + * Create Content Model from DOM tree in this editor + * @param option The option to customize the behavior of DOM to Content Model conversion + */ + createContentModel( + option?: DomToModelOption, + selectionOverride?: DOMSelection + ): ContentModelDocument { + const core = this.getCore(); + + return core.api.createContentModel(core, option, selectionOverride); + } + + /** + * Set content with content model + * @param model The content model to set + * @param option Additional options to customize the behavior of Content Model to DOM conversion + * @param onNodeCreated An optional callback that will be called when a DOM node is created + */ + setContentModel( + model: ContentModelDocument, + option?: ModelToDomOption, + onNodeCreated?: OnNodeCreated + ): DOMSelection | null { + const core = this.getCore(); + + return core.api.setContentModel(core, model, option, onNodeCreated); + } + + /** + * Get current running environment, such as if editor is running on Mac + */ + getEnvironment(): EditorEnvironment { + return this.getCore().environment; + } + + /** + * Get current DOM selection + */ + getDOMSelection(): DOMSelection | null { + const core = this.getCore(); + + return core.api.getDOMSelection(core); + } + + /** + * Set DOMSelection into editor content. + * This is the replacement of IEditor.select. + * @param selection The selection to set + */ + setDOMSelection(selection: DOMSelection) { + const core = this.getCore(); + + core.api.setDOMSelection(core, selection); + } + //#endregion //#region Node API @@ -1024,7 +1093,7 @@ export class AdapterEditor implements IEditor { * @returns the current EditorCore object * @throws a standard Error if there's no core object */ - protected getCore(): EditorCore { + protected getCore(): ContentModelEditorCore { if (!this.core) { throw new Error('Editor is already disposed'); } From b04a1d559937d211d71cb8bb4727a40178391b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 1 Nov 2023 13:44:47 -0300 Subject: [PATCH 026/111] toggleListType --- .../lib/utils/toggleListType.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts index a71c4025aac..ea2dab3a700 100644 --- a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts +++ b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts @@ -1,5 +1,5 @@ import blockFormat from '../utils/blockFormat'; -import { createVListFromRegion, getBlockElementAtNode } from 'roosterjs-editor-dom'; +import { createVListFromRegion, getBlockElementAtNode, VList } from 'roosterjs-editor-dom'; import { ExperimentalFeatures } from 'roosterjs-editor-types'; import type { BulletListType, IEditor, ListType, NumberingListType } from 'roosterjs-editor-types'; import type { @@ -58,12 +58,10 @@ export default function toggleListType( startNumber === 1 ? false : includeSiblingLists ); - const isNewList = chains.length === 0 && block.tagName != 'LI'; - if (vList && start && end) { vList.changeListType(start, end, listType); vList.setListStyleType(orderedStyle, unorderedStyle); - if (isNewList) { + if (isNewList(vList)) { vList.removeMargins(); } vList.writeBack( @@ -76,3 +74,11 @@ export default function toggleListType( apiNameOverride || 'toggleListType' ); } + +function isNewList(vList: VList | null) { + const list = vList?.rootList; + if (list) { + return list.childElementCount === 0; + } + return false; +} From 819adf417108538a65bda1335ef2644a20c083cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 1 Nov 2023 13:49:14 -0300 Subject: [PATCH 027/111] type --- packages/roosterjs-editor-api/lib/utils/toggleListType.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts index ea2dab3a700..0f0c3572c24 100644 --- a/packages/roosterjs-editor-api/lib/utils/toggleListType.ts +++ b/packages/roosterjs-editor-api/lib/utils/toggleListType.ts @@ -1,6 +1,7 @@ import blockFormat from '../utils/blockFormat'; -import { createVListFromRegion, getBlockElementAtNode, VList } from 'roosterjs-editor-dom'; +import { createVListFromRegion, getBlockElementAtNode } from 'roosterjs-editor-dom'; import { ExperimentalFeatures } from 'roosterjs-editor-types'; +import type { VList } from 'roosterjs-editor-dom'; import type { BulletListType, IEditor, ListType, NumberingListType } from 'roosterjs-editor-types'; import type { CompatibleBulletListType, From db4cbc40879a4c18fe82ba4f57e2411be035e445 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Wed, 1 Nov 2023 11:43:23 -0600 Subject: [PATCH 028/111] Fix Mouseout behavior to hide table editors (#2181) * init * init * fix build * Add comment --- .../lib/plugins/TableResize/TableResize.ts | 30 +- .../test/TableResize/tableResizeTest.ts | 264 ++++++++++++++++++ 2 files changed, 281 insertions(+), 13 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/TableResize/TableResize.ts b/packages/roosterjs-editor-plugins/lib/plugins/TableResize/TableResize.ts index 063bcc8c98c..dc9cea12d9b 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/TableResize/TableResize.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/TableResize/TableResize.ts @@ -1,5 +1,5 @@ import TableEditor from './editors/TableEditor'; -import { normalizeRect, safeInstanceOf } from 'roosterjs-editor-dom'; +import { contains, normalizeRect, safeInstanceOf } from 'roosterjs-editor-dom'; import { PluginEventType } from 'roosterjs-editor-types'; import type { CreateElementData, @@ -52,17 +52,18 @@ export default class TableResize implements EditorPlugin { this.editor = editor; this.onMouseMoveDisposer = this.editor.addDomEventHandler({ mousemove: this.onMouseMove, - mouseout: e => this.onMouseOut(e), }); + const scrollContainer = this.editor.getScrollContainer(); + scrollContainer.addEventListener('mouseout', this.onMouseOut); } - private onMouseOut = (ev: Event) => { + private onMouseOut = ({ relatedTarget, currentTarget }: MouseEvent) => { if ( - isMouseEvent(ev) && - safeInstanceOf(ev.relatedTarget, 'HTMLElement') && + safeInstanceOf(relatedTarget, 'HTMLElement') && + safeInstanceOf(currentTarget, 'HTMLElement') && this.tableEditor && - !this.tableEditor.isOwnedElement(ev.relatedTarget) && - !this.editor?.contains(ev.relatedTarget) + !this.tableEditor.isOwnedElement(relatedTarget) && + !contains(currentTarget, relatedTarget) ) { this.setTableEditor(null); } @@ -72,6 +73,8 @@ export default class TableResize implements EditorPlugin { * Dispose this plugin */ dispose() { + const scrollContainer = this.editor?.getScrollContainer(); + scrollContainer?.removeEventListener('mouseout', this.onMouseOut); this.onMouseMoveDisposer?.(); this.invalidateTableRects(); this.disposeTableEditor(); @@ -129,7 +132,12 @@ export default class TableResize implements EditorPlugin { this.tableEditor?.onMouseMove(x, y); }; - private setTableEditor(table: HTMLTableElement | null, e?: MouseEvent) { + /** + * @internal Public only for unit test + * @param table Table to use when setting the Editors + * @param event (Optional) Mouse event + */ + public setTableEditor(table: HTMLTableElement | null, event?: MouseEvent) { if (this.tableEditor && !this.tableEditor.isEditing() && table != this.tableEditor.table) { this.disposeTableEditor(); } @@ -145,7 +153,7 @@ export default class TableResize implements EditorPlugin { this.invalidateTableRects, this.onShowHelperElement, safeInstanceOf(container, 'HTMLElement') ? container : undefined, - e?.currentTarget + event?.currentTarget ); } } @@ -176,7 +184,3 @@ export default class TableResize implements EditorPlugin { } } } - -function isMouseEvent(e: Event): e is MouseEvent { - return !!(e as MouseEvent).pageX; -} diff --git a/packages/roosterjs-editor-plugins/test/TableResize/tableResizeTest.ts b/packages/roosterjs-editor-plugins/test/TableResize/tableResizeTest.ts index 3073b91896d..7a68f29e871 100644 --- a/packages/roosterjs-editor-plugins/test/TableResize/tableResizeTest.ts +++ b/packages/roosterjs-editor-plugins/test/TableResize/tableResizeTest.ts @@ -1,4 +1,6 @@ +import * as Contains from 'roosterjs-editor-dom/lib/utils/contains'; import * as TestHelper from 'roosterjs-editor-api/test/TestHelper'; +import { createElement } from 'roosterjs-editor-dom'; import { DEFAULT_TABLE, DEFAULT_TABLE_MERGED, EXCEL_TABLE, WORD_TABLE } from './tableData'; import { TableResize } from '../../lib/TableResize'; import { @@ -715,3 +717,265 @@ xdescribe('Table Resizer/Inserter tests', () => { expect(pluginName).toBe(expectedName); }); }); + +describe('TableResize', () => { + let editor: IEditor; + let plugin: TableResize; + const TEST_ID = 'inserterTest'; + + let mouseOutListener: undefined | ((this: HTMLElement, ev: MouseEvent) => any); + + beforeEach(() => { + editor = TestHelper.initEditor(TEST_ID); + plugin = new TableResize(); + + spyOn(editor, 'getScrollContainer').and.returnValue(({ + addEventListener: ( + type: K, + listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | AddEventListenerOptions + ) => { + if (type == 'mouseout') { + mouseOutListener = listener as (this: HTMLElement, ev: MouseEvent) => any; + } + }, + removeEventListener: ( + type: K, + listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | EventListenerOptions + ) => { + if (type == 'mouseout') { + mouseOutListener = undefined; + } + }, + })); + plugin.initialize(editor); + }); + + afterEach(() => { + plugin.dispose(); + editor.dispose(); + TestHelper.removeElement(TEST_ID); + document.body = document.createElement('body'); + }); + + it('Dismiss table editor on mouse out', () => { + const ele = createElement( + { + tag: 'div', + children: [ + { + tag: 'div', + children: ['asd'], + }, + ], + }, + editor.getDocument() + ); + const table = createElement( + { + tag: 'table', + children: [ + { + tag: 'tr', + children: [ + { + tag: 'td', + children: ['Test'], + }, + ], + }, + ], + }, + editor.getDocument() + ) as HTMLTableElement; + editor.insertNode(table); + + spyOn(plugin, 'setTableEditor').and.callThrough(); + + plugin.setTableEditor(table); + + if (mouseOutListener) { + const boundedListener = mouseOutListener.bind(ele); + spyOn(Contains, 'default').and.returnValue(false); + boundedListener(({ + currentTarget: ele, + relatedTarget: ele, + })); + + expect(plugin.setTableEditor).toHaveBeenCalledWith(null); + } + }); + + it('Do not dismiss table editor on mouse out, related target is contained in scroll container', () => { + const ele = createElement( + { + tag: 'div', + children: [ + { + tag: 'div', + children: ['asd'], + }, + ], + }, + editor.getDocument() + ); + const table = createElement( + { + tag: 'table', + children: [ + { + tag: 'tr', + children: [ + { + tag: 'td', + children: ['Test'], + }, + ], + }, + ], + }, + editor.getDocument() + ) as HTMLTableElement; + editor.insertNode(table); + + spyOn(plugin, 'setTableEditor').and.callThrough(); + + plugin.setTableEditor(table); + + if (mouseOutListener) { + const boundedListener = mouseOutListener.bind(ele); + spyOn(Contains, 'default').and.returnValue(true); + boundedListener(({ + currentTarget: ele, + relatedTarget: ele, + })); + + expect(plugin.setTableEditor).not.toHaveBeenCalledWith(null); + } + }); + + it('Do not dismiss table editor on mouse out, table editor not', () => { + const ele = createElement( + { + tag: 'div', + children: [ + { + tag: 'div', + children: ['asd'], + }, + ], + }, + editor.getDocument() + ); + + spyOn(plugin, 'setTableEditor').and.callThrough(); + + if (mouseOutListener) { + const boundedListener = mouseOutListener.bind(ele); + spyOn(Contains, 'default').and.returnValue(false); + boundedListener(({ + currentTarget: ele, + relatedTarget: ele, + })); + + expect(plugin.setTableEditor).not.toHaveBeenCalledWith(null); + } + }); + + it('Do not dismiss table editor on mouse out, related target null', () => { + const ele = createElement( + { + tag: 'div', + children: [ + { + tag: 'div', + children: ['asd'], + }, + ], + }, + editor.getDocument() + ); + const table = createElement( + { + tag: 'table', + children: [ + { + tag: 'tr', + children: [ + { + tag: 'td', + children: ['Test'], + }, + ], + }, + ], + }, + editor.getDocument() + ) as HTMLTableElement; + editor.insertNode(table); + + spyOn(plugin, 'setTableEditor').and.callThrough(); + + plugin.setTableEditor(table); + + if (mouseOutListener) { + const boundedListener = mouseOutListener.bind(ele); + spyOn(Contains, 'default').and.returnValue(false); + boundedListener(({ + currentTarget: ele, + relatedTarget: null, + })); + + expect(plugin.setTableEditor).not.toHaveBeenCalledWith(null); + } + }); + + it('Do not dismiss table editor on mouse out, currentTarget null', () => { + const ele = createElement( + { + tag: 'div', + children: [ + { + tag: 'div', + children: ['asd'], + }, + ], + }, + editor.getDocument() + ); + const table = createElement( + { + tag: 'table', + children: [ + { + tag: 'tr', + children: [ + { + tag: 'td', + children: ['Test'], + }, + ], + }, + ], + }, + editor.getDocument() + ) as HTMLTableElement; + editor.insertNode(table); + + spyOn(plugin, 'setTableEditor').and.callThrough(); + + plugin.setTableEditor(table); + + if (mouseOutListener) { + const boundedListener = mouseOutListener.bind(ele); + spyOn(Contains, 'default').and.returnValue(false); + boundedListener(({ + currentTarget: null, + relatedTarget: ele, + })); + + expect(plugin.setTableEditor).not.toHaveBeenCalledWith(null); + } + }); +}); From 8c444f3ee44a92928ee410d2922060e8d1090b5d Mon Sep 17 00:00:00 2001 From: Andres-CT98 <107568016+Andres-CT98@users.noreply.github.com> Date: Wed, 1 Nov 2023 16:32:34 -0600 Subject: [PATCH 029/111] Fix apply Table Inside borders operation and Demo site (#2184) * fix cases * fix demo --- .../contentModel/formatTableButton.ts | 4 +- .../contentModel/tableBorderApplyButton.ts | 24 ++++---- .../publicApi/table/applyTableBorderFormat.ts | 58 +++++++++++++++++++ 3 files changed, 74 insertions(+), 12 deletions(-) diff --git a/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts index 8abf48a67e6..789b9929698 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts @@ -67,12 +67,12 @@ const PREDEFINED_STYLES: Record< color /**topBorder */, color /**bottomBorder */, color /** verticalColors*/, - false /** bandedRows */, + true /** bandedRows */, false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, TableBorderFormat.FIRST_COLUMN_HEADER_EXTERNAL /** tableBorderFormat */, - null /** bgColorEven */, + '#B0B0B0' /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ ), diff --git a/demo/scripts/controls/ribbonButtons/contentModel/tableBorderApplyButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/tableBorderApplyButton.ts index 67b0ffa649d..bc40a23e91f 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/tableBorderApplyButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/tableBorderApplyButton.ts @@ -1,16 +1,20 @@ import MainPaneBase from '../../MainPaneBase'; -import { applyTableBorderFormat, isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; +import { + applyTableBorderFormat, + BorderOperations, + isContentModelEditor, +} from 'roosterjs-content-model-editor'; -const TABLE_OPERATIONS = { - menuNameTableAllBorder: 'AllBorders', - menuNameTableNoBorder: 'NoBorders', - menuNameTableLeftBorder: 'LeftBorders', - menuNameTableRightBorder: 'RightBorders', - menuNameTableTopBorder: 'TopBorders', - menuNameTableBottomBorder: 'BottomBorders', - menuNameTableInsideBorder: 'InsideBorders', - menuNameTableOutsideBorder: 'OutsideBorders', +const TABLE_OPERATIONS: Record = { + menuNameTableAllBorder: 'allBorders', + menuNameTableNoBorder: 'noBorders', + menuNameTableLeftBorder: 'leftBorders', + menuNameTableRightBorder: 'rightBorders', + menuNameTableTopBorder: 'topBorders', + menuNameTableBottomBorder: 'bottomBorders', + menuNameTableInsideBorder: 'insideBorders', + menuNameTableOutsideBorder: 'outsideBorders', }; export const tableBorderApplyButton: RibbonButton<'ribbonButtonTableBorder'> = { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts index babe9e00e77..aa406f96a58 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts @@ -162,6 +162,64 @@ export default function applyTableBorderFormat( break; case 'insideBorders': // Format cells - Inside borders + const singleCol = sel.lastCol == sel.firstCol; + const singleRow = sel.lastRow == sel.firstRow; + // Single cell selection + if (singleCol && singleRow) { + break; + } + // Single column selection + if (singleCol) { + applyBorderFormat( + tableModel.rows[sel.firstRow].cells[sel.firstCol], + borderFormat, + ['borderBottom'] + ); + for ( + let rowIndex = sel.firstRow + 1; + rowIndex <= sel.lastRow - 1; + rowIndex++ + ) { + const cell = tableModel.rows[rowIndex].cells[sel.firstCol]; + applyBorderFormat(cell, borderFormat, [ + 'borderTop', + 'borderBottom', + ]); + } + applyBorderFormat( + tableModel.rows[sel.lastRow].cells[sel.firstCol], + borderFormat, + ['borderTop'] + ); + break; + } + // Single row selection + if (singleRow) { + applyBorderFormat( + tableModel.rows[sel.firstRow].cells[sel.firstCol], + borderFormat, + ['borderRight'] + ); + for ( + let colIndex = sel.firstCol + 1; + colIndex <= sel.lastCol - 1; + colIndex++ + ) { + const cell = tableModel.rows[sel.firstRow].cells[colIndex]; + applyBorderFormat(cell, borderFormat, [ + 'borderLeft', + 'borderRight', + ]); + } + applyBorderFormat( + tableModel.rows[sel.firstRow].cells[sel.lastCol], + borderFormat, + ['borderLeft'] + ); + break; + } + + // For multiple rows and columns selections // Top left cell applyBorderFormat( tableModel.rows[sel.firstRow].cells[sel.firstCol], From 37e8fb5f673ca78c62b229efa9900742fa24187d Mon Sep 17 00:00:00 2001 From: Ian Elizondo Date: Thu, 2 Nov 2023 13:16:48 -0600 Subject: [PATCH 030/111] Add image size checkmarks to context menu (#2168) * Allow a max error un percentage size check * Allow a checkmark to be shown in ctx menu * Add checkmark to image sizes * Add missing type * Fix types * Add comment * Attempt to fix image selection * Revert unneeded changes in image selection * Use attr as backup in resize calc * Rely entirely on image selection * Revert changes to domeventplugin * Copy changes into content model adapter --- .../lib/corePlugins/ImageSelection.ts | 4 +- .../menus/createImageEditMenuProvider.tsx | 61 ++++++++++++------- .../lib/contextMenu/types/ContextMenuItem.ts | 8 +++ .../utils/createContextMenuProvider.ts | 14 ++++- .../lib/corePlugins/ImageSelection.ts | 4 +- .../lib/plugins/ImageEdit/api/isResizedTo.ts | 13 +++- 6 files changed, 74 insertions(+), 30 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/ImageSelection.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/ImageSelection.ts index bad0268be0a..1c43351ca57 100644 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/ImageSelection.ts +++ b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/ImageSelection.ts @@ -4,7 +4,7 @@ import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types' const Escape = 'Escape'; const Delete = 'Delete'; -const mouseLeftButton = 0; +const mouseMiddleButton = 1; /** * @internal @@ -44,7 +44,7 @@ export default class ImageSelection implements EditorPlugin { if ( safeInstanceOf(target, 'HTMLImageElement') && target.isContentEditable && - event.rawEvent.button === mouseLeftButton + event.rawEvent.button != mouseMiddleButton ) { this.editor.select(target); } diff --git a/packages-ui/roosterjs-react/lib/contextMenu/menus/createImageEditMenuProvider.tsx b/packages-ui/roosterjs-react/lib/contextMenu/menus/createImageEditMenuProvider.tsx index ba6ee5eb0ec..9de0ffd7345 100644 --- a/packages-ui/roosterjs-react/lib/contextMenu/menus/createImageEditMenuProvider.tsx +++ b/packages-ui/roosterjs-react/lib/contextMenu/menus/createImageEditMenuProvider.tsx @@ -1,8 +1,13 @@ import createContextMenuProvider from '../utils/createContextMenuProvider'; import showInputDialog from '../../inputDialog/utils/showInputDialog'; -import { canRegenerateImage, resetImage, resizeByPercentage } from 'roosterjs-editor-plugins'; -import { DocumentCommand, ImageEditOperation } from 'roosterjs-editor-types'; -import { safeInstanceOf } from 'roosterjs-editor-dom'; +import { + canRegenerateImage, + isResizedTo, + resetImage, + resizeByPercentage, +} from 'roosterjs-editor-plugins'; +import { DocumentCommand, ImageEditOperation, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { getObjectKeys } from 'roosterjs-editor-dom'; import { setImageAltText } from 'roosterjs-editor-api'; import type ContextMenuItem from '../types/ContextMenuItem'; import type { EditorPlugin, IEditor } from 'roosterjs-editor-types'; @@ -40,6 +45,13 @@ const ImageAltTextMenuItem: ContextMenuItem = { key: 'menuNameImageResize', unlocalizedText: 'Size', @@ -49,34 +61,40 @@ const ImageResizeMenuItem: ContextMenuItem { + onClick: (key, editor, _) => { + const selection = editor.getSelectionRangeEx(); + if (selection.type !== SelectionRangeTypes.ImageSelection) { + return; + } editor.addUndoSnapshot(() => { - let percentage = 0; - switch (key) { - case 'menuNameImageSizeSmall': - percentage = 0.25; - break; - case 'menuNameImageSizeMedium': - percentage = 0.5; - break; - case 'menuNameImageSizeOriginal': - percentage = 1; - break; - } + const percentage = sizeMap[key]; - if (percentage > 0) { + if (percentage != undefined && percentage > 0) { resizeByPercentage( editor, - node as HTMLImageElement, + selection.image, percentage, 10 /*minWidth*/, 10 /*minHeight*/ ); } else { - resetImage(editor, node as HTMLImageElement); + resetImage(editor, selection.image); } }); }, + getSelectedId: (editor, _) => { + const selection = editor.getSelectionRangeEx(); + return ( + (selection.type === SelectionRangeTypes.ImageSelection && + getObjectKeys(sizeMap).find(key => { + return key == 'menuNameImageSizeBestFit' + ? !selection.image.hasAttribute('width') && + !selection.image.hasAttribute('height') + : isResizedTo(selection.image, sizeMap[key]!); + })) || + null + ); + }, }; const ImageRotateMenuItem: ContextMenuItem = { @@ -184,8 +202,9 @@ const ImageCutMenuItem: ContextMenuItem = }, }; -function shouldShowImageEditItems(editor: IEditor, node: Node) { - return safeInstanceOf(node, 'HTMLImageElement') && node.isContentEditable; +function shouldShowImageEditItems(editor: IEditor, _: Node) { + const selection = editor.getSelectionRangeEx(); + return selection.type === SelectionRangeTypes.ImageSelection && !!selection.image; } /** diff --git a/packages-ui/roosterjs-react/lib/contextMenu/types/ContextMenuItem.ts b/packages-ui/roosterjs-react/lib/contextMenu/types/ContextMenuItem.ts index b1728ac2bda..52cb0d0e204 100644 --- a/packages-ui/roosterjs-react/lib/contextMenu/types/ContextMenuItem.ts +++ b/packages-ui/roosterjs-react/lib/contextMenu/types/ContextMenuItem.ts @@ -42,6 +42,14 @@ export default interface ContextMenuItem boolean; + /** + * A callback function to verify which subitem ID should have a checkmark + * @param editor The editor object that triggers this event + * @param targetNode The node that user is clicking onto + * @returns ID to be shown as selected, null for none + */ + getSelectedId?: (editor: IEditor, targetNode: Node) => TString | null; + /** * A key-value map for sub menu items, key is the key of menu item, value is unlocalized string * When click on a child item, onClick handler will be triggered with the key of the clicked child item passed in as the second parameter diff --git a/packages-ui/roosterjs-react/lib/contextMenu/utils/createContextMenuProvider.ts b/packages-ui/roosterjs-react/lib/contextMenu/utils/createContextMenuProvider.ts index 375c8e92f0e..f98737a8d7b 100644 --- a/packages-ui/roosterjs-react/lib/contextMenu/utils/createContextMenuProvider.ts +++ b/packages-ui/roosterjs-react/lib/contextMenu/utils/createContextMenuProvider.ts @@ -59,7 +59,7 @@ class ContextMenuProviderImpl .filter( item => !item.shouldShow || item.shouldShow(this.editor!, node, this.context) ) - .map(item => this.convertMenuItems(item)) + .map(item => this.convertMenuItems(item, node)) : []; } @@ -67,7 +67,11 @@ class ContextMenuProviderImpl this.uiUtilities = uiUtilities; } - private convertMenuItems(item: ContextMenuItem): IContextualMenuItem { + private convertMenuItems( + item: ContextMenuItem, + node: Node + ): IContextualMenuItem { + const selectedId = item.getSelectedId?.(this.editor!, node); return { key: item.key, data: item, @@ -85,6 +89,12 @@ class ContextMenuProviderImpl onRender: item.itemRender ? subItem => item.itemRender?.(subItem, () => this.onClick(item, key)) : undefined, + iconProps: + key == selectedId + ? { + iconName: 'Checkmark', + } + : undefined, })), ...(item.commandBarSubMenuProperties || {}), } diff --git a/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts b/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts index 6cde4d9d736..458d58ac8e6 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts @@ -4,7 +4,7 @@ import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types' const Escape = 'Escape'; const Delete = 'Delete'; -const mouseLeftButton = 0; +const mouseMiddleButton = 1; /** * Detect image selection and help highlight the image @@ -43,7 +43,7 @@ export default class ImageSelection implements EditorPlugin { if ( safeInstanceOf(target, 'HTMLImageElement') && target.isContentEditable && - event.rawEvent.button === mouseLeftButton + event.rawEvent.button != mouseMiddleButton ) { this.editor.select(target); } diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/api/isResizedTo.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/api/isResizedTo.ts index 3d4cf2ec963..b09edea9687 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/api/isResizedTo.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/api/isResizedTo.ts @@ -5,14 +5,21 @@ import { getEditInfoFromImage } from '../editInfoUtils/editInfo'; * Check if the image is already resized to the given percentage * @param image The image to check * @param percentage The percentage to check + * @param maxError Maximum difference of pixels to still be considered the same size */ -export default function isResizedTo(image: HTMLImageElement, percentage: number): boolean { +export default function isResizedTo( + image: HTMLImageElement, + percentage: number, + maxError: number = 1 +): boolean { const editInfo = getEditInfoFromImage(image); + //Image selection will sometimes return an image which is currently hidden and wrapped. Use HTML attributes as backup + const visibleHeight = editInfo.heightPx || image.height; + const visibleWidth = editInfo.widthPx || image.width; if (editInfo) { const { width, height } = getTargetSizeByPercentage(editInfo, percentage); return ( - Math.round(width) == Math.round(editInfo.widthPx) && - Math.round(height) == Math.round(editInfo.heightPx) + Math.abs(width - visibleWidth) < maxError && Math.abs(height - visibleHeight) < maxError ); } return false; From 2e6e2e76cfb576dc2076f0b462bcef9a67048161 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 6 Nov 2023 09:39:43 -0800 Subject: [PATCH 031/111] Revert previous standalone editor change (#2189) * Revert previous standalone change * fix build --- .../controls/AdapterEditorMainPane.tsx | 237 ---- demo/scripts/controls/titleBar/TitleBar.tsx | 2 +- demo/scripts/index.ts | 3 - .../lib/coreApi/addUndoSnapshot.ts | 137 -- .../lib/coreApi/attachDomEvent.ts | 64 - .../lib/coreApi/coreApiMap.ts | 49 - .../lib/coreApi/createPasteFragment.ts | 152 --- .../lib/coreApi/ensureTypeInContainer.ts | 88 -- .../lib/coreApi/focus.ts | 44 - .../lib/coreApi/getContent.ts | 91 -- .../lib/coreApi/getPendableFormatState.ts | 101 -- .../lib/coreApi/getSelectionRange.ts | 44 - .../lib/coreApi/getSelectionRangeEx.ts | 101 -- .../lib/coreApi/getStyleBasedFormatState.ts | 96 -- .../lib/coreApi/hasFocus.ts | 15 - .../lib/coreApi/insertNode.ts | 235 ---- .../lib/coreApi/restoreUndoSnapshot.ts | 69 -- .../lib/coreApi/select.ts | 179 --- .../lib/coreApi/selectImage.ts | 65 - .../lib/coreApi/selectRange.ts | 74 -- .../lib/coreApi/selectTable.ts | 268 ---- .../lib/coreApi/setContent.ts | 120 -- .../lib/coreApi/switchShadowEdit.ts | 111 -- .../lib/coreApi/transformColor.ts | 69 -- .../lib/coreApi/triggerEvent.ts | 44 - .../lib/coreApi/utils/addUniqueId.ts | 31 - .../lib/corePlugins/CopyPastePlugin.ts | 296 ----- .../lib/corePlugins/DOMEventPlugin.ts | 259 ---- .../lib/corePlugins/EditPlugin.ts | 96 -- .../lib/corePlugins/EntityPlugin.ts | 390 ------ .../lib/corePlugins/ImageSelection.ts | 104 -- .../lib/corePlugins/LifecyclePlugin.ts | 188 --- .../lib/corePlugins/MouseUpPlugin.ts | 72 -- .../lib/corePlugins/NormalizeTablePlugin.ts | 180 --- .../corePlugins/PendingFormatStatePlugin.ts | 184 --- .../lib/corePlugins/TypeInContainerPlugin.ts | 99 -- .../lib/corePlugins/UndoPlugin.ts | 279 ----- .../lib/corePlugins/createCorePlugins.ts | 66 - .../corePlugins/utils/forEachSelectedCell.ts | 22 - .../utils/inlineEntityOnPluginEvent.ts | 291 ----- .../utils/removeCellsOutsideSelection.ts | 37 - .../lib/editor/AdapterEditor.ts | 1104 ----------------- .../lib/editor/DarkColorHandlerImpl.ts | 173 --- .../lib/editor/createEditorCore.ts | 61 - .../lib/editor/isFeatureEnabled.ts | 16 - .../lib/index.ts | 2 - .../package.json | 14 - 47 files changed, 1 insertion(+), 6421 deletions(-) delete mode 100644 demo/scripts/controls/AdapterEditorMainPane.tsx delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/addUndoSnapshot.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/attachDomEvent.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/coreApiMap.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/createPasteFragment.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/ensureTypeInContainer.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/focus.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getContent.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getPendableFormatState.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRange.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRangeEx.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getStyleBasedFormatState.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/hasFocus.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/insertNode.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/restoreUndoSnapshot.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/select.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectImage.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectRange.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectTable.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/setContent.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/switchShadowEdit.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/transformColor.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/triggerEvent.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/coreApi/utils/addUniqueId.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/CopyPastePlugin.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/DOMEventPlugin.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EditPlugin.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EntityPlugin.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/ImageSelection.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/LifecyclePlugin.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/MouseUpPlugin.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/NormalizeTablePlugin.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/PendingFormatStatePlugin.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/TypeInContainerPlugin.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/UndoPlugin.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/createCorePlugins.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/forEachSelectedCell.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/removeCellsOutsideSelection.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/editor/AdapterEditor.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/editor/DarkColorHandlerImpl.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/editor/createEditorCore.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/editor/isFeatureEnabled.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/lib/index.ts delete mode 100644 packages-content-model/roosterjs-content-model-adapter/package.json diff --git a/demo/scripts/controls/AdapterEditorMainPane.tsx b/demo/scripts/controls/AdapterEditorMainPane.tsx deleted file mode 100644 index 9aa52e55073..00000000000 --- a/demo/scripts/controls/AdapterEditorMainPane.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import * as React from 'react'; -import * as ReactDOM from 'react-dom'; -import ApiPlaygroundPlugin from './sidePane/apiPlayground/ApiPlaygroundPlugin'; -import ContentModelPanePlugin from './sidePane/contentModel/ContentModelPanePlugin'; -import ContentModelRibbon from './ribbonButtons/contentModel/ContentModelRibbon'; -import EditorOptionsPlugin from './sidePane/editorOptions/EditorOptionsPlugin'; -import EventViewPlugin from './sidePane/eventViewer/EventViewPlugin'; -import FormatStatePlugin from './sidePane/formatState/FormatStatePlugin'; -import getToggleablePlugins from './getToggleablePlugins'; -import MainPaneBase from './MainPaneBase'; -import SampleEntityPlugin from './sampleEntity/SampleEntityPlugin'; -import SidePane from './sidePane/SidePane'; -import SnapshotPlugin from './sidePane/snapshot/SnapshotPlugin'; -import TitleBar from './titleBar/TitleBar'; -import { AdapterEditor } from 'roosterjs-content-model-adapter'; -import { arrayPush } from 'roosterjs-editor-dom'; -import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; -import { darkMode, DarkModeButtonStringKey } from './ribbonButtons/darkMode'; -import { EditorOptions, EditorPlugin } from 'roosterjs-editor-types'; -import { ExportButtonStringKey, exportContent } from './ribbonButtons/export'; -import { PartialTheme } from '@fluentui/react/lib/Theme'; -import { popout, PopoutButtonStringKey } from './ribbonButtons/popout'; -import { zoom, ZoomButtonStringKey } from './ribbonButtons/zoom'; -import { - createRibbonPlugin, - RibbonPlugin, - createPasteOptionPlugin, - createEmojiPlugin, - Ribbon, - RibbonButton, - AllButtonStringKeys, - getButtons, - AllButtonKeys, -} from 'roosterjs-react'; - -const styles = require('./MainPane.scss'); -type RibbonStringKeys = - | AllButtonStringKeys - | DarkModeButtonStringKey - | ZoomButtonStringKey - | ExportButtonStringKey - | PopoutButtonStringKey; - -const LightTheme: PartialTheme = { - palette: { - themePrimary: '#0099aa', - themeLighterAlt: '#f2fbfc', - themeLighter: '#cbeef2', - themeLight: '#a1dfe6', - themeTertiary: '#52c0cd', - themeSecondary: '#16a5b5', - themeDarkAlt: '#008a9a', - themeDark: '#007582', - themeDarker: '#005660', - neutralLighterAlt: '#faf9f8', - neutralLighter: '#f3f2f1', - neutralLight: '#edebe9', - neutralQuaternaryAlt: '#e1dfdd', - neutralQuaternary: '#d0d0d0', - neutralTertiaryAlt: '#c8c6c4', - neutralTertiary: '#a19f9d', - neutralSecondary: '#605e5c', - neutralPrimaryAlt: '#3b3a39', - neutralPrimary: '#323130', - neutralDark: '#201f1e', - black: '#000000', - white: '#ffffff', - }, -}; - -const DarkTheme: PartialTheme = { - palette: { - themePrimary: '#0091A1', - themeLighterAlt: '#f1fafb', - themeLighter: '#caecf0', - themeLight: '#9fdce3', - themeTertiary: '#4fbac6', - themeSecondary: '#159dac', - themeDarkAlt: '#008291', - themeDark: '#006e7a', - themeDarker: '#00515a', - neutralLighterAlt: '#3c3c3c', - neutralLighter: '#444444', - neutralLight: '#515151', - neutralQuaternaryAlt: '#595959', - neutralQuaternary: '#5f5f5f', - neutralTertiaryAlt: '#7a7a7a', - neutralTertiary: '#c8c8c8', - neutralSecondary: '#d0d0d0', - neutralPrimaryAlt: '#dadada', - neutralPrimary: '#ffffff', - neutralDark: '#f4f4f4', - black: '#f8f8f8', - white: '#333333', - }, -}; - -class MainPane extends MainPaneBase { - private formatStatePlugin: FormatStatePlugin; - private editorOptionPlugin: EditorOptionsPlugin; - private eventViewPlugin: EventViewPlugin; - private apiPlaygroundPlugin: ApiPlaygroundPlugin; - private contentModelPanePlugin: ContentModelPanePlugin; - private ribbonPlugin: RibbonPlugin; - private contentModelRibbonPlugin: RibbonPlugin; - private pasteOptionPlugin: EditorPlugin; - private emojiPlugin: EditorPlugin; - private toggleablePlugins: EditorPlugin[] | null = null; - private sampleEntityPlugin: SampleEntityPlugin; - private mainWindowButtons: RibbonButton[]; - private popoutWindowButtons: RibbonButton[]; - - constructor(props: {}) { - super(props); - - this.formatStatePlugin = new FormatStatePlugin(); - this.editorOptionPlugin = new EditorOptionsPlugin(); - this.eventViewPlugin = new EventViewPlugin(); - this.apiPlaygroundPlugin = new ApiPlaygroundPlugin(); - this.snapshotPlugin = new SnapshotPlugin(); - this.ribbonPlugin = createRibbonPlugin(); - this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); - this.contentModelPanePlugin = new ContentModelPanePlugin(); - this.pasteOptionPlugin = createPasteOptionPlugin(); - this.emojiPlugin = createEmojiPlugin(); - this.sampleEntityPlugin = new SampleEntityPlugin(); - - this.mainWindowButtons = getButtons([ - ...AllButtonKeys, - darkMode, - zoom, - exportContent, - popout, - ]); - this.popoutWindowButtons = getButtons([...AllButtonKeys, darkMode, zoom, exportContent]); - - this.state = { - showSidePane: window.location.hash != '', - popoutWindow: null, - initState: this.editorOptionPlugin.getBuildInPluginState(), - scale: 1, - isDarkMode: this.themeMatch?.matches || false, - editorCreator: null, - isRtl: false, - }; - } - - getStyles(): Record { - return styles; - } - - renderTitleBar() { - return ; - } - - renderRibbon(isPopout: boolean) { - return ( - <> - - - - ); - } - - renderSidePane(fullWidth: boolean) { - const styles = this.getStyles(); - - return ( - - ); - } - - getPlugins() { - this.toggleablePlugins = - this.toggleablePlugins || getToggleablePlugins(this.state.initState); - - const plugins = [ - ...this.toggleablePlugins, - this.ribbonPlugin, - this.contentModelRibbonPlugin, - this.contentModelPanePlugin.getInnerRibbonPlugin(), - this.pasteOptionPlugin, - this.emojiPlugin, - this.sampleEntityPlugin, - ]; - - if (this.state.showSidePane || this.state.popoutWindow) { - arrayPush(plugins, this.getSidePanePlugins()); - } - - plugins.push(this.updateContentPlugin); - - return plugins; - } - - resetEditor() { - this.toggleablePlugins = null; - this.setState({ - editorCreator: (div: HTMLDivElement, options: EditorOptions) => - new AdapterEditor(div, options), - }); - } - - getTheme(isDark: boolean): PartialTheme { - return isDark ? DarkTheme : LightTheme; - } - - private getSidePanePlugins() { - return [ - this.formatStatePlugin, - this.editorOptionPlugin, - this.eventViewPlugin, - this.apiPlaygroundPlugin, - this.snapshotPlugin, - this.contentModelPanePlugin, - ]; - } -} - -export function mount(parent: HTMLElement) { - ReactDOM.render(, parent); -} diff --git a/demo/scripts/controls/titleBar/TitleBar.tsx b/demo/scripts/controls/titleBar/TitleBar.tsx index 47108e88cdc..7f22a783018 100644 --- a/demo/scripts/controls/titleBar/TitleBar.tsx +++ b/demo/scripts/controls/titleBar/TitleBar.tsx @@ -7,7 +7,7 @@ const github = require('./iconmonstr-github-1.svg'); export interface TitleBarProps { className?: string; - mode: 'classical' | 'contentModel' | 'adapter'; + mode: 'classical' | 'contentModel'; } export default class TitleBar extends React.Component { diff --git a/demo/scripts/index.ts b/demo/scripts/index.ts index 722f9329dcc..269728b04f8 100644 --- a/demo/scripts/index.ts +++ b/demo/scripts/index.ts @@ -1,13 +1,10 @@ import { mount as mountClassicalEditorMainPane } from './controls/MainPane'; -import { mount as mountAdapterEditorMainPane } from './controls/AdapterEditorMainPane'; import { mount as mountContentModelEditorMainPane } from './controls/ContentModelEditorMainPane'; const search = document.location.search.substring(1).split('&'); if (search.some(x => x == 'cm=1')) { mountContentModelEditorMainPane(document.getElementById('mainPane')); -} else if (search.some(x => x == 'cm=2')) { - mountAdapterEditorMainPane(document.getElementById('mainPane')); } else { mountClassicalEditorMainPane(document.getElementById('mainPane')); } diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/addUndoSnapshot.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/addUndoSnapshot.ts deleted file mode 100644 index aa7919c803f..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/addUndoSnapshot.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { getSelectionPath, Position } from 'roosterjs-editor-dom'; -import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import type { - EntityState, - AddUndoSnapshot, - ChangeSource, - ContentChangedData, - ContentChangedEvent, - ContentMetadata, - EditorCore, - NodePosition, - SelectionRangeEx, -} from 'roosterjs-editor-types'; -import type { CompatibleChangeSource } from 'roosterjs-editor-types/lib/compatibleTypes'; - -/** - * @internal - * Call an editing callback with adding undo snapshots around, and trigger a ContentChanged event if change source is specified. - * Undo snapshot will not be added if this call is nested inside another addUndoSnapshot() call. - * @param core The EditorCore object - * @param callback The editing callback, accepting current selection start and end position, returns an optional object used as the data field of ContentChangedEvent. - * @param changeSource The ChangeSource string of ContentChangedEvent. @default ChangeSource.Format. Set to null to avoid triggering ContentChangedEvent - * @param canUndoByBackspace True if this action can be undone when user press Backspace key (aka Auto Complete). - * @param additionalData @optional parameter to provide additional data related to the ContentChanged Event. - */ -export const addUndoSnapshot: AddUndoSnapshot = ( - core: EditorCore, - callback: ((start: NodePosition | null, end: NodePosition | null) => any) | null, - changeSource: ChangeSource | CompatibleChangeSource | string | null, - canUndoByBackspace: boolean, - additionalData?: ContentChangedData -) => { - const undoState = core.undo; - const isNested = undoState.isNested; - let data: any; - - if (!isNested) { - undoState.isNested = true; - - // When there is getEntityState, it means this is triggered by an entity change. - // So if HTML content is not changed (hasNewContent is false), no need to add another snapshot before change - if (core.undo.hasNewContent || !additionalData?.getEntityState || !callback) { - addUndoSnapshotInternal(core, canUndoByBackspace, additionalData?.getEntityState?.()); - } - } - - try { - if (callback) { - const range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); - data = callback( - range && Position.getStart(range).normalize(), - range && Position.getEnd(range).normalize() - ); - - if (!isNested) { - const entityStates = additionalData?.getEntityState?.(); - addUndoSnapshotInternal(core, false /*isAutoCompleteSnapshot*/, entityStates); - } - } - } finally { - if (!isNested) { - undoState.isNested = false; - } - } - - if (callback && changeSource) { - const event: ContentChangedEvent = { - eventType: PluginEventType.ContentChanged, - source: changeSource, - data: data, - additionalData, - }; - core.api.triggerEvent(core, event, true /*broadcast*/); - } - - if (canUndoByBackspace) { - const range = core.api.getSelectionRange(core, false /*tryGetFromCache*/); - - if (range) { - core.undo.hasNewContent = false; - core.undo.autoCompletePosition = Position.getStart(range); - } - } -}; - -function addUndoSnapshotInternal( - core: EditorCore, - canUndoByBackspace: boolean, - entityStates?: EntityState[] -) { - if (!core.lifecycle.shadowEditFragment) { - const rangeEx = core.api.getSelectionRangeEx(core); - const isDarkMode = core.lifecycle.isDarkMode; - const metadata = createContentMetadata(core.contentDiv, rangeEx, isDarkMode) || null; - - core.undo.snapshotsService.addSnapshot( - { - html: core.contentDiv.innerHTML, - metadata, - knownColors: core.darkColorHandler?.getKnownColorsCopy() || [], - entityStates, - }, - canUndoByBackspace - ); - core.undo.hasNewContent = false; - } -} - -function createContentMetadata( - root: HTMLElement, - rangeEx: SelectionRangeEx, - isDarkMode: boolean -): ContentMetadata | undefined { - switch (rangeEx?.type) { - case SelectionRangeTypes.TableSelection: - return { - type: SelectionRangeTypes.TableSelection, - tableId: rangeEx.table.id, - isDarkMode: !!isDarkMode, - ...rangeEx.coordinates!, - }; - case SelectionRangeTypes.ImageSelection: - return { - type: SelectionRangeTypes.ImageSelection, - imageId: rangeEx.image.id, - isDarkMode: !!isDarkMode, - }; - case SelectionRangeTypes.Normal: - return { - type: SelectionRangeTypes.Normal, - isDarkMode: !!isDarkMode, - start: [], - end: [], - ...(getSelectionPath(root, rangeEx.ranges[0]) || {}), - }; - } -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/attachDomEvent.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/attachDomEvent.ts deleted file mode 100644 index 0ca6916e4e6..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/attachDomEvent.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { getObjectKeys } from 'roosterjs-editor-dom'; -import type { - AttachDomEvent, - DOMEventHandler, - DOMEventHandlerObject, - EditorCore, - PluginDomEvent, -} from 'roosterjs-editor-types'; - -/** - * @internal - * Attach a DOM event to the editor content DIV - * @param core The EditorCore object - * @param eventName The DOM event name - * @param pluginEventType Optional event type. When specified, editor will trigger a plugin event with this name when the DOM event is triggered - * @param beforeDispatch Optional callback function to be invoked when the DOM event is triggered before trigger plugin event - */ -export const attachDomEvent: AttachDomEvent = ( - core: EditorCore, - eventMap: Record -) => { - const disposers = getObjectKeys(eventMap || {}).map(key => { - const { pluginEventType, beforeDispatch } = extractHandler(eventMap[key]); - const eventName = key as keyof HTMLElementEventMap; - const onEvent = (event: HTMLElementEventMap[typeof eventName]) => { - if (beforeDispatch) { - beforeDispatch(event); - } - if (pluginEventType != null) { - core.api.triggerEvent( - core, - { - eventType: pluginEventType, - rawEvent: event, - }, - false /*broadcast*/ - ); - } - }; - - core.contentDiv.addEventListener(eventName, onEvent); - - return () => { - core.contentDiv.removeEventListener(eventName, onEvent); - }; - }); - return () => disposers.forEach(disposers => disposers()); -}; - -function extractHandler(handlerObj: DOMEventHandler): DOMEventHandlerObject { - let result: DOMEventHandlerObject = { - pluginEventType: null, - beforeDispatch: null, - }; - - if (typeof handlerObj === 'number') { - result.pluginEventType = handlerObj; - } else if (typeof handlerObj === 'function') { - result.beforeDispatch = handlerObj; - } else if (typeof handlerObj === 'object') { - result = handlerObj; - } - return result; -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/coreApiMap.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/coreApiMap.ts deleted file mode 100644 index dd12197703f..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/coreApiMap.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { addUndoSnapshot } from './addUndoSnapshot'; -import { attachDomEvent } from './attachDomEvent'; -import { createPasteFragment } from './createPasteFragment'; -import { ensureTypeInContainer } from './ensureTypeInContainer'; -import { focus } from './focus'; -import { getContent } from './getContent'; -import { getPendableFormatState } from './getPendableFormatState'; -import { getSelectionRange } from './getSelectionRange'; -import { getSelectionRangeEx } from './getSelectionRangeEx'; -import { getStyleBasedFormatState } from './getStyleBasedFormatState'; -import { hasFocus } from './hasFocus'; -import { insertNode } from './insertNode'; -import { restoreUndoSnapshot } from './restoreUndoSnapshot'; -import { select } from './select'; -import { selectImage } from './selectImage'; -import { selectRange } from './selectRange'; -import { selectTable } from './selectTable'; -import { setContent } from './setContent'; -import { switchShadowEdit } from './switchShadowEdit'; -import { transformColor } from './transformColor'; -import { triggerEvent } from './triggerEvent'; -import type { CoreApiMap } from 'roosterjs-editor-types'; - -/** - * @internal - */ -export const coreApiMap: CoreApiMap = { - attachDomEvent, - addUndoSnapshot, - createPasteFragment, - ensureTypeInContainer, - focus, - getContent, - getSelectionRange, - getSelectionRangeEx, - getStyleBasedFormatState, - getPendableFormatState, - hasFocus, - insertNode, - restoreUndoSnapshot, - select, - selectRange, - setContent, - switchShadowEdit, - transformColor, - triggerEvent, - selectTable, - selectImage, -}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/createPasteFragment.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/createPasteFragment.ts deleted file mode 100644 index 5dcc65d82a7..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/createPasteFragment.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { PasteType, PluginEventType } from 'roosterjs-editor-types'; -import { - applyFormat, - applyTextStyle, - createDefaultHtmlSanitizerOptions, - getPasteType, - handleImagePaste, - handleTextPaste, - moveChildNodes, - retrieveMetadataFromClipboard, - sanitizePasteContent, -} from 'roosterjs-editor-dom'; -import type { - BeforePasteEvent, - ClipboardData, - CreatePasteFragment, - EditorCore, - NodePosition, - DefaultFormat, -} from 'roosterjs-editor-types'; - -/** - * @internal - * Create a DocumentFragment for paste from a ClipboardData - * @param core The EditorCore object. - * @param clipboardData Clipboard data retrieved from clipboard - * @param position The position to paste to - * @param pasteAsText True to force use plain text as the content to paste, false to choose HTML or Image if any - * @param applyCurrentStyle True if apply format of current selection to the pasted content, - * false to keep original format - * @param pasteAsImage True if the image should be pasted as image - */ -export const createPasteFragment: CreatePasteFragment = ( - core: EditorCore, - clipboardData: ClipboardData, - position: NodePosition | null, - pasteAsText: boolean, - applyCurrentStyle: boolean, - pasteAsImage: boolean = false -) => { - if (!clipboardData) { - return null; - } - - const pasteType = getPasteType(pasteAsText, applyCurrentStyle, pasteAsImage); - - // Step 1: Prepare BeforePasteEvent object - const event = createBeforePasteEvent(core, clipboardData, pasteType); - return createFragmentFromClipboardData( - core, - clipboardData, - position, - pasteAsText, - applyCurrentStyle, - pasteAsImage, - event - ); -}; - -function createBeforePasteEvent( - core: EditorCore, - clipboardData: ClipboardData, - pasteType: PasteType -): BeforePasteEvent { - const options = createDefaultHtmlSanitizerOptions(); - - // Remove "caret-color" style generated by Safari to make sure caret shows in right color after paste - options.cssStyleCallbacks['caret-color'] = () => false; - - return { - eventType: PluginEventType.BeforePaste, - clipboardData, - fragment: core.contentDiv.ownerDocument.createDocumentFragment(), - sanitizingOption: options, - htmlBefore: '', - htmlAfter: '', - htmlAttributes: {}, - pasteType: pasteType, - }; -} - -/** - * Create a DocumentFragment for paste from a ClipboardData - * @param core The EditorCore object. - * @param clipboardData Clipboard data retrieved from clipboard - * @param position The position to paste to - * @param pasteAsText True to force use plain text as the content to paste, false to choose HTML or Image if any - * @param applyCurrentStyle True if apply format of current selection to the pasted content, - * @param pasteAsImage Whether to force paste as image - * @param event Event to trigger. - * false to keep original format - */ -function createFragmentFromClipboardData( - core: EditorCore, - clipboardData: ClipboardData, - position: NodePosition | null, - pasteAsText: boolean, - applyCurrentStyle: boolean, - pasteAsImage: boolean, - event: BeforePasteEvent -) { - const { fragment } = event; - const { rawHtml, text, imageDataUri } = clipboardData; - const doc: Document | undefined = rawHtml - ? new DOMParser().parseFromString(core.trustedHTMLHandler(rawHtml), 'text/html') - : undefined; - - // Step 2: Retrieve Metadata from Html and the Html that was copied. - retrieveMetadataFromClipboard(doc, event, core.trustedHTMLHandler); - - // Step 3: Fill the BeforePasteEvent object, especially the fragment for paste - if ((pasteAsImage && imageDataUri) || (!pasteAsText && !text && imageDataUri)) { - // Paste image - handleImagePaste(imageDataUri, fragment); - } else if (!pasteAsText && rawHtml && doc ? doc.body : false) { - moveChildNodes(fragment, doc?.body); - - if (applyCurrentStyle && position) { - const format = getCurrentFormat(core, position.node); - applyTextStyle(fragment, node => applyFormat(node, format)); - } - } else if (text) { - // Paste text - handleTextPaste(text, position, fragment); - } - - // Step 4: Trigger BeforePasteEvent so that plugins can do proper change before paste, when the type of paste is different than Plain Text - if (event.pasteType !== PasteType.AsPlainText) { - core.api.triggerEvent(core, event, true /*broadcast*/); - } - - // Step 5. Sanitize the fragment before paste to make sure the content is safe - sanitizePasteContent(event, position); - - return fragment; -} - -function getCurrentFormat(core: EditorCore, node: Node): DefaultFormat { - const pendableFormat = core.api.getPendableFormatState(core, true /** forceGetStateFromDOM*/); - const styleBasedFormat = core.api.getStyleBasedFormatState(core, node); - return { - fontFamily: styleBasedFormat.fontName, - fontSize: styleBasedFormat.fontSize, - textColor: styleBasedFormat.textColor, - backgroundColor: styleBasedFormat.backgroundColor, - textColors: styleBasedFormat.textColors, - backgroundColors: styleBasedFormat.backgroundColors, - bold: pendableFormat.isBold, - italic: pendableFormat.isItalic, - underline: pendableFormat.isUnderline, - }; -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/ensureTypeInContainer.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/ensureTypeInContainer.ts deleted file mode 100644 index 3860ac2a4d7..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/ensureTypeInContainer.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { ContentPosition, KnownCreateElementDataIndex, PositionType } from 'roosterjs-editor-types'; -import type { EditorCore, EnsureTypeInContainer, NodePosition } from 'roosterjs-editor-types'; -import { - applyFormat, - createElement, - createRange, - findClosestElementAncestor, - getBlockElementAtNode, - isNodeEmpty, - Position, - safeInstanceOf, -} from 'roosterjs-editor-dom'; - -/** - * @internal - * When typing goes directly under content div, many things can go wrong - * We fix it by wrapping it with a div and reposition cursor within the div - */ -export const ensureTypeInContainer: EnsureTypeInContainer = ( - core: EditorCore, - position: NodePosition, - keyboardEvent?: KeyboardEvent -) => { - const table = findClosestElementAncestor(position.node, core.contentDiv, 'table'); - let td: HTMLElement | null; - - if (table && (td = table.querySelector('td,th'))) { - position = new Position(td, PositionType.Begin); - } - position = position.normalize(); - - const block = getBlockElementAtNode(core.contentDiv, position.node); - let formatNode: HTMLElement | null; - - if (block) { - formatNode = block.collapseToSingleElement(); - if (isNodeEmpty(formatNode, false /* trimContent */, true /* shouldCountBrAsVisible */)) { - const brEl = formatNode.ownerDocument.createElement('br'); - formatNode.append(brEl); - } - // if the block is empty, apply default format - // Otherwise, leave it as it is as we don't want to change the style for existing data - // unless the block was just created by the keyboard event (e.g. ctrl+a & start typing) - const shouldSetNodeStyles = - isNodeEmpty(formatNode) || - (keyboardEvent && wasNodeJustCreatedByKeyboardEvent(keyboardEvent, formatNode)); - formatNode = formatNode && shouldSetNodeStyles ? formatNode : null; - } else { - // Only reason we don't get the selection block is that we have an empty content div - // which can happen when users removes everything (i.e. select all and DEL, or backspace from very end to begin) - // The fix is to add a DIV wrapping, apply default format and move cursor over - formatNode = createElement( - KnownCreateElementDataIndex.EmptyLine, - core.contentDiv.ownerDocument - ) as HTMLElement; - core.api.insertNode(core, formatNode, { - position: ContentPosition.End, - updateCursor: false, - replaceSelection: false, - insertOnNewLine: false, - }); - - // element points to a wrapping node we added "

                                                          ". We should move the selection left to
                                                          - position = new Position(formatNode, PositionType.Begin); - } - - if (formatNode && core.lifecycle.defaultFormat) { - applyFormat( - formatNode, - core.lifecycle.defaultFormat, - core.lifecycle.isDarkMode, - core.darkColorHandler - ); - } - - // If this is triggered by a keyboard event, let's select the new position - if (keyboardEvent) { - core.api.selectRange(core, createRange(new Position(position))); - } -}; - -function wasNodeJustCreatedByKeyboardEvent(event: KeyboardEvent, formatNode: HTMLElement) { - return ( - safeInstanceOf(event.target, 'Node') && - event.target.contains(formatNode) && - event.key === formatNode.innerText - ); -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/focus.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/focus.ts deleted file mode 100644 index 4490f5a24a0..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/focus.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { createRange, getFirstLeafNode } from 'roosterjs-editor-dom'; -import { PositionType } from 'roosterjs-editor-types'; -import type { EditorCore, Focus } from 'roosterjs-editor-types'; - -/** - * @internal - * Focus to editor. If there is a cached selection range, use it as current selection - * @param core The EditorCore object - */ -export const focus: Focus = (core: EditorCore) => { - if (!core.lifecycle.shadowEditFragment) { - if ( - !core.api.hasFocus(core) || - !core.api.getSelectionRange(core, false /*tryGetFromCache*/) - ) { - // Focus (document.activeElement indicates) and selection are mostly in sync, but could be out of sync in some extreme cases. - // i.e. if you programmatically change window selection to point to a non-focusable DOM element (i.e. tabindex=-1 etc.). - // On Chrome/Firefox, it does not change document.activeElement. On Edge/IE, it change document.activeElement to be body - // Although on Chrome/Firefox, document.activeElement points to editor, you cannot really type which we don't want (no cursor). - // So here we always do a live selection pull on DOM and make it point in Editor. The pitfall is, the cursor could be reset - // to very begin to of editor since we don't really have last saved selection (created on blur which does not fire in this case). - // It should be better than the case you cannot type - if ( - !core.domEvent.selectionRange || - !core.api.selectRange(core, core.domEvent.selectionRange, true /*skipSameRange*/) - ) { - const node = getFirstLeafNode(core.contentDiv) || core.contentDiv; - core.api.selectRange( - core, - createRange(node, PositionType.Begin), - true /*skipSameRange*/ - ); - } - } - - // remember to clear cached selection range - core.domEvent.selectionRange = null; - - // This is more a fallback to ensure editor gets focus if it didn't manage to move focus to editor - if (!core.api.hasFocus(core)) { - core.contentDiv.focus(); - } - } -}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getContent.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getContent.ts deleted file mode 100644 index 8135e2866e3..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getContent.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { ColorTransformDirection, GetContentMode, PluginEventType } from 'roosterjs-editor-types'; -import type { EditorCore, GetContent } from 'roosterjs-editor-types'; -import { - createRange, - getHtmlWithSelectionPath, - getSelectionPath, - getTextContent, - safeInstanceOf, -} from 'roosterjs-editor-dom'; -import type { CompatibleGetContentMode } from 'roosterjs-editor-types/lib/compatibleTypes'; - -/** - * @internal - * Get current editor content as HTML string - * @param core The EditorCore object - * @param mode specify what kind of HTML content to retrieve - * @returns HTML string representing current editor content - */ -export const getContent: GetContent = ( - core: EditorCore, - mode: GetContentMode | CompatibleGetContentMode -): string => { - let content: string | null = ''; - const triggerExtractContentEvent = mode == GetContentMode.CleanHTML; - const includeSelectionMarker = mode == GetContentMode.RawHTMLWithSelection; - - // When there is fragment for shadow edit, always use the cached fragment as document since HTML node in editor - // has been changed by uncommitted shadow edit which should be ignored. - const root = core.lifecycle.shadowEditFragment || core.contentDiv; - - if (mode == GetContentMode.PlainTextFast) { - content = root.textContent; - } else if (mode == GetContentMode.PlainText) { - content = getTextContent(root); - } else { - const clonedRoot = cloneNode(root); - clonedRoot.normalize(); - - const originalRange = core.api.getSelectionRange(core, true /*tryGetFromCache*/); - const path = !includeSelectionMarker - ? null - : core.lifecycle.shadowEditFragment - ? core.lifecycle.shadowEditSelectionPath - : originalRange - ? getSelectionPath(core.contentDiv, originalRange) - : null; - const range = path && createRange(clonedRoot, path.start, path.end); - - core.api.transformColor( - core, - clonedRoot, - false /*includeSelf*/, - null /*callback*/, - ColorTransformDirection.DarkToLight, - true /*forceTransform*/, - core.lifecycle.isDarkMode - ); - - if (triggerExtractContentEvent) { - core.api.triggerEvent( - core, - { - eventType: PluginEventType.ExtractContentWithDom, - clonedRoot, - }, - true /*broadcast*/ - ); - - content = clonedRoot.innerHTML; - } else if (range) { - // range is not null, which means we want to include a selection path in the content - content = getHtmlWithSelectionPath(clonedRoot, range); - } else { - content = clonedRoot.innerHTML; - } - } - - return content ?? ''; -}; - -function cloneNode(node: HTMLElement | DocumentFragment): HTMLElement { - let clonedNode: HTMLElement; - if (safeInstanceOf(node, 'DocumentFragment')) { - clonedNode = node.ownerDocument.createElement('div'); - clonedNode.appendChild(node.cloneNode(true /*deep*/)); - } else { - clonedNode = node.cloneNode(true /*deep*/) as HTMLElement; - } - - return clonedNode; -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getPendableFormatState.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getPendableFormatState.ts deleted file mode 100644 index f68e1a1ad5b..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getPendableFormatState.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { contains, getObjectKeys, getTagOfNode, Position } from 'roosterjs-editor-dom'; -import { NodeType } from 'roosterjs-editor-types'; -import type { PendableFormatNames } from 'roosterjs-editor-dom'; -import type { - EditorCore, - GetPendableFormatState, - NodePosition, - PendableFormatState, -} from 'roosterjs-editor-types'; - -/** - * @internal - * @param core The EditorCore object - * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. - * @returns The cached format state if it exists. If the cached position do not exist, search for pendable elements in the DOM tree and return the pendable format state. - */ -export const getPendableFormatState: GetPendableFormatState = ( - core: EditorCore, - forceGetStateFromDOM: boolean -): PendableFormatState => { - const range = core.api.getSelectionRange(core, true /* tryGetFromCache*/); - const cachedPendableFormatState = core.pendingFormatState.pendableFormatState; - const cachedPosition = core.pendingFormatState.pendableFormatPosition?.normalize(); - const currentPosition = range && Position.getStart(range).normalize(); - const isSamePosition = - currentPosition && - cachedPosition && - range.collapsed && - currentPosition.equalTo(cachedPosition); - - if (range && cachedPendableFormatState && isSamePosition && !forceGetStateFromDOM) { - return cachedPendableFormatState; - } else { - return currentPosition ? queryCommandStateFromDOM(core, currentPosition) : {}; - } -}; - -const PendableStyleCheckers: Record< - PendableFormatNames, - (tagName: string, style: CSSStyleDeclaration) => boolean -> = { - isBold: (tag, style) => - tag == 'B' || - tag == 'STRONG' || - tag == 'H1' || - tag == 'H2' || - tag == 'H3' || - tag == 'H4' || - tag == 'H5' || - tag == 'H6' || - parseInt(style.fontWeight) >= 700 || - ['bold', 'bolder'].indexOf(style.fontWeight) >= 0, - isUnderline: (tag, style) => tag == 'U' || style.textDecoration.indexOf('underline') >= 0, - isItalic: (tag, style) => tag == 'I' || tag == 'EM' || style.fontStyle === 'italic', - isSubscript: (tag, style) => tag == 'SUB' || style.verticalAlign === 'sub', - isSuperscript: (tag, style) => tag == 'SUP' || style.verticalAlign === 'super', - isStrikeThrough: (tag, style) => - tag == 'S' || tag == 'STRIKE' || style.textDecoration.indexOf('line-through') >= 0, -}; - -/** - * CssFalsyCheckers checks for non pendable format that might overlay a pendable format, then it can prevent getPendableFormatState return falsy pendable format states. - */ - -const CssFalsyCheckers: Record boolean> = { - isBold: style => - (style.fontWeight !== '' && parseInt(style.fontWeight) < 700) || - style.fontWeight === 'normal', - isUnderline: style => - style.textDecoration !== '' && style.textDecoration.indexOf('underline') < 0, - isItalic: style => style.fontStyle !== '' && style.fontStyle !== 'italic', - isSubscript: style => style.verticalAlign !== '' && style.verticalAlign !== 'sub', - isSuperscript: style => style.verticalAlign !== '' && style.verticalAlign !== 'super', - isStrikeThrough: style => - style.textDecoration !== '' && style.textDecoration.indexOf('line-through') < 0, -}; - -function queryCommandStateFromDOM( - core: EditorCore, - currentPosition: NodePosition -): PendableFormatState { - let node: Node | null = currentPosition.node; - const formatState: PendableFormatState = {}; - const pendableKeys: PendableFormatNames[] = []; - while (node && contains(core.contentDiv, node)) { - const tag = getTagOfNode(node); - const style = node.nodeType == NodeType.Element && (node as HTMLElement).style; - if (tag && style) { - getObjectKeys(PendableStyleCheckers).forEach(key => { - if (!(pendableKeys.indexOf(key) >= 0)) { - formatState[key] = formatState[key] || PendableStyleCheckers[key](tag, style); - if (CssFalsyCheckers[key](style)) { - pendableKeys.push(key); - } - } - }); - } - node = node.parentNode; - } - return formatState; -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRange.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRange.ts deleted file mode 100644 index 9e85478152c..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRange.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { contains, createRange } from 'roosterjs-editor-dom'; -import type { EditorCore, GetSelectionRange } from 'roosterjs-editor-types'; - -/** - * @internal - * Get current or cached selection range - * @param core The EditorCore object - * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now - * @returns A Range object of the selection range - */ -export const getSelectionRange: GetSelectionRange = ( - core: EditorCore, - tryGetFromCache: boolean -) => { - let result: Range | null = null; - - if (core.lifecycle.shadowEditFragment) { - result = - core.lifecycle.shadowEditSelectionPath && - createRange( - core.contentDiv, - core.lifecycle.shadowEditSelectionPath.start, - core.lifecycle.shadowEditSelectionPath.end - ); - - return result; - } else { - if (!tryGetFromCache || core.api.hasFocus(core)) { - const selection = core.contentDiv.ownerDocument.defaultView?.getSelection(); - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - if (contains(core.contentDiv, range)) { - result = range; - } - } - } - - if (!result && tryGetFromCache) { - result = core.domEvent.selectionRange; - } - - return result; - } -}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRangeEx.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRangeEx.ts deleted file mode 100644 index 049546a1813..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getSelectionRangeEx.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { contains, createRange, findClosestElementAncestor } from 'roosterjs-editor-dom'; -import { SelectionRangeTypes } from 'roosterjs-editor-types'; -import type { EditorCore, GetSelectionRangeEx, SelectionRangeEx } from 'roosterjs-editor-types'; - -/** - * @internal - * Get current or cached selection range - * @param core The EditorCore object - * @returns A Range object of the selection range - */ -export const getSelectionRangeEx: GetSelectionRangeEx = (core: EditorCore) => { - const result: SelectionRangeEx | null = null; - if (core.lifecycle.shadowEditFragment) { - const { - shadowEditTableSelectionPath, - shadowEditSelectionPath, - shadowEditImageSelectionPath, - } = core.lifecycle; - - if ((shadowEditTableSelectionPath?.length || 0) > 0) { - const ranges = core.lifecycle.shadowEditTableSelectionPath!.map(path => - createRange(core.contentDiv, path.start, path.end) - ); - - return { - type: SelectionRangeTypes.TableSelection, - ranges, - areAllCollapsed: checkAllCollapsed(ranges), - table: findClosestElementAncestor( - ranges[0].startContainer, - core.contentDiv, - 'table' - ) as HTMLTableElement, - coordinates: undefined, - }; - } else if ((shadowEditImageSelectionPath?.length || 0) > 0) { - const ranges = core.lifecycle.shadowEditImageSelectionPath!.map(path => - createRange(core.contentDiv, path.start, path.end) - ); - return { - type: SelectionRangeTypes.ImageSelection, - ranges, - areAllCollapsed: checkAllCollapsed(ranges), - image: findClosestElementAncestor( - ranges[0].startContainer, - core.contentDiv, - 'img' - ) as HTMLImageElement, - imageId: undefined, - }; - } else { - const shadowRange = - shadowEditSelectionPath && - createRange( - core.contentDiv, - shadowEditSelectionPath.start, - shadowEditSelectionPath.end - ); - - return createNormalSelectionEx(shadowRange ? [shadowRange] : []); - } - } else { - if (core.api.hasFocus(core)) { - if (core.domEvent.tableSelectionRange) { - return core.domEvent.tableSelectionRange; - } - - if (core.domEvent.imageSelectionRange) { - return core.domEvent.imageSelectionRange; - } - - const selection = core.contentDiv.ownerDocument.defaultView?.getSelection(); - if (!result && selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - if (contains(core.contentDiv, range)) { - return createNormalSelectionEx([range]); - } - } - } - - return ( - core.domEvent.tableSelectionRange ?? - core.domEvent.imageSelectionRange ?? - createNormalSelectionEx( - core.domEvent.selectionRange ? [core.domEvent.selectionRange] : [] - ) - ); - } -}; - -function createNormalSelectionEx(ranges: Range[]): SelectionRangeEx { - return { - type: SelectionRangeTypes.Normal, - ranges: ranges, - areAllCollapsed: checkAllCollapsed(ranges), - }; -} - -function checkAllCollapsed(ranges: Range[]): boolean { - return ranges.filter(range => range?.collapsed).length == ranges.length; -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getStyleBasedFormatState.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getStyleBasedFormatState.ts deleted file mode 100644 index 0be0504b38e..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/getStyleBasedFormatState.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { contains, getComputedStyles } from 'roosterjs-editor-dom'; -import { NodeType } from 'roosterjs-editor-types'; -import type { EditorCore, GetStyleBasedFormatState } from 'roosterjs-editor-types'; - -/** - * @internal - * Get style based format state from current selection, including font name/size and colors - * @param core The EditorCore objects - * @param node The node to get style from - */ -export const getStyleBasedFormatState: GetStyleBasedFormatState = ( - core: EditorCore, - node: Node | null -) => { - if (!node) { - return {}; - } - - let override: string[] = []; - const pendableFormatSpan = core.pendingFormatState.pendableFormatSpan; - - if (pendableFormatSpan) { - override = [ - pendableFormatSpan.style.fontFamily, - pendableFormatSpan.style.fontSize, - pendableFormatSpan.style.color, - pendableFormatSpan.style.backgroundColor, - ]; - } - - const styles = node - ? getComputedStyles(node, [ - 'font-family', - 'font-size', - 'color', - 'background-color', - 'line-height', - 'margin-top', - 'margin-bottom', - 'text-align', - 'direction', - 'font-weight', - ]) - : []; - const { contentDiv, darkColorHandler } = core; - - let styleTextColor: string | undefined; - let styleBackColor: string | undefined; - - while ( - node && - contains(contentDiv, node, true /*treatSameNodeAsContain*/) && - !(styleTextColor && styleBackColor) - ) { - if (node.nodeType == NodeType.Element) { - const element = node as HTMLElement; - - styleTextColor = styleTextColor || element.style.getPropertyValue('color'); - styleBackColor = styleBackColor || element.style.getPropertyValue('background-color'); - } - node = node.parentNode; - } - - if (!core.lifecycle.isDarkMode && node == core.contentDiv) { - styleTextColor = styleTextColor || styles[2]; - styleBackColor = styleBackColor || styles[3]; - } - - const textColor = darkColorHandler.parseColorValue(override[2] || styleTextColor); - const backColor = darkColorHandler.parseColorValue(override[3] || styleBackColor); - - return { - fontName: override[0] || styles[0], - fontSize: override[1] || styles[1], - textColor: textColor.lightModeColor, - backgroundColor: backColor.lightModeColor, - textColors: textColor.darkModeColor - ? { - lightModeColor: textColor.lightModeColor, - darkModeColor: textColor.darkModeColor, - } - : undefined, - backgroundColors: backColor.darkModeColor - ? { - lightModeColor: backColor.lightModeColor, - darkModeColor: backColor.darkModeColor, - } - : undefined, - lineHeight: styles[4], - marginTop: styles[5], - marginBottom: styles[6], - textAlign: styles[7], - direction: styles[8], - fontWeight: styles[9], - }; -}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/hasFocus.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/hasFocus.ts deleted file mode 100644 index 35ad6eb49a8..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/hasFocus.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { contains } from 'roosterjs-editor-dom'; -import type { EditorCore, HasFocus } from 'roosterjs-editor-types'; - -/** - * @internal - * Check if the editor has focus now - * @param core The EditorCore object - * @returns True if the editor has focus, otherwise false - */ -export const hasFocus: HasFocus = (core: EditorCore) => { - const activeElement = core.contentDiv.ownerDocument.activeElement; - return !!( - activeElement && contains(core.contentDiv, activeElement, true /*treatSameNodeAsContain*/) - ); -}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/insertNode.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/insertNode.ts deleted file mode 100644 index 8176d491d6a..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/insertNode.ts +++ /dev/null @@ -1,235 +0,0 @@ -import type { - BlockElement, - EditorCore, - InsertNode, - InsertOption, - NodePosition, -} from 'roosterjs-editor-types'; -import { - ContentPosition, - ColorTransformDirection, - NodeType, - PositionType, - RegionType, -} from 'roosterjs-editor-types'; -import { - createRange, - getBlockElementAtNode, - getFirstLastBlockElement, - isBlockElement, - isVoidHtmlElement, - Position, - safeInstanceOf, - toArray, - wrap, - adjustInsertPosition, - getRegionsFromRange, - splitTextNode, - splitParentNode, -} from 'roosterjs-editor-dom'; - -function getInitialRange( - core: EditorCore, - option: InsertOption -): { range: Range | null; rangeToRestore: Range | null } { - // Selection start replaces based on the current selection. - // Range inserts based on a provided range. - // Both have the potential to use the current selection to restore cursor position - // So in both cases we need to store the selection state. - let range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); - let rangeToRestore = null; - if (option.position == ContentPosition.Range) { - rangeToRestore = range; - range = option.range; - } else if (range) { - rangeToRestore = range.cloneRange(); - } - - return { range, rangeToRestore }; -} - -/** - * @internal - * Insert a DOM node into editor content - * @param core The EditorCore object. No op if null. - * @param option An insert option object to specify how to insert the node - */ -export const insertNode: InsertNode = ( - core: EditorCore, - node: Node, - option: InsertOption | null -) => { - option = option || { - position: ContentPosition.SelectionStart, - insertOnNewLine: false, - updateCursor: true, - replaceSelection: true, - insertToRegionRoot: false, - }; - const contentDiv = core.contentDiv; - - if (option.updateCursor) { - core.api.focus(core); - } - - if (option.position == ContentPosition.Outside) { - contentDiv.parentNode?.insertBefore(node, contentDiv.nextSibling); - return true; - } - - core.api.transformColor( - core, - node, - true /*includeSelf*/, - () => { - if (!option) { - return; - } - switch (option.position) { - case ContentPosition.Begin: - case ContentPosition.End: { - const isBegin = option.position == ContentPosition.Begin; - const block = getFirstLastBlockElement(contentDiv, isBegin); - let insertedNode: Node | Node[] | undefined; - if (block) { - const refNode = isBegin ? block.getStartNode() : block.getEndNode(); - if ( - option.insertOnNewLine || - refNode.nodeType == NodeType.Text || - isVoidHtmlElement(refNode) - ) { - // For insert on new line, or refNode is text or void html element (HR, BR etc.) - // which cannot have children, i.e.
                                                          hello
                                                          world
                                                          . 'hello', 'world' are the - // first and last node. Insert before 'hello' or after 'world', but still inside DIV - if (safeInstanceOf(node, 'DocumentFragment')) { - // if the node to be inserted is DocumentFragment, use its childNodes as insertedNode - // because insertBefore() returns an empty DocumentFragment - insertedNode = toArray(node.childNodes); - refNode.parentNode?.insertBefore( - node, - isBegin ? refNode : refNode.nextSibling - ); - } else { - insertedNode = refNode.parentNode?.insertBefore( - node, - isBegin ? refNode : refNode.nextSibling - ); - } - } else { - // if the refNode can have child, use appendChild (which is like to insert as first/last child) - // i.e.
                                                          hello
                                                          , the content will be inserted before/after hello - insertedNode = refNode.insertBefore( - node, - isBegin ? refNode.firstChild : null - ); - } - } else { - // No first block, this can happen when editor is empty. Use appendChild to insert the content in contentDiv - insertedNode = contentDiv.appendChild(node); - } - - // Final check to see if the inserted node is a block. If not block and the ask is to insert on new line, - // add a DIV wrapping - if (insertedNode && option.insertOnNewLine) { - const nodes = Array.isArray(insertedNode) ? insertedNode : [insertedNode]; - if (!isBlockElement(nodes[0]) || !isBlockElement(nodes[nodes.length - 1])) { - wrap(nodes); - } - } - - break; - } - case ContentPosition.DomEnd: - // Use appendChild to insert the node at the end of the content div. - const insertedNode = contentDiv.appendChild(node); - // Final check to see if the inserted node is a block. If not block and the ask is to insert on new line, - // add a DIV wrapping - if (insertedNode && option.insertOnNewLine && !isBlockElement(insertedNode)) { - wrap(insertedNode); - } - break; - case ContentPosition.Range: - case ContentPosition.SelectionStart: - let { range, rangeToRestore } = getInitialRange(core, option); - if (!range) { - return; - } - - // if to replace the selection and the selection is not collapsed, remove the the content at selection first - if (option.replaceSelection && !range.collapsed) { - range.deleteContents(); - } - - let pos: NodePosition = Position.getStart(range); - let blockElement: BlockElement | null; - - if (option.insertOnNewLine && option.insertToRegionRoot) { - pos = adjustInsertPositionRegionRoot(core, range, pos); - } else if ( - option.insertOnNewLine && - (blockElement = getBlockElementAtNode(contentDiv, pos.normalize().node)) - ) { - pos = adjustInsertPositionNewLine(blockElement, core, pos); - } else { - pos = adjustInsertPosition(contentDiv, node, pos, range); - } - - const nodeForCursor = - node.nodeType == NodeType.DocumentFragment ? node.lastChild : node; - - range = createRange(pos); - range.insertNode(node); - - if (option.updateCursor && nodeForCursor) { - rangeToRestore = createRange( - new Position(nodeForCursor, PositionType.After).normalize() - ); - } - - if (rangeToRestore) { - core.api.selectRange(core, rangeToRestore); - } - - break; - } - }, - ColorTransformDirection.LightToDark - ); - - return true; -}; - -function adjustInsertPositionRegionRoot(core: EditorCore, range: Range, position: NodePosition) { - const region = getRegionsFromRange(core.contentDiv, range, RegionType.Table)[0]; - let node: Node | null = position.node; - - if (region) { - if (node.nodeType == NodeType.Text && !position.isAtEnd) { - node = splitTextNode(node as Text, position.offset, true /*returnFirstPart*/); - } - - if (node != region.rootNode) { - while (node && node.parentNode != region.rootNode) { - splitParentNode(node, false /*splitBefore*/); - node = node.parentNode; - } - } - - if (node) { - position = new Position(node, PositionType.After); - } - } - - return position; -} - -function adjustInsertPositionNewLine(blockElement: BlockElement, core: EditorCore, pos: Position) { - let tempPos = new Position(blockElement.getEndNode(), PositionType.After); - if (safeInstanceOf(tempPos.node, 'HTMLTableRowElement')) { - const div = core.contentDiv.ownerDocument.createElement('div'); - const range = createRange(pos); - range.insertNode(div); - tempPos = new Position(div, PositionType.Begin); - } - return tempPos; -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/restoreUndoSnapshot.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/restoreUndoSnapshot.ts deleted file mode 100644 index 4433ebc3b7c..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/restoreUndoSnapshot.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { EntityOperation, PluginEventType } from 'roosterjs-editor-types'; -import { getEntityFromElement, getEntitySelector, queryElements } from 'roosterjs-editor-dom'; -import type { EditorCore, RestoreUndoSnapshot } from 'roosterjs-editor-types'; - -/** - * @internal - * Restore an undo snapshot into editor - * @param core The editor core object - * @param step Steps to move, can be 0, positive or negative - */ -export const restoreUndoSnapshot: RestoreUndoSnapshot = (core: EditorCore, step: number) => { - if (core.undo.hasNewContent && step < 0) { - core.api.addUndoSnapshot( - core, - null /*callback*/, - null /*changeSource*/, - false /*canUndoByBackspace*/ - ); - } - - const snapshot = core.undo.snapshotsService.move(step); - - if (snapshot && snapshot.html != null) { - try { - core.undo.isRestoring = true; - core.api.setContent( - core, - snapshot.html, - true /*triggerContentChangedEvent*/, - snapshot.metadata ?? undefined - ); - - const darkColorHandler = core.darkColorHandler; - const isDarkModel = core.lifecycle.isDarkMode; - - snapshot.knownColors.forEach(color => { - darkColorHandler.registerColor( - color.lightModeColor, - isDarkModel, - color.darkModeColor - ); - }); - - snapshot.entityStates?.forEach(entityState => { - const { type, id, state } = entityState; - const wrapper = queryElements( - core.contentDiv, - getEntitySelector(type, id) - )[0] as HTMLElement; - const entity = wrapper && getEntityFromElement(wrapper); - - if (entity) { - core.api.triggerEvent( - core, - { - eventType: PluginEventType.EntityOperation, - operation: EntityOperation.UpdateEntityState, - entity: entity, - state, - }, - false - ); - } - }); - } finally { - core.undo.isRestoring = false; - } - } -}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/select.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/select.ts deleted file mode 100644 index a3179bfea8e..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/select.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { contains, createRange, safeInstanceOf } from 'roosterjs-editor-dom'; -import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import type { - EditorCore, - NodePosition, - PositionType, - Select, - SelectionPath, - SelectionRangeEx, - TableSelection, -} from 'roosterjs-editor-types'; - -/** - * @internal - * Select content according to the given information. - * There are a bunch of allowed combination of parameters. See IEditor.select for more details - * @param core The editor core object - * @param arg1 A DOM Range, or SelectionRangeEx, or NodePosition, or Node, or Selection Path - * @param arg2 (optional) A NodePosition, or an offset number, or a PositionType, or a TableSelection - * @param arg3 (optional) A Node - * @param arg4 (optional) An offset number, or a PositionType - */ -export const select: Select = (core, arg1, arg2, arg3, arg4) => { - const rangeEx = buildRangeEx(core, arg1, arg2, arg3, arg4); - - if (rangeEx) { - const skipReselectOnFocus = core.domEvent.skipReselectOnFocus; - - // We are applying a new selection, so we don't need to apply cached selection in DOMEventPlugin. - // Set skipReselectOnFocus to skip this behavior - core.domEvent.skipReselectOnFocus = true; - - try { - applyRangeEx(core, rangeEx); - } finally { - core.domEvent.skipReselectOnFocus = skipReselectOnFocus; - } - } else { - core.domEvent.tableSelectionRange = core.api.selectTable(core, null); - core.domEvent.imageSelectionRange = core.api.selectImage(core, null); - } - - return !!rangeEx; -}; - -function buildRangeEx( - core: EditorCore, - arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, - arg2?: NodePosition | number | PositionType | TableSelection | null, - arg3?: Node, - arg4?: number | PositionType -) { - let rangeEx: SelectionRangeEx | null = null; - - if (isSelectionRangeEx(arg1)) { - rangeEx = arg1; - } else if (safeInstanceOf(arg1, 'HTMLTableElement') && isTableSelectionOrNull(arg2)) { - rangeEx = { - type: SelectionRangeTypes.TableSelection, - ranges: [], - areAllCollapsed: false, - table: arg1, - coordinates: arg2 ?? undefined, - }; - } else if (safeInstanceOf(arg1, 'HTMLImageElement') && typeof arg2 == 'undefined') { - rangeEx = { - type: SelectionRangeTypes.ImageSelection, - ranges: [], - areAllCollapsed: false, - image: arg1, - }; - } else { - const range = !arg1 - ? null - : safeInstanceOf(arg1, 'Range') - ? arg1 - : isSelectionPath(arg1) - ? createRange(core.contentDiv, arg1.start, arg1.end) - : isNodePosition(arg1) || safeInstanceOf(arg1, 'Node') - ? createRange( - arg1, - arg2, - arg3, - arg4 - ) - : null; - - rangeEx = range - ? { - type: SelectionRangeTypes.Normal, - ranges: [range], - areAllCollapsed: range.collapsed, - } - : null; - } - - return rangeEx; -} - -function applyRangeEx(core: EditorCore, rangeEx: SelectionRangeEx | null) { - switch (rangeEx?.type) { - case SelectionRangeTypes.TableSelection: - if (contains(core.contentDiv, rangeEx.table)) { - core.domEvent.imageSelectionRange = core.api.selectImage(core, null); - core.domEvent.tableSelectionRange = core.api.selectTable( - core, - rangeEx.table, - rangeEx.coordinates - ); - rangeEx = core.domEvent.tableSelectionRange; - } - break; - case SelectionRangeTypes.ImageSelection: - if (contains(core.contentDiv, rangeEx.image)) { - core.domEvent.tableSelectionRange = core.api.selectTable(core, null); - core.domEvent.imageSelectionRange = core.api.selectImage(core, rangeEx.image); - rangeEx = core.domEvent.imageSelectionRange; - } - break; - case SelectionRangeTypes.Normal: - core.domEvent.tableSelectionRange = core.api.selectTable(core, null); - core.domEvent.imageSelectionRange = core.api.selectImage(core, null); - - if (contains(core.contentDiv, rangeEx.ranges[0])) { - core.api.selectRange(core, rangeEx.ranges[0]); - } else { - rangeEx = null; - } - break; - } - - core.api.triggerEvent( - core, - { - eventType: PluginEventType.SelectionChanged, - selectionRangeEx: rangeEx, - }, - true /** broadcast **/ - ); -} - -function isSelectionRangeEx(obj: any): obj is SelectionRangeEx { - const rangeEx = obj as SelectionRangeEx; - return ( - rangeEx && - typeof rangeEx == 'object' && - typeof rangeEx.type == 'number' && - Array.isArray(rangeEx.ranges) - ); -} - -function isTableSelectionOrNull(obj: any): obj is TableSelection | null { - const selection = obj as TableSelection | null; - - return ( - selection === null || - (selection && - typeof selection == 'object' && - typeof selection.firstCell == 'object' && - typeof selection.lastCell == 'object') - ); -} - -function isSelectionPath(obj: any): obj is SelectionPath { - const path = obj as SelectionPath; - - return path && typeof path == 'object' && Array.isArray(path.start) && Array.isArray(path.end); -} - -function isNodePosition(obj: any): obj is NodePosition { - const pos = obj as NodePosition; - - return ( - pos && - typeof pos == 'object' && - typeof pos.node == 'object' && - typeof pos.offset == 'number' - ); -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectImage.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectImage.ts deleted file mode 100644 index 44bcfb974e6..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectImage.ts +++ /dev/null @@ -1,65 +0,0 @@ -import addUniqueId from './utils/addUniqueId'; -import { PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { - createRange, - Position, - removeGlobalCssStyle, - removeImportantStyleRule, - setGlobalCssStyles, -} from 'roosterjs-editor-dom'; -import type { EditorCore, ImageSelectionRange, SelectImage } from 'roosterjs-editor-types'; - -const IMAGE_ID = 'imageSelected'; -const CONTENT_DIV_ID = 'contentDiv_'; -const STYLE_ID = 'imageStyle'; -const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; - -/** - * @internal - * Select a image and save data of the selected range - * @param image Image to select - * @returns Selected image information - */ -export const selectImage: SelectImage = (core: EditorCore, image: HTMLImageElement | null) => { - unselect(core); - - let selection: ImageSelectionRange | null = null; - - if (image) { - const range = createRange(image); - - addUniqueId(image, IMAGE_ID); - addUniqueId(core.contentDiv, CONTENT_DIV_ID); - - core.api.selectRange(core, createRange(new Position(image, PositionType.After))); - - select(core, image); - - selection = { - type: SelectionRangeTypes.ImageSelection, - ranges: [range], - image: image, - areAllCollapsed: range.collapsed, - }; - } - - return selection; -}; - -const select = (core: EditorCore, image: HTMLImageElement) => { - removeImportantStyleRule(image, ['border', 'margin']); - const borderCSS = buildBorderCSS(core, image.id); - setGlobalCssStyles(core.contentDiv.ownerDocument, borderCSS, STYLE_ID + core.contentDiv.id); -}; - -const buildBorderCSS = (core: EditorCore, imageId: string): string => { - const divId = core.contentDiv.id; - const color = core.imageSelectionBorderColor || DEFAULT_SELECTION_BORDER_COLOR; - - return `#${divId} #${imageId} {outline-style: auto!important;outline-color: ${color}!important;caret-color: transparent!important;}`; -}; - -const unselect = (core: EditorCore) => { - const doc = core.contentDiv.ownerDocument; - removeGlobalCssStyle(doc, STYLE_ID + core.contentDiv.id); -}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectRange.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectRange.ts deleted file mode 100644 index d4816eb43fc..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectRange.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { hasFocus } from './hasFocus'; -import type { EditorCore, SelectRange } from 'roosterjs-editor-types'; -import { - contains, - getPendableFormatState, - Position, - PendableFormatCommandMap, - addRangeToSelection, - getObjectKeys, -} from 'roosterjs-editor-dom'; - -/** - * @internal - * Change the editor selection to the given range - * @param core The EditorCore object - * @param range The range to select - * @param skipSameRange When set to true, do nothing if the given range is the same with current selection - * in editor, otherwise it will always remove current selection range and set to the given one. - * This parameter is always treat as true in Edge to avoid some weird runtime exception. - */ -export const selectRange: SelectRange = ( - core: EditorCore, - range: Range, - skipSameRange?: boolean -) => { - if (!core.lifecycle.shadowEditSelectionPath && contains(core.contentDiv, range)) { - addRangeToSelection(range, skipSameRange); - - if (!hasFocus(core)) { - core.domEvent.selectionRange = range; - } - - if (range.collapsed) { - // If selected, and current selection is collapsed, - // need to restore pending format state if exists. - restorePendingFormatState(core); - } - - return true; - } else { - return false; - } -}; - -/** - * Restore cached pending format state (if exist) to current selection - */ -function restorePendingFormatState(core: EditorCore) { - const { - contentDiv, - pendingFormatState, - api: { getSelectionRange }, - } = core; - - if (pendingFormatState.pendableFormatState) { - const document = contentDiv.ownerDocument; - const formatState = getPendableFormatState(document); - getObjectKeys(PendableFormatCommandMap).forEach(key => { - if (!!pendingFormatState.pendableFormatState?.[key] != formatState[key]) { - document.execCommand( - PendableFormatCommandMap[key], - false /* showUI */, - undefined /* value */ - ); - } - }); - - const range = getSelectionRange(core, true /*tryGetFromCache*/); - const position: Position | null = range && Position.getStart(range); - if (position) { - pendingFormatState.pendableFormatPosition = position; - } - } -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectTable.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectTable.ts deleted file mode 100644 index 3d74d61b6bd..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/selectTable.ts +++ /dev/null @@ -1,268 +0,0 @@ -import addUniqueId from './utils/addUniqueId'; -import { PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { - createRange, - getTagOfNode, - isWholeTableSelected, - Position, - removeGlobalCssStyle, - removeImportantStyleRule, - setGlobalCssStyles, - toArray, - VTable, -} from 'roosterjs-editor-dom'; -import type { EditorCore, TableSelection, SelectTable, Coordinates } from 'roosterjs-editor-types'; - -const TABLE_ID = 'tableSelected'; -const CONTENT_DIV_ID = 'contentDiv_'; -const STYLE_ID = 'tableStyle'; -const SELECTED_CSS_RULE = - '{background-color: rgb(198,198,198) !important; caret-color: transparent}'; -const MAX_RULE_SELECTOR_LENGTH = 9000; - -/** - * @internal - * Select a table and save data of the selected range - * @param core The EditorCore object - * @param table table to select - * @param coordinates first and last cell of the selection, if this parameter is null, instead of - * selecting, will unselect the table. - * @returns true if successful - */ -export const selectTable: SelectTable = ( - core: EditorCore, - table: HTMLTableElement | null, - coordinates?: TableSelection -) => { - unselect(core); - - if (areValidCoordinates(coordinates) && table) { - addUniqueId(table, TABLE_ID); - addUniqueId(core.contentDiv, CONTENT_DIV_ID); - - const { ranges, isWholeTableSelected } = select(core, table, coordinates); - if (!isMergedCell(table, coordinates)) { - const cellToSelect = table.rows - .item(coordinates.firstCell.y) - ?.cells.item(coordinates.firstCell.x); - - if (cellToSelect) { - core.api.selectRange( - core, - createRange(new Position(cellToSelect, PositionType.Begin)) - ); - } - } - - return { - type: SelectionRangeTypes.TableSelection, - ranges, - table, - areAllCollapsed: ranges.filter(range => range?.collapsed).length == ranges.length, - coordinates, - isWholeTableSelected, - }; - } - - return null; -}; - -function buildCss( - table: HTMLTableElement, - coordinates: TableSelection, - contentDivSelector: string -): { cssRules: string[]; ranges: Range[]; isWholeTableSelected: boolean } { - const ranges: Range[] = []; - const selectors: string[] = []; - - const vTable = new VTable(table); - const isAllTableSelected = isWholeTableSelected(vTable, coordinates); - if (isAllTableSelected) { - handleAllTableSelected(contentDivSelector, vTable, selectors, ranges); - } else { - handleTableSelected(coordinates, vTable, contentDivSelector, selectors, ranges); - } - - const cssRules: string[] = []; - let currentRules: string = ''; - while (selectors.length > 0) { - currentRules += (currentRules.length > 0 ? ',' : '') + selectors.shift() || ''; - if ( - currentRules.length + (selectors[0]?.length || 0) > MAX_RULE_SELECTOR_LENGTH || - selectors.length == 0 - ) { - cssRules.push(currentRules + ' ' + SELECTED_CSS_RULE); - currentRules = ''; - } - } - - return { cssRules, ranges, isWholeTableSelected: isAllTableSelected }; -} - -function handleAllTableSelected( - contentDivSelector: string, - vTable: VTable, - selectors: string[], - ranges: Range[] -) { - const table = vTable.table; - const tableSelector = contentDivSelector + ' #' + table.id; - selectors.push(tableSelector, `${tableSelector} *`); - - const tableRange = new Range(); - tableRange.selectNode(table); - ranges.push(tableRange); -} - -function handleTableSelected( - coordinates: TableSelection, - vTable: VTable, - contentDivSelector: string, - selectors: string[], - ranges: Range[] -) { - const tr1 = coordinates.firstCell.y; - const td1 = coordinates.firstCell.x; - const tr2 = coordinates.lastCell.y; - const td2 = coordinates.lastCell.x; - const table = vTable.table; - - let firstSelected: HTMLTableCellElement | null = null; - let lastSelected: HTMLTableCellElement | null = null; - // Get whether table has thead, tbody or tfoot. - const tableChildren = toArray(table.childNodes).filter( - node => ['THEAD', 'TBODY', 'TFOOT'].indexOf(getTagOfNode(node)) > -1 - ); - // Set the start and end of each of the table children, so we can build the selector according the element between the table and the row. - let cont = 0; - const indexes = tableChildren.map(node => { - const result = { - el: getTagOfNode(node), - start: cont, - end: node.childNodes.length + cont, - }; - - cont = result.end; - return result; - }); - - vTable.cells?.forEach((row, rowIndex) => { - let tdCount = 0; - firstSelected = null; - lastSelected = null; - - //Get current TBODY/THEAD/TFOOT - const midElement = indexes.filter(ind => ind.start <= rowIndex && ind.end > rowIndex)[0]; - - const middleElSelector = midElement ? '>' + midElement.el + '>' : '>'; - const currentRow = - midElement && rowIndex + 1 >= midElement.start - ? rowIndex + 1 - midElement.start - : rowIndex + 1; - - for (let cellIndex = 0; cellIndex < row.length; cellIndex++) { - const cell = row[cellIndex].td; - if (cell) { - tdCount++; - if (rowIndex >= tr1 && rowIndex <= tr2 && cellIndex >= td1 && cellIndex <= td2) { - removeImportant(cell); - - const selector = generateCssFromCell( - contentDivSelector, - table.id, - middleElSelector, - currentRow, - getTagOfNode(cell), - tdCount - ); - const elementsSelector = selector + ' *'; - - selectors.push(selector, elementsSelector); - firstSelected = firstSelected || table.querySelector(selector); - lastSelected = table.querySelector(selector); - } - } - } - - if (firstSelected && lastSelected) { - const rowRange = new Range(); - rowRange.setStartBefore(firstSelected); - rowRange.setEndAfter(lastSelected); - ranges.push(rowRange); - } - }); -} - -function select( - core: EditorCore, - table: HTMLTableElement, - coordinates: TableSelection -): { ranges: Range[]; isWholeTableSelected: boolean } { - const contentDivSelector = '#' + core.contentDiv.id; - const { cssRules, ranges, isWholeTableSelected } = buildCss( - table, - coordinates, - contentDivSelector - ); - cssRules.forEach(css => - setGlobalCssStyles(core.contentDiv.ownerDocument, css, STYLE_ID + core.contentDiv.id) - ); - - return { ranges, isWholeTableSelected }; -} - -const unselect = (core: EditorCore) => { - const doc = core.contentDiv.ownerDocument; - removeGlobalCssStyle(doc, STYLE_ID + core.contentDiv.id); -}; - -function generateCssFromCell( - contentDivSelector: string, - tableId: string, - middleElSelector: string, - rowIndex: number, - cellTag: string, - index: number -): string { - return ( - contentDivSelector + - ' #' + - tableId + - middleElSelector + - ' tr:nth-child(' + - rowIndex + - ')>' + - cellTag + - ':nth-child(' + - index + - ')' - ); -} - -function removeImportant(cell: HTMLTableCellElement) { - if (cell) { - removeImportantStyleRule(cell, ['background-color', 'background']); - } -} - -function areValidCoordinates(input?: TableSelection): input is TableSelection { - if (input) { - const { firstCell, lastCell } = input || {}; - if (firstCell && lastCell) { - const handler = (coordinate: Coordinates) => - isValidCoordinate(coordinate.x) && isValidCoordinate(coordinate.y); - return handler(firstCell) && handler(lastCell); - } - } - - return false; -} - -function isValidCoordinate(input: number): boolean { - return (!!input || input == 0) && input > -1; -} - -function isMergedCell(table: HTMLTableElement, coordinates: TableSelection): boolean { - const { firstCell } = coordinates; - return !(table.rows.item(firstCell.y) && table.rows.item(firstCell.y)?.cells.item(firstCell.x)); -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/setContent.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/setContent.ts deleted file mode 100644 index 58e8eb86c72..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/setContent.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { - ChangeSource, - ColorTransformDirection, - PluginEventType, - SelectionRangeTypes, -} from 'roosterjs-editor-types'; -import { - createRange, - extractContentMetadata, - queryElements, - restoreContentWithEntityPlaceholder, -} from 'roosterjs-editor-dom'; -import type { ContentMetadata, EditorCore, SetContent } from 'roosterjs-editor-types'; - -/** - * @internal - * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered - * if triggerContentChangedEvent is set to true - * @param core The EditorCore object - * @param content HTML content to set in - * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true - * @param metadata @optional Metadata of the content that helps editor know the selection and color mode. - * If not passed, we will treat content as in light mode without selection - */ -export const setContent: SetContent = ( - core: EditorCore, - content: string, - triggerContentChangedEvent: boolean, - metadata?: ContentMetadata -) => { - let contentChanged = false; - if (core.contentDiv.innerHTML != content) { - core.api.triggerEvent( - core, - { - eventType: PluginEventType.BeforeSetContent, - newContent: content, - }, - true /*broadcast*/ - ); - - const entities = core.entity.entityMap; - const html = content || ''; - const body = new DOMParser().parseFromString( - core.trustedHTMLHandler?.(html) ?? html, - 'text/html' - ).body; - - restoreContentWithEntityPlaceholder(body, core.contentDiv, entities); - - const metadataFromContent = extractContentMetadata(core.contentDiv); - metadata = metadata || metadataFromContent; - selectContentMetadata(core, metadata); - contentChanged = true; - } - - const isDarkMode = core.lifecycle.isDarkMode; - - if ((!metadata && isDarkMode) || (metadata && !!metadata.isDarkMode != !!isDarkMode)) { - core.api.transformColor( - core, - core.contentDiv, - false /*includeSelf*/, - null /*callback*/, - isDarkMode ? ColorTransformDirection.LightToDark : ColorTransformDirection.DarkToLight, - true /*forceTransform*/, - metadata?.isDarkMode - ); - contentChanged = true; - } - - if (triggerContentChangedEvent && contentChanged) { - core.api.triggerEvent( - core, - { - eventType: PluginEventType.ContentChanged, - source: ChangeSource.SetContent, - }, - false /*broadcast*/ - ); - } -}; - -function selectContentMetadata(core: EditorCore, metadata: ContentMetadata | undefined) { - if (!core.lifecycle.shadowEditSelectionPath && metadata) { - core.domEvent.tableSelectionRange = null; - core.domEvent.imageSelectionRange = null; - core.domEvent.selectionRange = null; - - switch (metadata.type) { - case SelectionRangeTypes.Normal: - core.api.selectTable(core, null); - core.api.selectImage(core, null); - - const range = createRange(core.contentDiv, metadata.start, metadata.end); - core.api.selectRange(core, range); - break; - case SelectionRangeTypes.TableSelection: - const table = queryElements( - core.contentDiv, - '#' + metadata.tableId - )[0] as HTMLTableElement; - - if (table) { - core.domEvent.tableSelectionRange = core.api.selectTable(core, table, metadata); - } - break; - case SelectionRangeTypes.ImageSelection: - const image = queryElements( - core.contentDiv, - '#' + metadata.imageId - )[0] as HTMLImageElement; - - if (image) { - core.domEvent.imageSelectionRange = core.api.selectImage(core, image); - } - break; - } - } -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/switchShadowEdit.ts deleted file mode 100644 index 5d3dd632aaf..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/switchShadowEdit.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { - createRange, - getSelectionPath, - moveContentWithEntityPlaceholders, - restoreContentWithEntityPlaceholder, -} from 'roosterjs-editor-dom'; -import type { EditorCore, SelectionRangeEx, SwitchShadowEdit } from 'roosterjs-editor-types'; - -/** - * @internal - */ -export const switchShadowEdit: SwitchShadowEdit = (core: EditorCore, isOn: boolean): void => { - const { lifecycle, contentDiv } = core; - let { - shadowEditEntities, - shadowEditFragment, - shadowEditSelectionPath, - shadowEditTableSelectionPath, - shadowEditImageSelectionPath, - } = lifecycle; - const wasInShadowEdit = !!shadowEditFragment; - - const getShadowEditSelectionPath = ( - selectionType: SelectionRangeTypes, - shadowEditSelection?: SelectionRangeEx - ) => { - return ( - (shadowEditSelection?.type == selectionType && - shadowEditSelection.ranges - .map(range => getSelectionPath(contentDiv, range)) - .map(w => w!!)) || - null - ); - }; - - if (isOn) { - if (!wasInShadowEdit) { - const selection = core.api.getSelectionRangeEx(core); - const range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); - - shadowEditSelectionPath = range && getSelectionPath(contentDiv, range); - shadowEditTableSelectionPath = getShadowEditSelectionPath( - SelectionRangeTypes.TableSelection, - selection - ); - shadowEditImageSelectionPath = getShadowEditSelectionPath( - SelectionRangeTypes.ImageSelection, - selection - ); - - shadowEditEntities = {}; - shadowEditFragment = moveContentWithEntityPlaceholders(contentDiv, shadowEditEntities); - - core.api.triggerEvent( - core, - { - eventType: PluginEventType.EnteredShadowEdit, - fragment: shadowEditFragment, - selectionPath: shadowEditSelectionPath, - }, - false /*broadcast*/ - ); - - lifecycle.shadowEditFragment = shadowEditFragment; - lifecycle.shadowEditSelectionPath = shadowEditSelectionPath; - lifecycle.shadowEditTableSelectionPath = shadowEditTableSelectionPath; - lifecycle.shadowEditImageSelectionPath = shadowEditImageSelectionPath; - lifecycle.shadowEditEntities = shadowEditEntities; - } - - if (lifecycle.shadowEditFragment) { - restoreContentWithEntityPlaceholder( - lifecycle.shadowEditFragment, - contentDiv, - lifecycle.shadowEditEntities, - true /*insertClonedNode*/ - ); - } - } else { - lifecycle.shadowEditFragment = null; - lifecycle.shadowEditSelectionPath = null; - lifecycle.shadowEditEntities = null; - - if (wasInShadowEdit) { - core.api.triggerEvent( - core, - { - eventType: PluginEventType.LeavingShadowEdit, - }, - false /*broadcast*/ - ); - - if (shadowEditFragment) { - restoreContentWithEntityPlaceholder( - shadowEditFragment, - contentDiv, - shadowEditEntities - ); - } - - if (shadowEditSelectionPath) { - core.domEvent.selectionRange = createRange( - contentDiv, - shadowEditSelectionPath.start, - shadowEditSelectionPath.end - ); - } - } - } -}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/transformColor.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/transformColor.ts deleted file mode 100644 index c0b89cc38c1..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/transformColor.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { ColorTransformDirection } from 'roosterjs-editor-types'; -import type { EditorCore, TransformColor } from 'roosterjs-editor-types'; -import type { CompatibleColorTransformDirection } from 'roosterjs-editor-types/lib/compatibleTypes'; - -/** - * @internal - * Edit and transform color of elements between light mode and dark mode - * @param core The EditorCore object - * @param rootNode The root HTML elements to transform - * @param includeSelf True to transform the root node as well, otherwise false - * @param callback The callback function to invoke before do color transformation - * @param direction To specify the transform direction, light to dark, or dark to light - * @param forceTransform By default this function will only work when editor core is in dark mode. - * Pass true to this value to force do color transformation even editor core is in light mode - */ -export const transformColor: TransformColor = ( - core: EditorCore, - rootNode: Node | null, - includeSelf: boolean, - callback: (() => void) | null, - direction: ColorTransformDirection | CompatibleColorTransformDirection, - forceTransform?: boolean, - fromDarkMode: boolean = false -) => { - const { - darkColorHandler, - lifecycle: { onExternalContentTransform }, - } = core; - const toDarkMode = direction == ColorTransformDirection.LightToDark; - if (rootNode && (forceTransform || core.lifecycle.isDarkMode)) { - const transformer = onExternalContentTransform - ? (element: HTMLElement) => { - onExternalContentTransform(element, fromDarkMode, toDarkMode, darkColorHandler); - } - : (element: HTMLElement) => { - darkColorHandler.transformElementColor(element, fromDarkMode, toDarkMode); - }; - - iterateElements(rootNode, transformer, includeSelf); - } - - callback?.(); -}; - -function iterateElements( - root: Node, - transformer: (element: HTMLElement) => void, - includeSelf?: boolean -) { - if (includeSelf && isHTMLElement(root)) { - transformer(root); - } - - for (let child = root.firstChild; child; child = child.nextSibling) { - if (isHTMLElement(child)) { - transformer(child); - } - - iterateElements(child, transformer); - } -} - -// This is not a strict check, we just need to make sure this element has style so that we can set style to it -// We don't use safeInstanceOf() here since this function will be called very frequently when extract html content -// in dark mode, so we need to make sure this check is fast enough -function isHTMLElement(node: Node): node is HTMLElement { - const htmlElement = node; - return node.nodeType == Node.ELEMENT_NODE && !!htmlElement.style; -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/triggerEvent.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/triggerEvent.ts deleted file mode 100644 index 7fc7272bf7d..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/triggerEvent.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { PluginEventType } from 'roosterjs-editor-types'; -import type { EditorCore, EditorPlugin, PluginEvent, TriggerEvent } from 'roosterjs-editor-types'; -import type { CompatiblePluginEventType } from 'roosterjs-editor-types/lib/compatibleTypes'; - -const allowedEventsInShadowEdit: (PluginEventType | CompatiblePluginEventType)[] = [ - PluginEventType.EditorReady, - PluginEventType.BeforeDispose, - PluginEventType.ExtractContentWithDom, - PluginEventType.ZoomChanged, -]; - -/** - * @internal - * Trigger a plugin event - * @param core The EditorCore object - * @param pluginEvent The event object to trigger - * @param broadcast Set to true to skip the shouldHandleEventExclusively check - */ -export const triggerEvent: TriggerEvent = ( - core: EditorCore, - pluginEvent: PluginEvent, - broadcast: boolean -) => { - if ( - (!core.lifecycle.shadowEditFragment || - allowedEventsInShadowEdit.indexOf(pluginEvent.eventType) >= 0) && - (broadcast || !core.plugins.some(plugin => handledExclusively(pluginEvent, plugin))) - ) { - core.plugins.forEach(plugin => { - if (plugin.onPluginEvent) { - plugin.onPluginEvent(pluginEvent); - } - }); - } -}; - -function handledExclusively(event: PluginEvent, plugin: EditorPlugin): boolean { - if (plugin.onPluginEvent && plugin.willHandleEventExclusively?.(event)) { - plugin.onPluginEvent(event); - return true; - } - - return false; -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/utils/addUniqueId.ts b/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/utils/addUniqueId.ts deleted file mode 100644 index 9d3897bc5a3..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/coreApi/utils/addUniqueId.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @internal - * Add an unique id to element and ensure that is unique - * @param el The HTMLElement that will receive the id - * @param idPrefix The prefix that will antecede the id (Ex: tableSelected01) - */ -export default function addUniqueId(el: HTMLElement, idPrefix: string) { - const doc = el.ownerDocument; - if (!el.id) { - applyId(el, idPrefix, doc); - } else { - const elements = doc.querySelectorAll(`#${el.id}`); - if (elements.length > 1) { - el.removeAttribute('id'); - applyId(el, idPrefix, doc); - } - } -} - -function applyId(el: HTMLElement, idPrefix: string, doc: Document) { - let cont = 0; - const getElement = () => doc.getElementById(idPrefix + cont); - //Ensure that there are no elements with the same ID - let element = getElement(); - while (element) { - cont++; - element = getElement(); - } - - el.id = idPrefix + cont; -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/CopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/CopyPastePlugin.ts deleted file mode 100644 index b968d9a12cb..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/CopyPastePlugin.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { forEachSelectedCell } from './utils/forEachSelectedCell'; -import { removeCellsOutsideSelection } from './utils/removeCellsOutsideSelection'; -import { - addRangeToSelection, - createElement, - extractClipboardEvent, - moveChildNodes, - Browser, - setHtmlWithMetadata, - createRange, - VTable, - isWholeTableSelected, -} from 'roosterjs-editor-dom'; -import type { - CopyPastePluginState, - EditorOptions, - IEditor, - PluginWithState, - SelectionRangeEx, - TableSelection, -} from 'roosterjs-editor-types'; -import { - ChangeSource, - GetContentMode, - PluginEventType, - KnownCreateElementDataIndex, - SelectionRangeTypes, - TableOperation, -} from 'roosterjs-editor-types'; - -/** - * @internal - * Copy and paste plugin for handling onCopy and onPaste event - */ -export default class CopyPastePlugin implements PluginWithState { - private editor: IEditor | null = null; - private disposer: (() => void) | null = null; - private state: CopyPastePluginState; - - /** - * Construct a new instance of CopyPastePlugin - * @param options The editor options - */ - constructor(options: EditorOptions) { - this.state = { - allowedCustomPasteType: options.allowedCustomPasteType || [], - }; - } - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'CopyPaste'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - this.disposer = this.editor.addDomEventHandler({ - paste: e => this.onPaste(e), - copy: e => this.onCutCopy(e, false /*isCut*/), - cut: e => this.onCutCopy(e, true /*isCut*/), - }); - } - - /** - * Dispose this plugin - */ - dispose() { - if (this.disposer) { - this.disposer(); - } - this.disposer = null; - this.editor = null; - } - - /** - * Get plugin state object - */ - getState() { - return this.state; - } - - private onCutCopy(event: Event, isCut: boolean) { - if (this.editor) { - const selection = this.editor.getSelectionRangeEx(); - if (selection && !selection.areAllCollapsed) { - const html = this.editor.getContent(GetContentMode.RawHTMLWithSelection); - const tempDiv = this.getTempDiv(this.editor, true /*forceInLightMode*/); - const metadata = setHtmlWithMetadata( - tempDiv, - html, - this.editor.getTrustedHTMLHandler() - ); - let newRange: Range | null = null; - - if ( - selection.type === SelectionRangeTypes.TableSelection && - selection.coordinates - ) { - const table = tempDiv.querySelector( - `#${selection.table.id}` - ) as HTMLTableElement; - newRange = this.createTableRange(table, selection.coordinates); - if (isCut) { - this.deleteTableContent( - this.editor, - selection.table, - selection.coordinates - ); - } - } else if (selection.type === SelectionRangeTypes.ImageSelection) { - const image = tempDiv.querySelector('#' + selection.image.id); - - if (image) { - newRange = createRange(image); - if (isCut) { - this.deleteImage(this.editor, selection.image.id); - } - } - } else { - newRange = - metadata?.type === SelectionRangeTypes.Normal - ? createRange(tempDiv, metadata.start, metadata.end) - : null; - } - if (newRange) { - const cutCopyEvent = this.editor.triggerPluginEvent( - PluginEventType.BeforeCutCopy, - { - clonedRoot: tempDiv, - range: newRange, - rawEvent: event as ClipboardEvent, - isCut, - } - ); - - if (cutCopyEvent.range) { - addRangeToSelection(newRange); - } - - this.editor.runAsync(editor => { - this.cleanUpAndRestoreSelection(tempDiv, selection, !isCut /* isCopy */); - - if (isCut) { - editor.addUndoSnapshot(() => { - const position = editor.deleteSelectedContent(); - editor.focus(); - editor.select(position); - }, ChangeSource.Cut); - } - }); - } - } - } - } - - private onPaste = (event: Event) => { - let range: Range | null = null; - if (this.editor) { - const editor = this.editor; - extractClipboardEvent( - event as ClipboardEvent, - clipboardData => { - if (editor && !editor.isDisposed()) { - editor.paste(clipboardData); - } - }, - { - allowedCustomPasteType: this.state.allowedCustomPasteType, - getTempDiv: () => { - range = editor.getSelectionRange() ?? null; - return this.getTempDiv(editor); - }, - removeTempDiv: div => { - if (range) { - this.cleanUpAndRestoreSelection(div, range, false /* isCopy */); - } - }, - }, - this.editor.getSelectionRange() ?? undefined - ); - } - }; - - private getTempDiv(editor: IEditor, forceInLightMode?: boolean) { - const div = editor.getCustomData( - 'CopyPasteTempDiv', - () => { - const tempDiv = createElement( - KnownCreateElementDataIndex.CopyPasteTempDiv, - editor.getDocument() - ) as HTMLDivElement; - - editor.getDocument().body.appendChild(tempDiv); - - return tempDiv; - }, - tempDiv => tempDiv.parentNode?.removeChild(tempDiv) - ); - - if (forceInLightMode) { - div.style.backgroundColor = 'white'; - div.style.color = 'black'; - } - - div.style.display = ''; - div.focus(); - - return div; - } - - private cleanUpAndRestoreSelection( - tempDiv: HTMLDivElement, - range: Range | SelectionRangeEx, - isCopy: boolean - ) { - if (!!(range)?.type || (range).type == 0) { - const selection = range; - switch (selection.type) { - case SelectionRangeTypes.TableSelection: - case SelectionRangeTypes.ImageSelection: - this.editor?.select(selection); - break; - case SelectionRangeTypes.Normal: - const range = selection.ranges?.[0]; - this.restoreRange(range, isCopy); - break; - } - } else { - this.restoreRange(range, isCopy); - } - - tempDiv.style.backgroundColor = ''; - tempDiv.style.color = ''; - tempDiv.style.display = 'none'; - moveChildNodes(tempDiv); - } - - private restoreRange(range: Range, isCopy: boolean) { - if (range && this.editor) { - if (isCopy && Browser.isAndroid) { - range.collapse(); - } - this.editor.select(range); - } - } - - private createTableRange(table: HTMLTableElement, selection: TableSelection) { - const clonedVTable = new VTable(table as HTMLTableElement); - clonedVTable.selection = selection; - removeCellsOutsideSelection(clonedVTable); - clonedVTable.writeBack(); - return createRange(clonedVTable.table); - } - - private deleteTableContent( - editor: IEditor, - table: HTMLTableElement, - selection: TableSelection - ) { - const selectedVTable = new VTable(table); - selectedVTable.selection = selection; - - forEachSelectedCell(selectedVTable, cell => { - if (cell?.td) { - cell.td.innerHTML = editor.getTrustedHTMLHandler()('
                                                          '); - } - }); - - const wholeTableSelected = isWholeTableSelected(selectedVTable, selection); - const isWholeColumnSelected = - table.rows.length - 1 === selection.lastCell.y && selection.firstCell.y === 0; - if (wholeTableSelected) { - selectedVTable.edit(TableOperation.DeleteTable); - selectedVTable.writeBack(); - } else if (isWholeColumnSelected) { - selectedVTable.edit(TableOperation.DeleteColumn); - selectedVTable.writeBack(); - } - if (wholeTableSelected || isWholeColumnSelected) { - table.style.removeProperty('width'); - table.style.removeProperty('height'); - } - } - - private deleteImage(editor: IEditor, imageId: string) { - editor.queryElements('#' + imageId, node => { - editor.deleteNode(node); - }); - } -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/DOMEventPlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/DOMEventPlugin.ts deleted file mode 100644 index 206ab44193e..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/DOMEventPlugin.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { arrayPush, Browser, isCharacterValue } from 'roosterjs-editor-dom'; -import { ChangeSource, Keys, PluginEventType } from 'roosterjs-editor-types'; -import type { - ContextMenuProvider, - DOMEventHandler, - DOMEventPluginState, - EditorOptions, - EditorPlugin, - IEditor, - PluginWithState, -} from 'roosterjs-editor-types'; - -/** - * @internal - * DOMEventPlugin handles customized DOM events, including: - * 1. Keyboard event - * 2. Mouse event - * 3. IME state - * 4. Drop event - * 5. Focus and blur event - * 6. Input event - * 7. Scroll event - * It contains special handling for Safari since Safari cannot get correct selection when onBlur event is triggered in editor. - */ -export default class DOMEventPlugin implements PluginWithState { - private editor: IEditor | null = null; - private disposer: (() => void) | null = null; - private state: DOMEventPluginState; - - /** - * Construct a new instance of DOMEventPlugin - * @param options The editor options - * @param contentDiv The editor content DIV - */ - constructor(options: EditorOptions, contentDiv: HTMLDivElement) { - this.state = { - isInIME: false, - scrollContainer: options.scrollContainer || contentDiv, - selectionRange: null, - stopPrintableKeyboardEventPropagation: !options.allowKeyboardEventPropagation, - contextMenuProviders: - options.plugins?.filter>(isContextMenuProvider) || [], - tableSelectionRange: null, - imageSelectionRange: null, - }; - } - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'DOMEvent'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - - const document = this.editor.getDocument(); - //Record - const eventHandlers: Partial< - { [P in keyof HTMLElementEventMap]: DOMEventHandler } - > = { - // 1. Keyboard event - keypress: this.getEventHandler(PluginEventType.KeyPress), - keydown: this.getEventHandler(PluginEventType.KeyDown), - keyup: this.getEventHandler(PluginEventType.KeyUp), - - // 2. Mouse event - mousedown: PluginEventType.MouseDown, - contextmenu: this.onContextMenuEvent, - - // 3. IME state management - compositionstart: () => (this.state.isInIME = true), - compositionend: (rawEvent: CompositionEvent) => { - this.state.isInIME = false; - editor.triggerPluginEvent(PluginEventType.CompositionEnd, { - rawEvent, - }); - }, - - // 4. Drag and Drop event - dragstart: this.onDragStart, - drop: this.onDrop, - - // 5. Focus management - focus: this.onFocus, - - // 6. Input event - [Browser.isIE ? 'textinput' : 'input']: this.getEventHandler(PluginEventType.Input), - }; - - // 7. onBlur handlers - if (Browser.isSafari) { - document.addEventListener('mousedown', this.onMouseDownDocument, true /*useCapture*/); - document.addEventListener('keydown', this.onKeyDownDocument); - document.defaultView?.addEventListener('blur', this.cacheSelection); - } else if (Browser.isIEOrEdge) { - type EventHandlersIE = { - beforedeactivate: DOMEventHandler; - }; - (eventHandlers as EventHandlersIE).beforedeactivate = this.cacheSelection; - } else { - eventHandlers.blur = this.cacheSelection; - } - - this.disposer = editor.addDomEventHandler(>eventHandlers); - - // 8. Scroll event - this.state.scrollContainer.addEventListener('scroll', this.onScroll); - document.defaultView?.addEventListener('scroll', this.onScroll); - document.defaultView?.addEventListener('resize', this.onScroll); - } - - /** - * Dispose this plugin - */ - dispose() { - const document = this.editor?.getDocument(); - if (document && Browser.isSafari) { - document.removeEventListener( - 'mousedown', - this.onMouseDownDocument, - true /*useCapture*/ - ); - document.removeEventListener('keydown', this.onKeyDownDocument); - document.defaultView?.removeEventListener('blur', this.cacheSelection); - } - - document?.defaultView?.removeEventListener('resize', this.onScroll); - document?.defaultView?.removeEventListener('scroll', this.onScroll); - this.state.scrollContainer.removeEventListener('scroll', this.onScroll); - this.disposer?.(); - this.disposer = null; - this.editor = null; - } - - /** - * Get plugin state object - */ - getState() { - return this.state; - } - - private onDragStart = (e: Event) => { - const dragEvent = e as DragEvent; - const element = this.editor?.getElementAtCursor('*', dragEvent.target as Node); - - if (element && !element.isContentEditable) { - dragEvent.preventDefault(); - } - }; - private onDrop = () => { - this.editor?.runAsync(editor => { - editor.addUndoSnapshot(() => {}, ChangeSource.Drop); - }); - }; - - private onFocus = () => { - if (!this.state.skipReselectOnFocus) { - const { table, coordinates } = this.state.tableSelectionRange || {}; - const { image } = this.state.imageSelectionRange || {}; - - if (table && coordinates) { - this.editor?.select(table, coordinates); - } else if (image) { - this.editor?.select(image); - } else if (this.state.selectionRange) { - this.editor?.select(this.state.selectionRange); - } - } - - this.state.selectionRange = null; - }; - private onKeyDownDocument = (event: KeyboardEvent) => { - if (event.which == Keys.TAB && !event.defaultPrevented) { - this.cacheSelection(); - } - }; - - private onMouseDownDocument = (event: MouseEvent) => { - if ( - this.editor && - !this.state.selectionRange && - !this.editor.contains(event.target as Node) - ) { - this.cacheSelection(); - } - }; - - private cacheSelection = () => { - if (!this.state.selectionRange && this.editor) { - this.state.selectionRange = this.editor.getSelectionRange(false /*tryGetFromCache*/); - } - }; - private onScroll = (e: Event) => { - this.editor?.triggerPluginEvent(PluginEventType.Scroll, { - rawEvent: e, - scrollContainer: this.state.scrollContainer, - }); - }; - - private getEventHandler(eventType: PluginEventType): DOMEventHandler { - const beforeDispatch = (event: Event) => - eventType == PluginEventType.Input - ? this.onInputEvent(event) - : this.onKeyboardEvent(event); - - return this.state.stopPrintableKeyboardEventPropagation - ? { - pluginEventType: eventType, - beforeDispatch, - } - : eventType; - } - - private onKeyboardEvent = (event: KeyboardEvent) => { - if (isCharacterValue(event) || (event.which >= Keys.PAGEUP && event.which <= Keys.DOWN)) { - // Stop propagation for Character keys and Up/Down/Left/Right/Home/End/PageUp/PageDown - // since editor already handles these keys and no need to propagate to parents - event.stopPropagation(); - } - }; - - private onInputEvent = (event: InputEvent) => { - event.stopPropagation(); - }; - - private onContextMenuEvent = (event: MouseEvent) => { - const allItems: any[] = []; - const searcher = this.editor?.getContentSearcherOfCursor(); - const elementBeforeCursor = searcher?.getInlineElementBefore(); - - let eventTargetNode = event.target as Node; - if (event.button != 2 && elementBeforeCursor) { - eventTargetNode = elementBeforeCursor.getContainerNode(); - } - this.state.contextMenuProviders.forEach(provider => { - const items = provider.getContextMenuItems(eventTargetNode) ?? []; - if (items?.length > 0) { - if (allItems.length > 0) { - allItems.push(null); - } - arrayPush(allItems, items); - } - }); - this.editor?.triggerPluginEvent(PluginEventType.ContextMenu, { - rawEvent: event, - items: allItems, - }); - }; -} - -function isContextMenuProvider(source: EditorPlugin): source is ContextMenuProvider { - return !!(>source)?.getContextMenuItems; -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EditPlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EditPlugin.ts deleted file mode 100644 index ea627186823..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EditPlugin.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { isCtrlOrMetaPressed } from 'roosterjs-editor-dom'; -import { Keys, PluginEventType } from 'roosterjs-editor-types'; -import type { - EditPluginState, - GenericContentEditFeature, - IEditor, - PluginEvent, - PluginWithState, -} from 'roosterjs-editor-types'; - -/** - * @internal - * Edit Component helps handle Content edit features - */ -export default class EditPlugin implements PluginWithState { - private editor: IEditor | null = null; - private state: EditPluginState; - - /** - * Construct a new instance of EditPlugin - * @param options The editor options - */ - constructor() { - this.state = { - features: {}, - }; - } - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'Edit'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - } - - /** - * Dispose this plugin - */ - dispose() { - this.editor = null; - } - - /** - * Get plugin state object - */ - getState() { - return this.state; - } - - /** - * Handle events triggered from editor - * @param event PluginEvent object - */ - onPluginEvent(event: PluginEvent) { - let hasFunctionKey = false; - let features: GenericContentEditFeature[] | null = null; - let ctrlOrMeta = false; - const isKeyDownEvent = event.eventType == PluginEventType.KeyDown; - - if (isKeyDownEvent) { - const rawEvent = event.rawEvent; - const range = this.editor?.getSelectionRange(); - - ctrlOrMeta = isCtrlOrMetaPressed(rawEvent); - hasFunctionKey = ctrlOrMeta || rawEvent.altKey; - features = - this.state.features[rawEvent.which] || - (range && !range.collapsed && this.state.features[Keys.RANGE]); - } else if (event.eventType == PluginEventType.ContentChanged) { - features = this.state.features[Keys.CONTENTCHANGED]; - } - - for (let i = 0; features && i < features?.length; i++) { - const feature = features[i]; - if ( - (feature.allowFunctionKeys || !hasFunctionKey) && - this.editor && - feature.shouldHandleEvent(event, this.editor, ctrlOrMeta) - ) { - feature.handleEvent(event, this.editor); - if (isKeyDownEvent) { - event.handledByEditFeature = true; - } - break; - } - } - } -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EntityPlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EntityPlugin.ts deleted file mode 100644 index df8d8198e33..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/EntityPlugin.ts +++ /dev/null @@ -1,390 +0,0 @@ -import { - inlineEntityOnPluginEvent, - normalizeDelimitersInEditor, -} from './utils/inlineEntityOnPluginEvent'; -import { - Browser, - commitEntity, - getEntityFromElement, - getEntitySelector, - isCharacterValue, - toArray, - arrayPush, - createElement, - addRangeToSelection, - createRange, - isBlockElement, - getObjectKeys, -} from 'roosterjs-editor-dom'; -import type { - ContentChangedEvent, - Entity, - EntityOperationEvent, - EntityPluginState, - KnownEntityItem, - HtmlSanitizerOptions, - IEditor, - PluginEvent, - PluginMouseUpEvent, - PluginWithState, -} from 'roosterjs-editor-types'; -import { - ChangeSource, - ContentPosition, - EntityClasses, - EntityOperation, - Keys, - PluginEventType, - QueryScope, -} from 'roosterjs-editor-types'; -import type { CompatibleEntityOperation } from 'roosterjs-editor-types/lib/compatibleTypes'; - -const ENTITY_ID_REGEX = /_(\d{1,8})$/; - -const ENTITY_CSS_REGEX = '^' + EntityClasses.ENTITY_INFO_NAME + '$'; -const ENTITY_ID_CSS_REGEX = '^' + EntityClasses.ENTITY_ID_PREFIX; -const ENTITY_TYPE_CSS_REGEX = '^' + EntityClasses.ENTITY_TYPE_PREFIX; -const ENTITY_READONLY_CSS_REGEX = '^' + EntityClasses.ENTITY_READONLY_PREFIX; -const ALLOWED_CSS_CLASSES = [ - ENTITY_CSS_REGEX, - ENTITY_ID_CSS_REGEX, - ENTITY_TYPE_CSS_REGEX, - ENTITY_READONLY_CSS_REGEX, -]; -const REMOVE_ENTITY_OPERATIONS: (EntityOperation | CompatibleEntityOperation)[] = [ - EntityOperation.Overwrite, - EntityOperation.PartialOverwrite, - EntityOperation.RemoveFromStart, - EntityOperation.RemoveFromEnd, -]; - -/** - * @internal - * Entity Plugin helps handle all operations related to an entity and generate entity specified events - */ -export default class EntityPlugin implements PluginWithState { - private editor: IEditor | null = null; - private state: EntityPluginState; - - /** - * Construct a new instance of EntityPlugin - */ - constructor() { - this.state = { - entityMap: {}, - }; - } - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'Entity'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - } - - /** - * Dispose this plugin - */ - dispose() { - this.editor = null; - this.state.entityMap = {}; - } - - /** - * Get plugin state object - */ - getState() { - return this.state; - } - - /** - * Handle events triggered from editor - * @param event PluginEvent object - */ - onPluginEvent(event: PluginEvent) { - switch (event.eventType) { - case PluginEventType.MouseUp: - this.handleMouseUpEvent(event); - break; - case PluginEventType.KeyDown: - this.handleKeyDownEvent(event.rawEvent); - break; - case PluginEventType.BeforeCutCopy: - if (event.isCut) { - this.handleCutEvent(event.rawEvent); - } - break; - case PluginEventType.BeforePaste: - this.handleBeforePasteEvent(event.sanitizingOption); - break; - case PluginEventType.ContentChanged: - this.handleContentChangedEvent(event); - break; - case PluginEventType.EditorReady: - this.handleContentChangedEvent(); - break; - case PluginEventType.ExtractContentWithDom: - this.handleExtractContentWithDomEvent(event.clonedRoot); - break; - case PluginEventType.ContextMenu: - this.handleContextMenuEvent(event.rawEvent); - break; - case PluginEventType.EntityOperation: - this.handleEntityOperationEvent(event); - break; - } - - if (this.editor) { - inlineEntityOnPluginEvent(event, this.editor); - } - } - - private handleContextMenuEvent(event: UIEvent) { - const node = event.target as Node; - const entityElement = node && this.editor?.getElementAtCursor(getEntitySelector(), node); - - if (entityElement) { - event.preventDefault(); - this.triggerEvent(entityElement, EntityOperation.ContextMenu, event); - } - } - - private handleCutEvent = (event: ClipboardEvent) => { - const range = this.editor?.getSelectionRange(); - if (range && !range.collapsed) { - this.checkRemoveEntityForRange(event); - } - }; - - private handleMouseUpEvent(event: PluginMouseUpEvent) { - const { rawEvent, isClicking } = event; - const node = rawEvent.target as Node; - let entityElement: HTMLElement | null; - - if ( - this.editor && - isClicking && - node && - !!(entityElement = this.editor.getElementAtCursor(getEntitySelector(), node)) - ) { - this.triggerEvent(entityElement, EntityOperation.Click, rawEvent); - - workaroundSelectionIssueForIE(this.editor); - } - } - - private handleKeyDownEvent(event: KeyboardEvent) { - if ( - isCharacterValue(event) || - event.which == Keys.BACKSPACE || - event.which == Keys.DELETE || - event.which == Keys.ENTER - ) { - const range = this.editor?.getSelectionRange(); - if (range && !range.collapsed) { - this.checkRemoveEntityForRange(event); - } - } - } - - private handleBeforePasteEvent(sanitizingOption: HtmlSanitizerOptions) { - const range = this.editor?.getSelectionRange(); - - if (range && !range.collapsed) { - this.checkRemoveEntityForRange(null! /*rawEvent*/); - } - - if (sanitizingOption.additionalAllowedCssClasses) { - arrayPush(sanitizingOption.additionalAllowedCssClasses, ALLOWED_CSS_CLASSES); - } - } - - private handleContentChangedEvent(event?: ContentChangedEvent) { - let shouldNormalizeDelimiters: boolean = false; - // 1. find removed entities - getObjectKeys(this.state.entityMap).forEach(id => { - const item = this.state.entityMap[id]; - const element = item.element; - - if (this.editor && !item.isDeleted && !this.editor.contains(element)) { - item.isDeleted = true; - - this.triggerEvent(element, EntityOperation.Overwrite); - - if ( - !shouldNormalizeDelimiters && - !element.isContentEditable && - !isBlockElement(element) - ) { - shouldNormalizeDelimiters = true; - } - } - }); - - // 2. collect all new entities - const newEntities = - event?.source == ChangeSource.InsertEntity && event.data - ? [event.data as Entity] - : this.getExistingEntities().filter(entity => { - const item = this.state.entityMap[entity.id]; - - return !item || item.element != entity.wrapper || item.isDeleted; - }); - - // 3. Add new entities to known entity list, and hydrate - newEntities.forEach(entity => { - const { wrapper, type, id, isReadonly } = entity; - - entity.id = this.ensureUniqueId(type, id, wrapper); - commitEntity(wrapper, type, isReadonly, entity.id); // Use entity.id here because it is newly updated - this.handleNewEntity(entity); - }); - - if (shouldNormalizeDelimiters && this.editor) { - normalizeDelimitersInEditor(this.editor); - } - } - - private handleEntityOperationEvent(event: EntityOperationEvent) { - if (this.editor && REMOVE_ENTITY_OPERATIONS.indexOf(event.operation) >= 0) { - const item = this.state.entityMap[event.entity.id]; - - if (item) { - item.isDeleted = true; - } - } - } - - private handleExtractContentWithDomEvent(root: HTMLElement) { - toArray(root.querySelectorAll(getEntitySelector())).forEach(element => { - element.removeAttribute('contentEditable'); - - this.triggerEvent(element as HTMLElement, EntityOperation.ReplaceTemporaryContent); - }); - } - - private checkRemoveEntityForRange(event: Event) { - const editableEntityElements: HTMLElement[] = []; - const selector = getEntitySelector(); - this.editor?.queryElements(selector, QueryScope.OnSelection, element => { - if (element.isContentEditable) { - editableEntityElements.push(element); - } else { - this.triggerEvent(element, EntityOperation.Overwrite, event); - } - }); - - // For editable entities, we need to check if it is fully or partially covered by current selection, - // and trigger different events; - if (this.editor && editableEntityElements.length > 0) { - const inSelectionEntityElements = this.editor.queryElements( - selector, - QueryScope.InSelection - ); - editableEntityElements.forEach(element => { - const isFullyCovered = inSelectionEntityElements.indexOf(element) >= 0; - this.triggerEvent( - element, - isFullyCovered ? EntityOperation.Overwrite : EntityOperation.PartialOverwrite, - event - ); - }); - } - } - - private triggerEvent(element: HTMLElement, operation: EntityOperation, rawEvent?: Event) { - const entity = element && getEntityFromElement(element); - - return entity - ? this.editor?.triggerPluginEvent(PluginEventType.EntityOperation, { - operation, - rawEvent, - entity, - }) - : null; - } - - private handleNewEntity(entity: Entity) { - const { wrapper } = entity; - const event = this.triggerEvent(wrapper, EntityOperation.NewEntity); - - const newItem: KnownEntityItem = { - element: entity.wrapper, - }; - - if (event?.shouldPersist) { - newItem.canPersist = true; - } - - this.state.entityMap[entity.id] = newItem; - } - - private getExistingEntities(): Entity[] { - return ( - this.editor - ?.queryElements(getEntitySelector()) - .map(getEntityFromElement) - .filter((x): x is Entity => !!x) ?? [] - ); - } - - private ensureUniqueId(type: string, id: string, wrapper: HTMLElement) { - const match = ENTITY_ID_REGEX.exec(id); - const baseId = (match ? id.substr(0, id.length - match[0].length) : id) || type; - - // Make sure entity id is unique - let newId = ''; - - for (let num = (match && parseInt(match[1])) || 0; ; num++) { - newId = num > 0 ? `${baseId}_${num}` : baseId; - - const item = this.state.entityMap[newId]; - - if (!item || item.element == wrapper) { - break; - } - } - - return newId; - } -} - -/** - * IE will show a resize border around the readonly content within content editable DIV - * This is a workaround to remove it by temporarily move focus out of editor - */ -const workaroundSelectionIssueForIE = Browser.isIE - ? (editor: IEditor) => { - editor.runAsync(editor => { - const workaroundButton = editor.getCustomData('ENTITY_IE_FOCUS_BUTTON', () => { - const button = createElement( - { - tag: 'button', - style: 'overflow:hidden;position:fixed;width:0;height:0;top:-1000px', - }, - editor.getDocument() - ) as HTMLElement; - button.onblur = () => { - button.style.display = 'none'; - }; - - editor.insertNode(button, { - position: ContentPosition.Outside, - }); - - return button; - }); - - workaroundButton.style.display = ''; - addRangeToSelection(createRange(workaroundButton, 0)); - }); - } - : () => {}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/ImageSelection.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/ImageSelection.ts deleted file mode 100644 index 1c43351ca57..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/ImageSelection.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { PluginEventType, PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { Position, safeInstanceOf } from 'roosterjs-editor-dom'; -import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; - -const Escape = 'Escape'; -const Delete = 'Delete'; -const mouseMiddleButton = 1; - -/** - * @internal - * Detect image selection and help highlight the image - */ -export default class ImageSelection implements EditorPlugin { - private editor: IEditor | null = null; - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'ImageSelection'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - } - - /** - * Dispose this plugin - */ - dispose() { - this.editor?.select(null); - this.editor = null; - } - - onPluginEvent(event: PluginEvent) { - if (this.editor) { - switch (event.eventType) { - case PluginEventType.MouseUp: - const target = event.rawEvent.target; - if ( - safeInstanceOf(target, 'HTMLImageElement') && - target.isContentEditable && - event.rawEvent.button != mouseMiddleButton - ) { - this.editor.select(target); - } - break; - case PluginEventType.MouseDown: - const mouseTarget = event.rawEvent.target; - const mouseSelection = this.editor.getSelectionRangeEx(); - if ( - mouseSelection && - mouseSelection.type === SelectionRangeTypes.ImageSelection && - mouseSelection.image !== mouseTarget - ) { - this.editor.select(null); - } - break; - case PluginEventType.KeyDown: - const rawEvent = event.rawEvent; - const key = rawEvent.key; - const keyDownSelection = this.editor.getSelectionRangeEx(); - if ( - !rawEvent.ctrlKey && - !rawEvent.altKey && - !rawEvent.shiftKey && - !rawEvent.metaKey && - keyDownSelection.type === SelectionRangeTypes.ImageSelection - ) { - if (key === Escape) { - this.editor.select(keyDownSelection.image, PositionType.Before); - this.editor.getSelectionRange()?.collapse(); - event.rawEvent.stopPropagation(); - } else if (key === Delete) { - this.editor.deleteNode(keyDownSelection.image); - event.rawEvent.preventDefault(); - } else { - const position = new Position( - keyDownSelection.image, - PositionType.Before - ); - - this.editor.select(position); - } - } - break; - case PluginEventType.ContextMenu: - const contextMenuTarget = event.rawEvent.target; - const actualSelection = this.editor.getSelectionRangeEx(); - if ( - safeInstanceOf(contextMenuTarget, 'HTMLImageElement') && - (actualSelection.type !== SelectionRangeTypes.ImageSelection || - actualSelection.image !== contextMenuTarget) - ) { - this.editor.select(contextMenuTarget); - } - } - } - } -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/LifecyclePlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/LifecyclePlugin.ts deleted file mode 100644 index cd17471380c..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/LifecyclePlugin.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { ChangeSource, PluginEventType } from 'roosterjs-editor-types'; -import { getObjectKeys, setColor } from 'roosterjs-editor-dom'; -import type { - EditorOptions, - IEditor, - LifecyclePluginState, - PluginWithState, - PluginEvent, -} from 'roosterjs-editor-types'; - -const CONTENT_EDITABLE_ATTRIBUTE_NAME = 'contenteditable'; - -const DARK_MODE_DEFAULT_FORMAT = { - backgroundColors: { - darkModeColor: 'rgb(51,51,51)', - lightModeColor: 'rgb(255,255,255)', - }, - textColors: { - darkModeColor: 'rgb(255,255,255)', - lightModeColor: 'rgb(0,0,0)', - }, -}; - -/** - * @internal - * Lifecycle plugin handles editor initialization and disposing - */ -export default class LifecyclePlugin implements PluginWithState { - private editor: IEditor | null = null; - private state: LifecyclePluginState; - private initialContent: string; - private initializer: (() => void) | null = null; - private disposer: (() => void) | null = null; - private adjustColor: () => void; - - /** - * Construct a new instance of LifecyclePlugin - * @param options The editor options - * @param contentDiv The editor content DIV - */ - constructor(options: EditorOptions, contentDiv: HTMLDivElement) { - this.initialContent = options.initialContent || contentDiv.innerHTML || ''; - - // Make the container editable and set its selection styles - if (contentDiv.getAttribute(CONTENT_EDITABLE_ATTRIBUTE_NAME) === null) { - this.initializer = () => { - contentDiv.contentEditable = 'true'; - contentDiv.style.userSelect = 'text'; - }; - this.disposer = () => { - contentDiv.style.userSelect = ''; - contentDiv.removeAttribute(CONTENT_EDITABLE_ATTRIBUTE_NAME); - }; - } - this.adjustColor = options.doNotAdjustEditorColor - ? () => {} - : () => { - const { textColors, backgroundColors } = DARK_MODE_DEFAULT_FORMAT; - const { isDarkMode } = this.state; - const darkColorHandler = this.editor?.getDarkColorHandler(); - setColor( - contentDiv, - textColors, - false /*isBackground*/, - isDarkMode, - false /*shouldAdaptFontColor*/, - darkColorHandler - ); - setColor( - contentDiv, - backgroundColors, - true /*isBackground*/, - isDarkMode, - false /*shouldAdaptFontColor*/, - darkColorHandler - ); - }; - - const getDarkColor = options.getDarkColor ?? ((color: string) => color); - const defaultFormat = options.defaultFormat ? { ...options.defaultFormat } : null; - - if (defaultFormat) { - if (defaultFormat.textColor && !defaultFormat.textColors) { - defaultFormat.textColors = { - lightModeColor: defaultFormat.textColor, - darkModeColor: getDarkColor(defaultFormat.textColor), - }; - delete defaultFormat.textColor; - } - - if (defaultFormat.backgroundColor && !defaultFormat.backgroundColors) { - defaultFormat.backgroundColors = { - lightModeColor: defaultFormat.backgroundColor, - darkModeColor: getDarkColor(defaultFormat.backgroundColor), - }; - delete defaultFormat.backgroundColor; - } - } - - this.state = { - customData: {}, - defaultFormat, - isDarkMode: !!options.inDarkMode, - getDarkColor, - onExternalContentTransform: options.onExternalContentTransform ?? null, - experimentalFeatures: options.experimentalFeatures || [], - shadowEditFragment: null, - shadowEditEntities: null, - shadowEditSelectionPath: null, - shadowEditTableSelectionPath: null, - shadowEditImageSelectionPath: null, - }; - } - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'Lifecycle'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - - // Ensure initial content and its format - this.editor.setContent(this.initialContent, false /*triggerContentChangedEvent*/); - - // Set content DIV to be editable - this.initializer?.(); - - // Set editor background color for dark mode - this.adjustColor(); - - // Let other plugins know that we are ready - this.editor.triggerPluginEvent(PluginEventType.EditorReady, {}, true /*broadcast*/); - } - - /** - * Dispose this plugin - */ - dispose() { - this.editor?.triggerPluginEvent(PluginEventType.BeforeDispose, {}, true /*broadcast*/); - - getObjectKeys(this.state.customData).forEach(key => { - const data = this.state.customData[key]; - - if (data && data.disposer) { - data.disposer(data.value); - } - - delete this.state.customData[key]; - }); - - if (this.disposer) { - this.disposer(); - this.disposer = null; - this.initializer = null; - } - - this.editor = null; - } - - /** - * Get plugin state object - */ - getState() { - return this.state; - } - - /** - * Handle events triggered from editor - * @param event PluginEvent object - */ - onPluginEvent(event: PluginEvent) { - if ( - event.eventType == PluginEventType.ContentChanged && - (event.source == ChangeSource.SwitchToDarkMode || - event.source == ChangeSource.SwitchToLightMode) - ) { - this.state.isDarkMode = event.source == ChangeSource.SwitchToDarkMode; - this.adjustColor(); - } - } -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/MouseUpPlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/MouseUpPlugin.ts deleted file mode 100644 index 2f072e3b10d..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/MouseUpPlugin.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { PluginEventType } from 'roosterjs-editor-types'; -import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; - -/** - * @internal - * MouseUpPlugin help trigger MouseUp event even when mouse up happens outside editor - * as long as the mouse was pressed within Editor before - */ -export default class MouseUpPlugin implements EditorPlugin { - private editor: IEditor | null = null; - private mouseUpEventListerAdded: boolean = false; - private mouseDownX: number | null = null; - private mouseDownY: number | null = null; - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'MouseUp'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - } - - /** - * Dispose this plugin - */ - dispose() { - this.removeMouseUpEventListener(); - this.editor = null; - } - - /** - * Handle events triggered from editor - * @param event PluginEvent object - */ - onPluginEvent(event: PluginEvent) { - if ( - this.editor && - event.eventType == PluginEventType.MouseDown && - !this.mouseUpEventListerAdded - ) { - this.editor - .getDocument() - .addEventListener('mouseup', this.onMouseUp, true /*setCapture*/); - this.mouseUpEventListerAdded = true; - this.mouseDownX = event.rawEvent.pageX; - this.mouseDownY = event.rawEvent.pageY; - } - } - private removeMouseUpEventListener() { - if (this.editor && this.mouseUpEventListerAdded) { - this.mouseUpEventListerAdded = false; - this.editor.getDocument().removeEventListener('mouseup', this.onMouseUp, true); - } - } - - private onMouseUp = (rawEvent: MouseEvent) => { - if (this.editor) { - this.removeMouseUpEventListener(); - this.editor.triggerPluginEvent(PluginEventType.MouseUp, { - rawEvent, - isClicking: this.mouseDownX == rawEvent.pageX && this.mouseDownY == rawEvent.pageY, - }); - } - }; -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/NormalizeTablePlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/NormalizeTablePlugin.ts deleted file mode 100644 index 79ce990da14..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/NormalizeTablePlugin.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { - changeElementTag, - getTagOfNode, - moveChildNodes, - safeInstanceOf, - toArray, -} from 'roosterjs-editor-dom'; -import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; - -/** - * @internal - * TODO: Rename this plugin since it is not only for table now - * - * NormalizeTable plugin makes sure each table in editor has TBODY/THEAD/TFOOT tag around TR tags - * - * When we retrieve HTML content using innerHTML, browser will always add TBODY around TR nodes if there is not. - * This causes some issue when we restore the HTML content with selection path since the selection path is - * deeply coupled with DOM structure. So we need to always make sure there is already TBODY tag whenever - * new table is inserted, to make sure the selection path we created is correct. - */ -export default class NormalizeTablePlugin implements EditorPlugin { - private editor: IEditor | null = null; - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'NormalizeTable'; - } - - /** - * The first method that editor will call to a plugin when editor is initializing. - * It will pass in the editor instance, plugin should take this chance to save the - * editor reference so that it can call to any editor method or format API later. - * @param editor The editor object - */ - initialize(editor: IEditor) { - this.editor = editor; - } - - /** - * The last method that editor will call to a plugin before it is disposed. - * Plugin can take this chance to clear the reference to editor. After this method is - * called, plugin should not call to any editor method since it will result in error. - */ - dispose() { - this.editor = null; - } - - /** - * Core method for a plugin. Once an event happens in editor, editor will call this - * method of each plugin to handle the event as long as the event is not handled - * exclusively by another plugin. - * @param event The event to handle: - */ - onPluginEvent(event: PluginEvent) { - switch (event.eventType) { - case PluginEventType.EditorReady: - case PluginEventType.ContentChanged: - if (this.editor) { - this.normalizeTables(this.editor.queryElements('table')); - } - break; - - case PluginEventType.BeforePaste: - this.normalizeTables(toArray(event.fragment.querySelectorAll('table'))); - break; - - case PluginEventType.MouseDown: - this.normalizeTableFromEvent(event.rawEvent); - break; - - case PluginEventType.KeyDown: - if (event.rawEvent.shiftKey) { - this.normalizeTableFromEvent(event.rawEvent); - } - break; - - case PluginEventType.ExtractContentWithDom: - normalizeListsForExport(event.clonedRoot); - break; - } - } - - private normalizeTableFromEvent(event: KeyboardEvent | MouseEvent) { - const table = this.editor?.getElementAtCursor('table', event.target as Node); - - if (table) { - this.normalizeTables([table]); - } - } - - private normalizeTables(tables: HTMLTableElement[]) { - if (this.editor && tables.length > 0) { - const rangeEx = this.editor.getSelectionRangeEx(); - const { startContainer, endContainer, startOffset, endOffset } = - (rangeEx?.type == SelectionRangeTypes.Normal && rangeEx.ranges[0]) || {}; - - const isChanged = normalizeTables(tables); - - if (isChanged) { - if ( - startContainer && - endContainer && - typeof startOffset === 'number' && - typeof endOffset === 'number' - ) { - this.editor.select(startContainer, startOffset, endContainer, endOffset); - } else if ( - rangeEx?.type == SelectionRangeTypes.TableSelection && - rangeEx.coordinates - ) { - this.editor.select(rangeEx.table, rangeEx.coordinates); - } - } - } - } -} - -function normalizeTables(tables: HTMLTableElement[]) { - let isDOMChanged = false; - tables.forEach(table => { - let tbody: HTMLTableSectionElement | null = null; - - for (let child = table.firstChild; child; child = child.nextSibling) { - const tag = getTagOfNode(child); - switch (tag) { - case 'TR': - if (!tbody) { - tbody = table.ownerDocument.createElement('tbody'); - table.insertBefore(tbody, child); - } - - tbody.appendChild(child); - child = tbody; - isDOMChanged = true; - - break; - case 'TBODY': - if (tbody) { - moveChildNodes(tbody, child, true /*keepExistingChildren*/); - child.parentNode?.removeChild(child); - child = tbody; - isDOMChanged = true; - } else { - tbody = child as HTMLTableSectionElement; - } - break; - default: - tbody = null; - break; - } - } - - const colgroups = table.querySelectorAll('colgroup'); - const thead = table.querySelector('thead'); - if (thead) { - colgroups.forEach(colgroup => { - if (!thead.contains(colgroup)) { - thead.appendChild(colgroup); - } - }); - } - }); - - return isDOMChanged; -} - -function normalizeListsForExport(root: ParentNode) { - toArray(root.querySelectorAll('li')).forEach(li => { - const prevElement = li.previousSibling; - - if (li.style.display == 'block' && safeInstanceOf(prevElement, 'HTMLLIElement')) { - li.style.removeProperty('display'); - - prevElement.appendChild(changeElementTag(li, 'div')); - } - }); -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/PendingFormatStatePlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/PendingFormatStatePlugin.ts deleted file mode 100644 index 4a726f9f504..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/PendingFormatStatePlugin.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { ChangeSource, Keys, PluginEventType, PositionType } from 'roosterjs-editor-types'; -import { isCharacterValue, Position, setColor } from 'roosterjs-editor-dom'; -import type { - IEditor, - NodePosition, - PendingFormatStatePluginState, - PluginEvent, - PluginWithState, -} from 'roosterjs-editor-types'; - -const ZERO_WIDTH_SPACE = '\u200B'; - -/** - * @internal - * PendingFormatStatePlugin handles pending format state management - */ -export default class PendingFormatStatePlugin - implements PluginWithState { - private editor: IEditor | null = null; - private state: PendingFormatStatePluginState; - - /** - * Construct a new instance of PendingFormatStatePlugin - * @param options The editor options - * @param contentDiv The editor content DIV - */ - constructor() { - this.state = { - pendableFormatPosition: null, - pendableFormatState: null, - pendableFormatSpan: null, - }; - } - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'PendingFormatState'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - } - - /** - * Dispose this plugin - */ - dispose() { - this.editor = null; - this.clear(); - } - - /** - * Get plugin state object - */ - getState() { - return this.state; - } - - /** - * Handle events triggered from editor - * @param event PluginEvent object - */ - onPluginEvent(event: PluginEvent) { - switch (event.eventType) { - case PluginEventType.PendingFormatStateChanged: - // Got PendingFormatStateChanged event, cache current position and pending format if a format is passed in - // otherwise clear existing pending format. - if (event.formatState) { - this.state.pendableFormatPosition = this.getCurrentPosition(); - this.state.pendableFormatState = event.formatState; - this.state.pendableFormatSpan = event.formatCallback - ? this.createPendingFormatSpan(event.formatCallback) - : null; - } else { - this.clear(); - } - - break; - case PluginEventType.KeyDown: - case PluginEventType.MouseDown: - case PluginEventType.ContentChanged: - let currentPosition: NodePosition | null = null; - if ( - this.editor && - event.eventType == PluginEventType.KeyDown && - isCharacterValue(event.rawEvent) && - this.state.pendableFormatSpan - ) { - this.state.pendableFormatSpan.removeAttribute('contentEditable'); - this.editor.insertNode(this.state.pendableFormatSpan); - this.editor.select( - this.state.pendableFormatSpan, - PositionType.Begin, - this.state.pendableFormatSpan, - PositionType.End - ); - this.clear(); - } else if ( - (event.eventType == PluginEventType.KeyDown && - event.rawEvent.which >= Keys.PAGEUP && - event.rawEvent.which <= Keys.DOWN) || - (this.state.pendableFormatPosition && - (currentPosition = this.getCurrentPosition()) && - !this.state.pendableFormatPosition.equalTo(currentPosition)) || - (event.eventType == PluginEventType.ContentChanged && - (event.source == ChangeSource.SwitchToDarkMode || - event.source == ChangeSource.SwitchToLightMode)) - ) { - // If content or position is changed (by keyboard, mouse, or code), - // check if current position is still the same with the cached one (if exist), - // and clear cached format if position is changed since it is out-of-date now - this.clear(); - } - - break; - } - } - - private clear() { - this.state.pendableFormatPosition = null; - this.state.pendableFormatState = null; - this.state.pendableFormatSpan = null; - } - - private getCurrentPosition() { - const range = this.editor?.getSelectionRange(); - return (range && Position.getStart(range).normalize()) ?? null; - } - - private createPendingFormatSpan( - callback: (element: HTMLElement, isInnerNode?: boolean) => any - ) { - let span = this.state.pendableFormatSpan; - - if (!span && this.editor) { - const currentStyle = this.editor.getStyleBasedFormatState(); - const doc = this.editor.getDocument(); - const isDarkMode = this.editor.isDarkMode(); - - span = doc.createElement('span'); - span.contentEditable = 'true'; - span.appendChild(doc.createTextNode(ZERO_WIDTH_SPACE)); - - span.style.setProperty('font-family', currentStyle.fontName ?? null); - span.style.setProperty('font-size', currentStyle.fontSize ?? null); - - const darkColorHandler = this.editor.getDarkColorHandler(); - - if (currentStyle.textColors || currentStyle.textColor) { - setColor( - span, - (currentStyle.textColors || currentStyle.textColor)!, - false /*isBackground*/, - isDarkMode, - false /*shouldAdaptFontColor*/, - darkColorHandler - ); - } - - if (currentStyle.backgroundColors || currentStyle.backgroundColor) { - setColor( - span, - (currentStyle.backgroundColors || currentStyle.backgroundColor)!, - true /*isBackground*/, - isDarkMode, - false /*shouldAdaptFontColor*/, - darkColorHandler - ); - } - } - - if (span) { - callback(span); - } - - return span; - } -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/TypeInContainerPlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/TypeInContainerPlugin.ts deleted file mode 100644 index 227c1f2f3f4..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/TypeInContainerPlugin.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { PluginEventType } from 'roosterjs-editor-types'; -import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; -import { - Browser, - findClosestElementAncestor, - getTagOfNode, - isCtrlOrMetaPressed, - Position, -} from 'roosterjs-editor-dom'; - -/** - * @internal - * Typing Component helps to ensure typing is always happening under a DOM container - */ -export default class TypeInContainerPlugin implements EditorPlugin { - private editor: IEditor | null = null; - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'TypeInContainer'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - } - - /** - * Dispose this plugin - */ - dispose() { - this.editor = null; - } - - private isRangeEmpty(range: Range) { - if ( - range.collapsed && - range.startContainer.nodeType === Node.ELEMENT_NODE && - getTagOfNode(range.startContainer) == 'DIV' && - !range.startContainer.firstChild - ) { - return true; - } - return false; - } - - /** - * Handle events triggered from editor - * @param event PluginEvent object - */ - onPluginEvent(event: PluginEvent) { - // We need to check if the ctrl key or the meta key is pressed, - // browsers like Safari fire the "keypress" event when the meta key is pressed. - if ( - event.eventType == PluginEventType.KeyPress && - this.editor && - !(event.rawEvent && isCtrlOrMetaPressed(event.rawEvent)) - ) { - // If normalization was not possible before the keypress, - // check again after the keyboard event has been processed by browser native behavior. - // - // This handles the case where the keyboard event that first inserts content happens when - // there is already content under the selection (e.g. Ctrl+a -> type new content). - // - // Only schedule when the range is not collapsed to catch this edge case. - const range = this.editor.getSelectionRange(); - - const styledAncestor = - range && - findClosestElementAncestor(range.startContainer, undefined /* root */, '[style]'); - - if (!range || (!this.isRangeEmpty(range) && this.editor.contains(styledAncestor))) { - return; - } - - if (range.collapsed) { - this.editor.ensureTypeInContainer(Position.getStart(range), event.rawEvent); - } else { - const callback = () => { - const focusedPosition = this.editor?.getFocusedPosition(); - if (focusedPosition) { - this.editor?.ensureTypeInContainer(focusedPosition, event.rawEvent); - } - }; - - if (Browser.isMobileOrTablet) { - this.editor.getDocument().defaultView?.setTimeout(callback, 100); - } else { - this.editor.runAsync(callback); - } - } - } - } -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/UndoPlugin.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/UndoPlugin.ts deleted file mode 100644 index 4cd9c12de9b..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/UndoPlugin.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { ChangeSource, Keys, PluginEventType } from 'roosterjs-editor-types'; -import type { - ContentChangedEvent, - EditorOptions, - IEditor, - PluginEvent, - PluginWithState, - Snapshot, - UndoPluginState, - UndoSnapshotsService, -} from 'roosterjs-editor-types'; -import { - addSnapshotV2, - canMoveCurrentSnapshot, - clearProceedingSnapshotsV2, - createSnapshots, - isCtrlOrMetaPressed, - moveCurrentSnapshot, - canUndoAutoComplete, -} from 'roosterjs-editor-dom'; - -// Max stack size that cannot be exceeded. When exceeded, old undo history will be dropped -// to keep size under limit. This is kept at 10MB -const MAX_SIZE_LIMIT = 1e7; - -/** - * @internal - * Provides snapshot based undo service for Editor - */ -export default class UndoPlugin implements PluginWithState { - private editor: IEditor | null = null; - private lastKeyPress: number | null = null; - private state: UndoPluginState; - - /** - * Construct a new instance of UndoPlugin - * @param options The wrapper of the state object - */ - constructor(options: EditorOptions) { - this.state = { - snapshotsService: - options.undoMetadataSnapshotService || - createUndoSnapshotServiceBridge(options.undoSnapshotService) || - createUndoSnapshots(), - isRestoring: false, - hasNewContent: false, - isNested: false, - autoCompletePosition: null, - }; - } - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'Undo'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor): void { - this.editor = editor; - } - - /** - * Dispose this plugin - */ - dispose() { - this.editor = null; - } - - /** - * Get plugin state object - */ - getState() { - return this.state; - } - - /** - * Check if the plugin should handle the given event exclusively. - * @param event The event to check - */ - willHandleEventExclusively(event: PluginEvent) { - return ( - event.eventType == PluginEventType.KeyDown && - event.rawEvent.which == Keys.BACKSPACE && - !event.rawEvent.ctrlKey && - this.canUndoAutoComplete() - ); - } - - /** - * Handle events triggered from editor - * @param event PluginEvent object - */ - onPluginEvent(event: PluginEvent): void { - // if editor is in IME, don't do anything - if (!this.editor || this.editor.isInIME()) { - return; - } - - switch (event.eventType) { - case PluginEventType.EditorReady: - const undoState = this.editor.getUndoState(); - if (!undoState.canUndo && !undoState.canRedo) { - // Only add initial snapshot when there is no existing snapshot - // Otherwise preserved undo/redo state may be ruined - this.addUndoSnapshot(); - } - break; - case PluginEventType.KeyDown: - this.onKeyDown(event.rawEvent); - break; - case PluginEventType.KeyPress: - this.onKeyPress(event.rawEvent); - break; - case PluginEventType.CompositionEnd: - this.clearRedoForInput(); - this.addUndoSnapshot(); - break; - case PluginEventType.ContentChanged: - this.onContentChanged(event); - break; - case PluginEventType.BeforeKeyboardEditing: - this.onBeforeKeyboardEditing(event.rawEvent); - break; - } - } - - private onKeyDown(evt: KeyboardEvent): void { - // Handle backspace/delete when there is a selection to take a snapshot - // since we want the state prior to deletion restorable - // Ignore if keycombo is ALT+BACKSPACE - if ((evt.which == Keys.BACKSPACE && !evt.altKey) || evt.which == Keys.DELETE) { - if (evt.which == Keys.BACKSPACE && !evt.ctrlKey && this.canUndoAutoComplete()) { - evt.preventDefault(); - this.editor?.undo(); - this.state.autoCompletePosition = null; - this.lastKeyPress = evt.which; - } else if (!evt.defaultPrevented) { - const selectionRange = this.editor?.getSelectionRange(); - - // Add snapshot when - // 1. Something has been selected (not collapsed), or - // 2. It has a different key code from the last keyDown event (to prevent adding too many snapshot when keeping press the same key), or - // 3. Ctrl/Meta key is pressed so that a whole word will be deleted - if ( - selectionRange && - (!selectionRange.collapsed || - this.lastKeyPress != evt.which || - isCtrlOrMetaPressed(evt)) - ) { - this.addUndoSnapshot(); - } - - // Since some content is deleted, always set hasNewContent to true so that we will take undo snapshot next time - this.state.hasNewContent = true; - this.lastKeyPress = evt.which; - } - } else if (evt.which >= Keys.PAGEUP && evt.which <= Keys.DOWN) { - // PageUp, PageDown, Home, End, Left, Right, Up, Down - if (this.state.hasNewContent) { - this.addUndoSnapshot(); - } - this.lastKeyPress = 0; - } else if (this.lastKeyPress == Keys.BACKSPACE || this.lastKeyPress == Keys.DELETE) { - if (this.state.hasNewContent) { - this.addUndoSnapshot(); - } - } - } - - private onKeyPress(evt: KeyboardEvent): void { - if (evt.metaKey) { - // if metaKey is pressed, simply return since no actual effect will be taken on the editor. - // this is to prevent changing hasNewContent to true when meta + v to paste on Safari. - return; - } - - const range = this.editor?.getSelectionRange(); - if ( - (range && !range.collapsed) || - (evt.which == Keys.SPACE && this.lastKeyPress != Keys.SPACE) || - evt.which == Keys.ENTER - ) { - this.addUndoSnapshot(); - if (evt.which == Keys.ENTER) { - // Treat ENTER as new content so if there is no input after ENTER and undo, - // we restore the snapshot before ENTER - this.state.hasNewContent = true; - } - } else { - this.clearRedoForInput(); - } - - this.lastKeyPress = evt.which; - } - - private onBeforeKeyboardEditing(event: KeyboardEvent) { - // For keyboard event (triggered from Content Model), we can get its keycode from event.data - // And when user is keep pressing the same key, mark editor with "hasNewContent" so that next time user - // do some other action or press a different key, we will add undo snapshot - if (event.which != this.lastKeyPress) { - this.addUndoSnapshot(); - } - - this.lastKeyPress = event.which; - this.state.hasNewContent = true; - } - - private onContentChanged(event: ContentChangedEvent) { - if ( - !( - this.state.isRestoring || - event.source == ChangeSource.SwitchToDarkMode || - event.source == ChangeSource.SwitchToLightMode || - event.source == ChangeSource.Keyboard - ) - ) { - this.clearRedoForInput(); - } - } - - private clearRedoForInput() { - this.state.snapshotsService.clearRedo(); - this.lastKeyPress = 0; - this.state.hasNewContent = true; - } - - private canUndoAutoComplete() { - const focusedPosition = this.editor?.getFocusedPosition(); - return ( - this.state.snapshotsService.canUndoAutoComplete() && - !!focusedPosition && - !!this.state.autoCompletePosition?.equalTo(focusedPosition) - ); - } - - private addUndoSnapshot() { - this.editor?.addUndoSnapshot(); - this.state.autoCompletePosition = null; - } -} - -function createUndoSnapshots(): UndoSnapshotsService { - const snapshots = createSnapshots(MAX_SIZE_LIMIT); - - return { - canMove: (delta: number): boolean => canMoveCurrentSnapshot(snapshots, delta), - move: (delta: number): Snapshot | null => moveCurrentSnapshot(snapshots, delta), - addSnapshot: (snapshot: Snapshot, isAutoCompleteSnapshot: boolean) => - addSnapshotV2(snapshots, snapshot, isAutoCompleteSnapshot), - clearRedo: () => clearProceedingSnapshotsV2(snapshots), - canUndoAutoComplete: () => canUndoAutoComplete(snapshots), - }; -} - -function createUndoSnapshotServiceBridge( - service: UndoSnapshotsService | undefined -): UndoSnapshotsService | undefined { - let html: string | null; - return service - ? { - canMove: (delta: number) => service.canMove(delta), - move: (delta: number): Snapshot | null => - (html = service.move(delta)) ? { html, metadata: null, knownColors: [] } : null, - addSnapshot: (snapshot: Snapshot, isAutoCompleteSnapshot: boolean) => - service.addSnapshot( - snapshot.html + - (snapshot.metadata ? `` : ''), - isAutoCompleteSnapshot - ), - clearRedo: () => service.clearRedo(), - canUndoAutoComplete: () => service.canUndoAutoComplete(), - } - : undefined; -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/createCorePlugins.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/createCorePlugins.ts deleted file mode 100644 index f538a066bd6..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/createCorePlugins.ts +++ /dev/null @@ -1,66 +0,0 @@ -import CopyPastePlugin from './CopyPastePlugin'; -import DOMEventPlugin from './DOMEventPlugin'; -import EditPlugin from './EditPlugin'; -import EntityPlugin from './EntityPlugin'; -import ImageSelection from './ImageSelection'; -import LifecyclePlugin from './LifecyclePlugin'; -import MouseUpPlugin from './MouseUpPlugin'; -import NormalizeTablePlugin from './NormalizeTablePlugin'; -import PendingFormatStatePlugin from './PendingFormatStatePlugin'; -import TypeInContainerPlugin from './TypeInContainerPlugin'; -import UndoPlugin from './UndoPlugin'; -import type { CorePlugins, EditorOptions, PluginState } from 'roosterjs-editor-types'; - -/** - * @internal - */ -export interface CreateCorePluginResponse extends CorePlugins { - _placeholder: null; -} - -/** - * @internal - * Create Core Plugins - * @param contentDiv Content DIV of editor - * @param options Editor options - */ -export default function createCorePlugins( - contentDiv: HTMLDivElement, - options: EditorOptions -): CreateCorePluginResponse { - const map = options.corePluginOverride || {}; - // The order matters, some plugin needs to be put before/after others to make sure event - // can be handled in right order - return { - typeInContainer: map.typeInContainer || new TypeInContainerPlugin(), - edit: map.edit || new EditPlugin(), - pendingFormatState: map.pendingFormatState || new PendingFormatStatePlugin(), - _placeholder: null, - typeAfterLink: null!, //deprecated after firefox update - undo: map.undo || new UndoPlugin(options), - domEvent: map.domEvent || new DOMEventPlugin(options, contentDiv), - mouseUp: map.mouseUp || new MouseUpPlugin(), - copyPaste: map.copyPaste || new CopyPastePlugin(options), - entity: map.entity || new EntityPlugin(), - imageSelection: map.imageSelection || new ImageSelection(), - normalizeTable: map.normalizeTable || new NormalizeTablePlugin(), - lifecycle: map.lifecycle || new LifecyclePlugin(options, contentDiv), - }; -} - -/** - * @internal - * Get plugin state of core plugins - * @param corePlugins CorePlugins object - */ -export function getPluginState(corePlugins: CorePlugins): PluginState { - return { - domEvent: corePlugins.domEvent.getState(), - pendingFormatState: corePlugins.pendingFormatState.getState(), - edit: corePlugins.edit.getState(), - lifecycle: corePlugins.lifecycle.getState(), - undo: corePlugins.undo.getState(), - entity: corePlugins.entity.getState(), - copyPaste: corePlugins.copyPaste.getState(), - }; -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/forEachSelectedCell.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/forEachSelectedCell.ts deleted file mode 100644 index edd97016f3c..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/forEachSelectedCell.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { VCell } from 'roosterjs-editor-types'; -import type { VTable } from 'roosterjs-editor-dom'; - -/** - * @internal - * Executes an action to all the cells within the selection range. - * @param callback action to apply on each selected cell - * @returns the amount of cells modified - */ -export const forEachSelectedCell = (vTable: VTable, callback: (cell: VCell) => void): void => { - if (vTable.selection) { - const { lastCell, firstCell } = vTable.selection; - - for (let y = firstCell.y; y <= lastCell.y; y++) { - for (let x = firstCell.x; x <= lastCell.x; x++) { - if (vTable.cells && vTable.cells[y][x]?.td) { - callback(vTable.cells[y][x]); - } - } - } - } -}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts deleted file mode 100644 index e077b6cc5ce..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { - addDelimiters, - arrayPush, - createRange, - getDelimiterFromElement, - getEntityFromElement, - getEntitySelector, - isBlockElement, - isCharacterValue, - matchesSelector, - Position, - safeInstanceOf, - splitTextNode, -} from 'roosterjs-editor-dom'; -import type { Entity, IEditor, PluginEvent, PluginKeyDownEvent } from 'roosterjs-editor-types'; -import { - ChangeSource, - DelimiterClasses, - Keys, - NodeType, - PluginEventType, - PositionType, - SelectionRangeTypes, -} from 'roosterjs-editor-types'; - -const DELIMITER_SELECTOR = - '.' + DelimiterClasses.DELIMITER_AFTER + ',.' + DelimiterClasses.DELIMITER_BEFORE; -const ZERO_WIDTH_SPACE = '\u200B'; -const INLINE_ENTITY_SELECTOR = 'span' + getEntitySelector(); - -/** - * @internal - */ -export function inlineEntityOnPluginEvent(event: PluginEvent, editor: IEditor) { - switch (event.eventType) { - case PluginEventType.ContentChanged: - if (event.source === ChangeSource.SetContent) { - normalizeDelimitersInEditor(editor); - } - break; - case PluginEventType.EditorReady: - normalizeDelimitersInEditor(editor); - break; - - case PluginEventType.BeforePaste: - const { fragment, sanitizingOption } = event; - addDelimitersIfNeeded(fragment.querySelectorAll(INLINE_ENTITY_SELECTOR)); - - if (sanitizingOption.additionalAllowedCssClasses) { - arrayPush(sanitizingOption.additionalAllowedCssClasses, [ - DelimiterClasses.DELIMITER_AFTER, - DelimiterClasses.DELIMITER_BEFORE, - ]); - } - break; - - case PluginEventType.ExtractContentWithDom: - case PluginEventType.BeforeCutCopy: - event.clonedRoot.querySelectorAll(DELIMITER_SELECTOR).forEach(node => { - if (getDelimiterFromElement(node)) { - removeNode(node); - } else { - removeDelimiterAttr(node); - } - }); - break; - - case PluginEventType.KeyDown: - handleKeyDownEvent(editor, event); - break; - } -} - -function preventTypeInDelimiter(delimiter: HTMLElement) { - delimiter.normalize(); - const textNode = delimiter.firstChild as Node; - const index = textNode.nodeValue?.indexOf(ZERO_WIDTH_SPACE) ?? -1; - if (index >= 0) { - splitTextNode(textNode, index == 0 ? 1 : index, false /* returnFirstPart */); - let nodeToMove: Node | undefined; - delimiter.childNodes.forEach(node => { - if (node.nodeValue !== ZERO_WIDTH_SPACE) { - nodeToMove = node; - } - }); - if (nodeToMove) { - delimiter.parentElement?.insertBefore( - nodeToMove, - delimiter.className == DelimiterClasses.DELIMITER_BEFORE - ? delimiter - : delimiter.nextSibling - ); - const selection = nodeToMove.ownerDocument?.getSelection(); - - if (selection) { - selection.setPosition( - nodeToMove, - new Position(nodeToMove, PositionType.End).offset - ); - } - } - } -} - -/** - * @internal - */ -export function normalizeDelimitersInEditor(editor: IEditor) { - removeInvalidDelimiters(editor.queryElements(DELIMITER_SELECTOR)); - addDelimitersIfNeeded(editor.queryElements(INLINE_ENTITY_SELECTOR)); -} - -function addDelimitersIfNeeded(nodes: Element[] | NodeListOf) { - nodes.forEach(node => { - if (isEntityElement(node)) { - addDelimiters(node); - } - }); -} - -function isEntityElement(node: Node | null): node is HTMLElement { - return !!( - node && - safeInstanceOf(node, 'HTMLElement') && - isReadOnly(getEntityFromElement(node)) - ); -} - -function removeNode(el: Node | undefined | null) { - el?.parentElement?.removeChild(el); -} - -function isReadOnly(entity: Entity | null) { - return ( - entity?.isReadonly && - !isBlockElement(entity.wrapper) && - safeInstanceOf(entity.wrapper, 'HTMLElement') - ); -} - -function removeInvalidDelimiters(nodes: Element[] | NodeListOf) { - nodes.forEach(node => { - if (getDelimiterFromElement(node)) { - const sibling = node.classList.contains(DelimiterClasses.DELIMITER_BEFORE) - ? node.nextElementSibling - : node.previousElementSibling; - if (!(safeInstanceOf(sibling, 'HTMLElement') && getEntityFromElement(sibling))) { - removeNode(node); - } - } else { - removeDelimiterAttr(node); - } - }); -} - -function removeDelimiterAttr(node: Element | undefined | null, checkEntity: boolean = true) { - if (!node) { - return; - } - - const isAfter = node.classList.contains(DelimiterClasses.DELIMITER_AFTER); - const entitySibling = isAfter ? node.previousElementSibling : node.nextElementSibling; - if (checkEntity && entitySibling && isEntityElement(entitySibling)) { - return; - } - - node.classList.remove(DelimiterClasses.DELIMITER_AFTER, DelimiterClasses.DELIMITER_BEFORE); - - node.normalize(); - node.childNodes.forEach(cn => { - const index = cn.textContent?.indexOf(ZERO_WIDTH_SPACE) ?? -1; - if (index >= 0) { - createRange(cn, index, cn, index + 1)?.deleteContents(); - } - }); -} - -function handleCollapsedEnter(editor: IEditor, delimiter: HTMLElement) { - const isAfter = delimiter.classList.contains(DelimiterClasses.DELIMITER_AFTER); - const entity = !isAfter ? delimiter.nextSibling : delimiter.previousSibling; - const block = getBlock(editor, delimiter); - - editor.runAsync(() => { - if (!block) { - return; - } - const blockToCheck = isAfter ? block.nextSibling : block.previousSibling; - if (blockToCheck && safeInstanceOf(blockToCheck, 'HTMLElement')) { - const delimiters = blockToCheck.querySelectorAll(DELIMITER_SELECTOR); - // Check if the last or first delimiter still contain the delimiter class and remove it. - const delimiterToCheck = delimiters.item(isAfter ? 0 : delimiters.length - 1); - removeDelimiterAttr(delimiterToCheck); - } - - if (isEntityElement(entity)) { - const { nextElementSibling, previousElementSibling } = entity; - [nextElementSibling, previousElementSibling].forEach(el => { - // Check if after Enter the ZWS got removed but we still have a element with the class - // Remove the attributes of the element if it is invalid now. - if (el && matchesSelector(el, DELIMITER_SELECTOR) && !getDelimiterFromElement(el)) { - removeDelimiterAttr(el, false /* checkEntity */); - } - }); - // Add delimiters to the entity if needed because on Enter we can sometimes lose the ZWS of the element. - addDelimiters(entity); - } - }); -} - -const getPosition = (container: HTMLElement | null) => { - if (container && getDelimiterFromElement(container)) { - const isAfter = container.classList.contains(DelimiterClasses.DELIMITER_AFTER); - return new Position(container, isAfter ? PositionType.After : PositionType.Before); - } - return undefined; -}; - -function getBlock(editor: IEditor, element: Node | undefined) { - if (!element) { - return undefined; - } - - let block = editor.getBlockElementAtNode(element)?.getStartNode(); - - while (block && !isBlockElement(block)) { - block = editor.contains(block.parentElement) ? block.parentElement! : undefined; - } - - return block; -} - -function handleSelectionNotCollapsed(editor: IEditor, range: Range, event: KeyboardEvent) { - const { startContainer, endContainer, startOffset, endOffset } = range; - - const startElement = editor.getElementAtCursor(DELIMITER_SELECTOR, startContainer); - const endElement = editor.getElementAtCursor(DELIMITER_SELECTOR, endContainer); - - const startUpdate = getPosition(startElement); - const endUpdate = getPosition(endElement); - - if (startUpdate || endUpdate) { - editor.select( - startUpdate ?? new Position(startContainer, startOffset), - endUpdate ?? new Position(endContainer, endOffset) - ); - } - editor.runAsync(aEditor => { - const delimiter = aEditor.getElementAtCursor(DELIMITER_SELECTOR); - if (delimiter) { - preventTypeInDelimiter(delimiter); - if (event.which === Keys.ENTER) { - removeDelimiterAttr(delimiter); - } - } - }); -} - -function handleKeyDownEvent(editor: IEditor, event: PluginKeyDownEvent) { - const range = editor.getSelectionRangeEx(); - const { rawEvent } = event; - if (range.type != SelectionRangeTypes.Normal) { - return; - } - - if (range.areAllCollapsed && (isCharacterValue(rawEvent) || rawEvent.which === Keys.ENTER)) { - const position = editor.getFocusedPosition()?.normalize(); - if (!position) { - return; - } - - const { element, node } = position; - const refNode = element == node ? element.childNodes.item(position.offset) : element; - - const delimiter = editor.getElementAtCursor(DELIMITER_SELECTOR, refNode); - if (!delimiter) { - return; - } - - if (rawEvent.which === Keys.ENTER) { - handleCollapsedEnter(editor, delimiter); - } else if (delimiter.firstChild?.nodeType == NodeType.Text) { - editor.runAsync(() => preventTypeInDelimiter(delimiter)); - } - } else if (!range.areAllCollapsed && !rawEvent.shiftKey && rawEvent.which != Keys.SHIFT) { - const currentRange = range.ranges[0]; - if (!currentRange) { - return; - } - handleSelectionNotCollapsed(editor, currentRange, rawEvent); - } -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/removeCellsOutsideSelection.ts b/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/removeCellsOutsideSelection.ts deleted file mode 100644 index e2c75d2f55b..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/corePlugins/utils/removeCellsOutsideSelection.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { isWholeTableSelected } from 'roosterjs-editor-dom'; -import type { VTable } from 'roosterjs-editor-dom'; -import type { VCell } from 'roosterjs-editor-types'; - -/** - * @internal - * Remove the cells outside of the selection. - * @param vTable VTable to remove selection - */ -export const removeCellsOutsideSelection = (vTable: VTable) => { - if (vTable.selection) { - if (isWholeTableSelected(vTable, vTable.selection)) { - return; - } - - vTable.table.style.removeProperty('width'); - vTable.table.style.removeProperty('height'); - - const { firstCell, lastCell } = vTable.selection; - const resultCells: VCell[][] = []; - - const firstX = firstCell.x; - const firstY = firstCell.y; - const lastX = lastCell.x; - const lastY = lastCell.y; - - if (vTable.cells) { - vTable.cells.forEach((row, y) => { - row = row.filter((_, x) => y >= firstY && y <= lastY && x >= firstX && x <= lastX); - if (row.length > 0) { - resultCells.push(row); - } - }); - vTable.cells = resultCells; - } - } -}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/editor/AdapterEditor.ts b/packages-content-model/roosterjs-content-model-adapter/lib/editor/AdapterEditor.ts deleted file mode 100644 index 59c35fe73d2..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/editor/AdapterEditor.ts +++ /dev/null @@ -1,1104 +0,0 @@ -import { createContentModelEditorCore } from 'roosterjs-content-model-editor'; -import { isFeatureEnabled } from './isFeatureEnabled'; -import type { - ContentModelEditorCore, - EditorEnvironment, - IContentModelEditor, -} from 'roosterjs-content-model-editor'; -import type { - ContentModelDocument, - DOMSelection, - DomToModelOption, - ModelToDomOption, - OnNodeCreated, -} from 'roosterjs-content-model-types'; -import { - ChangeSource, - ColorTransformDirection, - ContentPosition, - GetContentMode, - PluginEventType, - PositionType, - QueryScope, - RegionType, -} from 'roosterjs-editor-types'; -import type { - BlockElement, - ClipboardData, - ContentChangedData, - DarkColorHandler, - DefaultFormat, - DOMEventHandler, - EditorOptions, - EditorUndoState, - ExperimentalFeatures, - GenericContentEditFeature, - IContentTraverser, - IEditor, - InsertOption, - IPositionContentSearcher, - NodePosition, - PendableFormatState, - PluginEvent, - PluginEventData, - PluginEventFromType, - Rect, - Region, - SelectionPath, - SelectionRangeEx, - SizeTransformer, - StyleBasedFormatState, - TableSelection, - TrustedHTMLHandler, -} from 'roosterjs-editor-types'; -import { - cacheGetEventData, - collapseNodes, - contains, - ContentTraverser, - deleteSelectedContent, - getRegionsFromRange, - findClosestElementAncestor, - getBlockElementAtNode, - getSelectionPath, - getTagOfNode, - isNodeEmpty, - Position, - PositionContentSearcher, - queryElements, - wrap, - isPositionAtBeginningOf, - toArray, -} from 'roosterjs-editor-dom'; -import type { - CompatibleChangeSource, - CompatibleColorTransformDirection, - CompatibleContentPosition, - CompatibleExperimentalFeatures, - CompatibleGetContentMode, - CompatiblePluginEventType, - CompatibleQueryScope, - CompatibleRegionType, -} from 'roosterjs-editor-types/lib/compatibleTypes'; - -/** - * RoosterJs adapter editor that supports Content Model and can be used by legacy roosterjs plugin - * (This class is still under development, temporarily do internal export for now.) - */ -export class AdapterEditor implements IEditor, IContentModelEditor { - private core: ContentModelEditorCore | null = null; - - /** - * Creates an instance of EditorBase - * @param contentDiv The DIV HTML element which will be the container element of editor - * @param options An optional options object to customize the editor - */ - constructor(contentDiv: HTMLDivElement, options: EditorOptions) { - // 1. Make sure all parameters are valid - if (getTagOfNode(contentDiv) != 'DIV') { - throw new Error('contentDiv must be an HTML DIV element'); - } - - // 2. Create editor core - this.core = createContentModelEditorCore(contentDiv, options); - - // 3. Initialize plugins - this.core.plugins.forEach(plugin => plugin.initialize(this)); - - // 4. Ensure user will type in a container node, not the editor content DIV - this.ensureTypeInContainer( - new Position(this.core.contentDiv, PositionType.Begin).normalize() - ); - } - - /** - * Dispose this editor, dispose all plugins and custom data - */ - public dispose(): void { - const core = this.getCore(); - - for (let i = core.plugins.length - 1; i >= 0; i--) { - const plugin = core.plugins[i]; - - try { - plugin.dispose(); - } catch (e) { - // Cache the error and pass it out, then keep going since dispose should always succeed - core.disposeErrorHandler?.(plugin, e as Error); - } - } - - core.darkColorHandler.reset(); - - this.core = null; - } - - /** - * Get whether this editor is disposed - * @returns True if editor is disposed, otherwise false - */ - public isDisposed(): boolean { - return !this.core; - } - - //#region Content Model Editor members - - /** - * Create Content Model from DOM tree in this editor - * @param option The option to customize the behavior of DOM to Content Model conversion - */ - createContentModel( - option?: DomToModelOption, - selectionOverride?: DOMSelection - ): ContentModelDocument { - const core = this.getCore(); - - return core.api.createContentModel(core, option, selectionOverride); - } - - /** - * Set content with content model - * @param model The content model to set - * @param option Additional options to customize the behavior of Content Model to DOM conversion - * @param onNodeCreated An optional callback that will be called when a DOM node is created - */ - setContentModel( - model: ContentModelDocument, - option?: ModelToDomOption, - onNodeCreated?: OnNodeCreated - ): DOMSelection | null { - const core = this.getCore(); - - return core.api.setContentModel(core, model, option, onNodeCreated); - } - - /** - * Get current running environment, such as if editor is running on Mac - */ - getEnvironment(): EditorEnvironment { - return this.getCore().environment; - } - - /** - * Get current DOM selection - */ - getDOMSelection(): DOMSelection | null { - const core = this.getCore(); - - return core.api.getDOMSelection(core); - } - - /** - * Set DOMSelection into editor content. - * This is the replacement of IEditor.select. - * @param selection The selection to set - */ - setDOMSelection(selection: DOMSelection) { - const core = this.getCore(); - - core.api.setDOMSelection(core, selection); - } - - //#endregion - - //#region Node API - - /** - * Insert node into editor - * @param node The node to insert - * @param option Insert options. Default value is: - * position: ContentPosition.SelectionStart - * updateCursor: true - * replaceSelection: true - * insertOnNewLine: false - * @returns true if node is inserted. Otherwise false - */ - public insertNode(node: Node, option?: InsertOption): boolean { - const core = this.getCore(); - return node ? core.api.insertNode(core, node, option ?? null) : false; - } - - /** - * Delete a node from editor content - * @param node The node to delete - * @returns true if node is deleted. Otherwise false - */ - public deleteNode(node: Node): boolean { - // Only remove the node when it falls within editor - if (node && this.contains(node) && node.parentNode) { - node.parentNode.removeChild(node); - return true; - } - - return false; - } - - /** - * Replace a node in editor content with another node - * @param existingNode The existing node to be replaced - * @param toNode node to replace to - * @param transformColorForDarkMode (optional) Whether to transform new node to dark mode. Default is false - * @returns true if node is replaced. Otherwise false - */ - public replaceNode( - existingNode: Node, - toNode: Node, - transformColorForDarkMode?: boolean - ): boolean { - const core = this.getCore(); - // Only replace the node when it falls within editor - if (this.contains(existingNode) && toNode) { - core.api.transformColor( - core, - transformColorForDarkMode ? toNode : null, - true /*includeSelf*/, - () => existingNode.parentNode?.replaceChild(toNode, existingNode), - ColorTransformDirection.LightToDark - ); - - return true; - } - - return false; - } - - /** - * Get BlockElement at given node - * @param node The node to create InlineElement - * @returns The BlockElement result - */ - public getBlockElementAtNode(node: Node): BlockElement | null { - return getBlockElementAtNode(this.getCore().contentDiv, node); - } - - public contains(arg: Node | Range | null): boolean { - if (!arg) { - return false; - } - return contains(this.getCore().contentDiv, arg); - } - - public queryElements( - selector: string, - scopeOrCallback: - | QueryScope - | CompatibleQueryScope - | ((node: Node) => any) = QueryScope.Body, - callback?: (node: Node) => any - ) { - const core = this.getCore(); - const result: HTMLElement[] = []; - const scope = scopeOrCallback instanceof Function ? QueryScope.Body : scopeOrCallback; - callback = scopeOrCallback instanceof Function ? scopeOrCallback : callback; - - const selectionEx = scope == QueryScope.Body ? null : this.getSelectionRangeEx(); - if (selectionEx) { - selectionEx.ranges.forEach(range => { - result.push(...queryElements(core.contentDiv, selector, callback, scope, range)); - }); - } else { - return queryElements(core.contentDiv, selector, callback, scope, undefined /* range */); - } - - return result; - } - - /** - * Collapse nodes within the given start and end nodes to their common ancestor node, - * split parent nodes if necessary - * @param start The start node - * @param end The end node - * @param canSplitParent True to allow split parent node there are nodes before start or after end under the same parent - * and the returned nodes will be all nodes from start through end after splitting - * False to disallow split parent - * @returns When canSplitParent is true, returns all node from start through end after splitting, - * otherwise just return start and end - */ - public collapseNodes(start: Node, end: Node, canSplitParent: boolean): Node[] { - return collapseNodes(this.getCore().contentDiv, start, end, canSplitParent); - } - - //#endregion - - //#region Content API - - /** - * Check whether the editor contains any visible content - * @param trim Whether trim the content string before check. Default is false - * @returns True if there's no visible content, otherwise false - */ - public isEmpty(trim?: boolean): boolean { - return isNodeEmpty(this.getCore().contentDiv, trim); - } - - /** - * Get current editor content as HTML string - * @param mode specify what kind of HTML content to retrieve - * @returns HTML string representing current editor content - */ - public getContent( - mode: GetContentMode | CompatibleGetContentMode = GetContentMode.CleanHTML - ): string { - const core = this.getCore(); - return core.api.getContent(core, mode); - } - - /** - * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered - * @param content HTML content to set in - * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true - */ - public setContent(content: string, triggerContentChangedEvent: boolean = true) { - const core = this.getCore(); - core.api.setContent(core, content, triggerContentChangedEvent); - } - - /** - * Insert HTML content into editor - * @param HTML content to insert - * @param option Insert options. Default value is: - * position: ContentPosition.SelectionStart - * updateCursor: true - * replaceSelection: true - * insertOnNewLine: false - */ - public insertContent(content: string, option?: InsertOption) { - if (content) { - const doc = this.getDocument(); - const body = new DOMParser().parseFromString( - this.getCore().trustedHTMLHandler(content), - 'text/html' - )?.body; - let allNodes = body?.childNodes ? toArray(body.childNodes) : []; - - // If it is to insert on new line, and there are more than one node in the collection, wrap all nodes with - // a parent DIV before calling insertNode on each top level sub node. Otherwise, every sub node may get wrapped - // separately to show up on its own line - if (option && option.insertOnNewLine && allNodes.length > 1) { - allNodes = [wrap(allNodes)]; - } - - const fragment = doc.createDocumentFragment(); - allNodes.forEach(node => fragment.appendChild(node)); - - this.insertNode(fragment, option); - } - } - - /** - * Delete selected content - */ - public deleteSelectedContent(): NodePosition | null { - const range = this.getSelectionRange(); - if (range && !range.collapsed) { - return deleteSelectedContent(this.getCore().contentDiv, range); - } - return null; - } - - /** - * Paste into editor using a clipboardData object - * @param clipboardData Clipboard data retrieved from clipboard - * @param pasteAsText Force pasting as plain text. Default value is false - * @param applyCurrentStyle True if apply format of current selection to the pasted content, - * false to keep original format. Default value is false. When pasteAsText is true, this parameter is ignored - * @param pasteAsImage: When set to true, if the clipboardData contains a imageDataUri will paste the image to the editor - */ - public paste( - clipboardData: ClipboardData, - pasteAsText: boolean = false, - applyCurrentFormat: boolean = false, - pasteAsImage: boolean = false - ) { - const core = this.getCore(); - if (!clipboardData) { - return; - } - - if (clipboardData.snapshotBeforePaste) { - // Restore original content before paste a new one - this.setContent(clipboardData.snapshotBeforePaste); - } else { - clipboardData.snapshotBeforePaste = this.getContent( - GetContentMode.RawHTMLWithSelection - ); - } - - const range = this.getSelectionRange(); - const pos = range && Position.getStart(range); - const fragment = core.api.createPasteFragment( - core, - clipboardData, - pos, - pasteAsText, - applyCurrentFormat, - pasteAsImage - ); - if (fragment) { - this.addUndoSnapshot(() => { - this.insertNode(fragment); - return clipboardData; - }, ChangeSource.Paste); - } - } - - //#endregion - - //#region Focus and Selection - - /** - * Get current selection range from Editor. - * It does a live pull on the selection, if nothing retrieved, return whatever we have in cache. - * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now. - * Default value is true - * @returns current selection range, or null if editor never got focus before - */ - public getSelectionRange(tryGetFromCache: boolean = true): Range | null { - const core = this.getCore(); - return core.api.getSelectionRange(core, tryGetFromCache); - } - - /** - * Get current selection range from Editor. - * It does a live pull on the selection, if nothing retrieved, return whatever we have in cache. - * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now. - * Default value is true - * @returns current selection range, or null if editor never got focus before - */ - public getSelectionRangeEx(): SelectionRangeEx { - const core = this.getCore(); - return core.api.getSelectionRangeEx(core); - } - - /** - * Get current selection in a serializable format - * It does a live pull on the selection, if nothing retrieved, return whatever we have in cache. - * @returns current selection path, or null if editor never got focus before - */ - public getSelectionPath(): SelectionPath | null { - const range = this.getSelectionRange(); - return range && getSelectionPath(this.getCore().contentDiv, range); - } - - /** - * Check if focus is in editor now - * @returns true if focus is in editor, otherwise false - */ - public hasFocus(): boolean { - const core = this.getCore(); - return core.api.hasFocus(core); - } - - /** - * Focus to this editor, the selection was restored to where it was before, no unexpected scroll. - */ - public focus() { - const core = this.getCore(); - core.api.focus(core); - } - - public select( - arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, - arg2?: NodePosition | number | PositionType | TableSelection | null, - arg3?: Node, - arg4?: number | PositionType - ): boolean { - const core = this.getCore(); - - return core.api.select(core, arg1, arg2, arg3, arg4); - } - - /** - * Get current focused position. Return null if editor doesn't have focus at this time. - */ - public getFocusedPosition(): NodePosition | null { - const sel = this.getDocument().defaultView?.getSelection(); - if (sel?.focusNode && this.contains(sel.focusNode)) { - return new Position(sel.focusNode, sel.focusOffset); - } - - const range = this.getSelectionRange(); - if (range) { - return Position.getStart(range); - } - - return null; - } - - /** - * Get an HTML element from current cursor position. - * When expectedTags is not specified, return value is the current node (if it is HTML element) - * or its parent node (if current node is a Text node). - * When expectedTags is specified, return value is the first ancestor of current node which has - * one of the expected tags. - * If no element found within editor by the given tag, return null. - * @param selector Optional, an HTML selector to find HTML element with. - * @param startFrom Start search from this node. If not specified, start from current focused position - * @param event Optional, if specified, editor will try to get cached result from the event object first. - * If it is not cached before, query from DOM and cache the result into the event object - */ - public getElementAtCursor( - selector?: string, - startFrom?: Node, - event?: PluginEvent - ): HTMLElement | null { - event = startFrom ? undefined : event; // Only use cache when startFrom is not specified, for different start position can have different result - - return ( - cacheGetEventData(event ?? null, 'GET_ELEMENT_AT_CURSOR_' + selector, () => { - if (!startFrom) { - const position = this.getFocusedPosition(); - startFrom = position?.node; - } - return ( - startFrom && - findClosestElementAncestor(startFrom, this.getCore().contentDiv, selector) - ); - }) ?? null - ); - } - - /** - * Check if this position is at beginning of the editor. - * This will return true if all nodes between the beginning of target node and the position are empty. - * @param position The position to check - * @returns True if position is at beginning of the editor, otherwise false - */ - public isPositionAtBeginning(position: NodePosition): boolean { - return isPositionAtBeginningOf(position, this.getCore().contentDiv); - } - - /** - * Get impacted regions from selection - */ - public getSelectedRegions( - type: RegionType | CompatibleRegionType = RegionType.Table - ): Region[] { - const selection = this.getSelectionRangeEx(); - const result: Region[] = []; - const contentDiv = this.getCore().contentDiv; - selection.ranges.forEach(range => { - result.push(...(range ? getRegionsFromRange(contentDiv, range, type) : [])); - }); - return result.filter((value, index, self) => { - return self.indexOf(value) === index; - }); - } - - //#endregion - - //#region EVENT API - - public addDomEventHandler( - nameOrMap: string | Record, - handler?: DOMEventHandler - ): () => void { - const eventsToMap = typeof nameOrMap == 'string' ? { [nameOrMap]: handler! } : nameOrMap; - const core = this.getCore(); - return core.api.attachDomEvent(core, eventsToMap); - } - - /** - * Trigger an event to be dispatched to all plugins - * @param eventType Type of the event - * @param data data of the event with given type, this is the rest part of PluginEvent with the given type - * @param broadcast indicates if the event needs to be dispatched to all plugins - * True means to all, false means to allow exclusive handling from one plugin unless no one wants that - * @returns the event object which is really passed into plugins. Some plugin may modify the event object so - * the result of this function provides a chance to read the modified result - */ - public triggerPluginEvent( - eventType: T, - data: PluginEventData, - broadcast: boolean = false - ): PluginEventFromType { - const core = this.getCore(); - const event = ({ - eventType, - ...data, - } as any) as PluginEventFromType; - core.api.triggerEvent(core, event, broadcast); - - return event; - } - - /** - * Trigger a ContentChangedEvent - * @param source Source of this event, by default is 'SetContent' - * @param data additional data for this event - */ - public triggerContentChangedEvent( - source: ChangeSource | CompatibleChangeSource | string = ChangeSource.SetContent, - data?: any - ) { - this.triggerPluginEvent(PluginEventType.ContentChanged, { - source, - data, - }); - } - - //#endregion - - //#region Undo API - - /** - * Undo last edit operation - */ - public undo() { - this.focus(); - const core = this.getCore(); - core.api.restoreUndoSnapshot(core, -1 /*step*/); - } - - /** - * Redo next edit operation - */ - public redo() { - this.focus(); - const core = this.getCore(); - core.api.restoreUndoSnapshot(core, 1 /*step*/); - } - - /** - * Add undo snapshot, and execute a format callback function, then add another undo snapshot, then trigger - * ContentChangedEvent with given change source. - * If this function is called nested, undo snapshot will only be added in the outside one - * @param callback The callback function to perform formatting, returns a data object which will be used as - * the data field in ContentChangedEvent if changeSource is not null. - * @param changeSource The change source to use when fire ContentChangedEvent. When the value is not null, - * a ContentChangedEvent will be fired with change source equal to this value - * @param canUndoByBackspace True if this action can be undone when user press Backspace key (aka Auto Complete). - */ - public addUndoSnapshot( - callback?: (start: NodePosition | null, end: NodePosition | null) => any, - changeSource?: ChangeSource | CompatibleChangeSource | string, - canUndoByBackspace?: boolean, - additionalData?: ContentChangedData - ) { - const core = this.getCore(); - core.api.addUndoSnapshot( - core, - callback ?? null, - changeSource ?? null, - canUndoByBackspace ?? false, - additionalData - ); - } - - /** - * Whether there is an available undo/redo snapshot - */ - public getUndoState(): EditorUndoState { - const { hasNewContent, snapshotsService } = this.getCore().undo; - return { - canUndo: hasNewContent || snapshotsService.canMove(-1 /*previousSnapshot*/), - canRedo: snapshotsService.canMove(1 /*nextSnapshot*/), - }; - } - - //#endregion - - //#region Misc - - /** - * Get document which contains this editor - * @returns The HTML document which contains this editor - */ - public getDocument(): Document { - return this.getCore().contentDiv.ownerDocument; - } - - /** - * Get the scroll container of the editor - */ - public getScrollContainer(): HTMLElement { - return this.getCore().domEvent.scrollContainer; - } - - /** - * Get custom data related to this editor - * @param key Key of the custom data - * @param getter Getter function. If custom data for the given key doesn't exist, - * call this function to get one and store it if it is specified. Otherwise return undefined - * @param disposer An optional disposer function to dispose this custom data when - * dispose editor. - */ - public getCustomData(key: string, getter?: () => T, disposer?: (value: T) => void): T { - const core = this.getCore(); - return (core.lifecycle.customData[key] = core.lifecycle.customData[key] || { - value: getter ? getter() : undefined, - disposer, - }).value as T; - } - - /** - * Check if editor is in IME input sequence - * @returns True if editor is in IME input sequence, otherwise false - */ - public isInIME(): boolean { - return this.getCore().domEvent.isInIME; - } - - /** - * Get default format of this editor - * @returns Default format object of this editor - */ - public getDefaultFormat(): DefaultFormat { - return this.getCore().lifecycle.defaultFormat ?? {}; - } - - /** - * Get a content traverser for the whole editor - * @param startNode The node to start from. If not passed, it will start from the beginning of the body - */ - public getBodyTraverser(startNode?: Node): IContentTraverser { - return ContentTraverser.createBodyTraverser(this.getCore().contentDiv, startNode); - } - - /** - * Get a content traverser for current selection - * @returns A content traverser, or null if editor never got focus before - */ - public getSelectionTraverser(range?: Range): IContentTraverser | null { - range = range ?? this.getSelectionRange() ?? undefined; - return range - ? ContentTraverser.createSelectionTraverser(this.getCore().contentDiv, range) - : null; - } - - /** - * Get a content traverser for current block element start from specified position - * @param startFrom Start position of the traverser. Default value is ContentPosition.SelectionStart - * @returns A content traverser, or null if editor never got focus before - */ - public getBlockTraverser( - startFrom: ContentPosition | CompatibleContentPosition = ContentPosition.SelectionStart - ): IContentTraverser | null { - const range = this.getSelectionRange(); - return range - ? ContentTraverser.createBlockTraverser(this.getCore().contentDiv, range, startFrom) - : null; - } - - /** - * Get a text traverser of current selection - * @param event Optional, if specified, editor will try to get cached result from the event object first. - * If it is not cached before, query from DOM and cache the result into the event object - * @returns A content traverser, or null if editor never got focus before - */ - public getContentSearcherOfCursor(event?: PluginEvent): IPositionContentSearcher | null { - return cacheGetEventData(event ?? null, 'ContentSearcher', () => { - const range = this.getSelectionRange(); - return ( - range && - new PositionContentSearcher(this.getCore().contentDiv, Position.getStart(range)) - ); - }); - } - - /** - * Run a callback function asynchronously - * @param callback The callback function to run - * @returns a function to cancel this async run - */ - public runAsync(callback: (editor: IEditor) => void) { - const win = this.getCore().contentDiv.ownerDocument.defaultView || window; - const handle = win.requestAnimationFrame(() => { - if (!this.isDisposed() && callback) { - callback(this); - } - }); - - return () => { - win.cancelAnimationFrame(handle); - }; - } - - /** - * Set DOM attribute of editor content DIV - * @param name Name of the attribute - * @param value Value of the attribute - */ - public setEditorDomAttribute(name: string, value: string | null) { - if (value === null) { - this.getCore().contentDiv.removeAttribute(name); - } else { - this.getCore().contentDiv.setAttribute(name, value); - } - } - - /** - * Get DOM attribute of editor content DIV, null if there is no such attribute. - * @param name Name of the attribute - */ - public getEditorDomAttribute(name: string): string | null { - return this.getCore().contentDiv.getAttribute(name); - } - - /** - * @deprecated Use getVisibleViewport() instead. - * - * Get current relative distance from top-left corner of the given element to top-left corner of editor content DIV. - * @param element The element to calculate from. If the given element is not in editor, return value will be null - * @param addScroll When pass true, The return value will also add scrollLeft and scrollTop if any. So the value - * may be different than what user is seeing from the view. When pass false, scroll position will be ignored. - * @returns An [x, y] array which contains the left and top distances, or null if the given element is not in editor. - */ - getRelativeDistanceToEditor(element: HTMLElement, addScroll?: boolean): number[] | null { - if (this.contains(element)) { - const contentDiv = this.getCore().contentDiv; - const editorRect = contentDiv.getBoundingClientRect(); - const elementRect = element.getBoundingClientRect(); - - if (editorRect && elementRect) { - let x = elementRect.left - editorRect?.left; - let y = elementRect.top - editorRect?.top; - - if (addScroll) { - x += contentDiv.scrollLeft; - y += contentDiv.scrollTop; - } - - return [x, y]; - } - } - - return null; - } - - /** - * Add a Content Edit feature. - * @param feature The feature to add - */ - public addContentEditFeature(feature: GenericContentEditFeature) { - const core = this.getCore(); - feature?.keys.forEach(key => { - const array = core.edit.features[key] || []; - array.push(feature); - core.edit.features[key] = array; - }); - } - - /** - * Remove a Content Edit feature. - * @param feature The feature to remove - */ - public removeContentEditFeature(feature: GenericContentEditFeature) { - const core = this.getCore(); - feature?.keys.forEach(key => { - const featureSet = core.edit.features[key]; - const index = featureSet?.indexOf(feature) ?? -1; - if (index >= 0) { - core.edit.features[key].splice(index, 1); - if (core.edit.features[key].length < 1) { - delete core.edit.features[key]; - } - } - }); - } - - /** - * Get style based format state from current selection, including font name/size and colors - */ - public getStyleBasedFormatState(node?: Node): StyleBasedFormatState { - if (!node) { - const range = this.getSelectionRange(); - node = (range && Position.getStart(range).normalize().node) ?? undefined; - } - const core = this.getCore(); - return core.api.getStyleBasedFormatState(core, node ?? null); - } - - /** - * Get the pendable format such as underline and bold - * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. - * @returns The pending format state - */ - public getPendableFormatState(forceGetStateFromDOM: boolean = false): PendableFormatState { - const core = this.getCore(); - return core.api.getPendableFormatState(core, forceGetStateFromDOM); - } - - /** - * Ensure user will type into a container element rather than into the editor content DIV directly - * @param position The position that user is about to type to - * @param keyboardEvent Optional keyboard event object - */ - public ensureTypeInContainer(position: NodePosition, keyboardEvent?: KeyboardEvent) { - const core = this.getCore(); - core.api.ensureTypeInContainer(core, position, keyboardEvent); - } - - //#endregion - - //#region Dark mode APIs - - /** - * Set the dark mode state and transforms the content to match the new state. - * @param nextDarkMode The next status of dark mode. True if the editor should be in dark mode, false if not. - */ - public setDarkModeState(nextDarkMode?: boolean) { - const isDarkMode = this.isDarkMode(); - - if (isDarkMode == !!nextDarkMode) { - return; - } - const core = this.getCore(); - - core.api.transformColor( - core, - core.contentDiv, - false /*includeSelf*/, - null /*callback*/, - nextDarkMode - ? ColorTransformDirection.LightToDark - : ColorTransformDirection.DarkToLight, - true /*forceTransform*/, - isDarkMode - ); - - this.triggerContentChangedEvent( - nextDarkMode ? ChangeSource.SwitchToDarkMode : ChangeSource.SwitchToLightMode - ); - } - - /** - * Check if the editor is in dark mode - * @returns True if the editor is in dark mode, otherwise false - */ - public isDarkMode(): boolean { - return this.getCore().lifecycle.isDarkMode; - } - - /** - * Transform the given node and all its child nodes to dark mode color if editor is in dark mode - * @param node The node to transform - * @param direction The transform direction. @default ColorTransformDirection.LightToDark - */ - public transformToDarkColor( - node: Node, - direction: - | ColorTransformDirection - | CompatibleColorTransformDirection = ColorTransformDirection.LightToDark - ) { - const core = this.getCore(); - core.api.transformColor(core, node, true /*includeSelf*/, null /*callback*/, direction); - } - - /** - * Get a darkColorHandler object for this editor. - */ - public getDarkColorHandler(): DarkColorHandler { - return this.getCore().darkColorHandler; - } - - /** - * Make the editor in "Shadow Edit" mode. - * In Shadow Edit mode, all format change will finally be ignored. - * This can be used for building a live preview feature for format button, to allow user - * see format result without really apply it. - * This function can be called repeated. If editor is already in shadow edit mode, we can still - * use this function to do more shadow edit operation. - */ - public startShadowEdit() { - const core = this.getCore(); - core.api.switchShadowEdit(core, true /*isOn*/); - } - - /** - * Leave "Shadow Edit" mode, all changes made during shadow edit will be discarded - */ - public stopShadowEdit() { - const core = this.getCore(); - core.api.switchShadowEdit(core, false /*isOn*/); - } - - /** - * Check if editor is in Shadow Edit mode - */ - public isInShadowEdit() { - return !!this.getCore().lifecycle.shadowEditFragment; - } - - /** - * Check if the given experimental feature is enabled - * @param feature The feature to check - */ - public isFeatureEnabled( - feature: ExperimentalFeatures | CompatibleExperimentalFeatures - ): boolean { - return isFeatureEnabled(this.getCore().lifecycle.experimentalFeatures, feature); - } - - /** - * Get a function to convert HTML string to trusted HTML string. - * By default it will just return the input HTML directly. To override this behavior, - * pass your own trusted HTML handler to EditorOptions.trustedHTMLHandler - * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types - */ - getTrustedHTMLHandler(): TrustedHTMLHandler { - return this.getCore().trustedHTMLHandler; - } - - /** - * @deprecated Use getZoomScale() instead - */ - getSizeTransformer(): SizeTransformer { - return this.getCore().sizeTransformer; - } - - /** - * Get current zoom scale, default value is 1 - * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale - * to let editor behave correctly especially for those mouse drag/drop behaviors - * @returns current zoom scale number - */ - getZoomScale(): number { - return this.getCore().zoomScale; - } - - /** - * Set current zoom scale, default value is 1 - * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale - * to let editor behave correctly especially for those mouse drag/drop behaviors - * @param scale The new scale number to set. It should be positive number and no greater than 10, otherwise it will be ignored. - */ - setZoomScale(scale: number): void { - const core = this.getCore(); - if (scale > 0 && scale <= 10) { - const oldValue = core.zoomScale; - core.zoomScale = scale; - - if (oldValue != scale) { - this.triggerPluginEvent( - PluginEventType.ZoomChanged, - { - oldZoomScale: oldValue, - newZoomScale: scale, - }, - true /*broadcast*/ - ); - } - } - } - - /** - * Retrieves the rect of the visible viewport of the editor. - */ - getVisibleViewport(): Rect | null { - return this.getCore().getVisibleViewport(); - } - - /** - * @returns the current EditorCore object - * @throws a standard Error if there's no core object - */ - protected getCore(): ContentModelEditorCore { - if (!this.core) { - throw new Error('Editor is already disposed'); - } - return this.core; - } - - //#endregion -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/editor/DarkColorHandlerImpl.ts b/packages-content-model/roosterjs-content-model-adapter/lib/editor/DarkColorHandlerImpl.ts deleted file mode 100644 index f87a42cb7f0..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/editor/DarkColorHandlerImpl.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { getObjectKeys, parseColor, setColor } from 'roosterjs-editor-dom'; -import type { - ColorKeyAndValue, - DarkColorHandler, - ModeIndependentColor, -} from 'roosterjs-editor-types'; - -const VARIABLE_REGEX = /^\s*var\(\s*(\-\-[a-zA-Z0-9\-_]+)\s*(?:,\s*(.*))?\)\s*$/; -const VARIABLE_PREFIX = 'var('; -const COLOR_VAR_PREFIX = 'darkColor'; -const enum ColorAttributeEnum { - CssColor = 0, - HtmlColor = 1, -} -const ColorAttributeName: { [key in ColorAttributeEnum]: string }[] = [ - { - [ColorAttributeEnum.CssColor]: 'color', - [ColorAttributeEnum.HtmlColor]: 'color', - }, - { - [ColorAttributeEnum.CssColor]: 'background-color', - [ColorAttributeEnum.HtmlColor]: 'bgcolor', - }, -]; - -/** - * @internal - */ -export default class DarkColorHandlerImpl implements DarkColorHandler { - private knownColors: Record> = {}; - - constructor(private contentDiv: HTMLElement, private getDarkColor: (color: string) => string) {} - - /** - * Get a copy of known colors - * @returns - */ - getKnownColorsCopy() { - return Object.values(this.knownColors); - } - - /** - * Given a light mode color value and an optional dark mode color value, register this color - * so that editor can handle it, then return the CSS color value for current color mode. - * @param lightModeColor Light mode color value - * @param isDarkMode Whether current color mode is dark mode - * @param darkModeColor Optional dark mode color value. If not passed, we will calculate one. - */ - registerColor(lightModeColor: string, isDarkMode: boolean, darkModeColor?: string): string { - const parsedColor = this.parseColorValue(lightModeColor); - let colorKey: string | undefined; - - if (parsedColor) { - lightModeColor = parsedColor.lightModeColor; - darkModeColor = parsedColor.darkModeColor || darkModeColor; - colorKey = parsedColor.key; - } - - if (isDarkMode && lightModeColor) { - colorKey = - colorKey || `--${COLOR_VAR_PREFIX}_${lightModeColor.replace(/[^\d\w]/g, '_')}`; - - if (!this.knownColors[colorKey]) { - darkModeColor = darkModeColor || this.getDarkColor(lightModeColor); - - this.knownColors[colorKey] = { lightModeColor, darkModeColor }; - this.contentDiv.style.setProperty(colorKey, darkModeColor); - } - - return `var(${colorKey}, ${lightModeColor})`; - } else { - return lightModeColor; - } - } - - /** - * Reset known color record, clean up registered color variables. - */ - reset(): void { - getObjectKeys(this.knownColors).forEach(key => this.contentDiv.style.removeProperty(key)); - this.knownColors = {}; - } - - /** - * Parse an existing color value, if it is in variable-based color format, extract color key, - * light color and query related dark color if any - * @param color The color string to parse - * @param isInDarkMode Whether current content is in dark mode. When set to true, if the color value is not in dark var format, - * we will treat is as a dark mode color and try to find a matched dark mode color. - */ - parseColorValue(color: string | undefined | null, isInDarkMode?: boolean): ColorKeyAndValue { - let key: string | undefined; - let lightModeColor = ''; - let darkModeColor: string | undefined; - - if (color) { - const match = color.startsWith(VARIABLE_PREFIX) ? VARIABLE_REGEX.exec(color) : null; - - if (match) { - if (match[2]) { - key = match[1]; - lightModeColor = match[2]; - darkModeColor = this.knownColors[key]?.darkModeColor; - } else { - lightModeColor = ''; - } - } else if (isInDarkMode) { - // If editor is in dark mode but the color is not in dark color format, it is possible the color was inserted from external code - // without any light color info. So we first try to see if there is a known dark color can match this color, and use its related - // light color as light mode color. Otherwise we need to drop this color to avoid show "white on white" content. - lightModeColor = this.findLightColorFromDarkColor(color) || ''; - - if (lightModeColor) { - darkModeColor = color; - } - } else { - lightModeColor = color; - } - } - - return { key, lightModeColor, darkModeColor }; - } - - /** - * Find related light mode color from dark mode color. - * @param darkColor The existing dark color - */ - findLightColorFromDarkColor(darkColor: string): string | null { - const rgbSearch = parseColor(darkColor); - - if (rgbSearch) { - const key = getObjectKeys(this.knownColors).find(key => { - const rgbCurrent = parseColor(this.knownColors[key].darkModeColor); - - return ( - rgbCurrent && - rgbCurrent[0] == rgbSearch[0] && - rgbCurrent[1] == rgbSearch[1] && - rgbCurrent[2] == rgbSearch[2] - ); - }); - - if (key) { - return this.knownColors[key].lightModeColor; - } - } - - return null; - } - - /** - * Transform element color, from dark to light or from light to dark - * @param element The element to transform color - * @param fromDarkMode Whether this is transforming color from dark mode - * @param toDarkMode Whether this is transforming color to dark mode - */ - transformElementColor(element: HTMLElement, fromDarkMode: boolean, toDarkMode: boolean): void { - ColorAttributeName.forEach((names, i) => { - const color = this.parseColorValue( - element.style.getPropertyValue(names[ColorAttributeEnum.CssColor]) || - element.getAttribute(names[ColorAttributeEnum.HtmlColor]), - !!fromDarkMode - ).lightModeColor; - - element.style.setProperty(names[ColorAttributeEnum.CssColor], null); - element.removeAttribute(names[ColorAttributeEnum.HtmlColor]); - - if (color && color != 'inherit') { - setColor(element, color, i != 0, toDarkMode, false /*shouldAdaptFontColor*/, this); - } - }); - } -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-adapter/lib/editor/createEditorCore.ts deleted file mode 100644 index 813e69288ea..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/editor/createEditorCore.ts +++ /dev/null @@ -1,61 +0,0 @@ -import createCorePlugins, { getPluginState } from '../corePlugins/createCorePlugins'; -import DarkColorHandlerImpl from './DarkColorHandlerImpl'; -import { arrayPush, getIntersectedRect } from 'roosterjs-editor-dom'; -import { coreApiMap } from '../coreApi/coreApiMap'; -import { getObjectKeys } from 'roosterjs-content-model-dom'; -import type { CoreCreator, EditorCore, EditorOptions, EditorPlugin } from 'roosterjs-editor-types'; - -/** - * @internal - * Create a new instance of Editor Core - * @param contentDiv The DIV HTML element which will be the container element of editor - * @param options An optional options object to customize the editor - */ -export const createEditorCore: CoreCreator = (contentDiv, options) => { - const corePlugins = createCorePlugins(contentDiv, options); - const plugins: EditorPlugin[] = []; - - getObjectKeys(corePlugins).forEach(name => { - if (name == '_placeholder') { - if (options.plugins) { - arrayPush(plugins, options.plugins); - } - } else { - plugins.push(corePlugins[name]); - } - }); - - const pluginState = getPluginState(corePlugins); - const zoomScale: number = (options.zoomScale ?? -1) > 0 ? options.zoomScale! : 1; - const getVisibleViewport = - options.getVisibleViewport || - (() => { - const scrollContainer = pluginState.domEvent.scrollContainer; - - return getIntersectedRect( - scrollContainer == core.contentDiv - ? [scrollContainer] - : [scrollContainer, core.contentDiv] - ); - }); - - const core: EditorCore = { - contentDiv, - api: { - ...coreApiMap, - ...(options.coreApiOverride || {}), - }, - originalApi: coreApiMap, - plugins: plugins.filter(x => !!x), - ...pluginState, - trustedHTMLHandler: options.trustedHTMLHandler || ((html: string) => html), - zoomScale: zoomScale, - sizeTransformer: options.sizeTransformer || ((size: number) => size / zoomScale), - getVisibleViewport, - imageSelectionBorderColor: options.imageSelectionBorderColor, - darkColorHandler: new DarkColorHandlerImpl(contentDiv, pluginState.lifecycle.getDarkColor), - disposeErrorHandler: options.disposeErrorHandler, - }; - - return core; -}; diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/editor/isFeatureEnabled.ts b/packages-content-model/roosterjs-content-model-adapter/lib/editor/isFeatureEnabled.ts deleted file mode 100644 index 17f1a552c44..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/editor/isFeatureEnabled.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ExperimentalFeatures } from 'roosterjs-editor-types'; -import type { CompatibleExperimentalFeatures } from 'roosterjs-editor-types/lib/compatibleTypes'; - -/** - * @internal - * Check if the given experimental feature is enabled - * @param featureSet All enabled features - * @param feature The feature to check - * @returns True if the given feature is enabled, otherwise false - */ -export function isFeatureEnabled( - featureSet: (ExperimentalFeatures | CompatibleExperimentalFeatures)[] | undefined, - feature: ExperimentalFeatures | CompatibleExperimentalFeatures -) { - return (featureSet || []).indexOf(feature) >= 0; -} diff --git a/packages-content-model/roosterjs-content-model-adapter/lib/index.ts b/packages-content-model/roosterjs-content-model-adapter/lib/index.ts deleted file mode 100644 index c3d93c07c6d..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/lib/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Classes -export { AdapterEditor } from './editor/AdapterEditor'; diff --git a/packages-content-model/roosterjs-content-model-adapter/package.json b/packages-content-model/roosterjs-content-model-adapter/package.json deleted file mode 100644 index 430e9ebd5b7..00000000000 --- a/packages-content-model/roosterjs-content-model-adapter/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "roosterjs-content-model-adapter", - "description": "Content Model for roosterjs (Under development)", - "dependencies": { - "tslib": "^2.3.1", - "roosterjs-editor-types": "", - "roosterjs-editor-dom": "", - "roosterjs-content-model-editor": "", - "roosterjs-content-model-dom": "", - "roosterjs-content-model-types": "" - }, - "version": "0.0.0", - "main": "./lib/index.ts" -} From 77b891b8bab83552b8aa2a226a454fdfb5685d25 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 6 Nov 2023 10:15:03 -0800 Subject: [PATCH 032/111] Move formatWithContentModel to be a core API (#2185) --- .../lib/editor/ContentModelEditor.ts | 21 + .../coreApi/formatContentModel.ts} | 99 ++-- .../ContentModelCopyPastePlugin.ts | 12 +- .../editor/createContentModelEditorCore.ts | 3 + .../lib/index.ts | 2 +- .../lib/modelApi/common/mergeModel.ts | 2 +- .../lib/modelApi/format/pendingFormat.ts | 28 + .../lib/publicApi/block/setAlignment.ts | 5 +- .../lib/publicApi/block/setDirection.ts | 5 +- .../lib/publicApi/block/setIndentation.ts | 7 +- .../lib/publicApi/block/toggleBlockQuote.ts | 13 +- .../lib/publicApi/editing/keyboardDelete.ts | 6 +- .../lib/publicApi/entity/insertEntity.ts | 6 +- .../publicApi/format/applyDefaultFormat.ts | 96 ++-- .../publicApi/format/applyPendingFormat.ts | 79 +-- .../lib/publicApi/format/clearFormat.ts | 24 +- .../publicApi/image/adjustImageSelection.ts | 31 +- .../lib/publicApi/image/insertImage.ts | 24 +- .../lib/publicApi/link/adjustLinkSelection.ts | 42 +- .../lib/publicApi/link/insertLink.ts | 6 +- .../lib/publicApi/link/removeLink.ts | 42 +- .../lib/publicApi/list/setListStartNumber.ts | 24 +- .../lib/publicApi/list/setListStyle.ts | 36 +- .../lib/publicApi/list/toggleBullet.ts | 6 +- .../lib/publicApi/list/toggleNumbering.ts | 6 +- .../publicApi/table/applyTableBorderFormat.ts | 512 +++++++++--------- .../lib/publicApi/table/editTable.ts | 168 +++--- .../lib/publicApi/table/formatTable.ts | 40 +- .../lib/publicApi/table/insertTable.ts | 52 +- .../lib/publicApi/table/setTableCellShade.ts | 36 +- .../utils/formatParagraphWithContentModel.ts | 7 +- .../utils/formatSegmentWithContentModel.ts | 98 ++-- .../lib/publicApi/utils/paste.ts | 6 +- .../lib/publicTypes/ContentModelEditorCore.ts | 31 +- .../lib/publicTypes/IContentModelEditor.ts | 17 + .../FormatWithContentModelContext.ts | 4 +- .../test/editor/ContentModelEditorTest.ts | 13 + .../editor/coreApi/formatContentModelTest.ts | 462 ++++++++++++++++ .../ContentModelFormatPluginTest.ts | 436 ++++++++------- .../createContentModelEditorCoreTest.ts | 11 + .../ContentModelCopyPastePluginTest.ts | 95 ++-- .../test/modelApi/format/pendingFormatTest.ts | 79 +++ .../publicApi/block/paragraphTestCommon.ts | 36 +- .../test/publicApi/block/setAlignmentTest.ts | 62 ++- .../publicApi/block/setIndentationTest.ts | 23 +- .../publicApi/block/toggleBlockQuoteTest.ts | 23 +- .../publicApi/editing/editingTestCommon.ts | 45 +- .../publicApi/editing/keyboardDeleteTest.ts | 38 +- .../test/publicApi/entity/insertEntityTest.ts | 40 +- .../format/applyPendingFormatTest.ts | 110 ++-- .../test/publicApi/format/clearFormatTest.ts | 25 +- .../test/publicApi/image/changeImageTest.ts | 61 +-- .../test/publicApi/image/insertImageTest.ts | 40 +- .../publicApi/link/adjustLinkSelectionTest.ts | 46 +- .../test/publicApi/link/insertLinkTest.ts | 53 +- .../test/publicApi/link/removeLinkTest.ts | 44 +- .../publicApi/list/setListStartNumberTest.ts | 34 +- .../test/publicApi/list/setListStyleTest.ts | 14 +- .../test/publicApi/list/toggleBulletTest.ts | 33 +- .../publicApi/list/toggleNumberingTest.ts | 43 +- .../publicApi/segment/changeFontSizeTest.ts | 83 +-- .../publicApi/segment/segmentTestCommon.ts | 37 +- .../table/applyTableBorderFormatTest.ts | 58 +- .../publicApi/table/setTableCellShadeTest.ts | 53 +- .../utils/formatImageWithContentModelTest.ts | 41 +- .../formatParagraphWithContentModelTest.ts | 66 +-- .../formatSegmentWithContentModelTest.ts | 54 +- .../utils/formatWithContentModelTest.ts | 342 ------------ .../test/publicApi/utils/pasteTest.ts | 42 +- 69 files changed, 2364 insertions(+), 1874 deletions(-) rename packages-content-model/roosterjs-content-model-editor/lib/{publicApi/utils/formatWithContentModel.ts => editor/coreApi/formatContentModel.ts} (58%) create mode 100644 packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index ea0a3a626e6..1e79c0c05fb 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -1,6 +1,10 @@ import { createContentModelEditorCore } from './createContentModelEditorCore'; import { EditorBase } from 'roosterjs-editor-core'; import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; +import type { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../publicTypes/parameter/FormatWithContentModelContext'; import type { ContentModelEditorOptions, EditorEnvironment, @@ -92,4 +96,21 @@ export default class ContentModelEditor core.api.setDOMSelection(core, selection); } + + /** + * The general API to do format change with Content Model + * It will grab a Content Model for current editor content, and invoke a callback function + * to do format change. Then according to the return value, write back the modified content model into editor. + * If there is cached model, it will be used and updated. + * @param formatter Formatter function, see ContentModelFormatter + * @param options More options, see FormatWithContentModelOptions + */ + formatContentModel( + formatter: ContentModelFormatter, + options?: FormatWithContentModelOptions + ): void { + const core = this.getCore(); + + core.api.formatContentModel(core, formatter, options); + } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts similarity index 58% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts rename to packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts index 135725ceb86..dd47f21a523 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts @@ -1,43 +1,32 @@ +import { ColorTransformDirection, EntityOperation, PluginEventType } from 'roosterjs-editor-types'; +import type ContentModelContentChangedEvent from '../../publicTypes/event/ContentModelContentChangedEvent'; import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; -import { EntityOperation, PluginEventType } from 'roosterjs-editor-types'; -import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; +import type { + ContentModelEditorCore, + FormatContentModel, +} from '../../publicTypes/ContentModelEditorCore'; import type { Entity } from 'roosterjs-editor-types'; -import type { ContentModelContentChangedEventData } from '../../publicTypes/event/ContentModelContentChangedEvent'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { - ContentModelFormatter, EntityRemovalOperation, FormatWithContentModelContext, - FormatWithContentModelOptions, } from '../../publicTypes/parameter/FormatWithContentModelContext'; import type { DOMSelection } from 'roosterjs-content-model-types'; /** + * @internal * The general API to do format change with Content Model * It will grab a Content Model for current editor content, and invoke a callback function * to do format change. Then according to the return value, write back the modified content model into editor. * If there is cached model, it will be used and updated. - * @param editor Content Model editor - * @param apiName Name of the format API + * @param core The ContentModelEditorCore object * @param formatter Formatter function, see ContentModelFormatter * @param options More options, see FormatWithContentModelOptions */ -export function formatWithContentModel( - editor: IContentModelEditor, - apiName: string, - formatter: ContentModelFormatter, - options?: FormatWithContentModelOptions -) { - const { - onNodeCreated, - preservePendingFormat, - getChangeData, - changeSource, - rawEvent, - selectionOverride, - } = options || {}; +export const formatContentModel: FormatContentModel = (core, formatter, options) => { + const { apiName, onNodeCreated, getChangeData, changeSource, rawEvent, selectionOverride } = + options || {}; - const model = editor.createContentModel(undefined /*option*/, selectionOverride); + const model = core.api.createContentModel(core, undefined /*option*/, selectionOverride); const context: FormatWithContentModelContext = { newEntities: [], deletedEntities: [], @@ -48,29 +37,22 @@ export function formatWithContentModel( if (formatter(model, context)) { const writeBack = () => { - handleNewEntities(editor, context); - handleDeletedEntities(editor, context); - handleImages(editor, context); + handleNewEntities(core, context); + handleDeletedEntities(core, context); + handleImages(core, context); selection = - editor.setContentModel(model, undefined /*options*/, onNodeCreated) || undefined; - - if (preservePendingFormat) { - const pendingFormat = getPendingFormat(editor); - const pos = editor.getFocusedPosition(); - - if (pendingFormat && pos) { - setPendingFormat(editor, pendingFormat, pos.node, pos.offset); - } - } + core.api.setContentModel(core, model, undefined /*options*/, onNodeCreated) || + undefined; }; if (context.skipUndoSnapshot) { writeBack(); } else { - editor.addUndoSnapshot( + core.api.addUndoSnapshot( + core, writeBack, - undefined /*changeSource, passing undefined here to avoid triggering ContentChangedEvent. We will trigger it using it with Content Model below */, + null /*changeSource, passing undefined here to avoid triggering ContentChangedEvent. We will trigger it using it with Content Model below */, false /*canUndoByBackspace*/, { formatApiName: apiName, @@ -78,7 +60,8 @@ export function formatWithContentModel( ); } - const eventData: ContentModelContentChangedEventData = { + const eventData: ContentModelContentChangedEvent = { + eventType: PluginEventType.ContentChanged, contentModel: model, selection: selection, source: changeSource || ChangeSource.Format, @@ -87,18 +70,24 @@ export function formatWithContentModel( formatApiName: apiName, }, }; - editor.triggerPluginEvent(PluginEventType.ContentChanged, eventData); + core.api.triggerEvent(core, eventData, true /*broadcast*/); } -} +}; -function handleNewEntities(editor: IContentModelEditor, context: FormatWithContentModelContext) { +function handleNewEntities(core: ContentModelEditorCore, context: FormatWithContentModelContext) { // TODO: Ideally we can trigger NewEntity event here. But to be compatible with original editor code, we don't do it here for now. // Once Content Model Editor can be standalone, we can change this behavior to move triggering NewEntity event code // from EntityPlugin to here - if (editor.isDarkMode()) { + if (core.lifecycle.isDarkMode) { context.newEntities.forEach(entity => { - editor.transformToDarkColor(entity.wrapper); + core.api.transformColor( + core, + entity.wrapper, + true /*includeSelf*/, + null /*callback*/, + ColorTransformDirection.LightToDark + ); }); } } @@ -112,7 +101,7 @@ const EntityOperationMap: Record = { }; function handleDeletedEntities( - editor: IContentModelEditor, + core: ContentModelEditorCore, context: FormatWithContentModelContext ) { context.deletedEntities.forEach( @@ -131,19 +120,25 @@ function handleDeletedEntities( isReadonly: !!isReadonly, wrapper, }; - editor.triggerPluginEvent(PluginEventType.EntityOperation, { - entity, - operation: EntityOperationMap[operation], - rawEvent: context.rawEvent, - }); + core.api.triggerEvent( + core, + { + eventType: PluginEventType.EntityOperation, + entity, + operation: EntityOperationMap[operation], + rawEvent: context.rawEvent, + }, + false /*broadcast*/ + ); } } ); } -function handleImages(editor: IContentModelEditor, context: FormatWithContentModelContext) { +function handleImages(core: ContentModelEditorCore, context: FormatWithContentModelContext) { if (context.newImages.length > 0) { - const viewport = editor.getVisibleViewport(); + const viewport = core.getVisibleViewport(); + if (viewport) { const { left, right } = viewport; const minMaxImageSize = 10; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts index 46d20818822..9b7406b7e55 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts @@ -6,7 +6,6 @@ import { ColorTransformDirection, PluginEventType } from 'roosterjs-editor-types import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; import { extractClipboardItems } from 'roosterjs-editor-dom'; -import { formatWithContentModel } from '../../publicApi/utils/formatWithContentModel'; import { iterateSelections } from '../../modelApi/selection/iterateSelections'; import { contentModelToDom, @@ -152,15 +151,15 @@ export default class ContentModelCopyPastePlugin implements PluginWithState { + this.editor.runAsync(e => { + const editor = e as IContentModelEditor; + cleanUpAndRestoreSelection(tempDiv); editor.focus(); - (editor as IContentModelEditor).setDOMSelection(selection); + editor.setDOMSelection(selection); if (isCut) { - formatWithContentModel( - editor as IContentModelEditor, - 'cut', + editor.formatContentModel( (model, context) => { if ( deleteSelection(model, [], context).deleteResult == @@ -172,6 +171,7 @@ export default class ContentModelCopyPastePlugin implements PluginWithState setModelAlignment(model, alignment)); + editor.formatContentModel(model => setModelAlignment(model, alignment), { + apiName: 'setAlignment', + }); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setDirection.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setDirection.ts index 83804c4dabe..ed273683572 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setDirection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setDirection.ts @@ -1,4 +1,3 @@ -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { setModelDirection } from '../../modelApi/block/setModelDirection'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -10,5 +9,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' export default function setDirection(editor: IContentModelEditor, direction: 'ltr' | 'rtl') { editor.focus(); - formatWithContentModel(editor, 'setDirection', model => setModelDirection(model, direction)); + editor.formatContentModel(model => setModelDirection(model, direction), { + apiName: 'setDirection', + }); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setIndentation.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setIndentation.ts index 6c01898cec3..dd92e35a7ab 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setIndentation.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setIndentation.ts @@ -1,4 +1,4 @@ -import { formatWithContentModel } from '../utils/formatWithContentModel'; +import { formatAndKeepPendingFormat } from '../../modelApi/format/pendingFormat'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; import { setModelIndentation } from '../../modelApi/block/setModelIndentation'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -16,9 +16,8 @@ export default function setIndentation( ) { editor.focus(); - formatWithContentModel( + formatAndKeepPendingFormat( editor, - 'setIndentation', model => { const result = setModelIndentation(model, indentation, length); @@ -29,7 +28,7 @@ export default function setIndentation( return result; }, { - preservePendingFormat: true, + apiName: 'setIndentation', } ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/toggleBlockQuote.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/toggleBlockQuote.ts index d3f4a69c6ef..5227618b0d9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/toggleBlockQuote.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/toggleBlockQuote.ts @@ -1,4 +1,4 @@ -import { formatWithContentModel } from '../utils/formatWithContentModel'; +import { formatAndKeepPendingFormat } from '../../modelApi/format/pendingFormat'; import { toggleModelBlockQuote } from '../../modelApi/block/toggleModelBlockQuote'; import type { ContentModelFormatContainerFormat } from 'roosterjs-content-model-types'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -33,12 +33,7 @@ export default function toggleBlockQuote( editor.focus(); - formatWithContentModel( - editor, - 'toggleBlockQuote', - model => toggleModelBlockQuote(model, fullQuoteFormat), - { - preservePendingFormat: true, - } - ); + formatAndKeepPendingFormat(editor, model => toggleModelBlockQuote(model, fullQuoteFormat), { + apiName: 'toggleBlockQuote', + }); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts index d22a9b8ee50..63d1e250890 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts @@ -2,7 +2,6 @@ import { ChangeSource } from '../../publicTypes/event/ContentModelContentChanged import { deleteAllSegmentBefore } from '../../modelApi/edit/deleteSteps/deleteAllSegmentBefore'; import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { isModifierKey } from '../../domUtils/eventUtils'; import { isNodeOfType } from 'roosterjs-content-model-dom'; import type { DeleteSelectionStep } from '../../modelApi/edit/utils/DeleteSelectionStep'; @@ -37,9 +36,7 @@ export default function keyboardDelete( let isDeleted = false; if (shouldDeleteWithContentModel(range, rawEvent)) { - formatWithContentModel( - editor, - rawEvent.key == 'Delete' ? 'handleDeleteKey' : 'handleBackspaceKey', + editor.formatContentModel( (model, context) => { const result = deleteSelection( model, @@ -55,6 +52,7 @@ export default function keyboardDelete( rawEvent, changeSource: ChangeSource.Keyboard, getChangeData: () => rawEvent.which, + apiName: rawEvent.key == 'Delete' ? 'handleDeleteKey' : 'handleBackspaceKey', } ); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts index 79415d37529..cf8756fdddd 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts @@ -1,6 +1,5 @@ import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; import { createEntity, normalizeContentModel } from 'roosterjs-content-model-dom'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { insertEntityModel } from '../../modelApi/entity/insertEntityModel'; import type { ContentModelEntity, DOMSelection } from 'roosterjs-content-model-types'; import type { Entity } from 'roosterjs-editor-types'; @@ -70,9 +69,7 @@ export default function insertEntity( const entityModel = createEntity(wrapper, true /*isReadonly*/, undefined /*format*/, type); - formatWithContentModel( - editor, - 'insertEntity', + editor.formatContentModel( (model, context) => { insertEntityModel( model, @@ -104,6 +101,7 @@ export default function insertEntity( return entity; }, + apiName: 'insertEntity', } ); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts index 5aae073592e..e27497a8cf8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts @@ -1,6 +1,5 @@ import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; import { isBlockElement, isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; @@ -34,41 +33,51 @@ export default function applyDefaultFormat( node = node.parentNode; } - formatWithContentModel(editor, 'input', (model, context) => { - const result = deleteSelection(model, [], context); + editor.formatContentModel( + (model, context) => { + const result = deleteSelection(model, [], context); - if (result.deleteResult == DeleteResult.Range) { - normalizeContentModel(model); - editor.addUndoSnapshot(); + if (result.deleteResult == DeleteResult.Range) { + normalizeContentModel(model); + editor.addUndoSnapshot(); - return true; - } else if ( - result.deleteResult == DeleteResult.NotDeleted && - result.insertPoint && - posContainer && - posOffset !== null - ) { - const { paragraph, path, marker } = result.insertPoint; - const blocks = path[0].blocks; - const blockCount = blocks.length; - const blockIndex = blocks.indexOf(paragraph); - - if ( - paragraph.isImplicit && - paragraph.segments.length == 1 && - paragraph.segments[0] == marker && - blockCount > 0 && - blockIndex == blockCount - 1 + return true; + } else if ( + result.deleteResult == DeleteResult.NotDeleted && + result.insertPoint && + posContainer && + posOffset !== null ) { - // Focus is in the last paragraph which is implicit and there is not other segments. - // This can happen when focus is moved after all other content under current block group. - // We need to check if browser will merge focus into previous paragraph by checking if - // previous block is block. If previous block is paragraph, browser will most likely merge - // the input into previous paragraph, then nothing need to do here. Otherwise we need to - // apply pending format since this input event will start a new real paragraph. - const previousBlock = blocks[blockIndex - 1]; + const { paragraph, path, marker } = result.insertPoint; + const blocks = path[0].blocks; + const blockCount = blocks.length; + const blockIndex = blocks.indexOf(paragraph); + + if ( + paragraph.isImplicit && + paragraph.segments.length == 1 && + paragraph.segments[0] == marker && + blockCount > 0 && + blockIndex == blockCount - 1 + ) { + // Focus is in the last paragraph which is implicit and there is not other segments. + // This can happen when focus is moved after all other content under current block group. + // We need to check if browser will merge focus into previous paragraph by checking if + // previous block is block. If previous block is paragraph, browser will most likely merge + // the input into previous paragraph, then nothing need to do here. Otherwise we need to + // apply pending format since this input event will start a new real paragraph. + const previousBlock = blocks[blockIndex - 1]; - if (previousBlock?.blockType != 'Paragraph') { + if (previousBlock?.blockType != 'Paragraph') { + internalApplyDefaultFormat( + editor, + defaultFormat, + marker.format, + posContainer, + posOffset + ); + } + } else if (paragraph.segments.every(x => x.segmentType != 'Text')) { internalApplyDefaultFormat( editor, defaultFormat, @@ -77,22 +86,17 @@ export default function applyDefaultFormat( posOffset ); } - } else if (paragraph.segments.every(x => x.segmentType != 'Text')) { - internalApplyDefaultFormat( - editor, - defaultFormat, - marker.format, - posContainer, - posOffset - ); - } - // We didn't do any change but just apply default format to pending format, so no need to write back - return false; - } else { - return false; + // We didn't do any change but just apply default format to pending format, so no need to write back + return false; + } else { + return false; + } + }, + { + apiName: 'input', } - }); + ); } function internalApplyDefaultFormat( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts index 432da79a18d..cfec82a5873 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts @@ -1,4 +1,3 @@ -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { getPendingFormat } from '../../modelApi/format/pendingFormat'; import { iterateSelections } from '../../modelApi/selection/iterateSelections'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -22,49 +21,57 @@ export default function applyPendingFormat(editor: IContentModelEditor, data: st if (format) { let isChanged = false; - formatWithContentModel(editor, 'applyPendingFormat', (model, context) => { - iterateSelections(model, (_, __, block, segments) => { - if ( - block?.blockType == 'Paragraph' && - segments?.length == 1 && - segments[0].segmentType == 'SelectionMarker' - ) { - const marker = segments[0]; - const index = block.segments.indexOf(marker); - const previousSegment = block.segments[index - 1]; + editor.formatContentModel( + (model, context) => { + iterateSelections(model, (_, __, block, segments) => { + if ( + block?.blockType == 'Paragraph' && + segments?.length == 1 && + segments[0].segmentType == 'SelectionMarker' + ) { + const marker = segments[0]; + const index = block.segments.indexOf(marker); + const previousSegment = block.segments[index - 1]; - if (previousSegment?.segmentType == 'Text') { - const text = previousSegment.text; - const subStr = text.substr(-data.length, data.length); + if (previousSegment?.segmentType == 'Text') { + const text = previousSegment.text; + const subStr = text.substr(-data.length, data.length); - // For space, there can be (space) or   ( ), we treat them as the same - if (subStr == data || (data == ANSI_SPACE && subStr == NON_BREAK_SPACE)) { - marker.format = { ...format }; - previousSegment.text = text.substring(0, text.length - data.length); + // For space, there can be (space) or   ( ), we treat them as the same + if ( + subStr == data || + (data == ANSI_SPACE && subStr == NON_BREAK_SPACE) + ) { + marker.format = { ...format }; + previousSegment.text = text.substring(0, text.length - data.length); - const newText = createText( - data == ANSI_SPACE ? NON_BREAK_SPACE : data, - { - ...previousSegment.format, - ...format, - } - ); + const newText = createText( + data == ANSI_SPACE ? NON_BREAK_SPACE : data, + { + ...previousSegment.format, + ...format, + } + ); - block.segments.splice(index, 0, newText); - setParagraphNotImplicit(block); - isChanged = true; + block.segments.splice(index, 0, newText); + setParagraphNotImplicit(block); + isChanged = true; + } } } + return true; + }); + + if (isChanged) { + normalizeContentModel(model); + context.skipUndoSnapshot = true; } - return true; - }); - if (isChanged) { - normalizeContentModel(model); - context.skipUndoSnapshot = true; + return isChanged; + }, + { + apiName: 'applyPendingFormat', } - - return isChanged; - }); + ); } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/clearFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/clearFormat.ts index 285ba03b6b7..05d0b56138e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/clearFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/clearFormat.ts @@ -1,5 +1,4 @@ import { clearModelFormat } from '../../modelApi/common/clearModelFormat'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { @@ -16,15 +15,22 @@ import type { export default function clearFormat(editor: IContentModelEditor) { editor.focus(); - formatWithContentModel(editor, 'clearFormat', model => { - const blocksToClear: [ContentModelBlockGroup[], ContentModelBlock][] = []; - const segmentsToClear: ContentModelSegment[] = []; - const tablesToClear: [ContentModelTable, boolean][] = []; + editor.formatContentModel( + model => { + const blocksToClear: [ContentModelBlockGroup[], ContentModelBlock][] = []; + const segmentsToClear: ContentModelSegment[] = []; + const tablesToClear: [ContentModelTable, boolean][] = []; - clearModelFormat(model, blocksToClear, segmentsToClear, tablesToClear); + clearModelFormat(model, blocksToClear, segmentsToClear, tablesToClear); - normalizeContentModel(model); + normalizeContentModel(model); - return blocksToClear.length > 0 || segmentsToClear.length > 0 || tablesToClear.length > 0; - }); + return ( + blocksToClear.length > 0 || segmentsToClear.length > 0 || tablesToClear.length > 0 + ); + }, + { + apiName: 'clearFormat', + } + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/adjustImageSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/adjustImageSelection.ts index 52bf444be05..e2f7705bed1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/adjustImageSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/adjustImageSelection.ts @@ -1,5 +1,4 @@ import { adjustSegmentSelection } from '../../modelApi/selection/adjustSegmentSelection'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; import type { ContentModelImage } from 'roosterjs-content-model-types'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -12,19 +11,23 @@ export default function adjustImageSelection( ): ContentModelImage | null { let image: ContentModelImage | null = null; - formatWithContentModel(editor, 'adjustImageSelection', model => - adjustSegmentSelection( - model, - target => { - if (target.isSelected && target.segmentType == 'Image') { - image = target; - return true; - } else { - return false; - } - }, - (target, ref) => target == ref - ) + editor.formatContentModel( + model => + adjustSegmentSelection( + model, + target => { + if (target.isSelected && target.segmentType == 'Image') { + image = target; + return true; + } else { + return false; + } + }, + (target, ref) => target == ref + ), + { + apiName: 'adjustImageSelection', + } ); return image; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts index b815bd51fa7..e6f69e12c15 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts @@ -1,5 +1,4 @@ import { addSegment, createContentModelDocument, createImage } from 'roosterjs-content-model-dom'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { mergeModel } from '../../modelApi/common/mergeModel'; import { readFile } from '../../domUtils/readFile'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -24,15 +23,20 @@ export default function insertImage(editor: IContentModelEditor, imageFileOrSrc: } function insertImageWithSrc(editor: IContentModelEditor, src: string) { - formatWithContentModel(editor, 'insertImage', (model, context) => { - const image = createImage(src, { backgroundColor: '' }); - const doc = createContentModelDocument(); + editor.formatContentModel( + (model, context) => { + const image = createImage(src, { backgroundColor: '' }); + const doc = createContentModelDocument(); - addSegment(doc, image); - mergeModel(model, doc, context, { - mergeFormat: 'mergeAll', - }); + addSegment(doc, image); + mergeModel(model, doc, context, { + mergeFormat: 'mergeAll', + }); - return true; - }); + return true; + }, + { + apiName: 'insertImage', + } + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/adjustLinkSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/adjustLinkSelection.ts index 8c7101d18bf..ef80f2661f8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/adjustLinkSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/adjustLinkSelection.ts @@ -1,7 +1,6 @@ import getSelectedSegments from '../selection/getSelectedSegments'; import { adjustSegmentSelection } from '../../modelApi/selection/adjustSegmentSelection'; import { adjustWordSelection } from '../../modelApi/selection/adjustWordSelection'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { setSelection } from '../../modelApi/selection/setSelection'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -13,29 +12,34 @@ export default function adjustLinkSelection(editor: IContentModelEditor): [strin let text = ''; let url: string | null = null; - formatWithContentModel(editor, 'adjustLinkSelection', model => { - let changed = adjustSegmentSelection( - model, - target => !!target.isSelected && !!target.link, - (target, ref) => !!target.link && target.link.format.href == ref.link!.format.href - ); - let segments = getSelectedSegments(model, false /*includingFormatHolder*/); - const firstSegment = segments[0]; + editor.formatContentModel( + model => { + let changed = adjustSegmentSelection( + model, + target => !!target.isSelected && !!target.link, + (target, ref) => !!target.link && target.link.format.href == ref.link!.format.href + ); + let segments = getSelectedSegments(model, false /*includingFormatHolder*/); + const firstSegment = segments[0]; - if (segments.length == 1 && firstSegment.segmentType == 'SelectionMarker') { - segments = adjustWordSelection(model, firstSegment); + if (segments.length == 1 && firstSegment.segmentType == 'SelectionMarker') { + segments = adjustWordSelection(model, firstSegment); - if (segments.length > 1) { - changed = true; - setSelection(model, segments[0], segments[segments.length - 1]); + if (segments.length > 1) { + changed = true; + setSelection(model, segments[0], segments[segments.length - 1]); + } } - } - text = segments.map(x => (x.segmentType == 'Text' ? x.text : '')).join(''); - url = segments[0]?.link?.format.href || null; + text = segments.map(x => (x.segmentType == 'Text' ? x.text : '')).join(''); + url = segments[0]?.link?.format.href || null; - return changed; - }); + return changed; + }, + { + apiName: 'adjustLinkSelection', + } + ); return [text, url]; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts index fba7dad76fe..a84e9653a5e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts @@ -1,6 +1,5 @@ import getSelectedSegments from '../selection/getSelectedSegments'; import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { getPendingFormat } from '../../modelApi/format/pendingFormat'; import { HtmlSanitizer, matchLink } from 'roosterjs-editor-dom'; import { mergeModel } from '../../modelApi/common/mergeModel'; @@ -49,9 +48,7 @@ export default function insertLink( const links: ContentModelLink[] = []; let anchorNode: Node | undefined; - formatWithContentModel( - editor, - 'insertLink', + editor.formatContentModel( (model, context) => { const segments = getSelectedSegments(model, false /*includingFormatHolder*/); const originalText = segments @@ -108,6 +105,7 @@ export default function insertLink( } }, getChangeData: () => anchorNode, + apiName: 'insertLink', } ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/removeLink.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/removeLink.ts index 45684396f2f..730998a4a4b 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/removeLink.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/removeLink.ts @@ -1,6 +1,5 @@ import getSelectedSegments from '../selection/getSelectedSegments'; import { adjustSegmentSelection } from '../../modelApi/selection/adjustSegmentSelection'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; /** @@ -12,26 +11,31 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' export default function removeLink(editor: IContentModelEditor) { editor.focus(); - formatWithContentModel(editor, 'removeLink', model => { - adjustSegmentSelection( - model, - target => !!target.isSelected && !!target.link, - (target, ref) => - target.isSelected || // Expand the selection to any link that is involved. So we can remove multiple links together - (!!target.link && target.link.format.href == ref.link!.format.href) - ); + editor.formatContentModel( + model => { + adjustSegmentSelection( + model, + target => !!target.isSelected && !!target.link, + (target, ref) => + target.isSelected || // Expand the selection to any link that is involved. So we can remove multiple links together + (!!target.link && target.link.format.href == ref.link!.format.href) + ); - const segments = getSelectedSegments(model, false /*includingFormatHolder*/); - let isChanged = false; + const segments = getSelectedSegments(model, false /*includingFormatHolder*/); + let isChanged = false; - segments.forEach(segment => { - if (segment.link) { - isChanged = true; + segments.forEach(segment => { + if (segment.link) { + isChanged = true; - delete segment.link; - } - }); + delete segment.link; + } + }); - return isChanged; - }); + return isChanged; + }, + { + apiName: 'removeLink', + } + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStartNumber.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStartNumber.ts index a1ebe108070..509912dffe7 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStartNumber.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStartNumber.ts @@ -1,4 +1,3 @@ -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { getFirstSelectedListItem } from '../../modelApi/selection/collectSelections'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -10,16 +9,21 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' export default function setListStartNumber(editor: IContentModelEditor, value: number) { editor.focus(); - formatWithContentModel(editor, 'setListStartNumber', model => { - const listItem = getFirstSelectedListItem(model); - const level = listItem?.levels[listItem?.levels.length - 1]; + editor.formatContentModel( + model => { + const listItem = getFirstSelectedListItem(model); + const level = listItem?.levels[listItem?.levels.length - 1]; - if (level) { - level.format.startNumberOverride = value; + if (level) { + level.format.startNumberOverride = value; - return true; - } else { - return false; + return true; + } else { + return false; + } + }, + { + apiName: 'setListStartNumber', } - }); + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStyle.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStyle.ts index aa2761d0257..15a101351b0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStyle.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStyle.ts @@ -1,5 +1,4 @@ import { findListItemsInSameThread } from '../../modelApi/list/findListItemsInSameThread'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { getFirstSelectedListItem } from '../../modelApi/selection/collectSelections'; import { updateListMetadata } from '../../domUtils/metadata/updateListMetadata'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -13,24 +12,29 @@ import type { ListMetadataFormat } from 'roosterjs-content-model-types'; export default function setListStyle(editor: IContentModelEditor, style: ListMetadataFormat) { editor.focus(); - formatWithContentModel(editor, 'setListStyle', model => { - const listItem = getFirstSelectedListItem(model); + editor.formatContentModel( + model => { + const listItem = getFirstSelectedListItem(model); - if (listItem) { - const listItems = findListItemsInSameThread(model, listItem); - const levelIndex = listItem.levels.length - 1; + if (listItem) { + const listItems = findListItemsInSameThread(model, listItem); + const levelIndex = listItem.levels.length - 1; - listItems.forEach(listItem => { - const level = listItem.levels[levelIndex]; + listItems.forEach(listItem => { + const level = listItem.levels[levelIndex]; - if (level) { - updateListMetadata(level, metadata => Object.assign({}, metadata, style)); - } - }); + if (level) { + updateListMetadata(level, metadata => Object.assign({}, metadata, style)); + } + }); - return true; - } else { - return false; + return true; + } else { + return false; + } + }, + { + apiName: 'setListStyle', } - }); + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleBullet.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleBullet.ts index 379ede04394..fdcd033524f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleBullet.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleBullet.ts @@ -1,4 +1,4 @@ -import { formatWithContentModel } from '../utils/formatWithContentModel'; +import { formatAndKeepPendingFormat } from '../../modelApi/format/pendingFormat'; import { setListType } from '../../modelApi/list/setListType'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -11,7 +11,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' export default function toggleBullet(editor: IContentModelEditor) { editor.focus(); - formatWithContentModel(editor, 'toggleBullet', model => setListType(model, 'UL'), { - preservePendingFormat: true, + formatAndKeepPendingFormat(editor, model => setListType(model, 'UL'), { + apiName: 'toggleBullet', }); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleNumbering.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleNumbering.ts index 7400d3d0c0b..4508b1343c5 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleNumbering.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleNumbering.ts @@ -1,4 +1,4 @@ -import { formatWithContentModel } from '../utils/formatWithContentModel'; +import { formatAndKeepPendingFormat } from '../../modelApi/format/pendingFormat'; import { setListType } from '../../modelApi/list/setListType'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -11,7 +11,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' export default function toggleNumbering(editor: IContentModelEditor) { editor.focus(); - formatWithContentModel(editor, 'toggleNumbering', model => setListType(model, 'OL'), { - preservePendingFormat: true, + formatAndKeepPendingFormat(editor, model => setListType(model, 'OL'), { + apiName: 'toggleNumbering', }); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts index aa406f96a58..1eef35e3181 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts @@ -1,5 +1,4 @@ import { extractBorderValues } from '../../domUtils/borderValues'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { getFirstSelectedTable } from '../../modelApi/selection/collectSelections'; import { getSelectedCells } from '../../modelApi/table/getSelectedCells'; import { parseValueWithUnit } from 'roosterjs-content-model-dom'; @@ -39,291 +38,316 @@ export default function applyTableBorderFormat( border: Border, operation: BorderOperations ) { - formatWithContentModel(editor, 'tableBorder', model => { - const [tableModel] = getFirstSelectedTable(model); + editor.formatContentModel( + model => { + const [tableModel] = getFirstSelectedTable(model); - if (tableModel) { - const sel = getSelectedCells(tableModel); - const perimeter: Perimeter = { - Top: false, - Bottom: false, - Left: false, - Right: false, - }; + if (tableModel) { + const sel = getSelectedCells(tableModel); + const perimeter: Perimeter = { + Top: false, + Bottom: false, + Left: false, + Right: false, + }; - // Create border format with table format as backup - let borderFormat = ''; - const format = tableModel.format; - const { width, style, color } = border; - const extractedBorder = extractBorderValues(format.borderTop); - const borderColor = extractedBorder.color; - const borderWidth = extractedBorder.width; - const borderStyle = extractedBorder.style; + // Create border format with table format as backup + let borderFormat = ''; + const format = tableModel.format; + const { width, style, color } = border; + const extractedBorder = extractBorderValues(format.borderTop); + const borderColor = extractedBorder.color; + const borderWidth = extractedBorder.width; + const borderStyle = extractedBorder.style; - if (width) { - borderFormat = parseValueWithUnit(width) + 'px'; - } else if (borderWidth) { - borderFormat = borderWidth; - } else { - borderFormat = '1px'; - } + if (width) { + borderFormat = parseValueWithUnit(width) + 'px'; + } else if (borderWidth) { + borderFormat = borderWidth; + } else { + borderFormat = '1px'; + } - if (style) { - borderFormat = `${borderFormat} ${style}`; - } else if (borderStyle) { - borderFormat = `${borderFormat} ${borderStyle}`; - } else { - borderFormat = `${borderFormat} solid`; - } + if (style) { + borderFormat = `${borderFormat} ${style}`; + } else if (borderStyle) { + borderFormat = `${borderFormat} ${borderStyle}`; + } else { + borderFormat = `${borderFormat} solid`; + } - if (color) { - borderFormat = `${borderFormat} ${color}`; - } else if (borderColor) { - borderFormat = `${borderFormat} ${borderColor}`; - } + if (color) { + borderFormat = `${borderFormat} ${color}`; + } else if (borderColor) { + borderFormat = `${borderFormat} ${borderColor}`; + } - if (sel) { - const operations: BorderOperations[] = [operation]; - while (operations.length) { - switch (operations.pop()) { - case 'noBorders': - // Do All borders but with empty border format - borderFormat = ''; - operations.push('allBorders'); - break; - case 'allBorders': - const allBorders: BorderPositions[] = [ - 'borderTop', - 'borderBottom', - 'borderLeft', - 'borderRight', - ]; - for (let rowIndex = sel.firstRow; rowIndex <= sel.lastRow; rowIndex++) { + if (sel) { + const operations: BorderOperations[] = [operation]; + while (operations.length) { + switch (operations.pop()) { + case 'noBorders': + // Do All borders but with empty border format + borderFormat = ''; + operations.push('allBorders'); + break; + case 'allBorders': + const allBorders: BorderPositions[] = [ + 'borderTop', + 'borderBottom', + 'borderLeft', + 'borderRight', + ]; for ( - let colIndex = sel.firstCol; - colIndex <= sel.lastCol; - colIndex++ + let rowIndex = sel.firstRow; + rowIndex <= sel.lastRow; + rowIndex++ ) { - const cell = tableModel.rows[rowIndex].cells[colIndex]; - // Format cells - All borders - applyBorderFormat(cell, borderFormat, allBorders); + for ( + let colIndex = sel.firstCol; + colIndex <= sel.lastCol; + colIndex++ + ) { + const cell = tableModel.rows[rowIndex].cells[colIndex]; + // Format cells - All borders + applyBorderFormat(cell, borderFormat, allBorders); + } } - } - // Format perimeter - perimeter.Top = true; - perimeter.Bottom = true; - perimeter.Left = true; - perimeter.Right = true; - break; - case 'leftBorders': - const leftBorder: BorderPositions[] = ['borderLeft']; - for (let rowIndex = sel.firstRow; rowIndex <= sel.lastRow; rowIndex++) { - const cell = tableModel.rows[rowIndex].cells[sel.firstCol]; - // Format cells - Left border - applyBorderFormat(cell, borderFormat, leftBorder); - } + // Format perimeter + perimeter.Top = true; + perimeter.Bottom = true; + perimeter.Left = true; + perimeter.Right = true; + break; + case 'leftBorders': + const leftBorder: BorderPositions[] = ['borderLeft']; + for ( + let rowIndex = sel.firstRow; + rowIndex <= sel.lastRow; + rowIndex++ + ) { + const cell = tableModel.rows[rowIndex].cells[sel.firstCol]; + // Format cells - Left border + applyBorderFormat(cell, borderFormat, leftBorder); + } - // Format perimeter - perimeter.Left = true; - break; - case 'rightBorders': - const rightBorder: BorderPositions[] = ['borderRight']; - for (let rowIndex = sel.firstRow; rowIndex <= sel.lastRow; rowIndex++) { - const cell = tableModel.rows[rowIndex].cells[sel.lastCol]; - // Format cells - Right border - applyBorderFormat(cell, borderFormat, rightBorder); - } + // Format perimeter + perimeter.Left = true; + break; + case 'rightBorders': + const rightBorder: BorderPositions[] = ['borderRight']; + for ( + let rowIndex = sel.firstRow; + rowIndex <= sel.lastRow; + rowIndex++ + ) { + const cell = tableModel.rows[rowIndex].cells[sel.lastCol]; + // Format cells - Right border + applyBorderFormat(cell, borderFormat, rightBorder); + } - // Format perimeter - perimeter.Right = true; - break; - case 'topBorders': - const topBorder: BorderPositions[] = ['borderTop']; - for (let colIndex = sel.firstCol; colIndex <= sel.lastCol; colIndex++) { - const cell = tableModel.rows[sel.firstRow].cells[colIndex]; - // Format cells - Top border - applyBorderFormat(cell, borderFormat, topBorder); - } + // Format perimeter + perimeter.Right = true; + break; + case 'topBorders': + const topBorder: BorderPositions[] = ['borderTop']; + for ( + let colIndex = sel.firstCol; + colIndex <= sel.lastCol; + colIndex++ + ) { + const cell = tableModel.rows[sel.firstRow].cells[colIndex]; + // Format cells - Top border + applyBorderFormat(cell, borderFormat, topBorder); + } - // Format perimeter - perimeter.Top = true; - break; - case 'bottomBorders': - const bottomBorder: BorderPositions[] = ['borderBottom']; - for (let colIndex = sel.firstCol; colIndex <= sel.lastCol; colIndex++) { - const cell = tableModel.rows[sel.lastRow].cells[colIndex]; - // Format cells - Bottom border - applyBorderFormat(cell, borderFormat, bottomBorder); - } + // Format perimeter + perimeter.Top = true; + break; + case 'bottomBorders': + const bottomBorder: BorderPositions[] = ['borderBottom']; + for ( + let colIndex = sel.firstCol; + colIndex <= sel.lastCol; + colIndex++ + ) { + const cell = tableModel.rows[sel.lastRow].cells[colIndex]; + // Format cells - Bottom border + applyBorderFormat(cell, borderFormat, bottomBorder); + } - // Format perimeter - perimeter.Bottom = true; - break; - case 'insideBorders': - // Format cells - Inside borders - const singleCol = sel.lastCol == sel.firstCol; - const singleRow = sel.lastRow == sel.firstRow; - // Single cell selection - if (singleCol && singleRow) { + // Format perimeter + perimeter.Bottom = true; break; - } - // Single column selection - if (singleCol) { + case 'insideBorders': + // Format cells - Inside borders + const singleCol = sel.lastCol == sel.firstCol; + const singleRow = sel.lastRow == sel.firstRow; + // Single cell selection + if (singleCol && singleRow) { + break; + } + // Single column selection + if (singleCol) { + applyBorderFormat( + tableModel.rows[sel.firstRow].cells[sel.firstCol], + borderFormat, + ['borderBottom'] + ); + for ( + let rowIndex = sel.firstRow + 1; + rowIndex <= sel.lastRow - 1; + rowIndex++ + ) { + const cell = tableModel.rows[rowIndex].cells[sel.firstCol]; + applyBorderFormat(cell, borderFormat, [ + 'borderTop', + 'borderBottom', + ]); + } + applyBorderFormat( + tableModel.rows[sel.lastRow].cells[sel.firstCol], + borderFormat, + ['borderTop'] + ); + break; + } + // Single row selection + if (singleRow) { + applyBorderFormat( + tableModel.rows[sel.firstRow].cells[sel.firstCol], + borderFormat, + ['borderRight'] + ); + for ( + let colIndex = sel.firstCol + 1; + colIndex <= sel.lastCol - 1; + colIndex++ + ) { + const cell = tableModel.rows[sel.firstRow].cells[colIndex]; + applyBorderFormat(cell, borderFormat, [ + 'borderLeft', + 'borderRight', + ]); + } + applyBorderFormat( + tableModel.rows[sel.firstRow].cells[sel.lastCol], + borderFormat, + ['borderLeft'] + ); + break; + } + + // For multiple rows and columns selections + // Top left cell applyBorderFormat( tableModel.rows[sel.firstRow].cells[sel.firstCol], borderFormat, - ['borderBottom'] + ['borderBottom', 'borderRight'] ); - for ( - let rowIndex = sel.firstRow + 1; - rowIndex <= sel.lastRow - 1; - rowIndex++ - ) { - const cell = tableModel.rows[rowIndex].cells[sel.firstCol]; - applyBorderFormat(cell, borderFormat, [ - 'borderTop', - 'borderBottom', - ]); - } + // Top right cell + applyBorderFormat( + tableModel.rows[sel.firstRow].cells[sel.lastCol], + borderFormat, + ['borderBottom', 'borderLeft'] + ); + // Bottom left cell applyBorderFormat( tableModel.rows[sel.lastRow].cells[sel.firstCol], borderFormat, - ['borderTop'] + ['borderTop', 'borderRight'] ); - break; - } - // Single row selection - if (singleRow) { + // Bottom right cell applyBorderFormat( - tableModel.rows[sel.firstRow].cells[sel.firstCol], + tableModel.rows[sel.lastRow].cells[sel.lastCol], borderFormat, - ['borderRight'] + ['borderTop', 'borderLeft'] ); + // First row for ( let colIndex = sel.firstCol + 1; - colIndex <= sel.lastCol - 1; + colIndex < sel.lastCol; colIndex++ ) { const cell = tableModel.rows[sel.firstRow].cells[colIndex]; applyBorderFormat(cell, borderFormat, [ + 'borderBottom', 'borderLeft', 'borderRight', ]); } - applyBorderFormat( - tableModel.rows[sel.firstRow].cells[sel.lastCol], - borderFormat, - ['borderLeft'] - ); + // Last row + for ( + let colIndex = sel.firstCol + 1; + colIndex < sel.lastCol; + colIndex++ + ) { + const cell = tableModel.rows[sel.lastRow].cells[colIndex]; + applyBorderFormat(cell, borderFormat, [ + 'borderTop', + 'borderLeft', + 'borderRight', + ]); + } + // First column + for ( + let rowIndex = sel.firstRow + 1; + rowIndex < sel.lastRow; + rowIndex++ + ) { + const cell = tableModel.rows[rowIndex].cells[sel.firstCol]; + applyBorderFormat(cell, borderFormat, [ + 'borderTop', + 'borderBottom', + 'borderRight', + ]); + } + // Last column + for ( + let rowIndex = sel.firstRow + 1; + rowIndex < sel.lastRow; + rowIndex++ + ) { + const cell = tableModel.rows[rowIndex].cells[sel.lastCol]; + applyBorderFormat(cell, borderFormat, [ + 'borderTop', + 'borderBottom', + 'borderLeft', + ]); + } + // Inner cells + sel.firstCol++; + sel.firstRow++; + sel.lastCol--; + sel.lastRow--; + operations.push('allBorders'); break; - } - - // For multiple rows and columns selections - // Top left cell - applyBorderFormat( - tableModel.rows[sel.firstRow].cells[sel.firstCol], - borderFormat, - ['borderBottom', 'borderRight'] - ); - // Top right cell - applyBorderFormat( - tableModel.rows[sel.firstRow].cells[sel.lastCol], - borderFormat, - ['borderBottom', 'borderLeft'] - ); - // Bottom left cell - applyBorderFormat( - tableModel.rows[sel.lastRow].cells[sel.firstCol], - borderFormat, - ['borderTop', 'borderRight'] - ); - // Bottom right cell - applyBorderFormat( - tableModel.rows[sel.lastRow].cells[sel.lastCol], - borderFormat, - ['borderTop', 'borderLeft'] - ); - // First row - for ( - let colIndex = sel.firstCol + 1; - colIndex < sel.lastCol; - colIndex++ - ) { - const cell = tableModel.rows[sel.firstRow].cells[colIndex]; - applyBorderFormat(cell, borderFormat, [ - 'borderBottom', - 'borderLeft', - 'borderRight', - ]); - } - // Last row - for ( - let colIndex = sel.firstCol + 1; - colIndex < sel.lastCol; - colIndex++ - ) { - const cell = tableModel.rows[sel.lastRow].cells[colIndex]; - applyBorderFormat(cell, borderFormat, [ - 'borderTop', - 'borderLeft', - 'borderRight', - ]); - } - // First column - for ( - let rowIndex = sel.firstRow + 1; - rowIndex < sel.lastRow; - rowIndex++ - ) { - const cell = tableModel.rows[rowIndex].cells[sel.firstCol]; - applyBorderFormat(cell, borderFormat, [ - 'borderTop', - 'borderBottom', - 'borderRight', - ]); - } - // Last column - for ( - let rowIndex = sel.firstRow + 1; - rowIndex < sel.lastRow; - rowIndex++ - ) { - const cell = tableModel.rows[rowIndex].cells[sel.lastCol]; - applyBorderFormat(cell, borderFormat, [ - 'borderTop', - 'borderBottom', - 'borderLeft', - ]); - } - // Inner cells - sel.firstCol++; - sel.firstRow++; - sel.lastCol--; - sel.lastRow--; - operations.push('allBorders'); - break; - case 'outsideBorders': - // Format cells - Outside borders - operations.push('topBorders'); - operations.push('bottomBorders'); - operations.push('leftBorders'); - operations.push('rightBorders'); - break; - default: - break; + case 'outsideBorders': + // Format cells - Outside borders + operations.push('topBorders'); + operations.push('bottomBorders'); + operations.push('leftBorders'); + operations.push('rightBorders'); + break; + default: + break; + } } + + //Format perimeter if necessary or possible + modifyPerimeter(tableModel, sel, borderFormat, perimeter); } - //Format perimeter if necessary or possible - modifyPerimeter(tableModel, sel, borderFormat, perimeter); + return true; + } else { + return false; } - - return true; - } else { - return false; + }, + { + apiName: 'tableBorder', } - }); + ); } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts index 443b2cf6cb0..19b35c4b499 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts @@ -5,7 +5,6 @@ import { deleteTable } from '../../modelApi/table/deleteTable'; import { deleteTableColumn } from '../../modelApi/table/deleteTableColumn'; import { deleteTableRow } from '../../modelApi/table/deleteTableRow'; import { ensureFocusableParagraphForTable } from '../../modelApi/table/ensureFocusableParagraphForTable'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { getFirstSelectedTable } from '../../modelApi/selection/collectSelections'; import { insertTableColumn } from '../../modelApi/table/insertTableColumn'; import { insertTableRow } from '../../modelApi/table/insertTableRow'; @@ -36,93 +35,98 @@ import { export default function editTable(editor: IContentModelEditor, operation: TableOperation) { editor.focus(); - formatWithContentModel(editor, 'editTable', model => { - const [tableModel, path] = getFirstSelectedTable(model); - - if (tableModel) { - switch (operation) { - case 'alignCellLeft': - case 'alignCellCenter': - case 'alignCellRight': - alignTableCellHorizontally(tableModel, operation); - break; - case 'alignCellTop': - case 'alignCellMiddle': - case 'alignCellBottom': - alignTableCellVertically(tableModel, operation); - break; - case 'alignCenter': - case 'alignLeft': - case 'alignRight': - alignTable(tableModel, operation); - break; - - case 'deleteColumn': - deleteTableColumn(tableModel); - break; - - case 'deleteRow': - deleteTableRow(tableModel); - break; - - case 'deleteTable': - deleteTable(tableModel); - break; - - case 'insertAbove': - case 'insertBelow': - insertTableRow(tableModel, operation); - break; - - case 'insertLeft': - case 'insertRight': - insertTableColumn(tableModel, operation); - break; - - case 'mergeAbove': - case 'mergeBelow': - mergeTableRow(tableModel, operation); - break; - - case 'mergeCells': - mergeTableCells(tableModel); - break; - - case 'mergeLeft': - case 'mergeRight': - mergeTableColumn(tableModel, operation); - break; - - case 'splitHorizontally': - splitTableCellHorizontally(tableModel); - break; - - case 'splitVertically': - splitTableCellVertically(tableModel); - break; - } + editor.formatContentModel( + model => { + const [tableModel, path] = getFirstSelectedTable(model); + + if (tableModel) { + switch (operation) { + case 'alignCellLeft': + case 'alignCellCenter': + case 'alignCellRight': + alignTableCellHorizontally(tableModel, operation); + break; + case 'alignCellTop': + case 'alignCellMiddle': + case 'alignCellBottom': + alignTableCellVertically(tableModel, operation); + break; + case 'alignCenter': + case 'alignLeft': + case 'alignRight': + alignTable(tableModel, operation); + break; + + case 'deleteColumn': + deleteTableColumn(tableModel); + break; + + case 'deleteRow': + deleteTableRow(tableModel); + break; + + case 'deleteTable': + deleteTable(tableModel); + break; + + case 'insertAbove': + case 'insertBelow': + insertTableRow(tableModel, operation); + break; + + case 'insertLeft': + case 'insertRight': + insertTableColumn(tableModel, operation); + break; + + case 'mergeAbove': + case 'mergeBelow': + mergeTableRow(tableModel, operation); + break; + + case 'mergeCells': + mergeTableCells(tableModel); + break; + + case 'mergeLeft': + case 'mergeRight': + mergeTableColumn(tableModel, operation); + break; + + case 'splitHorizontally': + splitTableCellHorizontally(tableModel); + break; + + case 'splitVertically': + splitTableCellVertically(tableModel); + break; + } - if (!hasSelectionInBlock(tableModel)) { - const paragraph = ensureFocusableParagraphForTable(model, path, tableModel); + if (!hasSelectionInBlock(tableModel)) { + const paragraph = ensureFocusableParagraphForTable(model, path, tableModel); - if (paragraph) { - const marker = createSelectionMarker(model.format); + if (paragraph) { + const marker = createSelectionMarker(model.format); - paragraph.segments.unshift(marker); - setParagraphNotImplicit(paragraph); - setSelection(model, marker); + paragraph.segments.unshift(marker); + setParagraphNotImplicit(paragraph); + setSelection(model, marker); + } } - } - normalizeTable(tableModel, model.format); + normalizeTable(tableModel, model.format); - if (hasMetadata(tableModel)) { - applyTableFormat(tableModel, undefined /*newFormat*/, true /*keepCellShade*/); - } + if (hasMetadata(tableModel)) { + applyTableFormat(tableModel, undefined /*newFormat*/, true /*keepCellShade*/); + } - return true; - } else { - return false; + return true; + } else { + return false; + } + }, + { + apiName: 'editTable', } - }); + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/formatTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/formatTable.ts index 949028cdf47..92a5e2b4044 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/formatTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/formatTable.ts @@ -1,5 +1,4 @@ import { applyTableFormat } from '../../modelApi/table/applyTableFormat'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { getFirstSelectedTable } from '../../modelApi/selection/collectSelections'; import { updateTableCellMetadata } from '../../domUtils/metadata/updateTableCellMetadata'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -18,25 +17,30 @@ export default function formatTable( ) { editor.focus(); - formatWithContentModel(editor, 'formatTable', model => { - const [tableModel] = getFirstSelectedTable(model); + editor.formatContentModel( + model => { + const [tableModel] = getFirstSelectedTable(model); - if (tableModel) { - // Wipe border metadata - tableModel.rows.forEach(row => { - row.cells.forEach(cell => { - updateTableCellMetadata(cell, metadata => { - if (metadata) { - delete metadata.borderOverride; - } - return metadata; + if (tableModel) { + // Wipe border metadata + tableModel.rows.forEach(row => { + row.cells.forEach(cell => { + updateTableCellMetadata(cell, metadata => { + if (metadata) { + delete metadata.borderOverride; + } + return metadata; + }); }); }); - }); - applyTableFormat(tableModel, format, keepCellShade); - return true; - } else { - return false; + applyTableFormat(tableModel, format, keepCellShade); + return true; + } else { + return false; + } + }, + { + apiName: 'formatTable', } - }); + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts index c46dc0b5e85..e19dbfdd4ea 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts @@ -2,7 +2,6 @@ import { applyTableFormat } from '../../modelApi/table/applyTableFormat'; import { createContentModelDocument, createSelectionMarker } from 'roosterjs-content-model-dom'; import { createTableStructure } from '../../modelApi/table/createTableStructure'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { getPendingFormat } from '../../modelApi/format/pendingFormat'; import { mergeModel } from '../../modelApi/common/mergeModel'; import { normalizeTable } from '../../modelApi/table/normalizeTable'; @@ -27,33 +26,38 @@ export default function insertTable( ) { editor.focus(); - formatWithContentModel(editor, 'insertTable', (model, context) => { - const insertPosition = deleteSelection(model, [], context).insertPoint; + editor.formatContentModel( + (model, context) => { + const insertPosition = deleteSelection(model, [], context).insertPoint; - if (insertPosition) { - const doc = createContentModelDocument(); - const table = createTableStructure(doc, columns, rows); + if (insertPosition) { + const doc = createContentModelDocument(); + const table = createTableStructure(doc, columns, rows); - normalizeTable(table, getPendingFormat(editor) || insertPosition.marker.format); - // Assign default vertical align - format = format || { verticalAlign: 'top' }; - applyTableFormat(table, format); - mergeModel(model, doc, context, { - insertPosition, - mergeFormat: 'mergeAll', - }); + normalizeTable(table, getPendingFormat(editor) || insertPosition.marker.format); + // Assign default vertical align + format = format || { verticalAlign: 'top' }; + applyTableFormat(table, format); + mergeModel(model, doc, context, { + insertPosition, + mergeFormat: 'mergeAll', + }); - const firstBlock = table.rows[0]?.cells[0]?.blocks[0]; + const firstBlock = table.rows[0]?.cells[0]?.blocks[0]; - if (firstBlock?.blockType == 'Paragraph') { - const marker = createSelectionMarker(firstBlock.segments[0]?.format); - firstBlock.segments.unshift(marker); - setSelection(model, marker); - } + if (firstBlock?.blockType == 'Paragraph') { + const marker = createSelectionMarker(firstBlock.segments[0]?.format); + firstBlock.segments.unshift(marker); + setSelection(model, marker); + } - return true; - } else { - return false; + return true; + } else { + return false; + } + }, + { + apiName: 'insertTable', } - }); + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/setTableCellShade.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/setTableCellShade.ts index f9483e86267..4f77eb14519 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/setTableCellShade.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/setTableCellShade.ts @@ -1,5 +1,4 @@ import hasSelectionInBlockGroup from '../selection/hasSelectionInBlockGroup'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; import { getFirstSelectedTable } from '../../modelApi/selection/collectSelections'; import { normalizeTable } from '../../modelApi/table/normalizeTable'; import { setTableCellBackgroundColor } from '../../modelApi/table/setTableCellBackgroundColor'; @@ -13,23 +12,28 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' export default function setTableCellShade(editor: IContentModelEditor, color: string | null) { editor.focus(); - formatWithContentModel(editor, 'setTableCellShade', model => { - const [table] = getFirstSelectedTable(model); + editor.formatContentModel( + model => { + const [table] = getFirstSelectedTable(model); - if (table) { - normalizeTable(table); + if (table) { + normalizeTable(table); - table.rows.forEach(row => - row.cells.forEach(cell => { - if (hasSelectionInBlockGroup(cell)) { - setTableCellBackgroundColor(cell, color, true /*isColorOverride*/); - } - }) - ); + table.rows.forEach(row => + row.cells.forEach(cell => { + if (hasSelectionInBlockGroup(cell)) { + setTableCellBackgroundColor(cell, color, true /*isColorOverride*/); + } + }) + ); - return true; - } else { - return false; + return true; + } else { + return false; + } + }, + { + apiName: 'setTableCellShade', } - }); + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts index 21321732213..7767f011e92 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts @@ -1,4 +1,4 @@ -import { formatWithContentModel } from './formatWithContentModel'; +import { formatAndKeepPendingFormat } from '../../modelApi/format/pendingFormat'; import { getSelectedParagraphs } from '../../modelApi/selection/collectSelections'; import type { ContentModelParagraph } from 'roosterjs-content-model-types'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -11,9 +11,8 @@ export function formatParagraphWithContentModel( apiName: string, setStyleCallback: (paragraph: ContentModelParagraph) => void ) { - formatWithContentModel( + formatAndKeepPendingFormat( editor, - apiName, model => { const paragraphs = getSelectedParagraphs(model); @@ -22,7 +21,7 @@ export function formatParagraphWithContentModel( return paragraphs.length > 0; }, { - preservePendingFormat: true, + apiName, } ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts index db26c1dd112..233cb8c3c37 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts @@ -1,5 +1,4 @@ import { adjustWordSelection } from '../../modelApi/selection/adjustWordSelection'; -import { formatWithContentModel } from './formatWithContentModel'; import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; import { getSelectedSegmentsAndParagraphs } from '../../modelApi/selection/collectSelections'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -29,59 +28,72 @@ export function formatSegmentWithContentModel( includingFormatHolder?: boolean, afterFormatCallback?: (model: ContentModelDocument) => void ) { - formatWithContentModel(editor, apiName, model => { - let segmentAndParagraphs = getSelectedSegmentsAndParagraphs(model, !!includingFormatHolder); - const pendingFormat = getPendingFormat(editor); - let isCollapsedSelection = - segmentAndParagraphs.length == 1 && - segmentAndParagraphs[0][0].segmentType == 'SelectionMarker'; + editor.formatContentModel( + model => { + let segmentAndParagraphs = getSelectedSegmentsAndParagraphs( + model, + !!includingFormatHolder + ); + const pendingFormat = getPendingFormat(editor); + let isCollapsedSelection = + segmentAndParagraphs.length == 1 && + segmentAndParagraphs[0][0].segmentType == 'SelectionMarker'; - if (isCollapsedSelection) { - const para = segmentAndParagraphs[0][1]; + if (isCollapsedSelection) { + const para = segmentAndParagraphs[0][1]; - segmentAndParagraphs = adjustWordSelection(model, segmentAndParagraphs[0][0]).map(x => [ - x, - para, - ]); + segmentAndParagraphs = adjustWordSelection( + model, + segmentAndParagraphs[0][0] + ).map(x => [x, para]); - if (segmentAndParagraphs.length > 1) { - isCollapsedSelection = false; + if (segmentAndParagraphs.length > 1) { + isCollapsedSelection = false; + } } - } - const formatsAndSegments: [ - ContentModelSegmentFormat, - ContentModelSegment | null, - ContentModelParagraph | null - ][] = pendingFormat - ? [[pendingFormat, null, null]] - : segmentAndParagraphs.map(item => [item[0].format, item[0], item[1]]); + const formatsAndSegments: [ + ContentModelSegmentFormat, + ContentModelSegment | null, + ContentModelParagraph | null + ][] = pendingFormat + ? [[pendingFormat, null, null]] + : segmentAndParagraphs.map(item => [item[0].format, item[0], item[1]]); - const isTurningOff = segmentHasStyleCallback - ? formatsAndSegments.every(([format, segment, paragraph]) => - segmentHasStyleCallback(format, segment, paragraph) - ) - : false; + const isTurningOff = segmentHasStyleCallback + ? formatsAndSegments.every(([format, segment, paragraph]) => + segmentHasStyleCallback(format, segment, paragraph) + ) + : false; - formatsAndSegments.forEach(([format, segment, paragraph]) => - toggleStyleCallback(format, !isTurningOff, segment, paragraph) - ); + formatsAndSegments.forEach(([format, segment, paragraph]) => + toggleStyleCallback(format, !isTurningOff, segment, paragraph) + ); - afterFormatCallback?.(model); + afterFormatCallback?.(model); - if (!pendingFormat && isCollapsedSelection) { - const pos = editor.getFocusedPosition(); + if (!pendingFormat && isCollapsedSelection) { + const pos = editor.getFocusedPosition(); - if (pos) { - setPendingFormat(editor, segmentAndParagraphs[0][0].format, pos.node, pos.offset); + if (pos) { + setPendingFormat( + editor, + segmentAndParagraphs[0][0].format, + pos.node, + pos.offset + ); + } } - } - if (isCollapsedSelection) { - editor.focus(); - return false; - } else { - return formatsAndSegments.length > 0; + if (isCollapsedSelection) { + editor.focus(); + return false; + } else { + return formatsAndSegments.length > 0; + } + }, + { + apiName, } - }); + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts index 2fc03bfe8f0..2feea8e386a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts @@ -1,6 +1,5 @@ import getSelectedSegments from '../selection/getSelectedSegments'; import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; -import { formatWithContentModel } from './formatWithContentModel'; import { GetContentMode, PasteType as OldPasteType, PluginEventType } from 'roosterjs-editor-types'; import { mergeModel } from '../../modelApi/common/mergeModel'; import { setPendingFormat } from '../../modelApi/format/pendingFormat'; @@ -72,9 +71,7 @@ export default function paste( editor.focus(); let originalFormat: ContentModelSegmentFormat | undefined; - formatWithContentModel( - editor, - 'Paste', + editor.formatContentModel( (model, context) => { const eventData = createBeforePasteEventData(editor, clipboardData, pasteType); const currentSegment = getSelectedSegments(model, true /*includingFormatHolder*/)[0]; @@ -115,6 +112,7 @@ export default function paste( { changeSource: ChangeSource.Paste, getChangeData: () => clipboardData, + apiName: 'paste', } ); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts index 77c718a272a..4f8d4feb8ec 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts @@ -1,6 +1,10 @@ import type { EditorEnvironment } from './IContentModelEditor'; import type { ContentModelPluginState } from './pluginState/ContentModelPluginState'; import type { CoreApiMap, EditorCore } from 'roosterjs-editor-types'; +import type { + ContentModelFormatter, + FormatWithContentModelOptions, +} from './parameter/FormatWithContentModelContext'; import type { ContentModelDocument, DOMSelection, @@ -52,11 +56,25 @@ export type SetContentModel = ( /** * Set current DOM selection from editor. This is the replacement of core API select - * @param core The ContentModelEditorCore object * @param selection The selection to set */ export type SetDOMSelection = (core: ContentModelEditorCore, selection: DOMSelection) => void; +/** + * The general API to do format change with Content Model + * It will grab a Content Model for current editor content, and invoke a callback function + * to do format change. Then according to the return value, write back the modified content model into editor. + * If there is cached model, it will be used and updated. + * @param core The ContentModelEditorCore object + * @param formatter Formatter function, see ContentModelFormatter + * @param options More options, see FormatWithContentModelOptions + */ +export type FormatContentModel = ( + core: ContentModelEditorCore, + formatter: ContentModelFormatter, + options?: FormatWithContentModelOptions +) => void; + /** * The interface for the map of core API for Content Model editor. * Editor can call call API from this map under ContentModelEditorCore object @@ -95,6 +113,17 @@ export interface ContentModelCoreApiMap extends CoreApiMap { * @param selection The selection to set */ setDOMSelection: SetDOMSelection; + + /** + * The general API to do format change with Content Model + * It will grab a Content Model for current editor content, and invoke a callback function + * to do format change. Then according to the return value, write back the modified content model into editor. + * If there is cached model, it will be used and updated. + * @param core The ContentModelEditorCore object + * @param formatter Formatter function, see ContentModelFormatter + * @param options More options, see FormatWithContentModelOptions + */ + formatContentModel: FormatContentModel; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index bb56a3f9a04..3d69671ea0a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -1,4 +1,8 @@ import type { EditorOptions, IEditor } from 'roosterjs-editor-types'; +import type { + ContentModelFormatter, + FormatWithContentModelOptions, +} from './parameter/FormatWithContentModelContext'; import type { ContentModelDocument, DOMSelection, @@ -68,6 +72,19 @@ export interface IContentModelEditor extends IEditor { * @param selection The selection to set */ setDOMSelection(selection: DOMSelection): void; + + /** + * The general API to do format change with Content Model + * It will grab a Content Model for current editor content, and invoke a callback function + * to do format change. Then according to the return value, write back the modified content model into editor. + * If there is cached model, it will be used and updated. + * @param formatter Formatter function, see ContentModelFormatter + * @param options More options, see FormatWithContentModelOptions + */ + formatContentModel( + formatter: ContentModelFormatter, + options?: FormatWithContentModelOptions + ): void; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts index ce7a0ff5b9b..68b458e6418 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts @@ -104,9 +104,9 @@ export interface FormatWithContentModelContext { */ export interface FormatWithContentModelOptions { /** - * When set to true, if there is pending format, it will be preserved after this format operation is done + * Name of the format API */ - preservePendingFormat?: boolean; + apiName?: string; /** * Raw event object that triggers this call diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts index 5b082f10a81..685adcb2726 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -202,6 +202,19 @@ describe('ContentModelEditor', () => { expect(domToContentModel.domToContentModel).not.toHaveBeenCalled(); }); + it('formatContentModel', () => { + const div = document.createElement('div'); + const editor = new ContentModelEditor(div); + const core = (editor as any).core; + const formatContentModelSpy = spyOn(core.api, 'formatContentModel'); + const callback = jasmine.createSpy('callback'); + const options = 'Options' as any; + + editor.formatContentModel(callback, options); + + expect(formatContentModelSpy).toHaveBeenCalledWith(core, callback, options); + }); + it('default format', () => { const div = document.createElement('div'); const editor = new ContentModelEditor(div, { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts new file mode 100644 index 00000000000..6882b29d5ed --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts @@ -0,0 +1,462 @@ +import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; +import { ChangeSource } from '../../../lib/publicTypes/event/ContentModelContentChangedEvent'; +import { ColorTransformDirection, EntityOperation, PluginEventType } from 'roosterjs-editor-types'; +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { ContentModelEditorCore } from '../../../lib/publicTypes/ContentModelEditorCore'; +import { createImage } from 'roosterjs-content-model-dom'; +import { formatContentModel } from '../../../lib/editor/coreApi/formatContentModel'; + +describe('formatContentModel', () => { + let core: ContentModelEditorCore; + let addUndoSnapshot: jasmine.Spy; + let createContentModel: jasmine.Spy; + let setContentModel: jasmine.Spy; + let mockedModel: ContentModelDocument; + let cacheContentModel: jasmine.Spy; + let getFocusedPosition: jasmine.Spy; + let triggerEvent: jasmine.Spy; + let getVisibleViewport: jasmine.Spy; + + const apiName = 'mockedApi'; + const mockedContainer = 'C' as any; + const mockedOffset = 'O' as any; + const mockedSelection = 'Selection' as any; + + beforeEach(() => { + mockedModel = ({} as any) as ContentModelDocument; + + addUndoSnapshot = jasmine + .createSpy('addUndoSnapshot') + .and.callFake((_, callback) => callback?.()); + createContentModel = jasmine.createSpy('createContentModel').and.returnValue(mockedModel); + setContentModel = jasmine.createSpy('setContentModel').and.returnValue(mockedSelection); + cacheContentModel = jasmine.createSpy('cacheContentModel'); + getFocusedPosition = jasmine + .createSpy('getFocusedPosition') + .and.returnValue({ node: mockedContainer, offset: mockedOffset }); + triggerEvent = jasmine.createSpy('triggerPluginEvent'); + getVisibleViewport = jasmine.createSpy('getVisibleViewport'); + + core = ({ + api: { + addUndoSnapshot, + createContentModel, + setContentModel, + cacheContentModel, + getFocusedPosition, + triggerEvent, + }, + lifecycle: {}, + getVisibleViewport, + } as any) as ContentModelEditorCore; + }); + + it('Callback return false', () => { + const callback = jasmine.createSpy('callback').and.returnValue(false); + + formatContentModel(core, callback, { apiName }); + + expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], + deletedEntities: [], + rawEvent: undefined, + newImages: [], + }); + expect(createContentModel).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).not.toHaveBeenCalled(); + expect(setContentModel).not.toHaveBeenCalled(); + expect(triggerEvent).not.toHaveBeenCalled(); + }); + + it('Callback return true', () => { + const callback = jasmine.createSpy('callback').and.returnValue(true); + + formatContentModel(core, callback, { apiName }); + + expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], + deletedEntities: [], + rawEvent: undefined, + newImages: [], + }); + expect(createContentModel).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot.calls.argsFor(0)[2]).toBe(null); + expect(addUndoSnapshot.calls.argsFor(0)[3]).toBe(false); + expect(addUndoSnapshot.calls.argsFor(0)[4]).toEqual({ + formatApiName: apiName, + }); + expect(setContentModel).toHaveBeenCalledTimes(1); + expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.ContentChanged, + contentModel: mockedModel, + selection: mockedSelection, + source: ChangeSource.Format, + data: undefined, + additionalData: { + formatApiName: apiName, + }, + }, + true + ); + }); + + it('Skip undo snapshot', () => { + const callback = jasmine.createSpy('callback').and.callFake((model, context) => { + context.skipUndoSnapshot = true; + return true; + }); + const mockedFormat = 'FORMAT' as any; + + spyOn(pendingFormat, 'getPendingFormat').and.returnValue(mockedFormat); + spyOn(pendingFormat, 'setPendingFormat'); + + formatContentModel(core, callback, { apiName }); + + expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], + deletedEntities: [], + rawEvent: undefined, + skipUndoSnapshot: true, + newImages: [], + }); + expect(createContentModel).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).not.toHaveBeenCalled(); + expect(setContentModel).toHaveBeenCalledTimes(1); + expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.ContentChanged, + contentModel: mockedModel, + selection: mockedSelection, + source: ChangeSource.Format, + data: undefined, + additionalData: { + formatApiName: apiName, + }, + }, + true + ); + }); + + it('Customize change source', () => { + const callback = jasmine.createSpy('callback').and.returnValue(true); + + formatContentModel(core, callback, { changeSource: 'TEST', apiName }); + + expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], + deletedEntities: [], + rawEvent: undefined, + newImages: [], + }); + expect(createContentModel).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(addUndoSnapshot.calls.argsFor(0)[2]).toBe(null!); + expect(setContentModel).toHaveBeenCalledTimes(1); + expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.ContentChanged, + contentModel: mockedModel, + selection: mockedSelection, + source: 'TEST', + data: undefined, + additionalData: { + formatApiName: apiName, + }, + }, + true + ); + }); + + it('Customize change source, getChangeData and skip undo snapshot', () => { + const callback = jasmine.createSpy('callback').and.callFake((model, context) => { + context.skipUndoSnapshot = true; + return true; + }); + const returnData = 'DATA'; + + formatContentModel(core, callback, { + apiName, + changeSource: 'TEST', + getChangeData: () => returnData, + }); + + expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], + deletedEntities: [], + rawEvent: undefined, + skipUndoSnapshot: true, + newImages: [], + }); + expect(createContentModel).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).not.toHaveBeenCalled(); + expect(setContentModel).toHaveBeenCalledTimes(1); + expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.ContentChanged, + contentModel: mockedModel, + selection: mockedSelection, + source: 'TEST', + data: returnData, + additionalData: { + formatApiName: apiName, + }, + }, + true + ); + }); + + it('Has onNodeCreated', () => { + const callback = jasmine.createSpy('callback').and.returnValue(true); + const onNodeCreated = jasmine.createSpy('onNodeCreated'); + + formatContentModel(core, callback, { onNodeCreated: onNodeCreated, apiName }); + + expect(callback).toHaveBeenCalledWith(mockedModel, { + newEntities: [], + deletedEntities: [], + rawEvent: undefined, + newImages: [], + }); + expect(createContentModel).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(setContentModel).toHaveBeenCalledTimes(1); + expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, onNodeCreated); + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.ContentChanged, + contentModel: mockedModel, + selection: mockedSelection, + source: ChangeSource.Format, + data: undefined, + additionalData: { + formatApiName: apiName, + }, + }, + true + ); + }); + + it('Has entity got deleted', () => { + const entity1 = { + entityFormat: { id: 'E1', entityType: 'E', isReadonly: true }, + wrapper: {}, + } as any; + const entity2 = { + entityFormat: { id: 'E2', entityType: 'E', isReadonly: true }, + wrapper: {}, + } as any; + const rawEvent = 'RawEvent' as any; + + formatContentModel( + core, + (model, context) => { + context.deletedEntities.push( + { + entity: entity1, + operation: 'removeFromStart', + }, + { + entity: entity2, + operation: 'removeFromEnd', + } + ); + return true; + }, + { + apiName, + rawEvent: rawEvent, + } + ); + + expect(setContentModel).toHaveBeenCalledTimes(1); + expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + + expect(triggerEvent).toHaveBeenCalledTimes(3); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.EntityOperation, + entity: { id: 'E1', type: 'E', isReadonly: true, wrapper: entity1.wrapper }, + operation: EntityOperation.RemoveFromStart, + rawEvent: rawEvent, + }, + false + ); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.EntityOperation, + entity: { id: 'E2', type: 'E', isReadonly: true, wrapper: entity2.wrapper }, + operation: EntityOperation.RemoveFromEnd, + rawEvent: rawEvent, + }, + false + ); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.ContentChanged, + contentModel: mockedModel, + selection: mockedSelection, + source: ChangeSource.Format, + data: undefined, + additionalData: { + formatApiName: apiName, + }, + }, + true + ); + }); + + it('Has new entity in dark mode', () => { + const wrapper1 = 'W1' as any; + const wrapper2 = 'W2' as any; + const entity1 = { + entityFormat: { id: 'E1', entityType: 'E', isReadonly: true }, + wrapper: wrapper1, + } as any; + const entity2 = { + entityFormat: { id: 'E2', entityType: 'E', isReadonly: true }, + wrapper: wrapper2, + } as any; + const rawEvent = 'RawEvent' as any; + const transformToDarkColorSpy = jasmine.createSpy('transformToDarkColor'); + const mockedData = 'DATA'; + + core.lifecycle.isDarkMode = true; + core.api.transformColor = transformToDarkColorSpy; + + formatContentModel( + core, + (model, context) => { + context.newEntities.push(entity1, entity2); + return true; + }, + { + apiName, + rawEvent: rawEvent, + getChangeData: () => mockedData, + } + ); + + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(setContentModel).toHaveBeenCalledTimes(1); + expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.ContentChanged, + contentModel: mockedModel, + selection: mockedSelection, + source: ChangeSource.Format, + data: mockedData, + additionalData: { + formatApiName: apiName, + }, + }, + true + ); + expect(transformToDarkColorSpy).toHaveBeenCalledTimes(2); + expect(transformToDarkColorSpy).toHaveBeenCalledWith( + core, + wrapper1, + true, + null, + ColorTransformDirection.LightToDark + ); + expect(transformToDarkColorSpy).toHaveBeenCalledWith( + core, + wrapper2, + true, + null, + ColorTransformDirection.LightToDark + ); + }); + + it('With selectionOverride', () => { + const range = 'MockedRangeEx' as any; + + formatContentModel(core, () => true, { + apiName, + selectionOverride: range, + }); + + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(createContentModel).toHaveBeenCalledWith(core, undefined, range); + expect(setContentModel).toHaveBeenCalledTimes(1); + expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.ContentChanged, + contentModel: mockedModel, + selection: mockedSelection, + source: ChangeSource.Format, + data: undefined, + additionalData: { + formatApiName: apiName, + }, + }, + true + ); + }); + + it('Has image', () => { + const image = createImage('test'); + const rawEvent = 'RawEvent' as any; + const getVisibleViewportSpy = jasmine + .createSpy('getVisibleViewport') + .and.returnValue({ top: 100, bottom: 200, left: 100, right: 200 }); + core.getVisibleViewport = getVisibleViewportSpy; + + formatContentModel( + core, + (model, context) => { + context.newImages.push(image); + return true; + }, + { + apiName, + rawEvent: rawEvent, + } + ); + + expect(getVisibleViewportSpy).toHaveBeenCalledTimes(1); + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(setContentModel).toHaveBeenCalledTimes(1); + expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.ContentChanged, + contentModel: mockedModel, + selection: mockedSelection, + source: ChangeSource.Format, + data: undefined, + additionalData: { + formatApiName: apiName, + }, + }, + true + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts index 8d22dad703a..e4d271edbaa 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts @@ -1,10 +1,12 @@ -import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import ContentModelFormatPlugin from '../../../lib/editor/corePlugins/ContentModelFormatPlugin'; -import { ChangeSource } from '../../../lib/publicTypes/event/ContentModelContentChangedEvent'; import { ContentModelFormatPluginState } from '../../../lib/publicTypes/pluginState/ContentModelFormatPluginState'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { PluginEventType } from 'roosterjs-editor-types'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { addSegment, createContentModelDocument, @@ -43,15 +45,28 @@ describe('ContentModelFormatPlugin', () => { spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ fontSize: '10px', }); + let formatResult: boolean | undefined; + + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }); + } + ); - const setContentModel = jasmine.createSpy('setContentModel'); const editor = ({ focus: jasmine.createSpy('focus'), createContentModel: () => model, - setContentModel, isInIME: () => false, cacheContentModel: () => {}, getEnvironment: () => ({}), + formatContentModel, } as any) as IContentModelEditor; const state = { defaultFormat: {}, @@ -68,7 +83,7 @@ describe('ContentModelFormatPlugin', () => { plugin.dispose(); - expect(setContentModel).toHaveBeenCalledTimes(0); + expect(formatResult).toBeFalse(); expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); }); @@ -111,19 +126,32 @@ describe('ContentModelFormatPlugin', () => { fontSize: '10px', }); - const setContentModel = jasmine.createSpy('setContentModel'); + let formatResult: boolean | undefined; const model = createContentModelDocument(); const marker = createSelectionMarker(); addSegment(model, marker); + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }); + } + ); + const editor = ({ focus: jasmine.createSpy('focus'), createContentModel: () => model, - setContentModel, isInIME: () => false, cacheContentModel: () => {}, getEnvironment: () => ({}), + formatContentModel, } as any) as IContentModelEditor; const state = { defaultFormat: {}, @@ -136,7 +164,7 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(setContentModel).toHaveBeenCalledTimes(0); + expect(formatResult).toBeFalse(); expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); }); @@ -148,17 +176,30 @@ describe('ContentModelFormatPlugin', () => { fontSize: '10px', }); - const setContentModel = jasmine.createSpy('setContentModel'); const model = createContentModelDocument(); const text = createText('a'); const marker = createSelectionMarker(); + let formatResult: boolean | undefined; addSegment(model, text); addSegment(model, marker); + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }); + } + ); + const editor = ({ createContentModel: () => model, - setContentModel, + formatContentModel, isInIME: () => false, focus: () => {}, addUndoSnapshot: (callback: () => void) => { @@ -181,33 +222,29 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: false, - segments: [ - { - segmentType: 'Text', - format: { fontSize: '10px' }, - text: 'a', - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }, - undefined, - undefined - ); + expect(formatResult).toBeTrue(); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: false, + segments: [ + { + segmentType: 'Text', + format: { fontSize: '10px' }, + text: 'a', + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); }); @@ -218,19 +255,30 @@ describe('ContentModelFormatPlugin', () => { fontSize: '10px', }); - const setContentModel = jasmine.createSpy('setContentModel'); + let formatResult: boolean | undefined; const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); const getVisibleViewport = jasmine.createSpy('getVisibleViewport'); const model = createContentModelDocument(); const text = createText('test a test', { fontFamily: 'Arial' }); const marker = createSelectionMarker(); + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + } + ); addSegment(model, text); addSegment(model, marker); const editor = ({ createContentModel: () => model, - setContentModel, + formatContentModel, focus: () => {}, addUndoSnapshot: (callback: () => void) => { callback(); @@ -252,47 +300,34 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.ContentChanged, { - contentModel: model, - selection: undefined, - data: undefined, - source: ChangeSource.Format, - additionalData: { - formatApiName: 'applyPendingFormat', - }, + expect(formatResult).toBeTrue(); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: false, + segments: [ + { + segmentType: 'Text', + format: { fontFamily: 'Arial' }, + text: 'test a ', + }, + { + segmentType: 'Text', + format: { fontSize: '10px', fontFamily: 'Arial' }, + text: 'test', + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], }); - expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: false, - segments: [ - { - segmentType: 'Text', - format: { fontFamily: 'Arial' }, - text: 'test a ', - }, - { - segmentType: 'Text', - format: { fontSize: '10px', fontFamily: 'Arial' }, - text: 'test', - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }, - undefined, - undefined - ); expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); }); @@ -437,6 +472,7 @@ describe('ContentModelFormatPlugin for default format', () => { let setPendingFormatSpy: jasmine.Spy; let cacheContentModelSpy: jasmine.Spy; let addUndoSnapshotSpy: jasmine.Spy; + let formatContentModelSpy: jasmine.Spy; beforeEach(() => { setPendingFormatSpy = spyOn(pendingFormat, 'setPendingFormat'); @@ -444,6 +480,7 @@ describe('ContentModelFormatPlugin for default format', () => { getDOMSelection = jasmine.createSpy('getDOMSelection'); cacheContentModelSpy = jasmine.createSpy('cacheContentModel'); addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + formatContentModelSpy = jasmine.createSpy('formatContentModelSpy'); contentDiv = document.createElement('div'); @@ -452,6 +489,7 @@ describe('ContentModelFormatPlugin for default format', () => { getDOMSelection, cacheContentModel: cacheContentModelSpy, addUndoSnapshot: addUndoSnapshotSpy, + formatContentModel: formatContentModelSpy, } as any) as IContentModelEditor; }); @@ -471,27 +509,25 @@ describe('ContentModelFormatPlugin for default format', () => { }, }); - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - } - ); + formatContentModelSpy.and.callFake((callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); plugin.initialize(editor); @@ -524,28 +560,26 @@ describe('ContentModelFormatPlugin for default format', () => { }, }); - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'test', - isSelected: true, - }, - ], - }, - ], - }); - } - ); + formatContentModelSpy.and.callFake((callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test', + isSelected: true, + }, + ], + }, + ], + }); + }); plugin.initialize(editor); @@ -574,27 +608,25 @@ describe('ContentModelFormatPlugin for default format', () => { }, }); - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - } - ); + formatContentModelSpy.and.callFake((callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); plugin.initialize(editor); @@ -627,27 +659,25 @@ describe('ContentModelFormatPlugin for default format', () => { }, }); - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - } - ); + formatContentModelSpy.and.callFake((callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); plugin.initialize(editor); @@ -678,26 +708,24 @@ describe('ContentModelFormatPlugin for default format', () => { }, }); - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - } - ); + formatContentModelSpy.and.callFake((callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); plugin.initialize(editor); @@ -725,27 +753,25 @@ describe('ContentModelFormatPlugin for default format', () => { }, }); - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_1: any, _2: any, callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - } - ); + formatContentModelSpy.and.callFake((callback: Function) => { + callback({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); getPendingFormatSpy.and.returnValue({ fontSize: '10pt', diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts index c7e433dd2a3..2e2ae8ca432 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts @@ -10,6 +10,7 @@ import { ContentModelEditorOptions } from '../../lib/publicTypes/IContentModelEd import { createContentModel } from '../../lib/editor/coreApi/createContentModel'; import { createContentModelEditorCore } from '../../lib/editor/createContentModelEditorCore'; import { createEditorContext } from '../../lib/editor/coreApi/createEditorContext'; +import { formatContentModel } from '../../lib/editor/coreApi/formatContentModel'; import { getDOMSelection } from '../../lib/editor/coreApi/getDOMSelection'; import { setContentModel } from '../../lib/editor/coreApi/setContentModel'; import { setDOMSelection } from '../../lib/editor/coreApi/setDOMSelection'; @@ -103,6 +104,7 @@ describe('createContentModelEditorCore', () => { setContentModel, getDOMSelection, setDOMSelection, + formatContentModel, }, originalApi: { a: 'b', @@ -111,6 +113,7 @@ describe('createContentModelEditorCore', () => { setContentModel, getDOMSelection, setDOMSelection, + formatContentModel, }, defaultDomToModelOptions: [ { processorOverride: { table: tablePreProcessor } }, @@ -181,6 +184,7 @@ describe('createContentModelEditorCore', () => { setContentModel, getDOMSelection, setDOMSelection, + formatContentModel, }, originalApi: { a: 'b', @@ -189,6 +193,7 @@ describe('createContentModelEditorCore', () => { setContentModel, getDOMSelection, setDOMSelection, + formatContentModel, }, defaultDomToModelOptions: [ { processorOverride: { table: tablePreProcessor } }, @@ -272,6 +277,7 @@ describe('createContentModelEditorCore', () => { setContentModel, getDOMSelection, setDOMSelection, + formatContentModel, }, originalApi: { a: 'b', @@ -280,6 +286,7 @@ describe('createContentModelEditorCore', () => { setContentModel, getDOMSelection, setDOMSelection, + formatContentModel, }, defaultDomToModelOptions: [ { processorOverride: { table: tablePreProcessor } }, @@ -343,6 +350,7 @@ describe('createContentModelEditorCore', () => { setContentModel, getDOMSelection, setDOMSelection, + formatContentModel, }, originalApi: { a: 'b', @@ -351,6 +359,7 @@ describe('createContentModelEditorCore', () => { setContentModel, getDOMSelection, setDOMSelection, + formatContentModel, }, defaultDomToModelOptions: [ { processorOverride: { table: tablePreProcessor } }, @@ -417,6 +426,7 @@ describe('createContentModelEditorCore', () => { setContentModel, getDOMSelection, setDOMSelection, + formatContentModel, }, originalApi: { a: 'b', @@ -425,6 +435,7 @@ describe('createContentModelEditorCore', () => { setContentModel, getDOMSelection, setDOMSelection, + formatContentModel, }, defaultDomToModelOptions: [ { processorOverride: { table: tablePreProcessor } }, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts index 88f180cf63b..0bb733cc13c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts @@ -6,12 +6,16 @@ import * as extractClipboardItemsFile from 'roosterjs-editor-dom/lib/clipboard/e import * as iterateSelectionsFile from '../../../lib/modelApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import * as PasteFile from '../../../lib/publicApi/utils/paste'; +import { ContentModelDocument, DOMSelection } from 'roosterjs-content-model-types'; import { createModelToDomContext } from 'roosterjs-content-model-dom'; import { createRange } from 'roosterjs-editor-dom'; import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; -import { DOMSelection } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { setEntityElementClasses } from 'roosterjs-content-model-dom/test/domUtils/entityUtilTest'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import ContentModelCopyPastePlugin, { onNodeCreated, } from '../../../lib/editor/corePlugins/ContentModelCopyPastePlugin'; @@ -23,7 +27,6 @@ import { } from 'roosterjs-editor-types'; const modelValue = 'model' as any; -const darkColorHandler = 'darkColorHandler' as any; const pasteModelValue = 'pasteModelValue' as any; const insertPointValue = 'insertPoint' as any; const deleteResultValue = 'deleteResult' as any; @@ -41,17 +44,21 @@ describe('ContentModelCopyPastePlugin |', () => { let createContentModelSpy: jasmine.Spy; let triggerPluginEventSpy: jasmine.Spy; let focusSpy: jasmine.Spy; - let undoSnapShotSpy: jasmine.Spy; + let formatContentModelSpy: jasmine.Spy; let setDOMSelectionSpy: jasmine.Spy; - let setContentModelSpy: jasmine.Spy; let isDisposed: jasmine.Spy; let pasteSpy: jasmine.Spy; let cloneModelSpy: jasmine.Spy; let transformToDarkColorSpy: jasmine.Spy; let getVisibleViewportSpy: jasmine.Spy; + let formatResult: boolean | undefined; + let modelResult: ContentModelDocument | undefined; beforeEach(() => { + modelResult = undefined; + formatResult = undefined; + div = document.createElement('div'); getDOMSelectionSpy = jasmine .createSpy('getDOMSelection') @@ -61,9 +68,7 @@ describe('ContentModelCopyPastePlugin |', () => { .and.returnValue(modelValue); triggerPluginEventSpy = jasmine.createSpy('triggerPluginEventSpy'); focusSpy = jasmine.createSpy('focusSpy'); - undoSnapShotSpy = jasmine.createSpy('undoSnapShotSpy'); setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); - setContentModelSpy = jasmine.createSpy('setContentModel'); pasteSpy = jasmine.createSpy('paste_'); isDisposed = jasmine.createSpy('isDisposed'); getVisibleViewportSpy = jasmine.createSpy('getVisibleViewport'); @@ -72,6 +77,19 @@ describe('ContentModelCopyPastePlugin |', () => { (model: any) => pasteModelValue ); transformToDarkColorSpy = jasmine.createSpy('transformToDarkColor'); + formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + modelResult = createContentModelSpy(); + formatResult = callback(modelResult!, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + } + ); + spyOn(addRangeToSelection, 'addRangeToSelection'); plugin = new ContentModelCopyPastePlugin({ @@ -97,15 +115,8 @@ describe('ContentModelCopyPastePlugin |', () => { focus() { focusSpy(); }, - addUndoSnapshot(callback: any, changeSource: any, canUndoByBackspace: any) { - callback?.(); - undoSnapShotSpy(callback, changeSource, canUndoByBackspace); - }, getDOMSelection: getDOMSelectionSpy, setDOMSelection: setDOMSelectionSpy, - setContentModel(model: any, option: any) { - setContentModelSpy(model, option); - }, getDocument() { return document; }, @@ -116,9 +127,6 @@ describe('ContentModelCopyPastePlugin |', () => { ) { return div; }, - getDarkColorHandler: () => { - return darkColorHandler; - }, isDarkMode: () => { return false; }, @@ -128,6 +136,7 @@ describe('ContentModelCopyPastePlugin |', () => { transformToDarkColor: transformToDarkColorSpy, isDisposed, getVisibleViewport: getVisibleViewportSpy, + formatContentModel: formatContentModelSpy, }); plugin.initialize(editor); @@ -143,9 +152,7 @@ describe('ContentModelCopyPastePlugin |', () => { createContentModelSpy.and.callThrough(); triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); - undoSnapShotSpy.and.callThrough(); setDOMSelectionSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); domEvents.copy?.({}); @@ -153,9 +160,8 @@ describe('ContentModelCopyPastePlugin |', () => { expect(createContentModelSpy).not.toHaveBeenCalled(); expect(triggerPluginEventSpy).not.toHaveBeenCalled(); expect(focusSpy).not.toHaveBeenCalled(); - expect(undoSnapShotSpy).not.toHaveBeenCalled(); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); - expect(setContentModelSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalsy(); }); it('Selection not Collapsed and normal selection', () => { @@ -172,7 +178,6 @@ describe('ContentModelCopyPastePlugin |', () => { triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); setDOMSelectionSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); // Act domEvents.copy?.({}); @@ -194,8 +199,8 @@ describe('ContentModelCopyPastePlugin |', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); // On Cut Spy - expect(undoSnapShotSpy).not.toHaveBeenCalled(); - expect(setContentModelSpy).not.toHaveBeenCalledWith(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalsy(); }); it('Selection not Collapsed and table selection', () => { @@ -221,7 +226,6 @@ describe('ContentModelCopyPastePlugin |', () => { triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); setDOMSelectionSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); // Act domEvents.copy?.({}); @@ -243,8 +247,8 @@ describe('ContentModelCopyPastePlugin |', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); // On Cut Spy - expect(undoSnapShotSpy).not.toHaveBeenCalled(); - expect(setContentModelSpy).not.toHaveBeenCalledWith(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalsy(); }); it('Selection not Collapsed and image selection', () => { @@ -266,7 +270,6 @@ describe('ContentModelCopyPastePlugin |', () => { triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); setDOMSelectionSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); // Act domEvents.copy?.({}); @@ -287,8 +290,8 @@ describe('ContentModelCopyPastePlugin |', () => { expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); // On Cut Spy - expect(undoSnapShotSpy).not.toHaveBeenCalled(); - expect(setContentModelSpy).not.toHaveBeenCalledWith(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalsy(); expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalledTimes(0); }); @@ -314,7 +317,6 @@ describe('ContentModelCopyPastePlugin |', () => { triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); setDOMSelectionSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); editor.isDarkMode = () => true; @@ -359,8 +361,8 @@ describe('ContentModelCopyPastePlugin |', () => { expect(cloneModelSpy).toHaveBeenCalledTimes(1); // On Cut Spy - expect(undoSnapShotSpy).not.toHaveBeenCalled(); - expect(setContentModelSpy).not.toHaveBeenCalledWith(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalsy(); expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalledTimes(0); }); }); @@ -376,9 +378,7 @@ describe('ContentModelCopyPastePlugin |', () => { createContentModelSpy.and.callThrough(); triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); - undoSnapShotSpy.and.callThrough(); setDOMSelectionSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); // Act domEvents.cut?.({}); @@ -388,9 +388,9 @@ describe('ContentModelCopyPastePlugin |', () => { expect(createContentModelSpy).not.toHaveBeenCalled(); expect(triggerPluginEventSpy).not.toHaveBeenCalled(); expect(focusSpy).not.toHaveBeenCalled(); - expect(undoSnapShotSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); expect(setDOMSelectionSpy).not.toHaveBeenCalled(); - expect(setContentModelSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalsy(); }); it('Selection not Collapsed', () => { @@ -414,7 +414,6 @@ describe('ContentModelCopyPastePlugin |', () => { triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); setDOMSelectionSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); // Act domEvents.cut?.({}); @@ -430,13 +429,14 @@ describe('ContentModelCopyPastePlugin |', () => { onNodeCreated ); expect(createContentModelSpy).toHaveBeenCalled(); - expect(triggerPluginEventSpy).toHaveBeenCalledTimes(2); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(focusSpy).toHaveBeenCalled(); expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); // On Cut Spy - expect(undoSnapShotSpy).toHaveBeenCalled(); - expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, undefined); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); + expect(modelResult).toEqual(modelValue); }); it('Selection not Collapsed and table selection', () => { @@ -465,7 +465,6 @@ describe('ContentModelCopyPastePlugin |', () => { triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); setDOMSelectionSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); // Act domEvents.cut?.({}); @@ -480,15 +479,15 @@ describe('ContentModelCopyPastePlugin |', () => { onNodeCreated ); expect(createContentModelSpy).toHaveBeenCalled(); - expect(triggerPluginEventSpy).toHaveBeenCalledTimes(2); expect(iterateSelectionsFile.iterateSelections).toHaveBeenCalled(); expect(focusSpy).toHaveBeenCalled(); expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); // On Cut Spy - expect(undoSnapShotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(deleteSelectionsFile.deleteSelection).toHaveBeenCalled(); - expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, undefined); + expect(formatResult).toBeTrue(); + expect(modelResult).toEqual(modelValue); expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(modelValue); }); @@ -515,7 +514,6 @@ describe('ContentModelCopyPastePlugin |', () => { triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); setDOMSelectionSpy.and.callThrough(); - setContentModelSpy.and.callThrough(); // Act domEvents.cut?.({}); @@ -530,14 +528,15 @@ describe('ContentModelCopyPastePlugin |', () => { onNodeCreated ); expect(createContentModelSpy).toHaveBeenCalled(); - expect(triggerPluginEventSpy).toHaveBeenCalledTimes(2); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(focusSpy).toHaveBeenCalled(); expect(setDOMSelectionSpy).toHaveBeenCalledWith(selectionValue); // On Cut Spy - expect(undoSnapShotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(deleteSelectionsFile.deleteSelection).toHaveBeenCalled(); - expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, undefined); + expect(formatResult).toBeTrue(); + expect(modelResult).toEqual(modelValue); expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(modelValue); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/pendingFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/pendingFormatTest.ts index 61f88ab0d05..377b3c4a660 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/pendingFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/pendingFormatTest.ts @@ -1,7 +1,9 @@ import ContentModelEditor from '../../../lib/editor/ContentModelEditor'; +import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { canApplyPendingFormat, clearPendingFormat, + formatAndKeepPendingFormat, getPendingFormat, setPendingFormat, } from '../../../lib/modelApi/format/pendingFormat'; @@ -170,4 +172,81 @@ describe('pendingFormat.canApplyPendingFormat', () => { expect(result).toBeFalse(); }); + + it('Preserve pending format, no pending format', () => { + const formatContentModel = jasmine.createSpy('formatContentModel').and.callFake(() => { + clearPendingFormat(editor); + }); + const getFocusedPosition = jasmine.createSpy('getFocusedPosition'); + const customData: any = {}; + + const editor = ({ + getCustomData: (key: string, getter?: () => any) => { + return (customData[key] = customData[key] || { + value: getter ? getter() : undefined, + }).value; + }, + formatContentModel, + getFocusedPosition, + } as any) as IContentModelEditor; + const formatter = jasmine.createSpy('formatter'); + const options = 'OPTIONS' as any; + + formatAndKeepPendingFormat(editor, formatter, options); + + expect(customData).toEqual({ + __ContentModelPendingFormat: Object({ + value: { format: null, posContainer: null, posOffset: null }, + }), + }); + expect(formatContentModel).toHaveBeenCalledWith(formatter, options); + }); + + it('Preserve pending format, have pending format', () => { + const mockedFormat = 'Format' as any; + const mockedContainer = 'Container' as any; + const mockedOffset = 'Offset' as any; + + const customData: any = { + __ContentModelPendingFormat: { + value: { + format: mockedFormat, + posContainer: mockedContainer, + posOffset: mockedOffset, + }, + }, + }; + + const formatContentModel = jasmine.createSpy('formatContentModel').and.callFake(() => { + clearPendingFormat(editor); + + expect(customData).toEqual({ + __ContentModelPendingFormat: { + value: { format: null, posContainer: null, posOffset: null }, + }, + }); + }); + const getFocusedPosition = jasmine.createSpy('getFocusedPosition'); + + const editor = ({ + getCustomData: (key: string, getter?: () => any) => { + return (customData[key] = customData[key] || { + value: getter ? getter() : undefined, + }).value; + }, + formatContentModel, + getFocusedPosition, + } as any) as IContentModelEditor; + const formatter = jasmine.createSpy('formatter'); + const options = 'OPTIONS' as any; + + formatAndKeepPendingFormat(editor, formatter, options); + + expect(customData).toEqual({ + __ContentModelPendingFormat: { + value: { format: null, posContainer: null, posOffset: null }, + }, + }); + expect(formatContentModel).toHaveBeenCalledWith(formatter, options); + }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts index e5f4dd98010..5e8f711202a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts @@ -1,5 +1,9 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; export function paragraphTestCommon( apiName: string, @@ -8,33 +12,27 @@ export function paragraphTestCommon( result: ContentModelDocument, calledTimes: number ) { - const addUndoSnapshot = jasmine - .createSpy() - .and.callFake((callback: () => void, source: string, canUndoByBackspace, param: any) => { - expect(source).toBe(undefined!); - expect(param.formatApiName).toBe(apiName); - callback(); + let formatResult: boolean | undefined; + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); }); - const setContentModel = jasmine.createSpy().and.callFake((model: ContentModelDocument) => { - expect(model).toEqual(result); - }); - const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - const getVisibleViewport = jasmine.createSpy('getVisibleViewport'); const editor = ({ - createContentModel: () => model, - addUndoSnapshot, focus: jasmine.createSpy(), - setContentModel, getCustomData: () => ({}), getFocusedPosition: () => ({}), - isDarkMode: () => false, - triggerPluginEvent, - getVisibleViewport, + formatContentModel, } as any) as IContentModelEditor; executionCallback(editor); - expect(addUndoSnapshot).toHaveBeenCalledTimes(calledTimes); - expect(setContentModel).toHaveBeenCalledTimes(calledTimes); + expect(model).toEqual(result); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBe(calledTimes > 0); expect(model).toEqual(result); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts index 124af97e439..7d77ab5bdc9 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts @@ -3,6 +3,10 @@ import setAlignment from '../../../lib/publicApi/block/setAlignment'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { paragraphTestCommon } from './paragraphTestCommon'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { ContentModelDocument, ContentModelListItem, @@ -414,13 +418,11 @@ describe('setAlignment', () => { describe('setAlignment in table', () => { let editor: IContentModelEditor; - let setContentModel: jasmine.Spy; let createContentModel: jasmine.Spy; let triggerPluginEvent: jasmine.Spy; let getVisibleViewport: jasmine.Spy; beforeEach(() => { - setContentModel = jasmine.createSpy('setContentModel'); createContentModel = jasmine.createSpy('createContentModel'); triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); getVisibleViewport = jasmine.createSpy('getVisibleViewport'); @@ -430,7 +432,6 @@ describe('setAlignment in table', () => { editor = ({ focus: () => {}, addUndoSnapshot: (callback: Function) => callback(), - setContentModel, createContentModel, isDarkMode: () => false, triggerPluginEvent, @@ -448,18 +449,25 @@ describe('setAlignment in table', () => { createContentModel.and.returnValue(model); + editor.formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + } + ); + setAlignment(editor, alignment); if (expectedTable) { - expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith( - { - blockGroupType: 'Document', - blocks: [expectedTable], - }, - undefined, - undefined - ); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [expectedTable], + }); } } @@ -846,19 +854,31 @@ describe('setAlignment in list', () => { model.blocks.push(list); createContentModel.and.returnValue(model); + let result: boolean | undefined; + + editor.formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + result = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }); + } + ); setAlignment(editor, alignment); if (expectedList) { - expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith( - { - blockGroupType: 'Document', - blocks: [expectedList], - }, - undefined, - undefined - ); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [expectedList], + }); + expect(result).toBeTrue(); + } else { + expect(result).toBeFalse(); } } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts index d8dda555f8e..19f3b51e5c3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts @@ -1,4 +1,4 @@ -import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; +import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import * as setModelIndentation from '../../../lib/modelApi/block/setModelIndentation'; import setIndentation from '../../../lib/publicApi/block/setIndentation'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; @@ -6,21 +6,33 @@ import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEdito describe('setIndentation', () => { const fakeModel: any = { a: 'b' }; let editor: IContentModelEditor; + let formatContentModelSpy: jasmine.Spy; beforeEach(() => { + formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: Function) => { + callback(fakeModel); + }); + editor = ({ - createContentModel: () => fakeModel, + formatContentModel: formatContentModelSpy, focus: jasmine.createSpy('focus'), } as any) as IContentModelEditor; + + spyOn(pendingFormat, 'formatAndKeepPendingFormat').and.callFake( + (editor, formatter, options) => { + editor.formatContentModel(formatter, options); + } + ); }); it('indent', () => { - spyOn(formatWithContentModel, 'formatWithContentModel').and.callThrough(); spyOn(setModelIndentation, 'setModelIndentation'); setIndentation(editor, 'indent'); - expect(formatWithContentModel.formatWithContentModel).toHaveBeenCalledTimes(1); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(setModelIndentation.setModelIndentation).toHaveBeenCalledTimes(1); expect(setModelIndentation.setModelIndentation).toHaveBeenCalledWith( fakeModel, @@ -30,12 +42,11 @@ describe('setIndentation', () => { }); it('outdent', () => { - spyOn(formatWithContentModel, 'formatWithContentModel').and.callThrough(); spyOn(setModelIndentation, 'setModelIndentation'); setIndentation(editor, 'outdent'); - expect(formatWithContentModel.formatWithContentModel).toHaveBeenCalledTimes(1); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(setModelIndentation.setModelIndentation).toHaveBeenCalledTimes(1); expect(setModelIndentation.setModelIndentation).toHaveBeenCalledWith( fakeModel, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts index e1516101856..303987f0690 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts @@ -1,4 +1,4 @@ -import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; +import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import * as toggleModelBlockQuote from '../../../lib/modelApi/block/toggleModelBlockQuote'; import toggleBlockQuote from '../../../lib/publicApi/block/toggleBlockQuote'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; @@ -6,21 +6,33 @@ import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEdito describe('toggleBlockQuote', () => { const fakeModel: any = { a: 'b' }; let editor: IContentModelEditor; + let formatContentModelSpy: jasmine.Spy; beforeEach(() => { + formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: Function) => { + callback(fakeModel); + }); + + spyOn(pendingFormat, 'formatAndKeepPendingFormat').and.callFake( + (editor, formatter, options) => { + editor.formatContentModel(formatter, options); + } + ); + editor = ({ focus: jasmine.createSpy('focus'), - createContentModel: () => fakeModel, + formatContentModel: formatContentModelSpy, } as any) as IContentModelEditor; }); it('toggleBlockQuote', () => { - spyOn(formatWithContentModel, 'formatWithContentModel').and.callThrough(); spyOn(toggleModelBlockQuote, 'toggleModelBlockQuote'); toggleBlockQuote(editor, { a: 'b', c: 'd' } as any); - expect(formatWithContentModel.formatWithContentModel).toHaveBeenCalledTimes(1); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledTimes(1); expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledWith(fakeModel, { marginTop: '1em', @@ -34,12 +46,11 @@ describe('toggleBlockQuote', () => { }); it('toggleBlockQuote with real format', () => { - spyOn(formatWithContentModel, 'formatWithContentModel').and.callThrough(); spyOn(toggleModelBlockQuote, 'toggleModelBlockQuote'); toggleBlockQuote(editor, { lineHeight: '2', textColor: 'red' }); - expect(formatWithContentModel.formatWithContentModel).toHaveBeenCalledTimes(1); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledTimes(1); expect(toggleModelBlockQuote.toggleModelBlockQuote).toHaveBeenCalledWith(fakeModel, { marginTop: '1em', diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts index 0a1a3834a15..2b0d984bc5d 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts @@ -1,7 +1,10 @@ import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { NodePosition } from 'roosterjs-editor-types'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; export function editingTestCommon( apiName: string, @@ -14,39 +17,29 @@ export function editingTestCommon( spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - const getVisibleViewport = jasmine.createSpy('getVisibleViewport'); - const triggerContentChangedEvent = jasmine.createSpy('triggerContentChangedEvent'); - const addUndoSnapshot = jasmine - .createSpy('addUndoSnapshot') - .and.callFake((callback: () => void, source: string, _, param: any) => { - expect(source).toBe('Format'); - expect(param.formatApiName).toBe(apiName); - callback(); - }); - const setContentModel = jasmine - .createSpy('setContentModel') - .and.callFake((model: ContentModelDocument) => { - expect(model).toEqual(result); + let formatResult: boolean | undefined; + + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }); }); + const editor = ({ - createContentModel: () => model, - cacheContentModel: jasmine.createSpy('cacheContentModel'), - addUndoSnapshot, - focus: jasmine.createSpy(), - setContentModel, triggerPluginEvent, - isDisposed: () => false, - getFocusedPosition: () => null! as NodePosition, - triggerContentChangedEvent, - getVisibleViewport, - isDarkMode: () => false, getEnvironment: () => ({}), + formatContentModel, } as any) as IContentModelEditor; executionCallback(editor); - expect(addUndoSnapshot).toHaveBeenCalledTimes(0); // Should not add undo snapshot since this will be handled by UndoPlugin instead - expect(setContentModel).toHaveBeenCalledTimes(calledTimes); expect(model).toEqual(result); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBe(calledTimes > 0); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts index 0cb566e5f5f..007daaf8bd2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts @@ -1,5 +1,4 @@ import * as deleteSelection from '../../../lib/modelApi/edit/deleteSelection'; -import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; import * as handleKeyboardEventResult from '../../../lib/editor/utils/handleKeyboardEventCommon'; import keyboardDelete from '../../../lib/publicApi/editing/keyboardDelete'; import { ChangeSource } from '../../../lib/publicTypes/event/ContentModelContentChangedEvent'; @@ -371,12 +370,11 @@ describe('keyboardDelete', () => { ); }); - it('Check parameter of formatWithContentModel, forward', () => { - const spy = spyOn(formatWithContentModel, 'formatWithContentModel'); - const addUndoSnapshot = jasmine.createSpy('addUndoSnapshot'); + it('Check parameter of formatContentModel, forward', () => { + const spy = jasmine.createSpy('formatContentModel'); const editor = ({ - addUndoSnapshot, + formatContentModel: spy, getDOMSelection: () => ({ type: 'range', range: { collapsed: false }, @@ -389,18 +387,17 @@ describe('keyboardDelete', () => { keyboardDelete(editor, event); - expect(spy.calls.argsFor(0)[0]).toBe(editor); - expect(spy.calls.argsFor(0)[1]).toBe('handleDeleteKey'); - expect(addUndoSnapshot).not.toHaveBeenCalled(); - expect(spy.calls.argsFor(0)[3]?.changeSource).toBe(ChangeSource.Keyboard); - expect(spy.calls.argsFor(0)[3]?.getChangeData?.()).toBe(Keys.DELETE); + expect(spy.calls.argsFor(0)[1]!.changeSource).toBe(ChangeSource.Keyboard); + expect(spy.calls.argsFor(0)[1]!.getChangeData?.()).toBe(Keys.DELETE); + expect(spy.calls.argsFor(0)[1]!.apiName).toBe('handleDeleteKey'); }); it('Check parameter of formatWithContentModel, backward', () => { - const spy = spyOn(formatWithContentModel, 'formatWithContentModel'); + const spy = jasmine.createSpy('formatContentModel'); const preventDefault = jasmine.createSpy('preventDefault'); const editor = { + formatContentModel: spy, getDOMSelection: () => ({ type: 'range', range: { collapsed: false }, @@ -415,14 +412,14 @@ describe('keyboardDelete', () => { keyboardDelete(editor, event); - expect(spy.calls.argsFor(0)[0]).toBe(editor); - expect(spy.calls.argsFor(0)[1]).toBe('handleBackspaceKey'); - expect(spy.calls.argsFor(0)[3]?.changeSource).toBe(ChangeSource.Keyboard); - expect(spy.calls.argsFor(0)[3]?.getChangeData?.()).toBe(which); + expect(spy.calls.argsFor(0)[1]!.apiName).toBe('handleBackspaceKey'); + expect(spy.calls.argsFor(0)[1]?.changeSource).toBe(ChangeSource.Keyboard); + expect(spy.calls.argsFor(0)[1]?.getChangeData?.()).toBe(which); }); it('No need to delete - Backspace', () => { const rawEvent = { key: 'Backspace' } as any; + const formatWithContentModelSpy = jasmine.createSpy('formatContentModel'); const range: DOMSelection = { type: 'range', range: ({ @@ -432,9 +429,9 @@ describe('keyboardDelete', () => { } as any) as Range, }; const editor = { + formatContentModel: formatWithContentModelSpy, getDOMSelection: () => range, } as any; - const formatWithContentModelSpy = spyOn(formatWithContentModel, 'formatWithContentModel'); const result = keyboardDelete(editor, rawEvent); @@ -444,6 +441,7 @@ describe('keyboardDelete', () => { it('No need to delete - Delete', () => { const rawEvent = { key: 'Delete' } as any; + const formatWithContentModelSpy = jasmine.createSpy('formatContentModel'); const range: DOMSelection = { type: 'range', range: ({ @@ -453,9 +451,9 @@ describe('keyboardDelete', () => { } as any) as Range, }; const editor = { + formatContentModel: formatWithContentModelSpy, getDOMSelection: () => range, } as any; - const formatWithContentModelSpy = spyOn(formatWithContentModel, 'formatWithContentModel'); const result = keyboardDelete(editor, rawEvent); @@ -465,6 +463,7 @@ describe('keyboardDelete', () => { it('Backspace from the beginning', () => { const rawEvent = { key: 'Backspace' } as any; + const formatWithContentModelSpy = jasmine.createSpy('formatContentModel'); const range: DOMSelection = { type: 'range', range: ({ @@ -475,9 +474,9 @@ describe('keyboardDelete', () => { }; const editor = { + formatContentModel: formatWithContentModelSpy, getDOMSelection: () => range, } as any; - const formatWithContentModelSpy = spyOn(formatWithContentModel, 'formatWithContentModel'); const result = keyboardDelete(editor, rawEvent); @@ -487,6 +486,7 @@ describe('keyboardDelete', () => { it('Delete from the last', () => { const rawEvent = { key: 'Delete' } as any; + const formatWithContentModelSpy = jasmine.createSpy('formatContentModel'); const range: DOMSelection = { type: 'range', range: ({ @@ -497,9 +497,9 @@ describe('keyboardDelete', () => { }; const editor = { + formatContentModel: formatWithContentModelSpy, getDOMSelection: () => range, } as any; - const formatWithContentModelSpy = spyOn(formatWithContentModel, 'formatWithContentModel'); const result = keyboardDelete(editor, rawEvent); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts index 79ff8e2b200..a1350441569 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts @@ -1,10 +1,12 @@ -import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; import * as insertEntityModel from '../../../lib/modelApi/entity/insertEntityModel'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import insertEntity from '../../../lib/publicApi/entity/insertEntity'; import { ChangeSource } from '../../../lib/publicTypes/event/ContentModelContentChangedEvent'; -import { FormatWithContentModelContext } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + FormatWithContentModelContext, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; describe('insertEntity', () => { let editor: IContentModelEditor; @@ -46,13 +48,12 @@ describe('insertEntity', () => { appendChild: appendChildSpy, } as any; - formatWithContentModelSpy = spyOn( - formatWithContentModel, - 'formatWithContentModel' - ).and.callFake((editor, apiName, formatter, options) => { - formatter(model, context); - options?.getChangeData?.(); - }); + formatWithContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((formatter: Function, options: FormatWithContentModelOptions) => { + formatter(model, context); + }); + triggerContentChangedEventSpy = jasmine.createSpy('triggerContentChangedEventSpy'); createElementSpy = jasmine.createSpy('createElementSpy').and.returnValue(wrapper); getDocumentSpy = jasmine.createSpy('getDocumentSpy').and.returnValue({ @@ -65,6 +66,7 @@ describe('insertEntity', () => { getDocument: getDocumentSpy, isDarkMode: isDarkModeSpy, transformToDarkColor: transformToDarkColorSpy, + formatContentModel: formatWithContentModelSpy, } as any; }); @@ -74,9 +76,8 @@ describe('insertEntity', () => { expect(createElementSpy).toHaveBeenCalledWith('span'); expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block'); expect(appendChildSpy).not.toHaveBeenCalled(); - expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); - expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); - expect(formatWithContentModelSpy.calls.argsFor(0)[3].changeSource).toEqual( + expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName); + expect(formatWithContentModelSpy.calls.argsFor(0)[1].changeSource).toEqual( ChangeSource.InsertEntity ); expect(insertEntityModelSpy).toHaveBeenCalledWith( @@ -120,9 +121,8 @@ describe('insertEntity', () => { expect(createElementSpy).toHaveBeenCalledWith('div'); expect(setPropertySpy).toHaveBeenCalledWith('display', null); expect(appendChildSpy).not.toHaveBeenCalled(); - expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); - expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); - expect(formatWithContentModelSpy.calls.argsFor(0)[3].changeSource).toEqual( + expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName); + expect(formatWithContentModelSpy.calls.argsFor(0)[1].changeSource).toEqual( ChangeSource.InsertEntity ); expect(insertEntityModelSpy).toHaveBeenCalledWith( @@ -173,9 +173,8 @@ describe('insertEntity', () => { expect(createElementSpy).toHaveBeenCalledWith('div'); expect(setPropertySpy).toHaveBeenCalledWith('display', 'none'); expect(appendChildSpy).toHaveBeenCalledWith(contentNode); - expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); - expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); - expect(formatWithContentModelSpy.calls.argsFor(0)[3].changeSource).toEqual( + expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName); + expect(formatWithContentModelSpy.calls.argsFor(0)[1].changeSource).toEqual( ChangeSource.InsertEntity ); @@ -222,9 +221,8 @@ describe('insertEntity', () => { expect(createElementSpy).toHaveBeenCalledWith('span'); expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block'); expect(appendChildSpy).not.toHaveBeenCalled(); - expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); - expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); - expect(formatWithContentModelSpy.calls.argsFor(0)[3].changeSource).toEqual( + expect(formatWithContentModelSpy.calls.argsFor(0)[1].apiName).toBe(apiName); + expect(formatWithContentModelSpy.calls.argsFor(0)[1].changeSource).toBe( ChangeSource.InsertEntity ); expect(insertEntityModelSpy).toHaveBeenCalledWith( diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts index fe370b86b14..07772f4848b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts @@ -1,9 +1,12 @@ -import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; import * as iterateSelections from '../../../lib/modelApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import applyPendingFormat from '../../../lib/publicApi/format/applyPendingFormat'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { ContentModelDocument, ContentModelParagraph, @@ -19,7 +22,6 @@ import { describe('applyPendingFormat', () => { it('Has pending format', () => { - const editor = ({} as any) as IContentModelEditor; const text: ContentModelText = { segmentType: 'Text', text: 'abc', @@ -44,16 +46,23 @@ describe('applyPendingFormat', () => { fontSize: '10px', }); - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_, apiName, callback) => { - expect(apiName).toEqual('applyPendingFormat'); - callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + } + ); + + const editor = ({ + formatContentModel: formatContentModelSpy, + } as any) as IContentModelEditor; + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); return false; @@ -61,6 +70,7 @@ describe('applyPendingFormat', () => { applyPendingFormat(editor, 'c'); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(model).toEqual({ blockGroupType: 'Document', blocks: [ @@ -92,7 +102,6 @@ describe('applyPendingFormat', () => { }); it('Has pending format but wrong text', () => { - const editor = ({} as any) as IContentModelEditor; const text: ContentModelText = { segmentType: 'Text', text: 'abc', @@ -117,12 +126,19 @@ describe('applyPendingFormat', () => { fontSize: '10px', }); - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_, apiName, callback) => { - expect(apiName).toEqual('applyPendingFormat'); - callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); - } - ); + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); + } + ); + + const editor = ({ + formatContentModel: formatContentModelSpy, + } as any) as IContentModelEditor; + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); return false; @@ -130,6 +146,7 @@ describe('applyPendingFormat', () => { applyPendingFormat(editor, 'd'); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(model).toEqual({ blockGroupType: 'Document', blocks: [ @@ -154,7 +171,6 @@ describe('applyPendingFormat', () => { }); it('No pending format', () => { - const editor = ({} as any) as IContentModelEditor; const text: ContentModelText = { segmentType: 'Text', text: 'abc', @@ -177,12 +193,11 @@ describe('applyPendingFormat', () => { spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_, apiName, callback) => { - expect(apiName).toEqual('applyPendingFormat'); - callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); - } - ); + const formatContentModelSpy = jasmine.createSpy('formatContentModel'); + const editor = ({ + formatContentModel: formatContentModelSpy, + } as any) as IContentModelEditor; + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); return false; @@ -190,6 +205,7 @@ describe('applyPendingFormat', () => { applyPendingFormat(editor, 'd'); + expect(formatContentModelSpy).not.toHaveBeenCalled(); expect(model).toEqual({ blockGroupType: 'Document', blocks: [ @@ -214,7 +230,6 @@ describe('applyPendingFormat', () => { }); it('Selection is not collapsed', () => { - const editor = ({} as any) as IContentModelEditor; const text: ContentModelText = { segmentType: 'Text', text: 'abc', @@ -235,12 +250,19 @@ describe('applyPendingFormat', () => { fontSize: '10px', }); - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_, apiName, callback) => { - expect(apiName).toEqual('applyPendingFormat'); - callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); - } - ); + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); + } + ); + + const editor = ({ + formatContentModel: formatContentModelSpy, + } as any) as IContentModelEditor; + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [text]); return false; @@ -248,6 +270,7 @@ describe('applyPendingFormat', () => { applyPendingFormat(editor, 'd'); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(model).toEqual({ blockGroupType: 'Document', blocks: [ @@ -268,7 +291,6 @@ describe('applyPendingFormat', () => { }); it('Implicit paragraph', () => { - const editor = ({} as any) as IContentModelEditor; const text = createText('test'); const marker = createSelectionMarker(); const paragraph = createParagraph(true /*isImplicit*/); @@ -280,12 +302,19 @@ describe('applyPendingFormat', () => { spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ fontSize: '10px', }); - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_, apiName, callback) => { - expect(apiName).toEqual('applyPendingFormat'); - callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); - } - ); + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + expect(options.apiName).toEqual('applyPendingFormat'); + callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); + } + ); + + const editor = ({ + formatContentModel: formatContentModelSpy, + } as any) as IContentModelEditor; + spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); return false; @@ -294,6 +323,7 @@ describe('applyPendingFormat', () => { applyPendingFormat(editor, 't'); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(model).toEqual({ blockGroupType: 'Document', blocks: [ diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts index 7eddbeba0bf..93e76a725ab 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts @@ -1,28 +1,35 @@ import * as clearModelFormat from '../../../lib/modelApi/common/clearModelFormat'; -import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import clearFormat from '../../../lib/publicApi/format/clearFormat'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; describe('clearFormat', () => { it('Clear format', () => { + const model = ('Model' as any) as ContentModelDocument; + const formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + expect(options.apiName).toEqual('clearFormat'); + callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); + } + ); + const editor = ({ focus: () => {}, + formatContentModel: formatContentModelSpy, } as any) as IContentModelEditor; - const model = ('Model' as any) as ContentModelDocument; - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (_, apiName, callback) => { - expect(apiName).toEqual('clearFormat'); - callback(model, { newEntities: [], deletedEntities: [], newImages: [] }); - } - ); spyOn(clearModelFormat, 'clearModelFormat'); spyOn(normalizeContentModel, 'normalizeContentModel'); clearFormat(editor); - expect(formatWithContentModel.formatWithContentModel).toHaveBeenCalledTimes(1); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(clearModelFormat.clearModelFormat).toHaveBeenCalledTimes(1); expect(clearModelFormat.clearModelFormat).toHaveBeenCalledWith(model, [], [], []); expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledTimes(1); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts index a2fec41de95..5ba65b4af8d 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts @@ -4,6 +4,10 @@ import changeImage from '../../../lib/publicApi/image/changeImage'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { PluginEventType } from 'roosterjs-editor-types'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { addSegment, createContentModelDocument, @@ -23,19 +27,6 @@ describe('changeImage', () => { result: ContentModelDocument, calledTimes: number ) { - const addUndoSnapshot = jasmine - .createSpy('addUndoSnapshot') - .and.callFake( - (callback: () => void, source: string, canUndoByBackspace, param: any) => { - expect(source).toBe(undefined!); - expect(param.formatApiName).toBe('changeImage'); - callback(); - } - ); - const setContentModel = jasmine.createSpy().and.callFake((model: ContentModelDocument) => { - expect(model).toEqual(result); - }); - spyOn(pendingFormat, 'setPendingFormat'); spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); @@ -43,25 +34,33 @@ describe('changeImage', () => { .createSpy() .and.returnValues({ type: 'image', image: imageNode }); triggerPluginEvent = jasmine.createSpy('triggerPluginEvent').and.callThrough(); - const getVisibleViewport = jasmine.createSpy().and.callThrough(); + + let formatResult: boolean | undefined; + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }); + } + ); const editor = ({ - createContentModel: () => model, - addUndoSnapshot, focus: jasmine.createSpy(), - setContentModel, isDisposed: () => false, - getDocument: () => document, getDOMSelection, triggerPluginEvent, - getVisibleViewport, - isDarkMode: () => false, + formatContentModel, } as any) as IContentModelEditor; executionCallback(editor); - expect(addUndoSnapshot).toHaveBeenCalledTimes(calledTimes); - expect(setContentModel).toHaveBeenCalledTimes(calledTimes); + expect(formatResult).toBe(calledTimes > 0); + expect(formatContentModel).toHaveBeenCalledTimes(1); expect(model).toEqual(result); } @@ -154,15 +153,6 @@ describe('changeImage', () => { }, 1 ); - - expect(triggerPluginEvent).toHaveBeenCalledTimes(1); - expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.ContentChanged, { - contentModel: doc, - selection: undefined, - source: 'Format', - data: undefined, - additionalData: { formatApiName: 'changeImage' }, - }); }); it('Doc with selection and image', () => { @@ -207,19 +197,12 @@ describe('changeImage', () => { 1 ); - expect(triggerPluginEvent).toHaveBeenCalledTimes(2); + expect(triggerPluginEvent).toHaveBeenCalledTimes(1); expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.EditImage, { image: imageNode, newSrc: testUrl, previousSrc: 'test', originalSrc: '', }); - expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.ContentChanged, { - contentModel: doc, - selection: undefined, - source: 'Format', - data: undefined, - additionalData: { formatApiName: 'changeImage' }, - }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts index 2beb29181ab..4d458dbd18c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts @@ -2,6 +2,10 @@ import * as readFile from '../../../lib/domUtils/readFile'; import insertImage from '../../../lib/publicApi/image/insertImage'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { addSegment, createContentModelDocument, @@ -18,37 +22,33 @@ describe('insertImage', () => { result: ContentModelDocument, calledTimes: number ) { - const addUndoSnapshot = jasmine - .createSpy() + let formatResult: boolean | undefined; + + const formatContentModel = jasmine + .createSpy('formatContentModel') .and.callFake( - (callback: () => void, source: string, canUndoByBackspace, param: any) => { - expect(source).toBe(undefined!); - expect(param.formatApiName).toBe(apiName); - callback(); + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); } ); - const setContentModel = jasmine.createSpy().and.callFake((model: ContentModelDocument) => { - expect(model).toEqual(result); - }); - const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - const getVisibleViewport = jasmine.createSpy('getVisibleViewport'); const editor = ({ - createContentModel: () => model, - addUndoSnapshot, focus: jasmine.createSpy(), - setContentModel, isDisposed: () => false, - getDocument: () => document, - isDarkMode: () => false, - triggerPluginEvent, - getVisibleViewport, + formatContentModel, } as any) as IContentModelEditor; executionCallback(editor); - expect(addUndoSnapshot).toHaveBeenCalledTimes(calledTimes); - expect(setContentModel).toHaveBeenCalledTimes(calledTimes); expect(model).toEqual(result); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatContentModel.calls.argsFor(0)[1]).toEqual({ + apiName, + }); + expect(formatResult).toBe(calledTimes > 0); } beforeEach(() => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts index 28ce8e237be..a9ad11d1bc3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts @@ -1,6 +1,10 @@ import adjustLinkSelection from '../../../lib/publicApi/link/adjustLinkSelection'; import { ContentModelDocument, ContentModelLink } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { addLink, addSegment, @@ -13,25 +17,33 @@ import { describe('adjustLinkSelection', () => { let editor: IContentModelEditor; - let setContentModel: jasmine.Spy; let createContentModel: jasmine.Spy; - let triggerPluginEvent: jasmine.Spy; - let getVisibleViewport: jasmine.Spy; + let formatContentModel: jasmine.Spy; + let formatResult: boolean | undefined; + let model: ContentModelDocument | undefined; beforeEach(() => { - setContentModel = jasmine.createSpy('setContentModel'); createContentModel = jasmine.createSpy('createContentModel'); - triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - getVisibleViewport = jasmine.createSpy('getVisibleViewport'); + + model = undefined; + formatResult = undefined; + + formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + model = createContentModel(); + + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + } + ); editor = ({ - focus: () => {}, - addUndoSnapshot: (callback: Function) => callback(), - setContentModel, - createContentModel, - isDarkMode: () => false, - triggerPluginEvent, - getVisibleViewport, + formatContentModel, } as any) as IContentModelEditor; }); @@ -45,11 +57,11 @@ describe('adjustLinkSelection', () => { const [text, url] = adjustLinkSelection(editor); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBe(!!expectedModel); + if (expectedModel) { - expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(expectedModel, undefined, undefined); - } else { - expect(setContentModel).not.toHaveBeenCalled(); + expect(model).toEqual(expectedModel); } expect(text).toBe(expectedText); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts index da7c7fd0422..34d2397f563 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts @@ -4,6 +4,10 @@ import { ChangeSource } from '../../../lib/publicTypes/event/ContentModelContent import { ContentModelDocument, ContentModelLink } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { PluginEventType } from 'roosterjs-editor-types'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { addSegment, createContentModelDocument, @@ -14,27 +18,11 @@ import { describe('insertLink', () => { let editor: IContentModelEditor; - let setContentModel: jasmine.Spy; - let createContentModel: jasmine.Spy; - let triggerPluginEvent: jasmine.Spy; - let getVisibleViewport: jasmine.Spy; beforeEach(() => { - setContentModel = jasmine.createSpy('setContentModel'); - createContentModel = jasmine.createSpy('createContentModel'); - triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - getVisibleViewport = jasmine.createSpy('getVisibleViewport'); - editor = ({ focus: () => {}, - addUndoSnapshot: (callback: Function) => callback(), - setContentModel, - createContentModel, getCustomData: () => ({}), - getFocusedPosition: () => ({}), - isDarkMode: () => false, - triggerPluginEvent, - getVisibleViewport, } as any) as IContentModelEditor; }); @@ -46,21 +34,40 @@ describe('insertLink', () => { displayText?: string, target?: string ) { - createContentModel.and.returnValue(model); + let formatResult: boolean | undefined; + + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + } + ); + + editor.formatContentModel = formatContentModel; insertLink(editor, url, title, displayText, target); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBe(!!expectedModel); + if (expectedModel) { - expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel.calls.argsFor(0)[0]).toEqual(expectedModel); - expect(typeof setContentModel.calls.argsFor(0)[2]).toEqual('function'); - } else { - expect(setContentModel).not.toHaveBeenCalled(); + expect(model).toEqual(expectedModel); } } it('Empty link string', () => { - runTest(createContentModelDocument(), '', null); + const formatContentModel = jasmine.createSpy('formatContentModel'); + + editor.formatContentModel = formatContentModel; + + insertLink(editor, ''); + + expect(formatContentModel).not.toHaveBeenCalled(); }); it('Valid url', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts index f4f36884304..6d9679db5cc 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts @@ -1,6 +1,10 @@ import removeLink from '../../../lib/publicApi/link/removeLink'; import { ContentModelDocument, ContentModelLink } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { addLink, addSegment, @@ -11,38 +15,38 @@ import { describe('removeLink', () => { let editor: IContentModelEditor; - let setContentModel: jasmine.Spy; - let createContentModel: jasmine.Spy; - let triggerPluginEvent: jasmine.Spy; - let getVisibleViewport: jasmine.Spy; beforeEach(() => { - setContentModel = jasmine.createSpy('setContentModel'); - createContentModel = jasmine.createSpy('createContentModel'); - triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - getVisibleViewport = jasmine.createSpy('getVisibleViewport'); - editor = ({ focus: () => {}, - addUndoSnapshot: (callback: Function) => callback(), - setContentModel, - createContentModel, - isDarkMode: () => false, - triggerPluginEvent, - getVisibleViewport, } as any) as IContentModelEditor; }); function runTest(model: ContentModelDocument, expectedModel: ContentModelDocument | null) { - createContentModel.and.returnValue(model); + let formatResult: boolean | undefined; + + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }); + } + ); + + editor.formatContentModel = formatContentModel; removeLink(editor); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBe(!!expectedModel); + if (expectedModel) { - expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(expectedModel, undefined, undefined); - } else { - expect(setContentModel).not.toHaveBeenCalled(); + expect(model).toEqual(expectedModel); } } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts index 1aec33f79ae..42e92628e14 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts @@ -1,6 +1,9 @@ -import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; import setListStartNumber from '../../../lib/publicApi/list/setListStartNumber'; import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; describe('setListStartNumber', () => { function runTest( @@ -8,27 +11,30 @@ describe('setListStartNumber', () => { expectedModel: ContentModelDocument, expectedResult: boolean ) { - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (editor, apiName, callback) => { - expect(apiName).toBe('setListStartNumber'); - const result = callback(input, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - - expect(result).toBe(expectedResult); - } - ); + let formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + expect(options.apiName).toBe('setListStartNumber'); + const result = callback(input, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + + expect(result).toBe(expectedResult); + } + ); setListStartNumber( { + formatContentModel: formatContentModelSpy, focus: () => {}, } as any, 2 ); - expect(formatWithContentModel.formatWithContentModel).toHaveBeenCalledTimes(1); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(input).toEqual(expectedModel); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts index 14efd88584e..811b7ad8972 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts @@ -1,4 +1,3 @@ -import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; import setListStyle from '../../../lib/publicApi/list/setListStyle'; import { ContentModelDocument, ListMetadataFormat } from 'roosterjs-content-model-types'; @@ -9,9 +8,10 @@ describe('setListStyle', () => { expectedModel: ContentModelDocument, expectedResult: boolean ) { - spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( - (editor, apiName, callback) => { - expect(apiName).toBe('setListStyle'); + const formatWithContentModelSpy = jasmine + .createSpy('formatWithContentModel') + .and.callFake((callback, options) => { + expect(options.apiName).toBe('setListStyle'); const result = callback(input, { newEntities: [], deletedEntities: [], @@ -19,17 +19,17 @@ describe('setListStyle', () => { }); expect(result).toBe(expectedResult); - } - ); + }); setListStyle( { focus: () => {}, + formatContentModel: formatWithContentModelSpy, } as any, style ); - expect(formatWithContentModel.formatWithContentModel).toHaveBeenCalledTimes(1); + expect(formatWithContentModelSpy).toHaveBeenCalledTimes(1); expect(input).toEqual(expectedModel); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts index 7538416b763..9f8b333842e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts @@ -2,37 +2,38 @@ import * as setListType from '../../../lib/modelApi/list/setListType'; import toggleBullet from '../../../lib/publicApi/list/toggleBullet'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; describe('toggleBullet', () => { let editor = ({} as any) as IContentModelEditor; - let addUndoSnapshot: jasmine.Spy; - let createContentModel: jasmine.Spy; - let setContentModel: jasmine.Spy; + let formatContentModel: jasmine.Spy; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; - let triggerPluginEvent: jasmine.Spy; - let getVisibleViewport: jasmine.Spy; beforeEach(() => { mockedModel = ({} as any) as ContentModelDocument; - addUndoSnapshot = jasmine.createSpy('addUndoSnapshot').and.callFake(callback => callback()); - createContentModel = jasmine.createSpy('createContentModel').and.returnValue(mockedModel); - setContentModel = jasmine.createSpy('setContentModel'); - triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + callback(mockedModel, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + } + ); focus = jasmine.createSpy('focus'); - getVisibleViewport = jasmine.createSpy('getVisibleViewport'); editor = ({ focus, - addUndoSnapshot, - createContentModel, - setContentModel, + formatContentModel, getCustomData: () => ({}), getFocusedPosition: () => ({}), - isDarkMode: () => false, - triggerPluginEvent, - getVisibleViewport, } as any) as IContentModelEditor; spyOn(setListType, 'setListType').and.returnValue(true); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts index 927b1385bbf..28c0142eeb1 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts @@ -1,38 +1,45 @@ +import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import * as setListType from '../../../lib/modelApi/list/setListType'; import toggleNumbering from '../../../lib/publicApi/list/toggleNumbering'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; describe('toggleNumbering', () => { let editor = ({} as any) as IContentModelEditor; - let addUndoSnapshot: jasmine.Spy; - let createContentModel: jasmine.Spy; - let setContentModel: jasmine.Spy; - let triggerPluginEvent: jasmine.Spy; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; - let getVisibleViewport: jasmine.Spy; beforeEach(() => { mockedModel = ({} as any) as ContentModelDocument; - addUndoSnapshot = jasmine.createSpy('addUndoSnapshot').and.callFake(callback => callback()); - createContentModel = jasmine.createSpy('createContentModel').and.returnValue(mockedModel); - setContentModel = jasmine.createSpy('setContentModel'); - triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); focus = jasmine.createSpy('focus'); - getVisibleViewport = jasmine.createSpy('getVisibleViewport'); + + spyOn(pendingFormat, 'formatAndKeepPendingFormat').and.callFake( + (editor, formatter, options) => { + editor.formatContentModel(formatter, options); + } + ); + + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + callback(mockedModel, { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }); + } + ); editor = ({ focus, - addUndoSnapshot, - createContentModel, - setContentModel, - getCustomData: () => ({}), - getFocusedPosition: () => ({}), - isDarkMode: () => false, - triggerPluginEvent, - getVisibleViewport, + formatContentModel, } as any) as IContentModelEditor; spyOn(setListType, 'setListType').and.returnValue(true); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts index 0ea70c15776..257c1676a9f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts @@ -5,6 +5,10 @@ import { createDomToModelContext, domToContentModel } from 'roosterjs-content-mo import { createRange } from 'roosterjs-editor-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { segmentTestCommon } from './segmentTestCommon'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; describe('changeFontSize', () => { function runTest( @@ -330,13 +334,6 @@ describe('changeFontSize', () => { it('Test format parser', () => { spyOn(pendingFormat, 'setPendingFormat'); spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); - const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - const getVisibleViewport = jasmine.createSpy('getVisibleViewport'); - - const addUndoSnapshot = jasmine.createSpy().and.callFake((callback: () => void) => { - callback(); - }); - const setContentModel = jasmine.createSpy(); const div = document.createElement('div'); const sub = document.createElement('sub'); @@ -344,44 +341,52 @@ describe('changeFontSize', () => { div.appendChild(sub); div.style.fontSize = '20pt'; + const model = domToContentModel(div, createDomToModelContext(undefined), { + type: 'range', + range: createRange(sub), + }); + + let formatResult: boolean | undefined; + + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + } + ); + const editor = ({ - createContentModel: (option: any) => - domToContentModel(div, createDomToModelContext(undefined), { - type: 'range', - range: createRange(sub), - }), - addUndoSnapshot, + formatContentModel, focus: jasmine.createSpy(), - setContentModel, - isDarkMode: () => false, - triggerPluginEvent, - getVisibleViewport, } as any) as IContentModelEditor; changeFontSize(editor, 'increase'); - expect(setContentModel).toHaveBeenCalledWith( - { - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test', - format: { superOrSubScriptSequence: 'sub' }, - isSelected: true, - }, - ], - isImplicit: true, - }, - ], - }, - undefined, - undefined - ); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: { superOrSubScriptSequence: 'sub' }, + isSelected: true, + }, + ], + isImplicit: true, + }, + ], + }); }); it('Paragraph has font size', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts index 56c0dbc8b29..6c45908ef73 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts @@ -2,6 +2,10 @@ import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { NodePosition } from 'roosterjs-editor-types'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; export function segmentTestCommon( apiName: string, @@ -13,33 +17,26 @@ export function segmentTestCommon( spyOn(pendingFormat, 'setPendingFormat'); spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); - const addUndoSnapshot = jasmine - .createSpy() - .and.callFake((callback: () => void, source: string, canUndoByBackspace, param: any) => { - expect(source).toBe(undefined!); - expect(param.formatApiName).toBe(apiName); - callback(); + let formatResult: boolean | undefined; + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + expect(options.apiName).toBe(apiName); + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); }); - const setContentModel = jasmine.createSpy().and.callFake((model: ContentModelDocument) => { - expect(model).toEqual(result); - }); - const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - const getVisibleViewport = jasmine.createSpy('getVisibleViewport'); const editor = ({ - createContentModel: () => model, - addUndoSnapshot, focus: jasmine.createSpy(), - setContentModel, - isDisposed: () => false, getFocusedPosition: () => null as NodePosition, - isDarkMode: () => false, - triggerPluginEvent, - getVisibleViewport, + formatContentModel, } as any) as IContentModelEditor; executionCallback(editor); - expect(addUndoSnapshot).toHaveBeenCalledTimes(calledTimes); - expect(setContentModel).toHaveBeenCalledTimes(calledTimes); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBe(calledTimes > 0); expect(model).toEqual(result); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts index 387c3477a70..0eac239a422 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts @@ -6,13 +6,13 @@ import { ContentModelTable, ContentModelTableCell } from 'roosterjs-content-mode import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { createTable, createTableCell } from 'roosterjs-content-model-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; describe('applyTableBorderFormat', () => { let editor: IContentModelEditor; - let setContentModel: jasmine.Spy; - let createContentModel: jasmine.Spy; - let triggerPluginEvent: jasmine.Spy; - let getVisibleViewport: jasmine.Spy; const width = '3px'; const style = 'double'; const color = '#AABBCC'; @@ -40,22 +40,9 @@ describe('applyTableBorderFormat', () => { } beforeEach(() => { - setContentModel = jasmine.createSpy('setContentModel'); - createContentModel = jasmine.createSpy('createContentModel'); - triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - getVisibleViewport = jasmine.createSpy('getVisibleViewport'); - spyOn(normalizeTable, 'normalizeTable'); - editor = ({ - focus: () => {}, - addUndoSnapshot: (callback: Function) => callback(), - setContentModel, - createContentModel, - isDarkMode: () => false, - triggerPluginEvent, - getVisibleViewport, - } as any) as IContentModelEditor; + editor = ({} as any) as IContentModelEditor; }); function runTest( @@ -67,23 +54,30 @@ describe('applyTableBorderFormat', () => { const model = createContentModelDocument(); model.blocks.push(table); - createContentModel.and.returnValue(model); + let formatResult: boolean | undefined; + + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + } + ); + + editor.formatContentModel = formatContentModel; applyTableBorderFormat(editor, border, operation); - if (expectedTable) { - expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith( - { - blockGroupType: 'Document', - blocks: [expectedTable], - }, - undefined, - undefined - ); - } else { - expect(setContentModel).not.toHaveBeenCalled(); - } + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [expectedTable], + }); } it('All Borders', () => { runTest( diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts index d4df63630a1..c277589dbdc 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts @@ -3,30 +3,19 @@ import setTableCellShade from '../../../lib/publicApi/table/setTableCellShade'; import { ContentModelTable } from 'roosterjs-content-model-types'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; describe('setTableCellShade', () => { let editor: IContentModelEditor; - let setContentModel: jasmine.Spy; - let createContentModel: jasmine.Spy; - let triggerPluginEvent: jasmine.Spy; - let getVisibleViewport: jasmine.Spy; beforeEach(() => { - setContentModel = jasmine.createSpy('setContentModel'); - createContentModel = jasmine.createSpy('createContentModel'); - triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - getVisibleViewport = jasmine.createSpy('getVisibleViewport'); - spyOn(normalizeTable, 'normalizeTable'); editor = ({ focus: () => {}, - addUndoSnapshot: (callback: Function) => callback(), - setContentModel, - createContentModel, - isDarkMode: () => false, - triggerPluginEvent, - getVisibleViewport, } as any) as IContentModelEditor; }); @@ -38,22 +27,32 @@ describe('setTableCellShade', () => { const model = createContentModelDocument(); model.blocks.push(table); - createContentModel.and.returnValue(model); + let formatResult: boolean | undefined; + + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + } + ); + + editor.formatContentModel = formatContentModel; setTableCellShade(editor, colorValue); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBe(!!expectedTable); + if (expectedTable) { - expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith( - { - blockGroupType: 'Document', - blocks: [expectedTable], - }, - undefined, - undefined - ); - } else { - expect(setContentModel).not.toHaveBeenCalled(); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [expectedTable], + }); } } it('Empty table', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts index 0cd533b469e..dc168cfabe3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts @@ -2,7 +2,10 @@ import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import formatImageWithContentModel from '../../../lib/publicApi/utils/formatImageWithContentModel'; import { ContentModelDocument, ContentModelImage } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { NodePosition } from 'roosterjs-editor-types'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { addSegment, createContentModelDocument, @@ -201,32 +204,24 @@ function segmentTestForPluginEvent( spyOn(pendingFormat, 'setPendingFormat'); spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); - const addUndoSnapshot = jasmine - .createSpy() - .and.callFake((callback: () => void, source: string, canUndoByBackspace, param: any) => { - expect(source).toBe(undefined!); - expect(param.formatApiName).toBe(apiName); - callback(); + let formatResult: boolean | undefined; + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + expect(options.apiName).toBe(apiName); + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); }); - const triggerPluginEvent = jasmine.createSpy().and.callFake(() => {}); - const getVisibleViewport = jasmine.createSpy().and.callFake(() => {}); - const setContentModel = jasmine.createSpy().and.callFake((model: ContentModelDocument) => { - expect(model).toEqual(result); - }); const editor = ({ - createContentModel: () => model, - addUndoSnapshot, - focus: jasmine.createSpy(), - setContentModel, - isDisposed: () => false, - getFocusedPosition: () => null as NodePosition, - triggerPluginEvent, - isDarkMode: () => false, - getVisibleViewport, + formatContentModel, } as any) as IContentModelEditor; executionCallback(editor); - expect(addUndoSnapshot).toHaveBeenCalledTimes(calledTimes); - expect(setContentModel).toHaveBeenCalledTimes(calledTimes); + + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBe(calledTimes > 0); expect(model).toEqual(result); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts index 38760f3ae5e..8d730e6bd74 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts @@ -1,7 +1,11 @@ import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { ContentModelDocument, ContentModelParagraph } from 'roosterjs-content-model-types'; import { formatParagraphWithContentModel } from '../../../lib/publicApi/utils/formatParagraphWithContentModel'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { createContentModelDocument, createParagraph, @@ -10,11 +14,6 @@ import { describe('formatParagraphWithContentModel', () => { let editor: IContentModelEditor; - let addUndoSnapshot: jasmine.Spy; - let setContentModel: jasmine.Spy; - let triggerPluginEvent: jasmine.Spy; - let focus: jasmine.Spy; - let getVisibleViewport: jasmine.Spy; let model: ContentModelDocument; const mockedContainer = 'C' as any; @@ -23,22 +22,23 @@ describe('formatParagraphWithContentModel', () => { const apiName = 'mockedApi'; beforeEach(() => { - addUndoSnapshot = jasmine.createSpy('addUndoSnapshot').and.callFake(callback => callback()); - setContentModel = jasmine.createSpy('setContentModel'); - triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - getVisibleViewport = jasmine.createSpy('getVisibleViewport'); - focus = jasmine.createSpy('focus'); + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: options.rawEvent, + }); + } + ); editor = ({ - focus, - addUndoSnapshot, - createContentModel: () => model, - setContentModel, - isDarkMode: () => false, getCustomData: () => ({}), getFocusedPosition: () => ({ node: mockedContainer, offset: mockedOffset }), - triggerPluginEvent, - getVisibleViewport, + formatContentModel, } as any) as IContentModelEditor; }); @@ -55,7 +55,6 @@ describe('formatParagraphWithContentModel', () => { blockGroupType: 'Document', blocks: [], }); - expect(addUndoSnapshot).not.toHaveBeenCalled(); }); it('doc with selection', () => { @@ -90,7 +89,6 @@ describe('formatParagraphWithContentModel', () => { }, ], }); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); }); it('Preserve pending format', () => { @@ -103,30 +101,14 @@ describe('formatParagraphWithContentModel', () => { para.segments.push(text); model.blocks.push(para); - let cachedPendingFormat: any = 'PendingFormat'; - let cachedPendingContainer: any = 'PendingContainer'; - let cachedPendingOffset: any = 'PendingOffset'; + spyOn(pendingFormat, 'formatAndKeepPendingFormat').and.callThrough(); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(cachedPendingFormat); - spyOn(pendingFormat, 'setPendingFormat').and.callFake((_, format, container, offset) => { - cachedPendingFormat = format; - cachedPendingContainer = container; - cachedPendingOffset = offset; - }); - spyOn(pendingFormat, 'clearPendingFormat').and.callFake(() => { - cachedPendingFormat = null; - cachedPendingContainer = null; - cachedPendingOffset = null; - }); + const callback = (paragraph: ContentModelParagraph) => { + paragraph.format.backgroundColor = 'red'; + }; - formatParagraphWithContentModel( - editor, - apiName, - paragraph => (paragraph.format.backgroundColor = 'red') - ); + formatParagraphWithContentModel(editor, apiName, callback); - expect(cachedPendingFormat).toEqual('PendingFormat'); - expect(cachedPendingContainer).toEqual(mockedContainer); - expect(cachedPendingOffset).toEqual(mockedOffset); + expect(pendingFormat.formatAndKeepPendingFormat).toHaveBeenCalled(); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts index c61a6067f43..5689a1c823c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts @@ -2,7 +2,10 @@ import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { formatSegmentWithContentModel } from '../../../lib/publicApi/utils/formatSegmentWithContentModel'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { NodePosition } from 'roosterjs-editor-types'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { createContentModelDocument, createParagraph, @@ -12,36 +15,37 @@ import { describe('formatSegmentWithContentModel', () => { let editor: IContentModelEditor; - let addUndoSnapshot: jasmine.Spy; - let setContentModel: jasmine.Spy; let focus: jasmine.Spy; let model: ContentModelDocument; let getPendingFormat: jasmine.Spy; let setPendingFormat: jasmine.Spy; - let triggerPluginEvent: jasmine.Spy; - let getVisibleViewport: jasmine.Spy; + let formatContentModel: jasmine.Spy; + let formatResult: boolean | undefined; const apiName = 'mockedApi'; beforeEach(() => { - addUndoSnapshot = jasmine.createSpy('addUndoSnapshot').and.callFake(callback => callback()); - setContentModel = jasmine.createSpy('setContentModel'); - triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - getVisibleViewport = jasmine.createSpy('getVisibleViewport'); + formatResult = undefined; focus = jasmine.createSpy('focus'); setPendingFormat = spyOn(pendingFormat, 'setPendingFormat'); getPendingFormat = spyOn(pendingFormat, 'getPendingFormat'); + formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(model, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + } + ); + editor = ({ focus, - addUndoSnapshot, - createContentModel: () => model, - setContentModel, - getFocusedPosition: () => null as NodePosition, - isDarkMode: () => false, - triggerPluginEvent, - getVisibleViewport, + formatContentModel, } as any) as IContentModelEditor; }); @@ -54,7 +58,8 @@ describe('formatSegmentWithContentModel', () => { blockGroupType: 'Document', blocks: [], }); - expect(addUndoSnapshot).not.toHaveBeenCalled(); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeFalse(); expect(getPendingFormat).toHaveBeenCalledTimes(1); expect(setPendingFormat).toHaveBeenCalledTimes(0); }); @@ -89,7 +94,8 @@ describe('formatSegmentWithContentModel', () => { }, ], }); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); expect(getPendingFormat).toHaveBeenCalledTimes(1); expect(setPendingFormat).toHaveBeenCalledTimes(0); }); @@ -135,7 +141,8 @@ describe('formatSegmentWithContentModel', () => { }, ], }); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); expect(segmentHasStyleCallback).toHaveBeenCalledTimes(1); expect(segmentHasStyleCallback).toHaveBeenCalledWith(text.format, text, para); expect(toggleStyleCallback).toHaveBeenCalledTimes(1); @@ -205,7 +212,8 @@ describe('formatSegmentWithContentModel', () => { }, ], }); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); expect(segmentHasStyleCallback).toHaveBeenCalledTimes(2); expect(segmentHasStyleCallback).toHaveBeenCalledWith(text1.format, text1, para); expect(segmentHasStyleCallback).toHaveBeenCalledWith(text3.format, text3, para); @@ -252,7 +260,8 @@ describe('formatSegmentWithContentModel', () => { }, ], }); - expect(addUndoSnapshot).toHaveBeenCalledTimes(0); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeFalse(); expect(getPendingFormat).toHaveBeenCalledTimes(1); expect(setPendingFormat).toHaveBeenCalledTimes(1); expect(setPendingFormat).toHaveBeenCalledWith( @@ -297,7 +306,8 @@ describe('formatSegmentWithContentModel', () => { }, ], }); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); + expect(formatContentModel).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); expect(pendingFormat).toEqual({ fontSize: '10px', fontFamily: 'test', diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts deleted file mode 100644 index 1c0c7ea1824..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts +++ /dev/null @@ -1,342 +0,0 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; -import { ChangeSource } from '../../../lib/publicTypes/event/ContentModelContentChangedEvent'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; -import { createImage } from 'roosterjs-content-model-dom'; -import { EntityOperation, PluginEventType } from 'roosterjs-editor-types'; -import { formatWithContentModel } from '../../../lib/publicApi/utils/formatWithContentModel'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; - -describe('formatWithContentModel', () => { - let editor: IContentModelEditor; - let addUndoSnapshot: jasmine.Spy; - let createContentModel: jasmine.Spy; - let setContentModel: jasmine.Spy; - let mockedModel: ContentModelDocument; - let cacheContentModel: jasmine.Spy; - let getFocusedPosition: jasmine.Spy; - let triggerPluginEvent: jasmine.Spy; - let getVisibleViewport: jasmine.Spy; - - const apiName = 'mockedApi'; - const mockedContainer = 'C' as any; - const mockedOffset = 'O' as any; - - beforeEach(() => { - mockedModel = ({} as any) as ContentModelDocument; - - addUndoSnapshot = jasmine.createSpy('addUndoSnapshot').and.callFake(callback => callback()); - createContentModel = jasmine.createSpy('createContentModel').and.returnValue(mockedModel); - setContentModel = jasmine.createSpy('setContentModel'); - cacheContentModel = jasmine.createSpy('cacheContentModel'); - getFocusedPosition = jasmine - .createSpy('getFocusedPosition') - .and.returnValue({ node: mockedContainer, offset: mockedOffset }); - triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - getVisibleViewport = jasmine.createSpy('getVisibleViewport'); - - editor = ({ - addUndoSnapshot, - createContentModel, - setContentModel, - cacheContentModel, - getFocusedPosition, - triggerPluginEvent, - getVisibleViewport, - isDarkMode: () => false, - } as any) as IContentModelEditor; - }); - - it('Callback return false', () => { - const callback = jasmine.createSpy('callback').and.returnValue(false); - - formatWithContentModel(editor, apiName, callback); - - expect(callback).toHaveBeenCalledWith(mockedModel, { - newEntities: [], - deletedEntities: [], - rawEvent: undefined, - newImages: [], - }); - expect(createContentModel).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).not.toHaveBeenCalled(); - expect(setContentModel).not.toHaveBeenCalled(); - }); - - it('Callback return true', () => { - const callback = jasmine.createSpy('callback').and.returnValue(true); - - formatWithContentModel(editor, apiName, callback); - - expect(callback).toHaveBeenCalledWith(mockedModel, { - newEntities: [], - deletedEntities: [], - rawEvent: undefined, - newImages: [], - }); - expect(createContentModel).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot.calls.argsFor(0)[1]).toBe(undefined); - expect(addUndoSnapshot.calls.argsFor(0)[2]).toBe(false); - expect(addUndoSnapshot.calls.argsFor(0)[3]).toEqual({ - formatApiName: apiName, - }); - expect(setContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(mockedModel, undefined, undefined); - }); - - it('Preserve pending format', () => { - const callback = jasmine.createSpy('callback').and.returnValue(true); - const mockedFormat = 'FORMAT' as any; - - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(mockedFormat); - spyOn(pendingFormat, 'setPendingFormat'); - - formatWithContentModel(editor, apiName, callback, { - preservePendingFormat: true, - }); - - expect(callback).toHaveBeenCalledWith(mockedModel, { - newEntities: [], - deletedEntities: [], - rawEvent: undefined, - newImages: [], - }); - expect(createContentModel).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot.calls.argsFor(0)[1]).toBe(undefined!); - expect(addUndoSnapshot.calls.argsFor(0)[2]).toBe(false); - expect(addUndoSnapshot.calls.argsFor(0)[3]).toEqual({ - formatApiName: apiName, - }); - expect(pendingFormat.setPendingFormat).toHaveBeenCalledTimes(1); - expect(pendingFormat.setPendingFormat).toHaveBeenCalledWith( - editor, - mockedFormat, - mockedContainer, - mockedOffset - ); - }); - - it('Skip undo snapshot', () => { - const callback = jasmine.createSpy('callback').and.callFake((model, context) => { - context.skipUndoSnapshot = true; - return true; - }); - const mockedFormat = 'FORMAT' as any; - - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(mockedFormat); - spyOn(pendingFormat, 'setPendingFormat'); - - formatWithContentModel(editor, apiName, callback); - - expect(callback).toHaveBeenCalledWith(mockedModel, { - newEntities: [], - deletedEntities: [], - rawEvent: undefined, - skipUndoSnapshot: true, - newImages: [], - }); - expect(createContentModel).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).not.toHaveBeenCalled(); - }); - - it('Customize change source', () => { - const callback = jasmine.createSpy('callback').and.returnValue(true); - - formatWithContentModel(editor, apiName, callback, { changeSource: 'TEST' }); - - expect(callback).toHaveBeenCalledWith(mockedModel, { - newEntities: [], - deletedEntities: [], - rawEvent: undefined, - newImages: [], - }); - expect(createContentModel).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).toHaveBeenCalled(); - expect(addUndoSnapshot.calls.argsFor(0)[1]).toBe(undefined!); - expect(triggerPluginEvent).toHaveBeenCalled(); - }); - - it('Customize change source and skip undo snapshot', () => { - const callback = jasmine.createSpy('callback').and.callFake((model, context) => { - context.skipUndoSnapshot = true; - return true; - }); - formatWithContentModel(editor, apiName, callback, { - changeSource: 'TEST', - getChangeData: () => 'DATA', - }); - - expect(callback).toHaveBeenCalledWith(mockedModel, { - newEntities: [], - deletedEntities: [], - rawEvent: undefined, - skipUndoSnapshot: true, - newImages: [], - }); - expect(createContentModel).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).not.toHaveBeenCalled(); - }); - - it('Has onNodeCreated', () => { - const callback = jasmine.createSpy('callback').and.returnValue(true); - const onNodeCreated = jasmine.createSpy('onNodeCreated'); - - formatWithContentModel(editor, apiName, callback, { onNodeCreated: onNodeCreated }); - - expect(callback).toHaveBeenCalledWith(mockedModel, { - newEntities: [], - deletedEntities: [], - rawEvent: undefined, - newImages: [], - }); - expect(createContentModel).toHaveBeenCalledTimes(1); - expect(addUndoSnapshot).toHaveBeenCalled(); - expect(setContentModel).toHaveBeenCalledWith(mockedModel, undefined, onNodeCreated); - }); - - it('Has getChangeData', () => { - const callback = jasmine.createSpy('callback').and.returnValue(true); - const mockedData = 'DATA' as any; - const getChangeData = jasmine.createSpy('getChangeData').and.returnValue(mockedData); - - formatWithContentModel(editor, apiName, callback, { getChangeData }); - - expect(callback).toHaveBeenCalledWith(mockedModel, { - newEntities: [], - deletedEntities: [], - rawEvent: undefined, - newImages: [], - }); - expect(createContentModel).toHaveBeenCalledTimes(1); - expect(setContentModel).toHaveBeenCalledWith(mockedModel, undefined, undefined); - expect(addUndoSnapshot).toHaveBeenCalled(); - expect(getChangeData).toHaveBeenCalled(); - }); - - it('Has entity got deleted', () => { - const entity1 = { - entityFormat: { id: 'E1', entityType: 'E', isReadonly: true }, - wrapper: {}, - } as any; - const entity2 = { - entityFormat: { id: 'E2', entityType: 'E', isReadonly: true }, - wrapper: {}, - } as any; - const rawEvent = 'RawEvent' as any; - - formatWithContentModel( - editor, - apiName, - (model, context) => { - context.deletedEntities.push( - { - entity: entity1, - operation: 'removeFromStart', - }, - { - entity: entity2, - operation: 'removeFromEnd', - } - ); - return true; - }, - { - rawEvent: rawEvent, - } - ); - - expect(triggerPluginEvent).toHaveBeenCalledTimes(3); - expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.EntityOperation, { - entity: { id: 'E1', type: 'E', isReadonly: true, wrapper: entity1.wrapper }, - operation: EntityOperation.RemoveFromStart, - rawEvent: rawEvent, - }); - expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.EntityOperation, { - entity: { id: 'E2', type: 'E', isReadonly: true, wrapper: entity2.wrapper }, - operation: EntityOperation.RemoveFromEnd, - rawEvent: rawEvent, - }); - }); - - it('Has new entity in dark mode', () => { - const wrapper1 = 'W1' as any; - const wrapper2 = 'W2' as any; - const entity1 = { - entityFormat: { id: 'E1', entityType: 'E', isReadonly: true }, - wrapper: wrapper1, - } as any; - const entity2 = { - entityFormat: { id: 'E2', entityType: 'E', isReadonly: true }, - wrapper: wrapper2, - } as any; - const rawEvent = 'RawEvent' as any; - const transformToDarkColorSpy = jasmine.createSpy('transformToDarkColor'); - const mockedData = 'DATA'; - - editor.isDarkMode = () => true; - editor.transformToDarkColor = transformToDarkColorSpy; - - formatWithContentModel( - editor, - apiName, - (model, context) => { - context.newEntities.push(entity1, entity2); - return true; - }, - { - rawEvent: rawEvent, - getChangeData: () => mockedData, - } - ); - - expect(triggerPluginEvent).toHaveBeenCalledTimes(1); - expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.ContentChanged, { - contentModel: mockedModel, - selection: undefined, - source: ChangeSource.Format, - data: mockedData, - additionalData: { - formatApiName: apiName, - }, - }); - expect(transformToDarkColorSpy).toHaveBeenCalledTimes(2); - expect(transformToDarkColorSpy).toHaveBeenCalledWith(wrapper1); - expect(transformToDarkColorSpy).toHaveBeenCalledWith(wrapper2); - }); - - it('With selectionOverride', () => { - const range = 'MockedRangeEx' as any; - - formatWithContentModel(editor, apiName, () => true, { - selectionOverride: range, - }); - - expect(createContentModel).toHaveBeenCalledWith(undefined, range); - }); - - it('Has image', () => { - const image = createImage('test'); - const rawEvent = 'RawEvent' as any; - const getVisibleViewportSpy = jasmine - .createSpy('getVisibleViewport') - .and.returnValue({ top: 100, bottom: 200, left: 100, right: 200 }); - const mockedData = 'DATA'; - editor.getVisibleViewport = getVisibleViewportSpy; - - formatWithContentModel( - editor, - apiName, - (model, context) => { - context.newImages.push(image); - return true; - }, - { - rawEvent: rawEvent, - getChangeData: () => mockedData, - } - ); - - expect(getVisibleViewportSpy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts index 8277f055433..1b7d0205993 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts @@ -11,11 +11,14 @@ import * as WacComponents from '../../../lib/editor/plugins/PastePlugin/WacCompo import * as WordDesktopFile from '../../../lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop'; import ContentModelEditor from '../../../lib/editor/ContentModelEditor'; import ContentModelPastePlugin from '../../../lib/editor/plugins/PastePlugin/ContentModelPastePlugin'; -import { ChangeSource } from '../../../lib/publicTypes/event/ContentModelContentChangedEvent'; import { ContentModelDocument, DomToModelOption } from 'roosterjs-content-model-types'; import { createContentModelDocument, tableProcessor } from 'roosterjs-content-model-dom'; import { expectEqual, initEditor } from '../../editor/plugins/paste/e2e/testUtils'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import paste, * as pasteF from '../../../lib/publicApi/utils/paste'; import { BeforePasteEvent, @@ -31,9 +34,7 @@ const DEFAULT_TIMES_ADD_PARSER_CALLED = 4; describe('Paste ', () => { let editor: IContentModelEditor; - let addUndoSnapshot: jasmine.Spy; let createContentModel: jasmine.Spy; - let setContentModel: jasmine.Spy; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; let mockedMergeModel: ContentModelDocument; @@ -46,6 +47,7 @@ describe('Paste ', () => { let getVisibleViewport: jasmine.Spy; let mergeModelSpy: jasmine.Spy; let setPendingFormatSpy: jasmine.Spy; + let formatResult: boolean | undefined; const mockedPos = 'POS' as any; @@ -66,9 +68,7 @@ describe('Paste ', () => { mockedModel = ({} as any) as ContentModelDocument; mockedMergeModel = ({} as any) as ContentModelDocument; - addUndoSnapshot = jasmine.createSpy('addUndoSnapshot').and.callFake(callback => callback()); createContentModel = jasmine.createSpy('createContentModel').and.returnValue(mockedModel); - setContentModel = jasmine.createSpy('setContentModel'); focus = jasmine.createSpy('focus'); getFocusedPosition = jasmine.createSpy('getFocusedPosition').and.returnValue(mockedPos); getContent = jasmine.createSpy('getContent'); @@ -113,12 +113,23 @@ describe('Paste ', () => { }, } as any, ]); + const formatContentModel = jasmine + .createSpy('formatContentModel') + .and.callFake( + (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + formatResult = callback(mockedModel, { + newEntities: [], + deletedEntities: [], + newImages: [], + }); + } + ); + + formatResult = undefined; editor = ({ focus, - addUndoSnapshot, createContentModel, - setContentModel, getFocusedPosition, getContent, getSelectionRange, @@ -127,6 +138,7 @@ describe('Paste ', () => { triggerPluginEvent, getVisibleViewport, isDarkMode: () => false, + formatContentModel, } as any) as IContentModelEditor; }); @@ -138,9 +150,8 @@ describe('Paste ', () => { it('Execute', () => { pasteF.default(editor, clipboardData); - expect(setContentModel).toHaveBeenCalled(); + expect(formatResult).toBeTrue(); expect(focus).toHaveBeenCalled(); - expect(addUndoSnapshot).toHaveBeenCalled(); expect(getContent).toHaveBeenCalled(); expect(triggerPluginEvent).toHaveBeenCalled(); expect(getDocument).toHaveBeenCalled(); @@ -151,20 +162,9 @@ describe('Paste ', () => { it('Execute | As plain text', () => { pasteF.default(editor, clipboardData, 'asPlainText'); - expect(setContentModel).toHaveBeenCalled(); + expect(formatResult).toBeTrue(); expect(focus).toHaveBeenCalled(); - expect(addUndoSnapshot).toHaveBeenCalled(); expect(getContent).toHaveBeenCalled(); - expect(triggerPluginEvent).toHaveBeenCalledTimes(1); - expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.ContentChanged, { - contentModel: mockedModel, - selection: undefined, - data: clipboardData, - source: ChangeSource.Paste, - additionalData: { - formatApiName: 'Paste', - }, - }); expect(getDocument).toHaveBeenCalled(); expect(getTrustedHTMLHandler).toHaveBeenCalled(); expect(mockedModel).toEqual(mockedMergeModel); From 095249e326c952903dd9fe3e95483cf03607ef9b Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 6 Nov 2023 12:15:48 -0800 Subject: [PATCH 033/111] Content Model: Allow clear cache from formatContentModel (#2186) * Move formatWithContentModel to be a core API * Content Model: Allow clear cache from formatContentModel --- .../lib/editor/coreApi/formatContentModel.ts | 9 ++- .../editor/utils/handleKeyboardEventCommon.ts | 6 +- .../event/ContentModelContentChangedEvent.ts | 2 +- .../FormatWithContentModelContext.ts | 6 ++ .../editor/coreApi/formatContentModelTest.ts | 57 +++++++++++++++++++ .../utils/handleKeyboardEventCommonTest.ts | 4 ++ .../publicApi/editing/keyboardDeleteTest.ts | 12 ++++ 7 files changed, 91 insertions(+), 5 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts index dd47f21a523..cf277de10b4 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts @@ -1,6 +1,6 @@ +import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; import { ColorTransformDirection, EntityOperation, PluginEventType } from 'roosterjs-editor-types'; import type ContentModelContentChangedEvent from '../../publicTypes/event/ContentModelContentChangedEvent'; -import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; import type { ContentModelEditorCore, FormatContentModel, @@ -62,8 +62,8 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) const eventData: ContentModelContentChangedEvent = { eventType: PluginEventType.ContentChanged, - contentModel: model, - selection: selection, + contentModel: context.clearModelCache ? undefined : model, + selection: context.clearModelCache ? undefined : selection, source: changeSource || ChangeSource.Format, data: getChangeData?.(), additionalData: { @@ -71,6 +71,9 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) }, }; core.api.triggerEvent(core, eventData, true /*broadcast*/); + } else if (context.clearModelCache) { + core.cache.cachedModel = undefined; + core.cache.cachedSelection = undefined; } }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts index af82943e0af..dc1c7d23d02 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts @@ -17,10 +17,14 @@ export function handleKeyboardEventResult( context: FormatWithContentModelContext ): boolean { context.skipUndoSnapshot = true; + context.clearModelCache = false; switch (result) { case DeleteResult.NotDeleted: - // We have not delete anything, we will let browser handle this event + // We have not delete anything, we will let browser handle this event, so that current cached model may be invalid + context.clearModelCache = true; + + // Return false here since we didn't do any change to Content Model, so no need to rewrite with Content Model return false; case DeleteResult.NothingToDelete: diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelContentChangedEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelContentChangedEvent.ts index f9e00e08a4b..f9acb825da6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelContentChangedEvent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelContentChangedEvent.ts @@ -72,7 +72,7 @@ export interface ContentModelContentChangedEventData extends ContentChangedEvent /** * The content model that is applied which causes this content changed event */ - contentModel: ContentModelDocument; + contentModel?: ContentModelDocument; /** * Selection range applied to the document diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts index 68b458e6418..6790a4185ca 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts @@ -97,6 +97,12 @@ export interface FormatWithContentModelContext { * Need to be set by the formatter function */ skipUndoSnapshot?: boolean; + + /** + * @optional + * When set to true, formatWithContentModel API will not keep cached Content Model. Next time when we need a Content Model, a new one will be created + */ + clearModelCache?: boolean; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts index 6882b29d5ed..f8bee279c9a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts @@ -47,6 +47,7 @@ describe('formatContentModel', () => { triggerEvent, }, lifecycle: {}, + cache: {}, getVisibleViewport, } as any) as ContentModelEditorCore; }); @@ -459,4 +460,60 @@ describe('formatContentModel', () => { true ); }); + + it('Has shouldClearCachedModel', () => { + formatContentModel( + core, + (model, context) => { + context.clearModelCache = true; + return true; + }, + { + apiName, + } + ); + + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(setContentModel).toHaveBeenCalledTimes(1); + expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); + expect(triggerEvent).toHaveBeenCalledTimes(1); + expect(triggerEvent).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.ContentChanged, + contentModel: undefined, + selection: undefined, + source: ChangeSource.Format, + data: undefined, + additionalData: { + formatApiName: apiName, + }, + }, + true + ); + }); + + it('Has shouldClearCachedModel, and callback return false', () => { + core.cache.cachedModel = 'Model' as any; + core.cache.cachedSelection = 'Selection' as any; + + formatContentModel( + core, + (model, context) => { + context.clearModelCache = true; + return false; + }, + { + apiName, + } + ); + + expect(addUndoSnapshot).not.toHaveBeenCalled(); + expect(setContentModel).not.toHaveBeenCalled(); + expect(triggerEvent).not.toHaveBeenCalled(); + expect(core.cache).toEqual({ + cachedModel: undefined, + cachedSelection: undefined, + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts index 34bdc29154e..32893de9472 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts @@ -64,6 +64,7 @@ describe('handleKeyboardEventResult', () => { rawEvent: mockedEvent, }); expect(context.skipUndoSnapshot).toBeTrue(); + expect(context.clearModelCache).toBeFalsy(); }); it('DeleteResult.NotDeleted', () => { @@ -88,6 +89,7 @@ describe('handleKeyboardEventResult', () => { expect(cacheContentModel).not.toHaveBeenCalledWith(null); expect(triggerPluginEvent).not.toHaveBeenCalled(); expect(context.skipUndoSnapshot).toBeTrue(); + expect(context.clearModelCache).toBeTruthy(); }); it('DeleteResult.Range', () => { @@ -114,6 +116,7 @@ describe('handleKeyboardEventResult', () => { rawEvent: mockedEvent, }); expect(context.skipUndoSnapshot).toBeFalse(); + expect(context.clearModelCache).toBeFalsy(); }); it('DeleteResult.NothingToDelete', () => { @@ -138,6 +141,7 @@ describe('handleKeyboardEventResult', () => { expect(cacheContentModel).not.toHaveBeenCalled(); expect(triggerPluginEvent).not.toHaveBeenCalled(); expect(context.skipUndoSnapshot).toBeTrue(); + expect(context.clearModelCache).toBeFalsy(); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts index 007daaf8bd2..9d3f9071ea6 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts @@ -33,6 +33,7 @@ describe('keyboardDelete', () => { expectedResult: ContentModelDocument, expectedSteps: DeleteSelectionStep[], expectedDelete: DeleteResult, + expectedClearModelCache: boolean, calledTimes: number ) { deleteSelectionSpy.and.returnValue({ @@ -73,6 +74,7 @@ describe('keyboardDelete', () => { rawEvent: mockedEvent, newImages: [], skipUndoSnapshot: true, + clearModelCache: expectedClearModelCache, }); } @@ -89,6 +91,7 @@ describe('keyboardDelete', () => { }, [null!, null!, forwardDeleteCollapsedSelection], DeleteResult.NotDeleted, + true, 0 ); }); @@ -106,6 +109,7 @@ describe('keyboardDelete', () => { }, [null!, null!, backwardDeleteCollapsedSelection], DeleteResult.NotDeleted, + true, 0 ); }); @@ -125,6 +129,7 @@ describe('keyboardDelete', () => { }, [null!, forwardDeleteWordSelection, forwardDeleteCollapsedSelection], DeleteResult.NotDeleted, + true, 0 ); }); @@ -144,6 +149,7 @@ describe('keyboardDelete', () => { }, [null!, backwardDeleteWordSelection, backwardDeleteCollapsedSelection], DeleteResult.NotDeleted, + true, 0 ); }); @@ -163,6 +169,7 @@ describe('keyboardDelete', () => { }, [null!, null!, forwardDeleteCollapsedSelection], DeleteResult.NotDeleted, + true, 0 ); }); @@ -182,6 +189,7 @@ describe('keyboardDelete', () => { }, [deleteAllSegmentBefore, null!, backwardDeleteCollapsedSelection], DeleteResult.NotDeleted, + true, 0 ); }); @@ -223,6 +231,7 @@ describe('keyboardDelete', () => { }, [null!, null!, forwardDeleteCollapsedSelection], DeleteResult.NotDeleted, + true, 0 ); }); @@ -264,6 +273,7 @@ describe('keyboardDelete', () => { }, [null!, null!, backwardDeleteCollapsedSelection], DeleteResult.NotDeleted, + true, 0 ); }); @@ -315,6 +325,7 @@ describe('keyboardDelete', () => { }, [null!, null!, forwardDeleteCollapsedSelection], DeleteResult.SingleChar, + false, 1 ); }); @@ -366,6 +377,7 @@ describe('keyboardDelete', () => { }, [null!, null!, backwardDeleteCollapsedSelection], DeleteResult.SingleChar, + false, 1 ); }); From 331b5a34762d44bcfd2ed21b510a498b1a0b606e Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 6 Nov 2023 12:23:45 -0800 Subject: [PATCH 034/111] Content Model: Potential perf improvement in getFormatState (#2187) --- .../lib/publicApi/format/getFormatState.ts | 45 +++++++++---------- .../publicApi/format/getFormatStateTest.ts | 14 ++++++ 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts index 4e2b4b64cc4..83a4cf72988 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts @@ -57,37 +57,34 @@ export function reducedModelChildProcessor( parent: ParentNode, context: FormatStateContext ) { - const selectionRootNode = getSelectionRootNode(context.selection); - - if (selectionRootNode) { - if (!context.nodeStack) { - context.nodeStack = createNodeStack(parent, selectionRootNode); - } + if (!context.nodeStack) { + const selectionRootNode = getSelectionRootNode(context.selection); + context.nodeStack = selectionRootNode ? createNodeStack(parent, selectionRootNode) : []; + } - const stackChild = context.nodeStack.pop(); + const stackChild = context.nodeStack.pop(); - if (stackChild) { - const [nodeStartOffset, nodeEndOffset] = getRegularSelectionOffsets(context, parent); + if (stackChild) { + const [nodeStartOffset, nodeEndOffset] = getRegularSelectionOffsets(context, parent); - // If selection is not on this node, skip getting node index to save some time since we don't need it here - const index = - nodeStartOffset >= 0 || nodeEndOffset >= 0 ? getChildIndex(parent, stackChild) : -1; + // If selection is not on this node, skip getting node index to save some time since we don't need it here + const index = + nodeStartOffset >= 0 || nodeEndOffset >= 0 ? getChildIndex(parent, stackChild) : -1; - if (index >= 0) { - handleRegularSelection(index, context, group, nodeStartOffset, nodeEndOffset); - } + if (index >= 0) { + handleRegularSelection(index, context, group, nodeStartOffset, nodeEndOffset); + } - processChildNode(group, stackChild, context); + processChildNode(group, stackChild, context); - if (index >= 0) { - handleRegularSelection(index + 1, context, group, nodeStartOffset, nodeEndOffset); - } - } else { - // No child node from node stack, that means we have reached the deepest node of selection. - // Now we can use default child processor to perform full sub tree scanning for content model, - // So that all selected node will be included. - context.defaultElementProcessors.child(group, parent, context); + if (index >= 0) { + handleRegularSelection(index + 1, context, group, nodeStartOffset, nodeEndOffset); } + } else { + // No child node from node stack, that means we have reached the deepest node of selection. + // Now we can use default child processor to perform full sub tree scanning for content model, + // So that all selected node will be included. + context.defaultElementProcessors.child(group, parent, context); } } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts index ecc1bbe8775..e1d4d6d67a4 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts @@ -1,4 +1,5 @@ import * as getPendingFormat from '../../../lib/modelApi/format/pendingFormat'; +import * as getSelectionRootNode from '../../../lib/modelApi/selection/getSelectionRootNode'; import * as retrieveModelFormatState from '../../../lib/modelApi/common/retrieveModelFormatState'; import { ContentModelFormatState } from '../../../lib/publicTypes/format/formatState/ContentModelFormatState'; import { DomToModelContext } from 'roosterjs-content-model-types'; @@ -180,8 +181,10 @@ describe('getFormatState', () => { ); }); }); + describe('reducedModelChildProcessor', () => { let context: DomToModelContext; + let getSelectionRootNodeSpy: jasmine.Spy; beforeEach(() => { context = createDomToModelContext(undefined, { @@ -189,6 +192,11 @@ describe('reducedModelChildProcessor', () => { child: reducedModelChildProcessor, }, }); + + getSelectionRootNodeSpy = spyOn( + getSelectionRootNode, + 'getSelectionRootNode' + ).and.callThrough(); }); it('Empty DOM', () => { @@ -201,6 +209,7 @@ describe('reducedModelChildProcessor', () => { blockGroupType: 'Document', blocks: [], }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); }); it('Single child node, with selected Node in context', () => { @@ -236,6 +245,7 @@ describe('reducedModelChildProcessor', () => { }, ], }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); }); it('Multiple child nodes, with selected Node in context', () => { @@ -277,6 +287,7 @@ describe('reducedModelChildProcessor', () => { }, ], }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); }); it('Multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { @@ -340,6 +351,7 @@ describe('reducedModelChildProcessor', () => { }, ], }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); }); it('Multiple layer with multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { @@ -399,6 +411,7 @@ describe('reducedModelChildProcessor', () => { { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, ], }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); }); it('With table, need to do format for all table cells', () => { @@ -478,5 +491,6 @@ describe('reducedModelChildProcessor', () => { }, ], }); + expect(getSelectionRootNodeSpy).toHaveBeenCalledTimes(1); }); }); From cbba2ce4e7cb809360fa0da9721993e2978706b8 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 7 Nov 2023 08:53:26 -0800 Subject: [PATCH 035/111] Content Model: Move pending format into editor core (#2188) * Move formatWithContentModel to be a core API * Content Model: Allow clear cache from formatContentModel * Content Model: Move pending format into editor core --- .../lib/editor/ContentModelEditor.ts | 8 + .../lib/editor/coreApi/formatContentModel.ts | 31 +- .../corePlugins/ContentModelFormatPlugin.ts | 43 +- .../editor/createContentModelEditorCore.ts | 1 + .../lib/index.ts | 6 +- .../lib/modelApi/format/applyDefaultFormat.ts | 105 +++ .../lib/modelApi/format/applyPendingFormat.ts | 75 +++ .../lib/modelApi/format/pendingFormat.ts | 107 --- .../lib/publicApi/block/setIndentation.ts | 8 +- .../lib/publicApi/block/toggleBlockQuote.ts | 14 +- .../publicApi/format/applyDefaultFormat.ts | 117 ---- .../publicApi/format/applyPendingFormat.ts | 77 --- .../lib/publicApi/format/getFormatState.ts | 3 +- .../lib/publicApi/link/insertLink.ts | 5 +- .../lib/publicApi/list/toggleBullet.ts | 14 +- .../lib/publicApi/list/toggleNumbering.ts | 14 +- .../lib/publicApi/table/insertTable.ts | 3 +- .../utils/formatParagraphWithContentModel.ts | 7 +- .../utils/formatSegmentWithContentModel.ts | 16 +- .../lib/publicApi/utils/paste.ts | 16 +- .../lib/publicTypes/IContentModelEditor.ts | 6 + .../FormatWithContentModelContext.ts | 10 + .../ContentModelFormatPluginState.ts | 25 + .../test/editor/ContentModelEditorTest.ts | 16 + .../editor/coreApi/formatContentModelTest.ts | 191 +++++- .../ContentModelFormatPluginTest.ts | 627 +++++++----------- .../createContentModelEditorCoreTest.ts | 5 + .../modelApi/format/applyDefaultFormatTest.ts | 407 ++++++++++++ .../format/applyPendingFormatTest.ts | 39 +- .../test/modelApi/format/pendingFormatTest.ts | 252 ------- .../publicApi/block/setIndentationTest.ts | 35 +- .../publicApi/block/toggleBlockQuoteTest.ts | 35 +- .../publicApi/editing/editingTestCommon.ts | 5 +- .../publicApi/editing/keyboardDeleteTest.ts | 2 +- .../publicApi/format/getFormatStateTest.ts | 4 +- .../test/publicApi/image/changeImageTest.ts | 5 +- .../test/publicApi/link/insertLinkTest.ts | 2 +- .../test/publicApi/list/toggleBulletTest.ts | 16 +- .../publicApi/list/toggleNumberingTest.ts | 22 +- .../publicApi/segment/changeFontSizeTest.ts | 6 +- .../publicApi/segment/segmentTestCommon.ts | 5 +- .../utils/formatImageWithContentModelTest.ts | 5 +- .../formatParagraphWithContentModelTest.ts | 23 +- .../formatSegmentWithContentModelTest.ts | 54 +- .../test/publicApi/utils/pasteTest.ts | 23 +- 45 files changed, 1347 insertions(+), 1143 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyPendingFormat.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/pendingFormat.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts rename packages-content-model/roosterjs-content-model-editor/test/{publicApi => modelApi}/format/applyPendingFormatTest.ts (93%) delete mode 100644 packages-content-model/roosterjs-content-model-editor/test/modelApi/format/pendingFormatTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 1e79c0c05fb..6ad41f70a6c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -12,6 +12,7 @@ import type { } from '../publicTypes/IContentModelEditor'; import type { ContentModelDocument, + ContentModelSegmentFormat, DOMSelection, DomToModelOption, ModelToDomOption, @@ -113,4 +114,11 @@ export default class ContentModelEditor core.api.formatContentModel(core, formatter, options); } + + /** + * Get pending format of editor if any, or return null + */ + getPendingFormat(): ContentModelSegmentFormat | null { + return this.getCore().format.pendingFormat?.format ?? null; + } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts index cf277de10b4..0a8c54014ce 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts @@ -44,6 +44,8 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) selection = core.api.setContentModel(core, model, undefined /*options*/, onNodeCreated) || undefined; + + handlePendingFormat(core, context, selection); }; if (context.skipUndoSnapshot) { @@ -71,9 +73,13 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) }, }; core.api.triggerEvent(core, eventData, true /*broadcast*/); - } else if (context.clearModelCache) { - core.cache.cachedModel = undefined; - core.cache.cachedSelection = undefined; + } else { + if (context.clearModelCache) { + core.cache.cachedModel = undefined; + core.cache.cachedSelection = undefined; + } + + handlePendingFormat(core, context, core.api.getDOMSelection(core)); } }; @@ -152,3 +158,22 @@ function handleImages(core: ContentModelEditorCore, context: FormatWithContentMo } } } + +function handlePendingFormat( + core: ContentModelEditorCore, + context: FormatWithContentModelContext, + selection?: DOMSelection | null +) { + const pendingFormat = + context.newPendingFormat == 'preserve' + ? core.format.pendingFormat?.format + : context.newPendingFormat; + + if (pendingFormat && selection?.type == 'range' && selection.range.collapsed) { + core.format.pendingFormat = { + format: { ...pendingFormat }, + posContainer: selection.range.startContainer, + posOffset: selection.range.startOffset, + }; + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts index 72d62a29148..45f8bb0a201 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts @@ -1,6 +1,5 @@ -import applyDefaultFormat from '../../publicApi/format/applyDefaultFormat'; -import applyPendingFormat from '../../publicApi/format/applyPendingFormat'; -import { canApplyPendingFormat, clearPendingFormat } from '../../modelApi/format/pendingFormat'; +import { applyDefaultFormat } from '../../modelApi/format/applyDefaultFormat'; +import { applyPendingFormat } from '../../modelApi/format/applyPendingFormat'; import { getObjectKeys } from 'roosterjs-content-model-dom'; import { isCharacterValue } from '../../domUtils/eventUtils'; import { PluginEventType } from 'roosterjs-editor-types'; @@ -107,7 +106,7 @@ export default class ContentModelFormatPlugin case PluginEventType.KeyDown: if (CursorMovingKeys.has(event.rawEvent.key)) { - clearPendingFormat(this.editor); + this.clearPendingFormat(); } else if ( this.hasDefaultFormat && (isCharacterValue(event.rawEvent) || event.rawEvent.key == ProcessKey) @@ -119,19 +118,45 @@ export default class ContentModelFormatPlugin case PluginEventType.MouseUp: case PluginEventType.ContentChanged: - if (!canApplyPendingFormat(this.editor)) { - clearPendingFormat(this.editor); + if (!this.canApplyPendingFormat()) { + this.clearPendingFormat(); } break; } } private checkAndApplyPendingFormat(data: string | null) { - if (this.editor && data) { - applyPendingFormat(this.editor, data); - clearPendingFormat(this.editor); + if (this.editor && data && this.state.pendingFormat) { + applyPendingFormat(this.editor, data, this.state.pendingFormat.format); + this.clearPendingFormat(); } } + + private clearPendingFormat() { + this.state.pendingFormat = null; + } + + /** + * @internal + * Check if this editor can apply pending format + * @param editor The editor to get format from + */ + private canApplyPendingFormat(): boolean { + let result = false; + + if (this.state.pendingFormat && this.editor) { + const selection = this.editor.getDOMSelection(); + const range = + selection?.type == 'range' && selection.range.collapsed ? selection.range : null; + const { posContainer, posOffset } = this.state.pendingFormat; + + if (range && range.startContainer == posContainer && range.startOffset == posOffset) { + result = true; + } + } + + return result; + } } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts index cb7853055ae..04682c5965b 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts @@ -147,6 +147,7 @@ function getPluginState(options: ContentModelEditorOptions): ContentModelPluginS backgroundColor: format.backgroundColors?.lightModeColor || format.backgroundColor || undefined, }, + pendingFormat: null, }, }; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 4d9e1f88a3a..3b74a3a7df5 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -96,7 +96,6 @@ export { default as setImageBorder } from './publicApi/image/setImageBorder'; export { default as setImageBoxShadow } from './publicApi/image/setImageBoxShadow'; export { default as changeImage } from './publicApi/image/changeImage'; export { default as getFormatState } from './publicApi/format/getFormatState'; -export { default as applyPendingFormat } from './publicApi/format/applyPendingFormat'; export { default as clearFormat } from './publicApi/format/clearFormat'; export { default as insertLink } from './publicApi/link/insertLink'; export { default as removeLink } from './publicApi/link/removeLink'; @@ -130,4 +129,7 @@ export { updateListMetadata } from './domUtils/metadata/updateListMetadata'; export { ContentModelCachePluginState } from './publicTypes/pluginState/ContentModelCachePluginState'; export { ContentModelPluginState } from './publicTypes/pluginState/ContentModelPluginState'; -export { ContentModelFormatPluginState } from './publicTypes/pluginState/ContentModelFormatPluginState'; +export { + ContentModelFormatPluginState, + PendingFormat, +} from './publicTypes/pluginState/ContentModelFormatPluginState'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts new file mode 100644 index 00000000000..9b5020b2c09 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts @@ -0,0 +1,105 @@ +import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; +import { deleteSelection } from '../../modelApi/edit/deleteSelection'; +import { isBlockElement, isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; +import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; + +/** + * @internal + * When necessary, set default format as current pending format so it will be applied when Input event is fired + * @param editor The Content Model Editor + * @param defaultFormat The default segment format to apply + */ +export function applyDefaultFormat( + editor: IContentModelEditor, + defaultFormat: ContentModelSegmentFormat +) { + const selection = editor.getDOMSelection(); + const range = selection?.type == 'range' ? selection.range : null; + const posContainer = range?.startContainer ?? null; + const posOffset = range?.startOffset ?? null; + + if (posContainer) { + let node: Node | null = posContainer; + + while (node && editor.contains(node)) { + if (isNodeOfType(node, 'ELEMENT_NODE')) { + if (node.getAttribute?.('style')) { + return; + } else if (isBlockElement(node)) { + break; + } + } + + node = node.parentNode; + } + } else { + return; + } + + editor.formatContentModel((model, context) => { + const result = deleteSelection(model, [], context); + + if (result.deleteResult == DeleteResult.Range) { + normalizeContentModel(model); + editor.addUndoSnapshot(); + + return true; + } else if ( + result.deleteResult == DeleteResult.NotDeleted && + result.insertPoint && + posContainer && + posOffset !== null + ) { + const { paragraph, path, marker } = result.insertPoint; + const blocks = path[0].blocks; + const blockCount = blocks.length; + const blockIndex = blocks.indexOf(paragraph); + + if ( + paragraph.isImplicit && + paragraph.segments.length == 1 && + paragraph.segments[0] == marker && + blockCount > 0 && + blockIndex == blockCount - 1 + ) { + // Focus is in the last paragraph which is implicit and there is not other segments. + // This can happen when focus is moved after all other content under current block group. + // We need to check if browser will merge focus into previous paragraph by checking if + // previous block is block. If previous block is paragraph, browser will most likely merge + // the input into previous paragraph, then nothing need to do here. Otherwise we need to + // apply pending format since this input event will start a new real paragraph. + const previousBlock = blocks[blockIndex - 1]; + + if (previousBlock?.blockType != 'Paragraph') { + context.newPendingFormat = getNewPendingFormat( + editor, + defaultFormat, + marker.format + ); + } + } else if (paragraph.segments.every(x => x.segmentType != 'Text')) { + context.newPendingFormat = getNewPendingFormat( + editor, + defaultFormat, + marker.format + ); + } + } + + // We didn't do any change but just apply default format to pending format, so no need to write back + return false; + }); +} + +function getNewPendingFormat( + editor: IContentModelEditor, + defaultFormat: ContentModelSegmentFormat, + markerFormat: ContentModelSegmentFormat +): ContentModelSegmentFormat { + return { + ...defaultFormat, + ...editor.getPendingFormat(), + ...markerFormat, + }; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyPendingFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyPendingFormat.ts new file mode 100644 index 00000000000..7fa99123c90 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyPendingFormat.ts @@ -0,0 +1,75 @@ +import { iterateSelections } from '../../modelApi/selection/iterateSelections'; +import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import { + createText, + normalizeContentModel, + setParagraphNotImplicit, +} from 'roosterjs-content-model-dom'; + +const ANSI_SPACE = '\u0020'; +const NON_BREAK_SPACE = '\u00A0'; + +/** + * @internal + * Apply pending format to the text user just input + * @param editor The editor to get format from + * @param data The text user just input + */ +export function applyPendingFormat( + editor: IContentModelEditor, + data: string, + format: ContentModelSegmentFormat +) { + let isChanged = false; + + editor.formatContentModel( + (model, context) => { + iterateSelections(model, (_, __, block, segments) => { + if ( + block?.blockType == 'Paragraph' && + segments?.length == 1 && + segments[0].segmentType == 'SelectionMarker' + ) { + const marker = segments[0]; + const index = block.segments.indexOf(marker); + const previousSegment = block.segments[index - 1]; + + if (previousSegment?.segmentType == 'Text') { + const text = previousSegment.text; + const subStr = text.substr(-data.length, data.length); + + // For space, there can be (space) or   ( ), we treat them as the same + if (subStr == data || (data == ANSI_SPACE && subStr == NON_BREAK_SPACE)) { + marker.format = { ...format }; + previousSegment.text = text.substring(0, text.length - data.length); + + const newText = createText( + data == ANSI_SPACE ? NON_BREAK_SPACE : data, + { + ...previousSegment.format, + ...format, + } + ); + + block.segments.splice(index, 0, newText); + setParagraphNotImplicit(block); + isChanged = true; + } + } + } + return true; + }); + + if (isChanged) { + normalizeContentModel(model); + context.skipUndoSnapshot = true; + } + + return isChanged; + }, + { + apiName: 'applyPendingFormat', + } + ); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/pendingFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/pendingFormat.ts deleted file mode 100644 index 8fd4ba9a39b..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/pendingFormat.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import type { - ContentModelFormatter, - FormatWithContentModelOptions, -} from '../../publicTypes/parameter/FormatWithContentModelContext'; - -/** - * @internal - * Get pending segment format from editor if any, otherwise null - * @param editor The editor to get format from - */ -export function getPendingFormat(editor: IContentModelEditor): ContentModelSegmentFormat | null { - return getPendingFormatHolder(editor).format; -} - -/** - * @internal - * Set pending segment format to editor - * @param editor The editor to set pending format to - * @param format The format to set. - * @param posContainer Container node of current focus position - * @param posOffset Offset number of current focus position - */ -export function setPendingFormat( - editor: IContentModelEditor, - format: ContentModelSegmentFormat, - posContainer: Node, - posOffset: number -) { - const holder = getPendingFormatHolder(editor); - - holder.format = format; - holder.posContainer = posContainer; - holder.posOffset = posOffset; -} - -/** - * @internal Clear pending format if any - * @param editor The editor to set pending format to - */ -export function clearPendingFormat(editor: IContentModelEditor) { - const holder = getPendingFormatHolder(editor); - - holder.format = null; - holder.posContainer = null; - holder.posOffset = null; -} - -/** - * @internal - * Check if this editor can apply pending format - * @param editor The editor to get format from - */ -export function canApplyPendingFormat(editor: IContentModelEditor): boolean { - const holder = getPendingFormatHolder(editor); - let result = false; - - if (holder.format && holder.posContainer && holder.posOffset !== null) { - const position = editor.getFocusedPosition(); - - if (position?.node == holder.posContainer && position?.offset == holder.posOffset) { - result = true; - } - } - - return result; -} - -/** - * @internal - * Execute a callback function and keep pending format state still available - * @param editor The editor to keep pending format - * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions - */ -export function formatAndKeepPendingFormat( - editor: IContentModelEditor, - formatter: ContentModelFormatter, - options?: FormatWithContentModelOptions -) { - const pendingFormat = getPendingFormat(editor); - - editor.formatContentModel(formatter, options); - - const pos = editor.getFocusedPosition(); - - if (pendingFormat && pos) { - setPendingFormat(editor, pendingFormat, pos.node, pos.offset); - } -} - -interface PendingFormatHolder { - format: ContentModelSegmentFormat | null; - posContainer: Node | null; - posOffset: number | null; -} - -const PendingFormatHolderKey = '__ContentModelPendingFormat'; - -function getPendingFormatHolder(editor: IContentModelEditor): PendingFormatHolder { - return editor.getCustomData(PendingFormatHolderKey, () => ({ - format: null, - posContainer: null, - posOffset: null, - })); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setIndentation.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setIndentation.ts index dd92e35a7ab..609e3c8315e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setIndentation.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setIndentation.ts @@ -1,4 +1,3 @@ -import { formatAndKeepPendingFormat } from '../../modelApi/format/pendingFormat'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; import { setModelIndentation } from '../../modelApi/block/setModelIndentation'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -16,15 +15,16 @@ export default function setIndentation( ) { editor.focus(); - formatAndKeepPendingFormat( - editor, - model => { + editor.formatContentModel( + (model, context) => { const result = setModelIndentation(model, indentation, length); if (result) { normalizeContentModel(model); } + context.newPendingFormat = 'preserve'; + return result; }, { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/toggleBlockQuote.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/toggleBlockQuote.ts index 5227618b0d9..471da98421c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/toggleBlockQuote.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/toggleBlockQuote.ts @@ -1,4 +1,3 @@ -import { formatAndKeepPendingFormat } from '../../modelApi/format/pendingFormat'; import { toggleModelBlockQuote } from '../../modelApi/block/toggleModelBlockQuote'; import type { ContentModelFormatContainerFormat } from 'roosterjs-content-model-types'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -33,7 +32,14 @@ export default function toggleBlockQuote( editor.focus(); - formatAndKeepPendingFormat(editor, model => toggleModelBlockQuote(model, fullQuoteFormat), { - apiName: 'toggleBlockQuote', - }); + editor.formatContentModel( + (model, context) => { + context.newPendingFormat = 'preserve'; + + return toggleModelBlockQuote(model, fullQuoteFormat); + }, + { + apiName: 'toggleBlockQuote', + } + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts deleted file mode 100644 index e27497a8cf8..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyDefaultFormat.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; -import { deleteSelection } from '../../modelApi/edit/deleteSelection'; -import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; -import { isBlockElement, isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; -import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; - -/** - * @internal - * When necessary, set default format as current pending format so it will be applied when Input event is fired - * @param editor The Content Model Editor - * @param defaultFormat The default segment format to apply - */ -export default function applyDefaultFormat( - editor: IContentModelEditor, - defaultFormat: ContentModelSegmentFormat -) { - const selection = editor.getDOMSelection(); - const range = selection?.type == 'range' ? selection.range : null; - const posContainer = range?.startContainer ?? null; - const posOffset = range?.startOffset ?? null; - let node = posContainer; - - while (node && editor.contains(node)) { - if (isNodeOfType(node, 'ELEMENT_NODE')) { - if (node.getAttribute?.('style')) { - return; - } else if (isBlockElement(node)) { - break; - } - } - - node = node.parentNode; - } - - editor.formatContentModel( - (model, context) => { - const result = deleteSelection(model, [], context); - - if (result.deleteResult == DeleteResult.Range) { - normalizeContentModel(model); - editor.addUndoSnapshot(); - - return true; - } else if ( - result.deleteResult == DeleteResult.NotDeleted && - result.insertPoint && - posContainer && - posOffset !== null - ) { - const { paragraph, path, marker } = result.insertPoint; - const blocks = path[0].blocks; - const blockCount = blocks.length; - const blockIndex = blocks.indexOf(paragraph); - - if ( - paragraph.isImplicit && - paragraph.segments.length == 1 && - paragraph.segments[0] == marker && - blockCount > 0 && - blockIndex == blockCount - 1 - ) { - // Focus is in the last paragraph which is implicit and there is not other segments. - // This can happen when focus is moved after all other content under current block group. - // We need to check if browser will merge focus into previous paragraph by checking if - // previous block is block. If previous block is paragraph, browser will most likely merge - // the input into previous paragraph, then nothing need to do here. Otherwise we need to - // apply pending format since this input event will start a new real paragraph. - const previousBlock = blocks[blockIndex - 1]; - - if (previousBlock?.blockType != 'Paragraph') { - internalApplyDefaultFormat( - editor, - defaultFormat, - marker.format, - posContainer, - posOffset - ); - } - } else if (paragraph.segments.every(x => x.segmentType != 'Text')) { - internalApplyDefaultFormat( - editor, - defaultFormat, - marker.format, - posContainer, - posOffset - ); - } - - // We didn't do any change but just apply default format to pending format, so no need to write back - return false; - } else { - return false; - } - }, - { - apiName: 'input', - } - ); -} - -function internalApplyDefaultFormat( - editor: IContentModelEditor, - defaultFormat: ContentModelSegmentFormat, - currentFormat: ContentModelSegmentFormat, - posContainer: Node, - posOffset: number -) { - const pendingFormat = getPendingFormat(editor) || {}; - const newFormat: ContentModelSegmentFormat = { - ...defaultFormat, - ...pendingFormat, - ...currentFormat, - }; - - setPendingFormat(editor, newFormat, posContainer, posOffset); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts deleted file mode 100644 index cfec82a5873..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { getPendingFormat } from '../../modelApi/format/pendingFormat'; -import { iterateSelections } from '../../modelApi/selection/iterateSelections'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import { - createText, - normalizeContentModel, - setParagraphNotImplicit, -} from 'roosterjs-content-model-dom'; - -const ANSI_SPACE = '\u0020'; -const NON_BREAK_SPACE = '\u00A0'; - -/** - * Apply pending format to the text user just input - * @param editor The editor to get format from - * @param data The text user just input - */ -export default function applyPendingFormat(editor: IContentModelEditor, data: string) { - const format = getPendingFormat(editor); - - if (format) { - let isChanged = false; - - editor.formatContentModel( - (model, context) => { - iterateSelections(model, (_, __, block, segments) => { - if ( - block?.blockType == 'Paragraph' && - segments?.length == 1 && - segments[0].segmentType == 'SelectionMarker' - ) { - const marker = segments[0]; - const index = block.segments.indexOf(marker); - const previousSegment = block.segments[index - 1]; - - if (previousSegment?.segmentType == 'Text') { - const text = previousSegment.text; - const subStr = text.substr(-data.length, data.length); - - // For space, there can be (space) or   ( ), we treat them as the same - if ( - subStr == data || - (data == ANSI_SPACE && subStr == NON_BREAK_SPACE) - ) { - marker.format = { ...format }; - previousSegment.text = text.substring(0, text.length - data.length); - - const newText = createText( - data == ANSI_SPACE ? NON_BREAK_SPACE : data, - { - ...previousSegment.format, - ...format, - } - ); - - block.segments.splice(index, 0, newText); - setParagraphNotImplicit(block); - isChanged = true; - } - } - } - return true; - }); - - if (isChanged) { - normalizeContentModel(model); - context.skipUndoSnapshot = true; - } - - return isChanged; - }, - { - apiName: 'applyPendingFormat', - } - ); - } -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts index 83a4cf72988..a2a1c601a33 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts @@ -1,4 +1,3 @@ -import { getPendingFormat } from '../../modelApi/format/pendingFormat'; import { getSelectionRootNode } from '../../modelApi/selection/getSelectionRootNode'; import { retrieveModelFormatState } from '../../modelApi/common/retrieveModelFormatState'; import type { ContentModelBlockGroup, DomToModelContext } from 'roosterjs-content-model-types'; @@ -16,7 +15,7 @@ import { * @param editor The editor to get format from */ export default function getFormatState(editor: IContentModelEditor): ContentModelFormatState { - const pendingFormat = getPendingFormat(editor); + const pendingFormat = editor.getPendingFormat(); const model = editor.createContentModel({ processorOverride: { child: reducedModelChildProcessor, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts index a84e9653a5e..3616c30d133 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts @@ -1,6 +1,5 @@ import getSelectedSegments from '../selection/getSelectedSegments'; import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; -import { getPendingFormat } from '../../modelApi/format/pendingFormat'; import { HtmlSanitizer, matchLink } from 'roosterjs-editor-dom'; import { mergeModel } from '../../modelApi/common/mergeModel'; import type { ContentModelLink } from 'roosterjs-content-model-types'; @@ -77,8 +76,8 @@ export default function insertLink( (!!text && text != originalText) ) { const segment = createText(text || (linkData ? linkData.originalUrl : url), { - ...(segments[0]?.format || {}), - ...(getPendingFormat(editor) || {}), + ...segments[0]?.format, + ...editor.getPendingFormat(), }); const doc = createContentModelDocument(); const link = createLink(linkUrl, anchorTitle, target); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleBullet.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleBullet.ts index fdcd033524f..abe38abd469 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleBullet.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleBullet.ts @@ -1,4 +1,3 @@ -import { formatAndKeepPendingFormat } from '../../modelApi/format/pendingFormat'; import { setListType } from '../../modelApi/list/setListType'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -11,7 +10,14 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' export default function toggleBullet(editor: IContentModelEditor) { editor.focus(); - formatAndKeepPendingFormat(editor, model => setListType(model, 'UL'), { - apiName: 'toggleBullet', - }); + editor.formatContentModel( + (model, context) => { + context.newPendingFormat = 'preserve'; + + return setListType(model, 'UL'); + }, + { + apiName: 'toggleBullet', + } + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleNumbering.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleNumbering.ts index 4508b1343c5..a7e360b530a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleNumbering.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleNumbering.ts @@ -1,4 +1,3 @@ -import { formatAndKeepPendingFormat } from '../../modelApi/format/pendingFormat'; import { setListType } from '../../modelApi/list/setListType'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -11,7 +10,14 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' export default function toggleNumbering(editor: IContentModelEditor) { editor.focus(); - formatAndKeepPendingFormat(editor, model => setListType(model, 'OL'), { - apiName: 'toggleNumbering', - }); + editor.formatContentModel( + (model, context) => { + context.newPendingFormat = 'preserve'; + + return setListType(model, 'OL'); + }, + { + apiName: 'toggleNumbering', + } + ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts index e19dbfdd4ea..810dea9e607 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts @@ -2,7 +2,6 @@ import { applyTableFormat } from '../../modelApi/table/applyTableFormat'; import { createContentModelDocument, createSelectionMarker } from 'roosterjs-content-model-dom'; import { createTableStructure } from '../../modelApi/table/createTableStructure'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; -import { getPendingFormat } from '../../modelApi/format/pendingFormat'; import { mergeModel } from '../../modelApi/common/mergeModel'; import { normalizeTable } from '../../modelApi/table/normalizeTable'; import { setSelection } from '../../modelApi/selection/setSelection'; @@ -34,7 +33,7 @@ export default function insertTable( const doc = createContentModelDocument(); const table = createTableStructure(doc, columns, rows); - normalizeTable(table, getPendingFormat(editor) || insertPosition.marker.format); + normalizeTable(table, editor.getPendingFormat() || insertPosition.marker.format); // Assign default vertical align format = format || { verticalAlign: 'top' }; applyTableFormat(table, format); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts index 7767f011e92..be73c4cdd32 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts @@ -1,4 +1,3 @@ -import { formatAndKeepPendingFormat } from '../../modelApi/format/pendingFormat'; import { getSelectedParagraphs } from '../../modelApi/selection/collectSelections'; import type { ContentModelParagraph } from 'roosterjs-content-model-types'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -11,12 +10,12 @@ export function formatParagraphWithContentModel( apiName: string, setStyleCallback: (paragraph: ContentModelParagraph) => void ) { - formatAndKeepPendingFormat( - editor, - model => { + editor.formatContentModel( + (model, context) => { const paragraphs = getSelectedParagraphs(model); paragraphs.forEach(setStyleCallback); + context.newPendingFormat = 'preserve'; return paragraphs.length > 0; }, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts index 233cb8c3c37..6c3ba558be6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts @@ -1,5 +1,4 @@ import { adjustWordSelection } from '../../modelApi/selection/adjustWordSelection'; -import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; import { getSelectedSegmentsAndParagraphs } from '../../modelApi/selection/collectSelections'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { @@ -29,12 +28,12 @@ export function formatSegmentWithContentModel( afterFormatCallback?: (model: ContentModelDocument) => void ) { editor.formatContentModel( - model => { + (model, context) => { let segmentAndParagraphs = getSelectedSegmentsAndParagraphs( model, !!includingFormatHolder ); - const pendingFormat = getPendingFormat(editor); + const pendingFormat = editor.getPendingFormat(); let isCollapsedSelection = segmentAndParagraphs.length == 1 && segmentAndParagraphs[0][0].segmentType == 'SelectionMarker'; @@ -73,16 +72,7 @@ export function formatSegmentWithContentModel( afterFormatCallback?.(model); if (!pendingFormat && isCollapsedSelection) { - const pos = editor.getFocusedPosition(); - - if (pos) { - setPendingFormat( - editor, - segmentAndParagraphs[0][0].format, - pos.node, - pos.offset - ); - } + context.newPendingFormat = segmentAndParagraphs[0][0].format; } if (isCollapsedSelection) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts index 2feea8e386a..84886c59708 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts @@ -2,7 +2,6 @@ import getSelectedSegments from '../selection/getSelectedSegments'; import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; import { GetContentMode, PasteType as OldPasteType, PluginEventType } from 'roosterjs-editor-types'; import { mergeModel } from '../../modelApi/common/mergeModel'; -import { setPendingFormat } from '../../modelApi/format/pendingFormat'; import type { InsertPoint } from '../../publicTypes/selection/InsertPoint'; import type { ContentModelDocument, @@ -106,6 +105,10 @@ export default function paste( originalFormat = insertPoint.marker.format; } + if (originalFormat) { + context.newPendingFormat = { ...EmptySegmentFormat, ...originalFormat }; // Use empty format as initial value to clear any other format inherits from pasted content + } + return true; }, @@ -115,17 +118,6 @@ export default function paste( apiName: 'paste', } ); - - const pos = editor.getFocusedPosition(); - - if (originalFormat && pos) { - setPendingFormat( - editor, - { ...EmptySegmentFormat, ...originalFormat }, // Use empty format as initial value to clear any other format inherits from pasted content - pos.node, - pos.offset - ); - } } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index 3d69671ea0a..8775e2b026c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -5,6 +5,7 @@ import type { } from './parameter/FormatWithContentModelContext'; import type { ContentModelDocument, + ContentModelSegmentFormat, DOMSelection, DomToModelOption, ModelToDomOption, @@ -85,6 +86,11 @@ export interface IContentModelEditor extends IEditor { formatter: ContentModelFormatter, options?: FormatWithContentModelOptions ): void; + + /** + * Get pending format of editor if any, or return null + */ + getPendingFormat(): ContentModelSegmentFormat | null; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts index 6790a4185ca..e907a3d7fae 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts @@ -2,6 +2,7 @@ import type { ContentModelDocument, ContentModelEntity, ContentModelImage, + ContentModelSegmentFormat, DOMSelection, OnNodeCreated, } from 'roosterjs-content-model-types'; @@ -103,6 +104,15 @@ export interface FormatWithContentModelContext { * When set to true, formatWithContentModel API will not keep cached Content Model. Next time when we need a Content Model, a new one will be created */ clearModelCache?: boolean; + + /** + * @optional + * Specify new pending format. + * To keep current format event selection position is changed, set this value to "preserved", editor will update pending format position to the new position + * To set a new pending format, set this property to the format object + * Otherwise, leave it there and editor will automatically decide if the original pending format is still available + */ + newPendingFormat?: ContentModelSegmentFormat | 'preserve'; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelFormatPluginState.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelFormatPluginState.ts index 81444445060..743dfefc555 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelFormatPluginState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelFormatPluginState.ts @@ -1,5 +1,25 @@ import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +/** + * Pending format holder interface + */ +export interface PendingFormat { + /** + * The pending format + */ + format: ContentModelSegmentFormat; + + /** + * Container node of pending format + */ + posContainer: Node; + + /** + * Offset under container node of pending format + */ + posOffset: number; +} + /** * Plugin state for ContentModelFormatPlugin */ @@ -8,4 +28,9 @@ export interface ContentModelFormatPluginState { * Default format of this editor */ defaultFormat: ContentModelSegmentFormat; + + /** + * Pending format + */ + pendingFormat: PendingFormat | null; } diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts index 685adcb2726..47a14d34e74 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -4,6 +4,7 @@ import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelT import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import ContentModelEditor from '../../lib/editor/ContentModelEditor'; import { ContentModelDocument, EditorContext } from 'roosterjs-content-model-types'; +import { ContentModelEditorCore } from '../../lib/publicTypes/ContentModelEditorCore'; import { EditorPlugin, PluginEventType } from 'roosterjs-editor-types'; const editorContext: EditorContext = { @@ -248,6 +249,21 @@ describe('ContentModelEditor', () => { }); }); + it('getPendingFormat', () => { + const div = document.createElement('div'); + const editor = new ContentModelEditor(div); + const core: ContentModelEditorCore = (editor as any).core; + const mockedFormat = 'FORMAT' as any; + + expect(editor.getPendingFormat()).toBeNull(); + + core.format.pendingFormat = { + format: mockedFormat, + } as any; + + expect(editor.getPendingFormat()).toEqual(mockedFormat); + }); + it('dispose', () => { const div = document.createElement('div'); div.style.fontFamily = 'Arial'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts index f8bee279c9a..1e4c49a28a9 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts @@ -1,7 +1,6 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import { ChangeSource } from '../../../lib/publicTypes/event/ContentModelContentChangedEvent'; import { ColorTransformDirection, EntityOperation, PluginEventType } from 'roosterjs-editor-types'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { ContentModelEditorCore } from '../../../lib/publicTypes/ContentModelEditorCore'; import { createImage } from 'roosterjs-content-model-dom'; import { formatContentModel } from '../../../lib/editor/coreApi/formatContentModel'; @@ -15,7 +14,7 @@ describe('formatContentModel', () => { let cacheContentModel: jasmine.Spy; let getFocusedPosition: jasmine.Spy; let triggerEvent: jasmine.Spy; - let getVisibleViewport: jasmine.Spy; + let getDOMSelection: jasmine.Spy; const apiName = 'mockedApi'; const mockedContainer = 'C' as any; @@ -35,7 +34,7 @@ describe('formatContentModel', () => { .createSpy('getFocusedPosition') .and.returnValue({ node: mockedContainer, offset: mockedOffset }); triggerEvent = jasmine.createSpy('triggerPluginEvent'); - getVisibleViewport = jasmine.createSpy('getVisibleViewport'); + getDOMSelection = jasmine.createSpy('getDOMSelection').and.returnValue(null); core = ({ api: { @@ -45,10 +44,10 @@ describe('formatContentModel', () => { cacheContentModel, getFocusedPosition, triggerEvent, + getDOMSelection, }, lifecycle: {}, cache: {}, - getVisibleViewport, } as any) as ContentModelEditorCore; }); @@ -111,10 +110,6 @@ describe('formatContentModel', () => { context.skipUndoSnapshot = true; return true; }); - const mockedFormat = 'FORMAT' as any; - - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(mockedFormat); - spyOn(pendingFormat, 'setPendingFormat'); formatContentModel(core, callback, { apiName }); @@ -516,4 +511,182 @@ describe('formatContentModel', () => { cachedSelection: undefined, }); }); + + describe('Pending foramt', () => { + const mockedStartContainer1 = 'CONTAINER1' as any; + const mockedStartOffset1 = 'OFFSET1' as any; + const mockedFormat1: ContentModelSegmentFormat = { fontSize: '10pt' }; + + const mockedStartContainer2 = 'CONTAINER2' as any; + const mockedStartOffset2 = 'OFFSET2' as any; + const mockedFormat2: ContentModelSegmentFormat = { fontFamily: 'Arial' }; + + beforeEach(() => { + core.format = { + defaultFormat: {}, + pendingFormat: null, + }; + + const mockedRange = { + type: 'range', + range: { + collapsed: true, + startContainer: mockedStartContainer2, + startOffset: mockedStartOffset2, + }, + } as any; + + core.api.setContentModel = () => mockedRange; + core.api.getDOMSelection = () => mockedRange; + }); + + it('No pending format, callback returns true, preserve pending format', () => { + formatContentModel(core, (model, context) => { + context.newPendingFormat = 'preserve'; + return true; + }); + + expect(core.format.pendingFormat).toBeNull(); + }); + + it('No pending format, callback returns false, preserve pending format', () => { + formatContentModel(core, (model, context) => { + context.newPendingFormat = 'preserve'; + return false; + }); + + expect(core.format.pendingFormat).toBeNull(); + }); + + it('Has pending format, callback returns true, preserve pending format', () => { + core.format.pendingFormat = { + format: mockedFormat1, + posContainer: mockedStartContainer1, + posOffset: mockedStartOffset1, + }; + + formatContentModel(core, (model, context) => { + context.newPendingFormat = 'preserve'; + return true; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat1, + posContainer: mockedStartContainer2, + posOffset: mockedStartOffset2, + } as any); + }); + + it('Has pending format, callback returns false, preserve pending format', () => { + core.format.pendingFormat = { + format: mockedFormat1, + posContainer: mockedStartContainer1, + posOffset: mockedStartOffset1, + }; + + formatContentModel(core, (model, context) => { + context.newPendingFormat = 'preserve'; + return false; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat1, + posContainer: mockedStartContainer2, + posOffset: mockedStartOffset2, + } as any); + }); + + it('No pending format, callback returns true, new format', () => { + formatContentModel(core, (model, context) => { + context.newPendingFormat = mockedFormat2; + return true; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat2, + posContainer: mockedStartContainer2, + posOffset: mockedStartOffset2, + }); + }); + + it('No pending format, callback returns false, new format', () => { + formatContentModel(core, (model, context) => { + context.newPendingFormat = mockedFormat2; + return false; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat2, + posContainer: mockedStartContainer2, + posOffset: mockedStartOffset2, + }); + }); + + it('Has pending format, callback returns true, new format', () => { + core.format.pendingFormat = { + format: mockedFormat1, + posContainer: mockedStartContainer1, + posOffset: mockedStartOffset1, + }; + + formatContentModel(core, (model, context) => { + context.newPendingFormat = mockedFormat2; + return true; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat2, + posContainer: mockedStartContainer2, + posOffset: mockedStartOffset2, + }); + }); + + it('Has pending format, callback returns false, new format', () => { + core.format.pendingFormat = { + format: mockedFormat1, + posContainer: mockedStartContainer1, + posOffset: mockedStartOffset1, + }; + + formatContentModel(core, (model, context) => { + context.newPendingFormat = mockedFormat2; + return false; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat2, + posContainer: mockedStartContainer2, + posOffset: mockedStartOffset2, + }); + }); + + it('Has pending format, callback returns false, preserve format, selection is not collapsed', () => { + core.format.pendingFormat = { + format: mockedFormat1, + posContainer: mockedStartContainer1, + posOffset: mockedStartOffset1, + }; + + core.api.getDOMSelection = () => + ({ + type: 'range', + range: { + collapsed: false, + startContainer: mockedStartContainer2, + startOffset: mockedStartOffset2, + }, + } as any); + + formatContentModel(core, (model, context) => { + context.newPendingFormat = 'preserve'; + return false; + }); + + expect(core.format.pendingFormat).toEqual({ + format: mockedFormat1, + posContainer: mockedStartContainer1, + posOffset: mockedStartOffset1, + }); + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts index e4d271edbaa..d25378e2fd5 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts @@ -1,30 +1,34 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; +import * as applyPendingFormat from '../../../lib/modelApi/format/applyPendingFormat'; import ContentModelFormatPlugin from '../../../lib/editor/corePlugins/ContentModelFormatPlugin'; -import { ContentModelFormatPluginState } from '../../../lib/publicTypes/pluginState/ContentModelFormatPluginState'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { PluginEventType } from 'roosterjs-editor-types'; import { - ContentModelFormatter, - FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; + ContentModelFormatPluginState, + PendingFormat, +} from '../../../lib/publicTypes/pluginState/ContentModelFormatPluginState'; import { addSegment, createContentModelDocument, createSelectionMarker, - createText, } from 'roosterjs-content-model-dom'; describe('ContentModelFormatPlugin', () => { - it('no pending format, trigger key down event', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); + const mockedFormat = { + fontSize: '10px', + }; + + beforeEach(() => { + spyOn(applyPendingFormat, 'applyPendingFormat'); + }); + it('no pending format, trigger key down event', () => { const editor = ({ cacheContentModel: () => {}, isDarkMode: () => false, } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: ({} as any) as PendingFormat, }; const plugin = new ContentModelFormatPlugin(state); plugin.initialize(editor); @@ -36,40 +40,23 @@ describe('ContentModelFormatPlugin', () => { plugin.dispose(); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); + expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(state.pendingFormat).toBeNull(); }); it('no selection, trigger input event', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - let formatResult: boolean | undefined; - - const formatContentModel = jasmine - .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }); - } - ); - const editor = ({ focus: jasmine.createSpy('focus'), createContentModel: () => model, isInIME: () => false, cacheContentModel: () => {}, getEnvironment: () => ({}), - formatContentModel, } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: { + format: mockedFormat, + } as any, }; const plugin = new ContentModelFormatPlugin(state); const model = createContentModelDocument(); @@ -83,17 +70,16 @@ describe('ContentModelFormatPlugin', () => { plugin.dispose(); - expect(formatResult).toBeFalse(); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); + expect(applyPendingFormat.applyPendingFormat).toHaveBeenCalledTimes(1); + expect(applyPendingFormat.applyPendingFormat).toHaveBeenCalledWith( + editor, + 'a', + mockedFormat + ); + expect(state.pendingFormat).toBeNull(); }); - it('with pending format and selection, has correct text before, trigger input event with isComposing = true', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - const setContentModel = jasmine.createSpy('setContentModel'); + it('with pending format and selection, trigger input event with isComposing = true', () => { const model = createContentModelDocument(); const marker = createSelectionMarker(); @@ -101,12 +87,14 @@ describe('ContentModelFormatPlugin', () => { const editor = ({ createContentModel: () => model, - setContentModel, cacheContentModel: () => {}, getEnvironment: () => ({}), } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: { + format: mockedFormat, + } as any, }; const plugin = new ContentModelFormatPlugin(state); plugin.initialize(editor); @@ -116,169 +104,17 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(setContentModel).toHaveBeenCalledTimes(0); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(0); - }); - - it('with pending format and selection, no correct text before, trigger input event', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - - let formatResult: boolean | undefined; - const model = createContentModelDocument(); - const marker = createSelectionMarker(); - - addSegment(model, marker); - - const formatContentModel = jasmine - .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }); - } - ); - - const editor = ({ - focus: jasmine.createSpy('focus'), - createContentModel: () => model, - isInIME: () => false, - cacheContentModel: () => {}, - getEnvironment: () => ({}), - formatContentModel, - } as any) as IContentModelEditor; - const state = { - defaultFormat: {}, - }; - const plugin = new ContentModelFormatPlugin(state); - plugin.initialize(editor); - plugin.onPluginEvent({ - eventType: PluginEventType.Input, - rawEvent: ({ data: 'a' } as any) as InputEvent, - }); - plugin.dispose(); - - expect(formatResult).toBeFalse(); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); - }); - - it('with pending format and selection, has correct text before, trigger input event', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'setPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - - const model = createContentModelDocument(); - const text = createText('a'); - const marker = createSelectionMarker(); - let formatResult: boolean | undefined; - - addSegment(model, text); - addSegment(model, marker); - - const formatContentModel = jasmine - .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - rawEvent: options.rawEvent, - }); - } - ); - - const editor = ({ - createContentModel: () => model, - formatContentModel, - isInIME: () => false, - focus: () => {}, - addUndoSnapshot: (callback: () => void) => { - callback(); - }, - cacheContentModel: () => {}, - isDarkMode: () => false, - triggerPluginEvent: jasmine.createSpy('triggerPluginEvent'), - getVisibleViewport: jasmine.createSpy('getVisibleViewport'), - getEnvironment: () => ({}), - } as any) as IContentModelEditor; - const state = { - defaultFormat: {}, - }; - const plugin = new ContentModelFormatPlugin(state); - plugin.initialize(editor); - plugin.onPluginEvent({ - eventType: PluginEventType.Input, - rawEvent: ({ data: 'a' } as any) as InputEvent, - }); - plugin.dispose(); - - expect(formatResult).toBeTrue(); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: false, - segments: [ - { - segmentType: 'Text', - format: { fontSize: '10px' }, - text: 'a', - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], + expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(state.pendingFormat).toEqual({ + format: mockedFormat, }); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); }); - it('with pending format and selection, has correct text before, trigger CompositionEnd event', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - - let formatResult: boolean | undefined; + it('with pending format and selection, trigger CompositionEnd event', () => { const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); const getVisibleViewport = jasmine.createSpy('getVisibleViewport'); - const model = createContentModelDocument(); - const text = createText('test a test', { fontFamily: 'Arial' }); - const marker = createSelectionMarker(); - const formatContentModel = jasmine - .createSpy('formatContentModel') - .and.callFake( - (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { - newEntities: [], - deletedEntities: [], - newImages: [], - }); - } - ); - - addSegment(model, text); - addSegment(model, marker); const editor = ({ - createContentModel: () => model, - formatContentModel, focus: () => {}, addUndoSnapshot: (callback: () => void) => { callback(); @@ -290,6 +126,9 @@ describe('ContentModelFormatPlugin', () => { } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: { + format: mockedFormat, + } as any, }; const plugin = new ContentModelFormatPlugin(state); @@ -300,54 +139,26 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(formatResult).toBeTrue(); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: false, - segments: [ - { - segmentType: 'Text', - format: { fontFamily: 'Arial' }, - text: 'test a ', - }, - { - segmentType: 'Text', - format: { fontSize: '10px', fontFamily: 'Arial' }, - text: 'test', - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); + expect(applyPendingFormat.applyPendingFormat).toHaveBeenCalledWith( + editor, + 'test', + mockedFormat + ); + expect(state.pendingFormat).toBeNull(); }); it('Non-input and cursor moving key down should not trigger pending format change', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - - const setContentModel = jasmine.createSpy('setContentModel'); const model = createContentModelDocument(); const editor = ({ createContentModel: () => model, - setContentModel, cacheContentModel: () => {}, } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: { + format: mockedFormat, + } as any, }; const plugin = new ContentModelFormatPlugin(state); plugin.initialize(editor); @@ -357,24 +168,17 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(setContentModel).toHaveBeenCalledTimes(0); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(0); + expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(state.pendingFormat).toEqual({ + format: mockedFormat, + }); }); it('Content changed event', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'canApplyPendingFormat').and.returnValue(false); - spyOn(pendingFormat, 'setPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - - const setContentModel = jasmine.createSpy('setContentModel'); const model = createContentModelDocument(); const editor = ({ createContentModel: () => model, - setContentModel, addUndoSnapshot: (callback: () => void) => { callback(); }, @@ -382,8 +186,14 @@ describe('ContentModelFormatPlugin', () => { } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: { + format: mockedFormat, + } as any, }; const plugin = new ContentModelFormatPlugin(state); + + spyOn(plugin as any, 'canApplyPendingFormat').and.returnValue(false); + plugin.initialize(editor); plugin.onPluginEvent({ eventType: PluginEventType.ContentChanged, @@ -391,32 +201,28 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(setContentModel).toHaveBeenCalledTimes(0); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); - expect(pendingFormat.canApplyPendingFormat).toHaveBeenCalledTimes(1); + expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(state.pendingFormat).toBeNull(); + expect((plugin as any).canApplyPendingFormat).toHaveBeenCalledTimes(1); }); it('Mouse up event', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'canApplyPendingFormat').and.returnValue(false); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - - const setContentModel = jasmine.createSpy('setContentModel'); const model = createContentModelDocument(); const editor = ({ createContentModel: () => model, - setContentModel, cacheContentModel: () => {}, } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: { + format: mockedFormat, + } as any, }; const plugin = new ContentModelFormatPlugin(state); + spyOn(plugin as any, 'canApplyPendingFormat').and.returnValue(false); + plugin.initialize(editor); plugin.onPluginEvent({ eventType: PluginEventType.MouseUp, @@ -424,33 +230,29 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(setContentModel).toHaveBeenCalledTimes(0); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledTimes(1); - expect(pendingFormat.clearPendingFormat).toHaveBeenCalledWith(editor); - expect(pendingFormat.canApplyPendingFormat).toHaveBeenCalledTimes(1); + expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(state.pendingFormat).toBeNull(); + expect((plugin as any).canApplyPendingFormat).toHaveBeenCalledTimes(1); }); it('Mouse up event and pending format can still be applied', () => { - spyOn(pendingFormat, 'clearPendingFormat'); - spyOn(pendingFormat, 'canApplyPendingFormat').and.returnValue(true); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - - const setContentModel = jasmine.createSpy('setContentModel'); const model = createContentModelDocument(); const editor = ({ createContentModel: () => model, - setContentModel, cacheContentModel: () => {}, getEnvironment: () => ({}), } as any) as IContentModelEditor; const state = { defaultFormat: {}, + pendingFormat: { + format: mockedFormat, + } as any, }; const plugin = new ContentModelFormatPlugin(state); + spyOn(plugin as any, 'canApplyPendingFormat').and.returnValue(true); + plugin.initialize(editor); plugin.onPluginEvent({ eventType: PluginEventType.MouseUp, @@ -458,9 +260,11 @@ describe('ContentModelFormatPlugin', () => { }); plugin.dispose(); - expect(setContentModel).toHaveBeenCalledTimes(0); - expect(pendingFormat.clearPendingFormat).not.toHaveBeenCalled(); - expect(pendingFormat.canApplyPendingFormat).toHaveBeenCalledTimes(1); + expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); + expect(state.pendingFormat).toEqual({ + format: mockedFormat, + }); + expect((plugin as any).canApplyPendingFormat).toHaveBeenCalledTimes(1); }); }); @@ -469,14 +273,12 @@ describe('ContentModelFormatPlugin for default format', () => { let contentDiv: HTMLDivElement; let getDOMSelection: jasmine.Spy; let getPendingFormatSpy: jasmine.Spy; - let setPendingFormatSpy: jasmine.Spy; let cacheContentModelSpy: jasmine.Spy; let addUndoSnapshotSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; beforeEach(() => { - setPendingFormatSpy = spyOn(pendingFormat, 'setPendingFormat'); - getPendingFormatSpy = spyOn(pendingFormat, 'getPendingFormat'); + getPendingFormatSpy = jasmine.createSpy('getPendingFormat'); getDOMSelection = jasmine.createSpy('getDOMSelection'); cacheContentModelSpy = jasmine.createSpy('cacheContentModel'); addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); @@ -487,6 +289,7 @@ describe('ContentModelFormatPlugin for default format', () => { editor = ({ contains: (e: Node) => contentDiv != e && contentDiv.contains(e), getDOMSelection, + getPendingFormat: getPendingFormatSpy, cacheContentModel: cacheContentModelSpy, addUndoSnapshot: addUndoSnapshotSpy, formatContentModel: formatContentModelSpy, @@ -496,6 +299,7 @@ describe('ContentModelFormatPlugin for default format', () => { it('Collapsed range, text input, under editor directly', () => { const state: ContentModelFormatPluginState = { defaultFormat: { fontFamily: 'Arial' }, + pendingFormat: null, }; const plugin = new ContentModelFormatPlugin(state); const rawEvent = { key: 'a' } as any; @@ -509,24 +313,29 @@ describe('ContentModelFormatPlugin for default format', () => { }, }); + let context = {} as any; + formatContentModelSpy.and.callFake((callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); + callback( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + context + ); }); plugin.initialize(editor); @@ -536,20 +345,19 @@ describe('ContentModelFormatPlugin for default format', () => { rawEvent, }); - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { fontFamily: 'Arial' }, - contentDiv, - 0 - ); + expect(context).toEqual({ + newPendingFormat: { fontFamily: 'Arial' }, + }); }); it('Expanded range, text input, under editor directly', () => { const state = { defaultFormat: { fontFamily: 'Arial' }, + pendingFormat: null as any, }; const plugin = new ContentModelFormatPlugin(state); const rawEvent = { key: 'a' } as any; + const context = {} as any; getDOMSelection.and.returnValue({ type: 'range', @@ -561,24 +369,27 @@ describe('ContentModelFormatPlugin for default format', () => { }); formatContentModelSpy.and.callFake((callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'test', - isSelected: true, - }, - ], - }, - ], - }); + callback( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test', + isSelected: true, + }, + ], + }, + ], + }, + context + ); }); plugin.initialize(editor); @@ -588,16 +399,18 @@ describe('ContentModelFormatPlugin for default format', () => { rawEvent, }); - expect(setPendingFormatSpy).not.toHaveBeenCalled(); + expect(context).toEqual({}); expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); }); it('Collapsed range, IME input, under editor directly', () => { const state = { defaultFormat: { fontFamily: 'Arial' }, + pendingFormat: null as any, }; const plugin = new ContentModelFormatPlugin(state); const rawEvent = { key: 'Process' } as any; + const context = {} as any; getDOMSelection.and.returnValue({ type: 'range', @@ -609,23 +422,26 @@ describe('ContentModelFormatPlugin for default format', () => { }); formatContentModelSpy.and.callFake((callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); + callback( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + context + ); }); plugin.initialize(editor); @@ -635,20 +451,19 @@ describe('ContentModelFormatPlugin for default format', () => { rawEvent, }); - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { fontFamily: 'Arial' }, - contentDiv, - 0 - ); + expect(context).toEqual({ + newPendingFormat: { fontFamily: 'Arial' }, + }); }); it('Collapsed range, other input, under editor directly', () => { - const state = { + const state: ContentModelFormatPluginState = { defaultFormat: { fontFamily: 'Arial' }, + pendingFormat: null as any, }; const plugin = new ContentModelFormatPlugin(state); const rawEvent = { key: 'Up' } as any; + const context = {} as any; getDOMSelection.and.returnValue({ type: 'range', @@ -660,23 +475,26 @@ describe('ContentModelFormatPlugin for default format', () => { }); formatContentModelSpy.and.callFake((callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); + callback( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + context + ); }); plugin.initialize(editor); @@ -686,16 +504,18 @@ describe('ContentModelFormatPlugin for default format', () => { rawEvent, }); - expect(setPendingFormatSpy).not.toHaveBeenCalled(); + expect(context).toEqual({}); }); it('Collapsed range, normal input, not under editor directly, no style', () => { const state = { defaultFormat: { fontFamily: 'Arial' }, + pendingFormat: null as any, }; const plugin = new ContentModelFormatPlugin(state); const rawEvent = { key: 'a' } as any; const div = document.createElement('div'); + const context = {} as any; contentDiv.appendChild(div); @@ -709,22 +529,25 @@ describe('ContentModelFormatPlugin for default format', () => { }); formatContentModelSpy.and.callFake((callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); + callback( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + context + ); }); plugin.initialize(editor); @@ -734,15 +557,21 @@ describe('ContentModelFormatPlugin for default format', () => { rawEvent, }); - expect(setPendingFormatSpy).toHaveBeenCalledWith(editor, { fontFamily: 'Arial' }, div, 0); + expect(context).toEqual({ + newPendingFormat: { fontFamily: 'Arial' }, + }); }); it('Collapsed range, text input, under editor directly, has pending format', () => { const state = { defaultFormat: { fontFamily: 'Arial' }, + pendingFormat: null as any, }; const plugin = new ContentModelFormatPlugin(state); const rawEvent = { key: 'a' } as any; + const context = {} as any; + + getPendingFormatSpy.and.returnValue(null); getDOMSelection.and.returnValue({ type: 'range', @@ -754,23 +583,26 @@ describe('ContentModelFormatPlugin for default format', () => { }); formatContentModelSpy.and.callFake((callback: Function) => { - callback({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); + callback( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }, + context + ); }); getPendingFormatSpy.and.returnValue({ @@ -784,11 +616,8 @@ describe('ContentModelFormatPlugin for default format', () => { rawEvent, }); - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { fontFamily: 'Arial', fontSize: '10pt' }, - contentDiv, - 0 - ); + expect(context).toEqual({ + newPendingFormat: { fontFamily: 'Arial', fontSize: '10pt' }, + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts index 2e2ae8ca432..02cc93c65f3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts @@ -140,6 +140,7 @@ describe('createContentModelEditorCore', () => { textColor: undefined, backgroundColor: undefined, }, + pendingFormat: null, }, contentDiv: { style: {}, @@ -220,6 +221,7 @@ describe('createContentModelEditorCore', () => { textColor: undefined, backgroundColor: undefined, }, + pendingFormat: null, }, contentDiv: { style: {}, @@ -313,6 +315,7 @@ describe('createContentModelEditorCore', () => { textColor: 'red', backgroundColor: 'blue', }, + pendingFormat: null, }, contentDiv: { style: {}, @@ -384,6 +387,7 @@ describe('createContentModelEditorCore', () => { textColor: undefined, backgroundColor: undefined, }, + pendingFormat: null, }, defaultDomToModelConfig: mockedDomToModelConfig, defaultModelToDomConfig: mockedModelToDomConfig, @@ -462,6 +466,7 @@ describe('createContentModelEditorCore', () => { textColor: undefined, backgroundColor: undefined, }, + pendingFormat: null, }, contentDiv: { style: {}, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts new file mode 100644 index 00000000000..c3823bee4e3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts @@ -0,0 +1,407 @@ +import * as deleteSelection from '../../../lib/modelApi/edit/deleteSelection'; +import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; +import { applyDefaultFormat } from '../../../lib/modelApi/format/applyDefaultFormat'; +import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; +import { InsertPoint } from '../../../lib/publicTypes/selection/InsertPoint'; +import { + createContentModelDocument, + createDivider, + createImage, + createParagraph, + createSelectionMarker, + createText, +} from 'roosterjs-content-model-dom'; +import { + ContentModelFormatter, + FormatWithContentModelContext, + FormatWithContentModelOptions, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +import type { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; + +describe('applyDefaultFormat', () => { + let editor: IContentModelEditor; + let getDOMSelectionSpy: jasmine.Spy; + let formatContentModelSpy: jasmine.Spy; + let deleteSelectionSpy: jasmine.Spy; + let normalizeContentModelSpy: jasmine.Spy; + let addUndoSnapshotSpy: jasmine.Spy; + let getPendingFormatSpy: jasmine.Spy; + + let context: FormatWithContentModelContext | undefined; + let model: ContentModelDocument; + + let formatResult: boolean | undefined; + + const defaultFormat: ContentModelSegmentFormat = { + fontFamily: 'Arial', + fontSize: '10pt', + }; + + beforeEach(() => { + context = undefined; + formatResult = undefined; + model = createContentModelDocument(); + + getDOMSelectionSpy = jasmine.createSpy('getDOMSelectionSpy'); + deleteSelectionSpy = spyOn(deleteSelection, 'deleteSelection'); + normalizeContentModelSpy = spyOn(normalizeContentModel, 'normalizeContentModel'); + addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + getPendingFormatSpy = jasmine.createSpy('getPendingFormat'); + + formatContentModelSpy = jasmine + .createSpy('formatContentModelSpy') + .and.callFake( + (formatter: ContentModelFormatter, options: FormatWithContentModelOptions) => { + context = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; + + formatResult = formatter(model, context); + } + ); + + editor = { + contains: () => true, + getDOMSelection: getDOMSelectionSpy, + formatContentModel: formatContentModelSpy, + addUndoSnapshot: addUndoSnapshotSpy, + getPendingFormat: getPendingFormatSpy, + } as any; + }); + + it('No selection', () => { + getDOMSelectionSpy.and.returnValue(null); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Selection already has style', () => { + const node = document.createElement('div'); + node.style.fontFamily = 'Tahoma'; + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).not.toHaveBeenCalled(); + }); + + it('Good selection, delete range ', () => { + const node = document.createElement('div'); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.Range, + insertPoint: null!, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).toHaveBeenCalledWith(model); + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); + expect(formatResult).toBeTrue(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Good selection, NothingToDelete ', () => { + const node = document.createElement('div'); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.NothingToDelete, + insertPoint: null!, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).not.toHaveBeenCalledWith(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Good selection, SingleChar ', () => { + const node = document.createElement('div'); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.SingleChar, + insertPoint: null!, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).not.toHaveBeenCalledWith(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Good selection, NotDeleted, has text segment ', () => { + const node = document.createElement('div'); + const marker = createSelectionMarker(); + const text = createText('test'); + const para = createParagraph(); + + para.segments.push(text, marker); + model.blocks.push(para); + + const insertPoint: InsertPoint = { + marker, + path: [model], + paragraph: para, + }; + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.NotDeleted, + insertPoint, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Good selection, NotDeleted, no text segment ', () => { + const node = document.createElement('div'); + const marker = createSelectionMarker(); + const img = createImage('test'); + const para = createParagraph(); + + para.segments.push(img, marker); + model.blocks.push(para); + + const insertPoint: InsertPoint = { + marker, + path: [model], + paragraph: para, + }; + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.NotDeleted, + insertPoint, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + newPendingFormat: { fontFamily: 'Arial', fontSize: '10pt' }, + }); + }); + + it('Good selection, NotDeleted, implicit and marker is the first segment, previous block is paragraph', () => { + const node = document.createElement('div'); + const marker = createSelectionMarker(); + const paraPrev = createParagraph(); + const para = createParagraph(true /*isImplicit*/); + + para.segments.push(marker); + model.blocks.push(paraPrev, para); + + const insertPoint: InsertPoint = { + marker, + path: [model], + paragraph: para, + }; + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.NotDeleted, + insertPoint, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Good selection, NotDeleted, implicit and marker is the first segment, previous block is not paragraph', () => { + const node = document.createElement('div'); + const marker = createSelectionMarker(); + const divider = createDivider('hr'); + const para = createParagraph(true /*isImplicit*/); + + para.segments.push(marker); + model.blocks.push(divider, para); + + const insertPoint: InsertPoint = { + marker, + path: [model], + paragraph: para, + }; + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.NotDeleted, + insertPoint, + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + newPendingFormat: { fontFamily: 'Arial', fontSize: '10pt' }, + }); + }); + + it('Good selection, NotDeleted, no text segment, has pending format and marker format', () => { + const node = document.createElement('div'); + const marker = createSelectionMarker({ + textColor: 'green', + backgroundColor: 'yellow', + }); + const img = createImage('test'); + const para = createParagraph(); + + para.segments.push(img, marker); + model.blocks.push(para); + + const insertPoint: InsertPoint = { + marker, + path: [model], + paragraph: para, + }; + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: node, + startOffset: 0, + }, + }); + + deleteSelectionSpy.and.returnValue({ + deleteResult: DeleteResult.NotDeleted, + insertPoint, + }); + + getPendingFormatSpy.and.returnValue({ + fontSize: '20pt', + textColor: 'red', + }); + + applyDefaultFormat(editor, defaultFormat); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeFalse(); + expect(context).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + newPendingFormat: { + fontFamily: 'Arial', + fontSize: '20pt', + textColor: 'green', + backgroundColor: 'yellow', + }, + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts similarity index 93% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts rename to packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts index 07772f4848b..5faa0d91c22 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts @@ -1,7 +1,6 @@ import * as iterateSelections from '../../../lib/modelApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; -import applyPendingFormat from '../../../lib/publicApi/format/applyPendingFormat'; +import { applyPendingFormat } from '../../../lib/modelApi/format/applyPendingFormat'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { ContentModelFormatter, @@ -42,10 +41,6 @@ describe('applyPendingFormat', () => { blocks: [paragraph], }; - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - const formatContentModelSpy = jasmine .createSpy('formatContentModel') .and.callFake( @@ -68,7 +63,9 @@ describe('applyPendingFormat', () => { return false; }); - applyPendingFormat(editor, 'c'); + applyPendingFormat(editor, 'c', { + fontSize: '10px', + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(model).toEqual({ @@ -122,10 +119,6 @@ describe('applyPendingFormat', () => { blocks: [paragraph], }; - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - const formatContentModelSpy = jasmine .createSpy('formatContentModel') .and.callFake( @@ -144,7 +137,9 @@ describe('applyPendingFormat', () => { return false; }); - applyPendingFormat(editor, 'd'); + applyPendingFormat(editor, 'd', { + fontSize: '10px', + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(model).toEqual({ @@ -191,8 +186,6 @@ describe('applyPendingFormat', () => { blocks: [paragraph], }; - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); - const formatContentModelSpy = jasmine.createSpy('formatContentModel'); const editor = ({ formatContentModel: formatContentModelSpy, @@ -203,9 +196,8 @@ describe('applyPendingFormat', () => { return false; }); - applyPendingFormat(editor, 'd'); + applyPendingFormat(editor, 'd', {}); - expect(formatContentModelSpy).not.toHaveBeenCalled(); expect(model).toEqual({ blockGroupType: 'Document', blocks: [ @@ -246,10 +238,6 @@ describe('applyPendingFormat', () => { blocks: [paragraph], }; - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); - const formatContentModelSpy = jasmine .createSpy('formatContentModel') .and.callFake( @@ -268,7 +256,9 @@ describe('applyPendingFormat', () => { return false; }); - applyPendingFormat(editor, 'd'); + applyPendingFormat(editor, 'd', { + fontSize: '10px', + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(model).toEqual({ @@ -299,9 +289,6 @@ describe('applyPendingFormat', () => { paragraph.segments.push(text, marker); model.blocks.push(paragraph); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue({ - fontSize: '10px', - }); const formatContentModelSpy = jasmine .createSpy('formatContentModel') .and.callFake( @@ -321,7 +308,9 @@ describe('applyPendingFormat', () => { }); spyOn(normalizeContentModel, 'normalizeContentModel').and.callThrough(); - applyPendingFormat(editor, 't'); + applyPendingFormat(editor, 't', { + fontSize: '10px', + }); expect(formatContentModelSpy).toHaveBeenCalledTimes(1); expect(model).toEqual({ diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/pendingFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/pendingFormatTest.ts deleted file mode 100644 index 377b3c4a660..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/pendingFormatTest.ts +++ /dev/null @@ -1,252 +0,0 @@ -import ContentModelEditor from '../../../lib/editor/ContentModelEditor'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { - canApplyPendingFormat, - clearPendingFormat, - formatAndKeepPendingFormat, - getPendingFormat, - setPendingFormat, -} from '../../../lib/modelApi/format/pendingFormat'; - -describe('pendingFormat.getPendingFormat', () => { - it('no format', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const format = getPendingFormat(editor); - - expect(format).toBeNull(); - }); - - it('has format', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const mockedFormat = 'FORMAT' as any; - - (editor as any).core.lifecycle.customData.__ContentModelPendingFormat = { - value: { - format: mockedFormat, - }, - }; - - const format = getPendingFormat(editor); - - expect(format).toBe(mockedFormat); - }); -}); - -describe('pendingFormat.setPendingFormat', () => { - it('set format', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const mockedFormat = 'FORMAT' as any; - const mockedContainer = 'C' as any; - const mockedOffset = 'O' as any; - - setPendingFormat(editor, mockedFormat, mockedContainer, mockedOffset); - - expect((editor as any).core.lifecycle.customData.__ContentModelPendingFormat.value).toEqual( - { - format: mockedFormat, - posContainer: mockedContainer, - posOffset: mockedOffset, - } - ); - }); -}); - -describe('pendingFormat.clearPendingFormat', () => { - it('clear format', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const mockedFormat = 'FORMAT' as any; - const mockedContainer = 'C' as any; - const mockedOffset = 'O' as any; - - (editor as any).core.lifecycle.customData.__ContentModelPendingFormat = { - value: { - format: mockedFormat, - posContainer: mockedContainer, - posOffset: mockedOffset, - }, - }; - - clearPendingFormat(editor); - - expect((editor as any).core.lifecycle.customData.__ContentModelPendingFormat.value).toEqual( - { - format: null, - posContainer: null, - posOffset: null, - } - ); - }); -}); - -describe('pendingFormat.canApplyPendingFormat', () => { - it('can apply format', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const mockedFormat = 'FORMAT' as any; - - const mockedContainer = 'C' as any; - const mockedOffset = 'O' as any; - - editor.getFocusedPosition = () => ({ node: mockedContainer, offset: mockedOffset } as any); - - (editor as any).core.lifecycle.customData.__ContentModelPendingFormat = { - value: { - format: mockedFormat, - posContainer: mockedContainer, - posOffset: mockedOffset, - }, - }; - - const result = canApplyPendingFormat(editor); - - expect(result).toBeTrue(); - }); - - it('no pending format', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - - const equalTo = jasmine.createSpy('equalto').and.returnValue(true); - const mockedPosition2 = { - equalTo, - }; - - editor.getFocusedPosition = () => mockedPosition2 as any; - - const result = canApplyPendingFormat(editor); - - expect(result).toBeFalse(); - expect(equalTo).not.toHaveBeenCalled(); - }); - - it('no current position', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const mockedFormat = 'FORMAT' as any; - const mockedPosition = 'POSITION' as any; - - const equalTo = jasmine.createSpy('equalto').and.returnValue(true); - - editor.getFocusedPosition = () => null as any; - - (editor as any).core.lifecycle.customData.__ContentModelPendingFormat = { - value: { - format: mockedFormat, - position: mockedPosition, - }, - }; - - const result = canApplyPendingFormat(editor); - - expect(result).toBeFalse(); - expect(equalTo).not.toHaveBeenCalledWith(); - }); - - it('position is not the same', () => { - const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - const mockedFormat = 'FORMAT' as any; - const mockedContainer1 = 'C1'; - const mockedContainer2 = 'C2'; - - const mockedPosition2 = { - node: mockedContainer2, - offset: 1, - }; - - editor.getFocusedPosition = () => mockedPosition2 as any; - - (editor as any).core.lifecycle.customData.__ContentModelPendingFormat = { - value: { - format: mockedFormat, - posContainer: mockedContainer1, - posOffset: 0, - }, - }; - - const result = canApplyPendingFormat(editor); - - expect(result).toBeFalse(); - }); - - it('Preserve pending format, no pending format', () => { - const formatContentModel = jasmine.createSpy('formatContentModel').and.callFake(() => { - clearPendingFormat(editor); - }); - const getFocusedPosition = jasmine.createSpy('getFocusedPosition'); - const customData: any = {}; - - const editor = ({ - getCustomData: (key: string, getter?: () => any) => { - return (customData[key] = customData[key] || { - value: getter ? getter() : undefined, - }).value; - }, - formatContentModel, - getFocusedPosition, - } as any) as IContentModelEditor; - const formatter = jasmine.createSpy('formatter'); - const options = 'OPTIONS' as any; - - formatAndKeepPendingFormat(editor, formatter, options); - - expect(customData).toEqual({ - __ContentModelPendingFormat: Object({ - value: { format: null, posContainer: null, posOffset: null }, - }), - }); - expect(formatContentModel).toHaveBeenCalledWith(formatter, options); - }); - - it('Preserve pending format, have pending format', () => { - const mockedFormat = 'Format' as any; - const mockedContainer = 'Container' as any; - const mockedOffset = 'Offset' as any; - - const customData: any = { - __ContentModelPendingFormat: { - value: { - format: mockedFormat, - posContainer: mockedContainer, - posOffset: mockedOffset, - }, - }, - }; - - const formatContentModel = jasmine.createSpy('formatContentModel').and.callFake(() => { - clearPendingFormat(editor); - - expect(customData).toEqual({ - __ContentModelPendingFormat: { - value: { format: null, posContainer: null, posOffset: null }, - }, - }); - }); - const getFocusedPosition = jasmine.createSpy('getFocusedPosition'); - - const editor = ({ - getCustomData: (key: string, getter?: () => any) => { - return (customData[key] = customData[key] || { - value: getter ? getter() : undefined, - }).value; - }, - formatContentModel, - getFocusedPosition, - } as any) as IContentModelEditor; - const formatter = jasmine.createSpy('formatter'); - const options = 'OPTIONS' as any; - - formatAndKeepPendingFormat(editor, formatter, options); - - expect(customData).toEqual({ - __ContentModelPendingFormat: { - value: { format: null, posContainer: null, posOffset: null }, - }, - }); - expect(formatContentModel).toHaveBeenCalledWith(formatter, options); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts index 19f3b51e5c3..83aba0f0690 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts @@ -1,30 +1,35 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import * as setModelIndentation from '../../../lib/modelApi/block/setModelIndentation'; import setIndentation from '../../../lib/publicApi/block/setIndentation'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelContext, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; describe('setIndentation', () => { const fakeModel: any = { a: 'b' }; let editor: IContentModelEditor; let formatContentModelSpy: jasmine.Spy; + let context: FormatWithContentModelContext; beforeEach(() => { + context = undefined!; formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake((callback: Function) => { - callback(fakeModel); + .and.callFake((callback: ContentModelFormatter) => { + context = { + newEntities: [], + newImages: [], + deletedEntities: [], + }; + callback(fakeModel, context); }); editor = ({ formatContentModel: formatContentModelSpy, focus: jasmine.createSpy('focus'), + getPendingFormat: () => null as any, } as any) as IContentModelEditor; - - spyOn(pendingFormat, 'formatAndKeepPendingFormat').and.callFake( - (editor, formatter, options) => { - editor.formatContentModel(formatter, options); - } - ); }); it('indent', () => { @@ -39,6 +44,12 @@ describe('setIndentation', () => { 'indent', undefined ); + expect(context).toEqual({ + newEntities: [], + newImages: [], + deletedEntities: [], + newPendingFormat: 'preserve', + }); }); it('outdent', () => { @@ -53,5 +64,11 @@ describe('setIndentation', () => { 'outdent', undefined ); + expect(context).toEqual({ + newEntities: [], + newImages: [], + deletedEntities: [], + newPendingFormat: 'preserve', + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts index 303987f0690..9d39f25aca2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts @@ -1,26 +1,31 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import * as toggleModelBlockQuote from '../../../lib/modelApi/block/toggleModelBlockQuote'; import toggleBlockQuote from '../../../lib/publicApi/block/toggleBlockQuote'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + ContentModelFormatter, + FormatWithContentModelContext, +} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; describe('toggleBlockQuote', () => { const fakeModel: any = { a: 'b' }; let editor: IContentModelEditor; let formatContentModelSpy: jasmine.Spy; + let context: FormatWithContentModelContext; beforeEach(() => { + context = undefined!; + formatContentModelSpy = jasmine .createSpy('formatContentModel') - .and.callFake((callback: Function) => { - callback(fakeModel); + .and.callFake((callback: ContentModelFormatter) => { + context = { + newEntities: [], + newImages: [], + deletedEntities: [], + }; + callback(fakeModel, context); }); - spyOn(pendingFormat, 'formatAndKeepPendingFormat').and.callFake( - (editor, formatter, options) => { - editor.formatContentModel(formatter, options); - } - ); - editor = ({ focus: jasmine.createSpy('focus'), formatContentModel: formatContentModelSpy, @@ -43,6 +48,12 @@ describe('toggleBlockQuote', () => { a: 'b', c: 'd', } as any); + expect(context).toEqual({ + newEntities: [], + newImages: [], + deletedEntities: [], + newPendingFormat: 'preserve', + }); }); it('toggleBlockQuote with real format', () => { @@ -61,5 +72,11 @@ describe('toggleBlockQuote', () => { lineHeight: '2', textColor: 'red', } as any); + expect(context).toEqual({ + newEntities: [], + newImages: [], + deletedEntities: [], + newPendingFormat: 'preserve', + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts index 2b0d984bc5d..38b257941c2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts @@ -1,4 +1,3 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { @@ -13,9 +12,6 @@ export function editingTestCommon( result: ContentModelDocument, calledTimes: number ) { - spyOn(pendingFormat, 'setPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); - const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); let formatResult: boolean | undefined; @@ -23,6 +19,7 @@ export function editingTestCommon( const formatContentModel = jasmine .createSpy('formatContentModel') .and.callFake((callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { + expect(options.apiName).toBe(apiName); formatResult = callback(model, { newEntities: [], deletedEntities: [], diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts index 9d3f9071ea6..322aa7d6570 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts @@ -49,7 +49,7 @@ describe('keyboardDelete', () => { let editor: any; editingTestCommon( - 'handleBackspaceKey', + key == 'Delete' ? 'handleDeleteKey' : 'handleBackspaceKey', newEditor => { editor = newEditor; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts index e1d4d6d67a4..f943577ef10 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts @@ -1,4 +1,3 @@ -import * as getPendingFormat from '../../../lib/modelApi/format/pendingFormat'; import * as getSelectionRootNode from '../../../lib/modelApi/selection/getSelectionRootNode'; import * as retrieveModelFormatState from '../../../lib/modelApi/common/retrieveModelFormatState'; import { ContentModelFormatState } from '../../../lib/publicTypes/format/formatState/ContentModelFormatState'; @@ -38,6 +37,7 @@ describe('getFormatState', () => { }), isDarkMode: () => false, getZoomScale: () => 1, + getPendingFormat: () => pendingFormat, createContentModel: (options: DomToModelOption) => { const model = createContentModelDocument(); const editorDiv = document.createElement('div'); @@ -66,8 +66,6 @@ describe('getFormatState', () => { }, } as any) as IContentModelEditor; - spyOn(getPendingFormat, 'getPendingFormat').and.returnValue(pendingFormat); - const result = getFormatState(editor); expect(retrieveModelFormatState.retrieveModelFormatState).toHaveBeenCalledTimes(1); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts index 5ba65b4af8d..ebac050117a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts @@ -1,4 +1,3 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import * as readFile from '../../../lib/domUtils/readFile'; import changeImage from '../../../lib/publicApi/image/changeImage'; import { ContentModelDocument } from 'roosterjs-content-model-types'; @@ -27,9 +26,6 @@ describe('changeImage', () => { result: ContentModelDocument, calledTimes: number ) { - spyOn(pendingFormat, 'setPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); - const getDOMSelection = jasmine .createSpy() .and.returnValues({ type: 'image', image: imageNode }); @@ -52,6 +48,7 @@ describe('changeImage', () => { const editor = ({ focus: jasmine.createSpy(), isDisposed: () => false, + getPendingFormat: () => null as any, getDOMSelection, triggerPluginEvent, formatContentModel, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts index 34d2397f563..146642963b6 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts @@ -22,7 +22,7 @@ describe('insertLink', () => { beforeEach(() => { editor = ({ focus: () => {}, - getCustomData: () => ({}), + getPendingFormat: () => null as any, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts index 9f8b333842e..f71a75b3d0b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts @@ -4,6 +4,7 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { ContentModelFormatter, + FormatWithContentModelContext, FormatWithContentModelOptions, } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; @@ -12,19 +13,23 @@ describe('toggleBullet', () => { let formatContentModel: jasmine.Spy; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; + let context: FormatWithContentModelContext; beforeEach(() => { mockedModel = ({} as any) as ContentModelDocument; + context = undefined!; formatContentModel = jasmine .createSpy('formatContentModel') .and.callFake( (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - callback(mockedModel, { + context = { newEntities: [], deletedEntities: [], newImages: [], - }); + rawEvent: options.rawEvent, + }; + callback(mockedModel, context); } ); focus = jasmine.createSpy('focus'); @@ -44,5 +49,12 @@ describe('toggleBullet', () => { expect(setListType.setListType).toHaveBeenCalledTimes(1); expect(setListType.setListType).toHaveBeenCalledWith(mockedModel, 'UL'); + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: undefined, + newPendingFormat: 'preserve', + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts index 28c0142eeb1..134276ddbef 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts @@ -1,10 +1,10 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import * as setListType from '../../../lib/modelApi/list/setListType'; import toggleNumbering from '../../../lib/publicApi/list/toggleNumbering'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { ContentModelFormatter, + FormatWithContentModelContext, FormatWithContentModelOptions, } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; @@ -12,28 +12,25 @@ describe('toggleNumbering', () => { let editor = ({} as any) as IContentModelEditor; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; + let context: FormatWithContentModelContext; beforeEach(() => { mockedModel = ({} as any) as ContentModelDocument; + context = undefined!; focus = jasmine.createSpy('focus'); - spyOn(pendingFormat, 'formatAndKeepPendingFormat').and.callFake( - (editor, formatter, options) => { - editor.formatContentModel(formatter, options); - } - ); - const formatContentModel = jasmine .createSpy('formatContentModel') .and.callFake( (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - callback(mockedModel, { + context = { newEntities: [], deletedEntities: [], newImages: [], rawEvent: options.rawEvent, - }); + }; + callback(mockedModel, context); } ); @@ -50,5 +47,12 @@ describe('toggleNumbering', () => { expect(setListType.setListType).toHaveBeenCalledTimes(1); expect(setListType.setListType).toHaveBeenCalledWith(mockedModel, 'OL'); + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: undefined, + newPendingFormat: 'preserve', + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts index 257c1676a9f..d8c68162f43 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts @@ -1,6 +1,5 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import changeFontSize from '../../../lib/publicApi/segment/changeFontSize'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; import { createRange } from 'roosterjs-editor-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; @@ -332,8 +331,6 @@ describe('changeFontSize', () => { }); it('Test format parser', () => { - spyOn(pendingFormat, 'setPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); const div = document.createElement('div'); const sub = document.createElement('sub'); @@ -363,6 +360,7 @@ describe('changeFontSize', () => { const editor = ({ formatContentModel, focus: jasmine.createSpy(), + getPendingFormat: () => null as ContentModelSegmentFormat, } as any) as IContentModelEditor; changeFontSize(editor, 'increase'); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts index 6c45908ef73..c2983dca8f9 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts @@ -1,4 +1,3 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { NodePosition } from 'roosterjs-editor-types'; @@ -14,9 +13,6 @@ export function segmentTestCommon( result: ContentModelDocument, calledTimes: number ) { - spyOn(pendingFormat, 'setPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); - let formatResult: boolean | undefined; const formatContentModel = jasmine .createSpy('formatContentModel') @@ -31,6 +27,7 @@ export function segmentTestCommon( const editor = ({ focus: jasmine.createSpy(), getFocusedPosition: () => null as NodePosition, + getPendingFormat: () => null as any, formatContentModel, } as any) as IContentModelEditor; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts index dc168cfabe3..f2c92f3e5ca 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts @@ -1,4 +1,3 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import formatImageWithContentModel from '../../../lib/publicApi/utils/formatImageWithContentModel'; import { ContentModelDocument, ContentModelImage } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; @@ -201,9 +200,6 @@ function segmentTestForPluginEvent( result: ContentModelDocument, calledTimes: number ) { - spyOn(pendingFormat, 'setPendingFormat'); - spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); - let formatResult: boolean | undefined; const formatContentModel = jasmine .createSpy('formatContentModel') @@ -217,6 +213,7 @@ function segmentTestForPluginEvent( }); const editor = ({ formatContentModel, + getPendingFormat: () => null as any, } as any) as IContentModelEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts index 8d730e6bd74..dbc2e3e8a35 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts @@ -1,9 +1,9 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import { ContentModelDocument, ContentModelParagraph } from 'roosterjs-content-model-types'; import { formatParagraphWithContentModel } from '../../../lib/publicApi/utils/formatParagraphWithContentModel'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { ContentModelFormatter, + FormatWithContentModelContext, FormatWithContentModelOptions, } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { @@ -15,6 +15,7 @@ import { describe('formatParagraphWithContentModel', () => { let editor: IContentModelEditor; let model: ContentModelDocument; + let context: FormatWithContentModelContext; const mockedContainer = 'C' as any; const mockedOffset = 'O' as any; @@ -22,16 +23,20 @@ describe('formatParagraphWithContentModel', () => { const apiName = 'mockedApi'; beforeEach(() => { + context = undefined!; + const formatContentModel = jasmine .createSpy('formatContentModel') .and.callFake( (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - callback(model, { + context = { newEntities: [], - deletedEntities: [], newImages: [], + deletedEntities: [], rawEvent: options.rawEvent, - }); + }; + + callback(model, context); } ); @@ -101,14 +106,18 @@ describe('formatParagraphWithContentModel', () => { para.segments.push(text); model.blocks.push(para); - spyOn(pendingFormat, 'formatAndKeepPendingFormat').and.callThrough(); - const callback = (paragraph: ContentModelParagraph) => { paragraph.format.backgroundColor = 'red'; }; formatParagraphWithContentModel(editor, apiName, callback); - expect(pendingFormat.formatAndKeepPendingFormat).toHaveBeenCalled(); + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + rawEvent: undefined, + newPendingFormat: 'preserve', + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts index 5689a1c823c..18859242ca3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts @@ -1,9 +1,9 @@ -import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { formatSegmentWithContentModel } from '../../../lib/publicApi/utils/formatSegmentWithContentModel'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { ContentModelFormatter, + FormatWithContentModelContext, FormatWithContentModelOptions, } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { @@ -18,34 +18,36 @@ describe('formatSegmentWithContentModel', () => { let focus: jasmine.Spy; let model: ContentModelDocument; let getPendingFormat: jasmine.Spy; - let setPendingFormat: jasmine.Spy; let formatContentModel: jasmine.Spy; let formatResult: boolean | undefined; + let context: FormatWithContentModelContext | undefined; const apiName = 'mockedApi'; beforeEach(() => { + context = undefined; formatResult = undefined; focus = jasmine.createSpy('focus'); - setPendingFormat = spyOn(pendingFormat, 'setPendingFormat'); - getPendingFormat = spyOn(pendingFormat, 'getPendingFormat'); - formatContentModel = jasmine .createSpy('formatContentModel') .and.callFake( (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(model, { + context = { newEntities: [], deletedEntities: [], newImages: [], - }); + }; + formatResult = callback(model, context); } ); + getPendingFormat = jasmine.createSpy('getPendingFormat'); + editor = ({ focus, formatContentModel, + getPendingFormat, } as any) as IContentModelEditor; }); @@ -61,7 +63,6 @@ describe('formatSegmentWithContentModel', () => { expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeFalse(); expect(getPendingFormat).toHaveBeenCalledTimes(1); - expect(setPendingFormat).toHaveBeenCalledTimes(0); }); it('doc with selection', () => { @@ -97,7 +98,11 @@ describe('formatSegmentWithContentModel', () => { expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeTrue(); expect(getPendingFormat).toHaveBeenCalledTimes(1); - expect(setPendingFormat).toHaveBeenCalledTimes(0); + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + }); }); it('doc with selection, all segments are already in expected state', () => { @@ -148,7 +153,11 @@ describe('formatSegmentWithContentModel', () => { expect(toggleStyleCallback).toHaveBeenCalledTimes(1); expect(toggleStyleCallback).toHaveBeenCalledWith(text.format, false, text, para); expect(getPendingFormat).toHaveBeenCalledTimes(1); - expect(setPendingFormat).toHaveBeenCalledTimes(0); + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + }); }); it('doc with selection, some segments are in expected state', () => { @@ -221,7 +230,11 @@ describe('formatSegmentWithContentModel', () => { expect(toggleStyleCallback).toHaveBeenCalledWith(text1.format, true, text1, para); expect(toggleStyleCallback).toHaveBeenCalledWith(text3.format, true, text3, para); expect(getPendingFormat).toHaveBeenCalledTimes(1); - expect(setPendingFormat).toHaveBeenCalledTimes(0); + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + }); }); it('Collapsed selection', () => { @@ -263,16 +276,15 @@ describe('formatSegmentWithContentModel', () => { expect(formatContentModel).toHaveBeenCalledTimes(1); expect(formatResult).toBeFalse(); expect(getPendingFormat).toHaveBeenCalledTimes(1); - expect(setPendingFormat).toHaveBeenCalledTimes(1); - expect(setPendingFormat).toHaveBeenCalledWith( - editor, - { + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + newPendingFormat: { fontSize: '10px', fontFamily: 'test', }, - mockedContainer, - mockedOffset - ); + }); }); it('With pending format', () => { @@ -313,6 +325,10 @@ describe('formatSegmentWithContentModel', () => { fontFamily: 'test', }); expect(getPendingFormat).toHaveBeenCalledTimes(1); - expect(setPendingFormat).toHaveBeenCalledTimes(0); + expect(context).toEqual({ + newEntities: [], + deletedEntities: [], + newImages: [], + }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts index 1b7d0205993..1b5126950ab 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts @@ -4,7 +4,6 @@ import * as ExcelF from '../../../lib/editor/plugins/PastePlugin/Excel/processPa import * as getPasteSourceF from '../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/getPasteSource'; import * as getSelectedSegmentsF from '../../../lib/publicApi/selection/getSelectedSegments'; import * as mergeModelFile from '../../../lib/modelApi/common/mergeModel'; -import * as pendingFormatF from '../../../lib/modelApi/format/pendingFormat'; import * as PPT from '../../../lib/editor/plugins/PastePlugin/PowerPoint/processPastedContentFromPowerPoint'; import * as setProcessorF from '../../../lib/editor/plugins/PastePlugin/utils/setProcessor'; import * as WacComponents from '../../../lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents'; @@ -17,6 +16,7 @@ import { expectEqual, initEditor } from '../../editor/plugins/paste/e2e/testUtil import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { ContentModelFormatter, + FormatWithContentModelContext, FormatWithContentModelOptions, } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import paste, * as pasteF from '../../../lib/publicApi/utils/paste'; @@ -46,8 +46,8 @@ describe('Paste ', () => { let triggerPluginEvent: jasmine.Spy; let getVisibleViewport: jasmine.Spy; let mergeModelSpy: jasmine.Spy; - let setPendingFormatSpy: jasmine.Spy; let formatResult: boolean | undefined; + let context: FormatWithContentModelContext | undefined; const mockedPos = 'POS' as any; @@ -73,7 +73,6 @@ describe('Paste ', () => { getFocusedPosition = jasmine.createSpy('getFocusedPosition').and.returnValue(mockedPos); getContent = jasmine.createSpy('getContent'); getDocument = jasmine.createSpy('getDocument').and.returnValue(document); - setPendingFormatSpy = spyOn(pendingFormatF, 'setPendingFormat'); triggerPluginEvent = jasmine.createSpy('triggerPluginEvent').and.returnValue({ clipboardData, fragment: document.createDocumentFragment(), @@ -117,15 +116,17 @@ describe('Paste ', () => { .createSpy('formatContentModel') .and.callFake( (callback: ContentModelFormatter, options: FormatWithContentModelOptions) => { - formatResult = callback(mockedModel, { + context = { newEntities: [], deletedEntities: [], newImages: [], - }); + }; + formatResult = callback(mockedModel, context); } ); formatResult = undefined; + context = undefined; editor = ({ focus, @@ -196,9 +197,11 @@ describe('Paste ', () => { }, }); - expect(setPendingFormatSpy).toHaveBeenCalledWith( - editor, - { + expect(context).toEqual({ + newEntities: [], + newImages: [], + deletedEntities: [], + newPendingFormat: { backgroundColor: '', fontFamily: 'Arial', fontSize: '', @@ -211,9 +214,7 @@ describe('Paste ', () => { textColor: '', underline: false, }, - mockedNode, - mockedOffset - ); + }); }); }); From d79c2ca5d571409e062adecab095041345b15732 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 7 Nov 2023 09:10:09 -0800 Subject: [PATCH 036/111] Convert DeleteResult from const enum to string literal type (#2191) --- .../ContentModelCopyPastePlugin.ts | 6 +- .../editor/utils/handleKeyboardEventCommon.ts | 12 +- .../lib/modelApi/edit/deleteSelection.ts | 7 +- .../deleteSteps/deleteAllSegmentBefore.ts | 3 +- .../deleteSteps/deleteCollapsedSelection.ts | 11 +- .../edit/deleteSteps/deleteWordSelection.ts | 5 +- .../edit/utils/DeleteSelectionStep.ts | 53 +++++- .../edit/utils/deleteExpandedSelection.ts | 11 +- .../lib/modelApi/entity/insertEntityModel.ts | 3 +- .../lib/modelApi/format/applyDefaultFormat.ts | 5 +- .../lib/publicApi/editing/keyboardDelete.ts | 3 +- .../ContentModelCopyPastePluginTest.ts | 5 +- .../utils/handleKeyboardEventCommonTest.ts | 17 +- .../test/modelApi/edit/deleteSelectionTest.ts | 179 +++++++++--------- .../modelApi/format/applyDefaultFormatTest.ts | 17 +- .../publicApi/editing/keyboardDeleteTest.ts | 20 +- 16 files changed, 191 insertions(+), 166 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts index 9b7406b7e55..fbf80729f0e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts @@ -3,7 +3,6 @@ import { addRangeToSelection } from '../../domUtils/addRangeToSelection'; import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; import { cloneModel } from '../../modelApi/common/cloneModel'; import { ColorTransformDirection, PluginEventType } from 'roosterjs-editor-types'; -import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; import { extractClipboardItems } from 'roosterjs-editor-dom'; import { iterateSelections } from '../../modelApi/selection/iterateSelections'; @@ -161,10 +160,7 @@ export default class ContentModelCopyPastePlugin implements PluginWithState { - if ( - deleteSelection(model, [], context).deleteResult == - DeleteResult.Range - ) { + if (deleteSelection(model, [], context).deleteResult == 'range') { normalizeContentModel(model); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts index dc1c7d23d02..96d339a3fe3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts @@ -1,6 +1,6 @@ -import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; import { PluginEventType } from 'roosterjs-editor-types'; +import type { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; import type { ContentModelDocument } from 'roosterjs-content-model-types'; import type { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -20,25 +20,25 @@ export function handleKeyboardEventResult( context.clearModelCache = false; switch (result) { - case DeleteResult.NotDeleted: + case 'notDeleted': // We have not delete anything, we will let browser handle this event, so that current cached model may be invalid context.clearModelCache = true; // Return false here since we didn't do any change to Content Model, so no need to rewrite with Content Model return false; - case DeleteResult.NothingToDelete: + case 'nothingToDelete': // We known there is nothing to delete, no need to let browser keep handling the event rawEvent.preventDefault(); return false; - case DeleteResult.Range: - case DeleteResult.SingleChar: + case 'range': + case 'singleChar': // We have deleted what we need from content model, no need to let browser keep handling the event rawEvent.preventDefault(); normalizeContentModel(model); - if (result == DeleteResult.Range) { + if (result == 'range') { // A range is about to be deleted, so add an undo snapshot immediately context.skipUndoSnapshot = false; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSelection.ts index 7fa31e40c52..cdca6cee69e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSelection.ts @@ -1,5 +1,4 @@ import { deleteExpandedSelection } from './utils/deleteExpandedSelection'; -import { DeleteResult } from './utils/DeleteSelectionStep'; import type { ContentModelDocument } from 'roosterjs-content-model-types'; import type { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; import type { @@ -23,7 +22,7 @@ export function deleteSelection( if ( step && isValidDeleteSelectionContext(context) && - context.deleteResult == DeleteResult.NotDeleted + context.deleteResult == 'notDeleted' ) { step(context); } @@ -46,8 +45,8 @@ function mergeParagraphAfterDelete(context: DeleteSelectionContext) { if ( insertPoint && - deleteResult != DeleteResult.NotDeleted && - deleteResult != DeleteResult.NothingToDelete && + deleteResult != 'notDeleted' && + deleteResult != 'nothingToDelete' && lastParagraph && lastParagraph != insertPoint.paragraph && lastTableContext == insertPoint.tableContext diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts index 3a2d1624a07..4322d24cc6c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts @@ -1,4 +1,3 @@ -import { DeleteResult } from '../utils/DeleteSelectionStep'; import { deleteSegment } from '../utils/deleteSegment'; import type { DeleteSelectionStep } from '../utils/DeleteSelectionStep'; @@ -15,7 +14,7 @@ export const deleteAllSegmentBefore: DeleteSelectionStep = context => { segment.isSelected = true; if (deleteSegment(paragraph, segment, context.formatContext)) { - context.deleteResult = DeleteResult.Range; + context.deleteResult = 'range'; } } }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteCollapsedSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteCollapsedSelection.ts index 48fa94cf146..fc288d874a0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteCollapsedSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteCollapsedSelection.ts @@ -1,6 +1,5 @@ import { createInsertPoint } from '../utils/createInsertPoint'; import { deleteBlock } from '../utils/deleteBlock'; -import { DeleteResult } from '../utils/DeleteSelectionStep'; import { deleteSegment } from '../utils/deleteSegment'; import { getLeafSiblingBlock } from '../../block/getLeafSiblingBlock'; import { setParagraphNotImplicit } from 'roosterjs-content-model-dom'; @@ -22,7 +21,7 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS if (segmentToDelete) { if (deleteSegment(paragraph, segmentToDelete, context.formatContext, direction)) { - context.deleteResult = DeleteResult.SingleChar; + context.deleteResult = 'singleChar'; // It is possible that we have deleted everything from this paragraph, so we need to mark it as not implicit // to avoid losing its format. See https://github.com/microsoft/roosterjs/issues/1953 @@ -35,7 +34,7 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS if (siblingSegment) { // When selection is under general segment, need to check if it has a sibling sibling, and delete from it if (deleteSegment(block, siblingSegment, context.formatContext, direction)) { - context.deleteResult = DeleteResult.Range; + context.deleteResult = 'range'; } } else { if (isForward) { @@ -50,7 +49,7 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS delete block.cachedElement; } - context.deleteResult = DeleteResult.Range; + context.deleteResult = 'range'; } // When go across table, getLeafSiblingBlock will return null, when we are here, we must be in the same table context @@ -65,14 +64,14 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS direction ) ) { - context.deleteResult = DeleteResult.Range; + context.deleteResult = 'range'; } } } else { // We have nothing to delete, in this case we don't want browser handle it as well. // Because when Backspace on an empty document, it will also delete the only DIV and SPAN element, causes // editor is really empty. We don't want that happen. So the handling should stop here. - context.deleteResult = DeleteResult.NothingToDelete; + context.deleteResult = 'nothingToDelete'; } }; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteWordSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteWordSelection.ts index c00bfecd59e..75e92ebdcc0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteWordSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteWordSelection.ts @@ -1,4 +1,3 @@ -import { DeleteResult } from '../utils/DeleteSelectionStep'; import { isPunctuation, isSpace, normalizeText } from '../../../domUtils/stringUtil'; import { isWhiteSpacePreserved } from 'roosterjs-content-model-dom'; import type { ContentModelParagraph } from 'roosterjs-content-model-types'; @@ -124,7 +123,7 @@ function* iterateSegments( newText = normalizeText(newText, forward); } - context.deleteResult = DeleteResult.Range; + context.deleteResult = 'range'; if (newText) { segment.text = newText; @@ -155,7 +154,7 @@ function* iterateSegments( i -= step; } - context.deleteResult = DeleteResult.Range; + context.deleteResult = 'range'; } break; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/DeleteSelectionStep.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/DeleteSelectionStep.ts index 6422aca74c0..70719614f96 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/DeleteSelectionStep.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/DeleteSelectionStep.ts @@ -5,39 +5,80 @@ import type { TableSelectionContext } from '../../../publicTypes/selection/Table /** * @internal + * Delete selection result */ -export const enum DeleteResult { - NotDeleted, - SingleChar, - Range, - NothingToDelete, -} +export type DeleteResult = + /** + * Content Model could not finish deletion, need to let browser handle it + */ + | 'notDeleted' + + /** + * Deleted a single char, no need to let browser keep handling + */ + | 'singleChar' + + /** + * Deleted a range, no need to let browser keep handling + */ + | 'range' + + /** + * There is nothing to delete, no need to let browser keep handling + */ + | 'nothingToDelete'; /** * @internal + * Result of deleteSelection API */ export interface DeleteSelectionResult { + /** + * Insert point position after delete, or null if there is no insert point + */ insertPoint: InsertPoint | null; + + /** + * Delete result + */ deleteResult: DeleteResult; } /** * @internal + * A context object used by DeleteSelectionStep */ export interface DeleteSelectionContext extends DeleteSelectionResult { + /** + * Last paragraph after previous step + */ lastParagraph?: ContentModelParagraph; + + /** + * Last table context after previous step + */ lastTableContext?: TableSelectionContext; + + /** + * Format context provided by formatContentModel API + */ formatContext?: FormatWithContentModelContext; } /** * @internal + * DeleteSelectionContext with a valid insert point that can be handled by next step */ export interface ValidDeleteSelectionContext extends DeleteSelectionContext { + /** + * Insert point position after delete + */ insertPoint: InsertPoint; } /** * @internal + * Represents a step function for deleteSelection API + * @param context The valid delete selection context object returned from previous step */ export type DeleteSelectionStep = (context: ValidDeleteSelectionContext) => void; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts index 809e80d35fc..fa5c322f997 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts @@ -1,6 +1,5 @@ import { createInsertPoint } from '../utils/createInsertPoint'; import { deleteBlock } from '../utils/deleteBlock'; -import { DeleteResult } from '../utils/DeleteSelectionStep'; import { deleteSegment } from '../utils/deleteSegment'; import { iterateSelections } from '../../selection/iterateSelections'; import type { ContentModelDocument } from 'roosterjs-content-model-types'; @@ -30,7 +29,7 @@ export function deleteExpandedSelection( formatContext?: FormatWithContentModelContext ): DeleteSelectionContext { const context: DeleteSelectionContext = { - deleteResult: DeleteResult.NotDeleted, + deleteResult: 'notDeleted', insertPoint: null, formatContext, }; @@ -75,14 +74,14 @@ export function deleteExpandedSelection( tableContext ); } else if (deleteSegment(block, segment, context.formatContext)) { - context.deleteResult = DeleteResult.Range; + context.deleteResult = 'range'; } }); // Since we are operating on this paragraph and it possible we delete everything from this paragraph, // Need to make it "not implicit" so that it will always have a container element, so that when we do normalization // of this paragraph, a BR can be added if need - if (context.deleteResult == DeleteResult.Range) { + if (context.deleteResult == 'range') { setParagraphNotImplicit(block); } } @@ -91,7 +90,7 @@ export function deleteExpandedSelection( const blocks = path[0].blocks; if (deleteBlock(blocks, block, paragraph, context.formatContext)) { - context.deleteResult = DeleteResult.Range; + context.deleteResult = 'range'; } } else if (tableContext) { // Delete a whole table cell @@ -105,7 +104,7 @@ export function deleteExpandedSelection( delete cell.cachedElement; delete row.cachedElement; - context.deleteResult = DeleteResult.Range; + context.deleteResult = 'range'; } if (!context.insertPoint) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts index d6874fe26b0..ff76454cc9b 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts @@ -1,4 +1,3 @@ -import { DeleteResult } from '../edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../edit/deleteSelection'; import { getClosestAncestorBlockGroupIndex } from '../common/getClosestAncestorBlockGroupIndex'; import { setSelection } from '../selection/setSelection'; @@ -40,7 +39,7 @@ export function insertEntityModel( } else if ((deleteResult = deleteSelection(model, [], context)).insertPoint) { const { marker, paragraph, path } = deleteResult.insertPoint; - if (deleteResult.deleteResult == DeleteResult.Range) { + if (deleteResult.deleteResult == 'range') { normalizeContentModel(model); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts index 9b5020b2c09..d68be3fd827 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts @@ -1,4 +1,3 @@ -import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; import { isBlockElement, isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; @@ -40,13 +39,13 @@ export function applyDefaultFormat( editor.formatContentModel((model, context) => { const result = deleteSelection(model, [], context); - if (result.deleteResult == DeleteResult.Range) { + if (result.deleteResult == 'range') { normalizeContentModel(model); editor.addUndoSnapshot(); return true; } else if ( - result.deleteResult == DeleteResult.NotDeleted && + result.deleteResult == 'notDeleted' && result.insertPoint && posContainer && posOffset !== null diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts index 63d1e250890..e8c03b54440 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts @@ -1,6 +1,5 @@ import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; import { deleteAllSegmentBefore } from '../../modelApi/edit/deleteSteps/deleteAllSegmentBefore'; -import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; import { isModifierKey } from '../../domUtils/eventUtils'; import { isNodeOfType } from 'roosterjs-content-model-dom'; @@ -44,7 +43,7 @@ export default function keyboardDelete( context ).deleteResult; - isDeleted = result != DeleteResult.NotDeleted; + isDeleted = result != 'notDeleted'; return handleKeyboardEventResult(editor, model, rawEvent, result, context); }, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts index 0bb733cc13c..4615bb1d8e6 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts @@ -9,7 +9,6 @@ import * as PasteFile from '../../../lib/publicApi/utils/paste'; import { ContentModelDocument, DOMSelection } from 'roosterjs-content-model-types'; import { createModelToDomContext } from 'roosterjs-content-model-dom'; import { createRange } from 'roosterjs-editor-dom'; -import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { setEntityElementClasses } from 'roosterjs-content-model-dom/test/domUtils/entityUtilTest'; import { @@ -449,7 +448,7 @@ describe('ContentModelCopyPastePlugin |', () => { }; spyOn(deleteSelectionsFile, 'deleteSelection').and.returnValue({ - deleteResult: DeleteResult.Range, + deleteResult: 'range', insertPoint: null!, }); spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { @@ -501,7 +500,7 @@ describe('ContentModelCopyPastePlugin |', () => { }; spyOn(deleteSelectionsFile, 'deleteSelection').and.returnValue({ - deleteResult: DeleteResult.Range, + deleteResult: 'range', insertPoint: null!, }); spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts index 32893de9472..471d9ae7f67 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts @@ -1,5 +1,4 @@ import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; import { FormatWithContentModelContext } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { PluginEventType } from 'roosterjs-editor-types'; @@ -38,7 +37,7 @@ describe('handleKeyboardEventResult', () => { spyOn(normalizeContentModel, 'normalizeContentModel'); }); - it('DeleteResult.SingleChar', () => { + it('singleChar', () => { const mockedModel = 'MODEL' as any; const which = 'WHICH' as any; (mockedEvent).which = which; @@ -51,7 +50,7 @@ describe('handleKeyboardEventResult', () => { mockedEditor, mockedModel, mockedEvent, - DeleteResult.SingleChar, + 'singleChar', context ); @@ -67,7 +66,7 @@ describe('handleKeyboardEventResult', () => { expect(context.clearModelCache).toBeFalsy(); }); - it('DeleteResult.NotDeleted', () => { + it('notDeleted', () => { const mockedModel = 'MODEL' as any; const context: FormatWithContentModelContext = { newEntities: [], @@ -78,7 +77,7 @@ describe('handleKeyboardEventResult', () => { mockedEditor, mockedModel, mockedEvent, - DeleteResult.NotDeleted, + 'notDeleted', context ); @@ -92,7 +91,7 @@ describe('handleKeyboardEventResult', () => { expect(context.clearModelCache).toBeTruthy(); }); - it('DeleteResult.Range', () => { + it('range', () => { const mockedModel = 'MODEL' as any; const context: FormatWithContentModelContext = { newEntities: [], @@ -103,7 +102,7 @@ describe('handleKeyboardEventResult', () => { mockedEditor, mockedModel, mockedEvent, - DeleteResult.Range, + 'range', context ); @@ -119,7 +118,7 @@ describe('handleKeyboardEventResult', () => { expect(context.clearModelCache).toBeFalsy(); }); - it('DeleteResult.NothingToDelete', () => { + it('nothingToDelete', () => { const mockedModel = 'MODEL' as any; const context: FormatWithContentModelContext = { newEntities: [], @@ -130,7 +129,7 @@ describe('handleKeyboardEventResult', () => { mockedEditor, mockedModel, mockedEvent, - DeleteResult.NothingToDelete, + 'nothingToDelete', context ); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts index 961c1ba5622..6d22c95196f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts @@ -1,6 +1,5 @@ import { ContentModelEntity, ContentModelSelectionMarker } from 'roosterjs-content-model-types'; import { DeletedEntity } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; -import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../../lib/modelApi/edit/deleteSelection'; import { createBr, @@ -47,7 +46,7 @@ describe('deleteSelection - selectionOnly', () => { ], }); - expect(result.deleteResult).toBe(DeleteResult.NotDeleted); + expect(result.deleteResult).toBe('notDeleted'); expect(result.insertPoint).toBeNull(); }); @@ -61,7 +60,7 @@ describe('deleteSelection - selectionOnly', () => { const result = deleteSelection(model); - expect(result.deleteResult).toBe(DeleteResult.NotDeleted); + expect(result.deleteResult).toBe('notDeleted'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -101,7 +100,7 @@ describe('deleteSelection - selectionOnly', () => { const result = deleteSelection(model); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -150,7 +149,7 @@ describe('deleteSelection - selectionOnly', () => { const result = deleteSelection(model); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -198,7 +197,7 @@ describe('deleteSelection - selectionOnly', () => { const result = deleteSelection(model); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -252,7 +251,7 @@ describe('deleteSelection - selectionOnly', () => { const result = deleteSelection(model); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -322,7 +321,7 @@ describe('deleteSelection - selectionOnly', () => { const result = deleteSelection(model); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -423,7 +422,7 @@ describe('deleteSelection - selectionOnly', () => { const result = deleteSelection(model); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -475,7 +474,7 @@ describe('deleteSelection - selectionOnly', () => { const result = deleteSelection(model); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -532,7 +531,7 @@ describe('deleteSelection - selectionOnly', () => { newImages: [], }); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -591,7 +590,7 @@ describe('deleteSelection - selectionOnly', () => { newImages: [], }); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -651,7 +650,7 @@ describe('deleteSelection - selectionOnly', () => { isSelected: true, }; - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker, paragraph: { @@ -694,7 +693,7 @@ describe('deleteSelection - selectionOnly', () => { isSelected: true, }; - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker, paragraph: { @@ -736,7 +735,7 @@ describe('deleteSelection - selectionOnly', () => { isSelected: true, }; - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker, paragraph: { @@ -784,7 +783,7 @@ describe('deleteSelection - selectionOnly', () => { isSelected: true, }; - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker, paragraph: { @@ -826,7 +825,7 @@ describe('deleteSelection - selectionOnly', () => { isSelected: true, }; - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker, paragraph: { @@ -867,7 +866,7 @@ describe('deleteSelection - selectionOnly', () => { isSelected: true, }; - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker, paragraph: { @@ -907,7 +906,7 @@ describe('deleteSelection - selectionOnly', () => { isSelected: true, }; - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker, paragraph: { @@ -947,7 +946,7 @@ describe('deleteSelection - selectionOnly', () => { isSelected: true, }; - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker, paragraph: { @@ -997,7 +996,7 @@ describe('deleteSelection - forward', () => { ], }); - expect(result.deleteResult).toBe(DeleteResult.NotDeleted); + expect(result.deleteResult).toBe('notDeleted'); expect(result.insertPoint).toBeNull(); }); @@ -1011,7 +1010,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.NothingToDelete); + expect(result.deleteResult).toBe('nothingToDelete'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1051,7 +1050,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.SingleChar); + expect(result.deleteResult).toBe('singleChar'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1099,7 +1098,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1157,7 +1156,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1211,7 +1210,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.SingleChar); + expect(result.deleteResult).toBe('singleChar'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1270,7 +1269,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1325,7 +1324,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.SingleChar); + expect(result.deleteResult).toBe('singleChar'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1367,7 +1366,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1408,7 +1407,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1450,7 +1449,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1497,7 +1496,7 @@ describe('deleteSelection - forward', () => { newImages: [], }); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1545,7 +1544,7 @@ describe('deleteSelection - forward', () => { newImages: [], }); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1591,7 +1590,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1659,7 +1658,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1722,7 +1721,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1787,7 +1786,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1859,7 +1858,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1913,7 +1912,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -1961,7 +1960,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -2015,7 +2014,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -2085,7 +2084,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -2186,7 +2185,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -2244,7 +2243,7 @@ describe('deleteSelection - forward', () => { isSelected: true, }; - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker, paragraph: { @@ -2287,7 +2286,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.NothingToDelete); + expect(result.deleteResult).toBe('nothingToDelete'); expect(result.insertPoint).toEqual({ marker: marker, @@ -2338,7 +2337,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: marker, @@ -2390,7 +2389,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.SingleChar); + expect(result.deleteResult).toBe('singleChar'); expect(result.insertPoint).toEqual({ marker: marker, @@ -2434,7 +2433,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.SingleChar); + expect(result.deleteResult).toBe('singleChar'); expect(result.insertPoint).toEqual({ marker: marker, @@ -2480,7 +2479,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.SingleChar); + expect(result.deleteResult).toBe('singleChar'); expect(result.insertPoint).toEqual({ marker: marker, @@ -2529,7 +2528,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.SingleChar); + expect(result.deleteResult).toBe('singleChar'); expect(result.insertPoint).toEqual({ marker: marker, @@ -2574,7 +2573,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteWordSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: marker, @@ -2617,7 +2616,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteWordSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: marker, @@ -2660,7 +2659,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteWordSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: marker, @@ -2703,7 +2702,7 @@ describe('deleteSelection - forward', () => { const result = deleteSelection(model, [forwardDeleteWordSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: marker, @@ -2756,7 +2755,7 @@ describe('deleteSelection - backward', () => { ], }); - expect(result.deleteResult).toBe(DeleteResult.NotDeleted); + expect(result.deleteResult).toBe('notDeleted'); expect(result.insertPoint).toBeNull(); }); @@ -2770,7 +2769,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.NothingToDelete); + expect(result.deleteResult).toBe('nothingToDelete'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -2810,7 +2809,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.SingleChar); + expect(result.deleteResult).toBe('singleChar'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -2858,7 +2857,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -2916,7 +2915,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -2970,7 +2969,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3028,7 +3027,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3083,7 +3082,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.SingleChar); + expect(result.deleteResult).toBe('singleChar'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3125,7 +3124,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3166,7 +3165,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3208,7 +3207,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3255,7 +3254,7 @@ describe('deleteSelection - backward', () => { newImages: [], }); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3304,7 +3303,7 @@ describe('deleteSelection - backward', () => { newImages: [], }); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3350,7 +3349,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3418,7 +3417,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3481,7 +3480,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3546,7 +3545,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3618,7 +3617,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3672,7 +3671,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3720,7 +3719,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3774,7 +3773,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3844,7 +3843,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -3945,7 +3944,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: { segmentType: 'SelectionMarker', @@ -4003,7 +4002,7 @@ describe('deleteSelection - backward', () => { isSelected: true, }; - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker, paragraph: { @@ -4046,7 +4045,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.NothingToDelete); + expect(result.deleteResult).toBe('nothingToDelete'); expect(result.insertPoint).toEqual({ marker: marker, @@ -4097,7 +4096,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: marker, @@ -4149,7 +4148,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.SingleChar); + expect(result.deleteResult).toBe('singleChar'); expect(result.insertPoint).toEqual({ marker: marker, @@ -4193,7 +4192,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.SingleChar); + expect(result.deleteResult).toBe('singleChar'); expect(result.insertPoint).toEqual({ marker: marker, @@ -4239,7 +4238,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.SingleChar); + expect(result.deleteResult).toBe('singleChar'); expect(result.insertPoint).toEqual({ marker: marker, @@ -4288,7 +4287,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.SingleChar); + expect(result.deleteResult).toBe('singleChar'); expect(result.insertPoint).toEqual({ marker: marker, @@ -4333,7 +4332,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteWordSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: marker, @@ -4381,7 +4380,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteWordSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: marker, @@ -4424,7 +4423,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteWordSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: marker, @@ -4467,7 +4466,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteWordSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: marker, @@ -4512,7 +4511,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteWordSelection]); - expect(result.deleteResult).toBe(DeleteResult.Range); + expect(result.deleteResult).toBe('range'); expect(result.insertPoint).toEqual({ marker: marker, @@ -4556,7 +4555,7 @@ describe('deleteSelection - backward', () => { const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - expect(result.deleteResult).toBe(DeleteResult.SingleChar); + expect(result.deleteResult).toBe('singleChar'); expect(result.insertPoint).toEqual({ marker: marker, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts index c3823bee4e3..f7e9c72280d 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts @@ -2,7 +2,6 @@ import * as deleteSelection from '../../../lib/modelApi/edit/deleteSelection'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { applyDefaultFormat } from '../../../lib/modelApi/format/applyDefaultFormat'; import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; import { InsertPoint } from '../../../lib/publicTypes/selection/InsertPoint'; import { createContentModelDocument, @@ -109,7 +108,7 @@ describe('applyDefaultFormat', () => { }); deleteSelectionSpy.and.returnValue({ - deleteResult: DeleteResult.Range, + deleteResult: 'range', insertPoint: null!, }); @@ -138,7 +137,7 @@ describe('applyDefaultFormat', () => { }); deleteSelectionSpy.and.returnValue({ - deleteResult: DeleteResult.NothingToDelete, + deleteResult: 'nothingToDelete', insertPoint: null!, }); @@ -167,7 +166,7 @@ describe('applyDefaultFormat', () => { }); deleteSelectionSpy.and.returnValue({ - deleteResult: DeleteResult.SingleChar, + deleteResult: 'singleChar', insertPoint: null!, }); @@ -208,7 +207,7 @@ describe('applyDefaultFormat', () => { }); deleteSelectionSpy.and.returnValue({ - deleteResult: DeleteResult.NotDeleted, + deleteResult: 'notDeleted', insertPoint, }); @@ -249,7 +248,7 @@ describe('applyDefaultFormat', () => { }); deleteSelectionSpy.and.returnValue({ - deleteResult: DeleteResult.NotDeleted, + deleteResult: 'notDeleted', insertPoint, }); @@ -291,7 +290,7 @@ describe('applyDefaultFormat', () => { }); deleteSelectionSpy.and.returnValue({ - deleteResult: DeleteResult.NotDeleted, + deleteResult: 'notDeleted', insertPoint, }); @@ -332,7 +331,7 @@ describe('applyDefaultFormat', () => { }); deleteSelectionSpy.and.returnValue({ - deleteResult: DeleteResult.NotDeleted, + deleteResult: 'notDeleted', insertPoint, }); @@ -377,7 +376,7 @@ describe('applyDefaultFormat', () => { }); deleteSelectionSpy.and.returnValue({ - deleteResult: DeleteResult.NotDeleted, + deleteResult: 'notDeleted', insertPoint, }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts index 322aa7d6570..82302b4469c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts @@ -90,7 +90,7 @@ describe('keyboardDelete', () => { blocks: [], }, [null!, null!, forwardDeleteCollapsedSelection], - DeleteResult.NotDeleted, + 'notDeleted', true, 0 ); @@ -108,7 +108,7 @@ describe('keyboardDelete', () => { blocks: [], }, [null!, null!, backwardDeleteCollapsedSelection], - DeleteResult.NotDeleted, + 'notDeleted', true, 0 ); @@ -128,7 +128,7 @@ describe('keyboardDelete', () => { blocks: [], }, [null!, forwardDeleteWordSelection, forwardDeleteCollapsedSelection], - DeleteResult.NotDeleted, + 'notDeleted', true, 0 ); @@ -148,7 +148,7 @@ describe('keyboardDelete', () => { blocks: [], }, [null!, backwardDeleteWordSelection, backwardDeleteCollapsedSelection], - DeleteResult.NotDeleted, + 'notDeleted', true, 0 ); @@ -168,7 +168,7 @@ describe('keyboardDelete', () => { blocks: [], }, [null!, null!, forwardDeleteCollapsedSelection], - DeleteResult.NotDeleted, + 'notDeleted', true, 0 ); @@ -188,7 +188,7 @@ describe('keyboardDelete', () => { blocks: [], }, [deleteAllSegmentBefore, null!, backwardDeleteCollapsedSelection], - DeleteResult.NotDeleted, + 'notDeleted', true, 0 ); @@ -230,7 +230,7 @@ describe('keyboardDelete', () => { ], }, [null!, null!, forwardDeleteCollapsedSelection], - DeleteResult.NotDeleted, + 'notDeleted', true, 0 ); @@ -272,7 +272,7 @@ describe('keyboardDelete', () => { ], }, [null!, null!, backwardDeleteCollapsedSelection], - DeleteResult.NotDeleted, + 'notDeleted', true, 0 ); @@ -324,7 +324,7 @@ describe('keyboardDelete', () => { ], }, [null!, null!, forwardDeleteCollapsedSelection], - DeleteResult.SingleChar, + 'singleChar', false, 1 ); @@ -376,7 +376,7 @@ describe('keyboardDelete', () => { ], }, [null!, null!, backwardDeleteCollapsedSelection], - DeleteResult.SingleChar, + 'singleChar', false, 1 ); From 528caf4984d86bc919b815eb90f4c7a86a71d58b Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 7 Nov 2023 09:26:13 -0800 Subject: [PATCH 037/111] Move paste plugin to roosterjs-content-model-plugins package (#2192) --- demo/scripts/controls/getToggleablePlugins.ts | 2 +- demo/scripts/tsconfig.json | 8 +++---- .../lib/editor/coreApi/createContentModel.ts | 2 +- .../ContentModelCopyPastePlugin.ts | 2 +- .../lib/index.ts | 2 +- .../common => publicApi/model}/cloneModel.ts | 13 +++++++++--- .../editor/coreApi/createContentModelTest.ts | 2 +- .../ContentModelCopyPastePluginTest.ts | 2 +- .../model}/cloneModelTest.ts | 2 +- .../test/publicApi/utils/pasteTest.ts | 21 +++++++++++-------- .../lib/index.ts | 1 + .../lib/paste}/ContentModelPastePlugin.ts | 10 +++++---- .../Excel/processPastedContentFromExcel.ts | 2 +- .../processPastedContentFromPowerPoint.ts | 0 .../processPastedContentWacComponents.ts | 2 +- .../processPastedContentFromWordDesktop.ts | 2 +- .../paste}/WordDesktop/processWordComments.ts | 0 .../paste}/WordDesktop/processWordLists.ts | 0 .../pasteSourceValidations/constants.ts | 0 .../documentContainWacElements.ts | 0 .../pasteSourceValidations/getPasteSource.ts | 0 .../isExcelDesktopDocument.ts | 0 .../isExcelOnlineDocument.ts | 0 .../isGoogleSheetDocument.ts | 0 .../isPowerPointDesktopDocument.ts | 0 .../isWordDesktopDocument.ts | 0 .../shouldConvertToSingleImage.ts | 0 .../lib/paste}/utils/addParser.ts | 0 .../lib/paste}/utils/deprecatedColorParser.ts | 0 .../lib/paste}/utils/getStyles.ts | 0 .../lib/paste}/utils/linkParser.ts | 0 .../lib/paste}/utils/setProcessor.ts | 0 .../package.json | 15 +++++++++++++ .../paste}/ContentModelPastePluginTest.ts | 21 +++++++++---------- .../test}/paste/deprecatedColorParserTest.ts | 2 +- .../paste/e2e/cmPasteFromExcelOnlineTest.ts | 5 ++--- .../test}/paste/e2e/cmPasteFromExcelTest.ts | 5 ++--- .../test}/paste/e2e/cmPasteFromWacTest.ts | 5 ++--- .../test}/paste/e2e/cmPasteFromWordTest.ts | 6 ++---- .../test}/paste/e2e/cmPasteTest.ts | 5 ++--- .../test}/paste/e2e/testUtils.ts | 12 +++++------ .../test}/paste/linkParserTest.ts | 2 +- .../documentContainWacElementsTest.ts | 4 ++-- .../getPasteSourceTest.ts | 4 ++-- .../isExcelDesktopDocumentTest.ts | 4 ++-- .../isExcelOnlineDocumentTest.ts | 4 ++-- .../isGoogleSheetDocumentTest.ts | 6 +++--- .../isPowerPointDesktopDocumentTest.ts | 4 ++-- .../isWordDesktopDocumentTest.ts | 4 ++-- .../pasteSourceValidations/pasteTestUtils.ts | 0 .../shouldConvertToSingleImageTest.ts | 4 ++-- .../processPastedContentFromExcelTest.ts | 4 ++-- .../processPastedContentFromPowerPointTest.ts | 2 +- .../paste/processPastedContentFromWacTest.ts | 5 +++-- ...processPastedContentFromWordDesktopTest.ts | 6 +++--- .../test}/paste/utils/getStylesTest.ts | 2 +- .../lib/createContentModelEditor.ts | 3 ++- .../roosterjs-content-model/lib/index.ts | 1 + .../roosterjs-content-model/package.json | 1 + 59 files changed, 117 insertions(+), 92 deletions(-) rename packages-content-model/roosterjs-content-model-editor/lib/{modelApi/common => publicApi/model}/cloneModel.ts (95%) rename packages-content-model/roosterjs-content-model-editor/test/{modelApi/common => publicApi/model}/cloneModelTest.ts (99%) create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/index.ts rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/ContentModelPastePlugin.ts (95%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/Excel/processPastedContentFromExcel.ts (96%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/PowerPoint/processPastedContentFromPowerPoint.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/WacComponents/processPastedContentWacComponents.ts (98%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/WordDesktop/processPastedContentFromWordDesktop.ts (97%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/WordDesktop/processWordComments.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/WordDesktop/processWordLists.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/pasteSourceValidations/constants.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/pasteSourceValidations/documentContainWacElements.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/pasteSourceValidations/getPasteSource.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/pasteSourceValidations/isExcelDesktopDocument.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/pasteSourceValidations/isExcelOnlineDocument.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/pasteSourceValidations/isGoogleSheetDocument.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/pasteSourceValidations/isPowerPointDesktopDocument.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/pasteSourceValidations/isWordDesktopDocument.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/pasteSourceValidations/shouldConvertToSingleImage.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/utils/addParser.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/utils/deprecatedColorParser.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/utils/getStyles.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/utils/linkParser.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/plugins/PastePlugin => roosterjs-content-model-plugins/lib/paste}/utils/setProcessor.ts (100%) create mode 100644 packages-content-model/roosterjs-content-model-plugins/package.json rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test/paste}/ContentModelPastePluginTest.ts (88%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/deprecatedColorParserTest.ts (88%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/e2e/cmPasteFromExcelOnlineTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/e2e/cmPasteFromExcelTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/e2e/cmPasteFromWacTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/e2e/cmPasteFromWordTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/e2e/cmPasteTest.ts (98%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/e2e/testUtils.ts (70%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/linkParserTest.ts (98%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/pasteSourceValidations/documentContainWacElementsTest.ts (91%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/pasteSourceValidations/getPasteSourceTest.ts (94%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/pasteSourceValidations/isExcelDesktopDocumentTest.ts (83%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/pasteSourceValidations/isExcelOnlineDocumentTest.ts (79%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/pasteSourceValidations/isGoogleSheetDocumentTest.ts (71%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/pasteSourceValidations/isPowerPointDesktopDocumentTest.ts (68%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/pasteSourceValidations/isWordDesktopDocumentTest.ts (82%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/pasteSourceValidations/pasteTestUtils.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/pasteSourceValidations/shouldConvertToSingleImageTest.ts (82%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/processPastedContentFromExcelTest.ts (98%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/processPastedContentFromPowerPointTest.ts (96%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/processPastedContentFromWacTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/processPastedContentFromWordDesktopTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-plugins/test}/paste/utils/getStylesTest.ts (92%) diff --git a/demo/scripts/controls/getToggleablePlugins.ts b/demo/scripts/controls/getToggleablePlugins.ts index 0f0b9157812..501dc6d1c28 100644 --- a/demo/scripts/controls/getToggleablePlugins.ts +++ b/demo/scripts/controls/getToggleablePlugins.ts @@ -2,7 +2,7 @@ import BuildInPluginState, { BuildInPluginList, UrlPlaceholder } from './BuildIn import { Announce } from 'roosterjs-editor-plugins/lib/Announce'; import { AutoFormat } from 'roosterjs-editor-plugins/lib/AutoFormat'; import { ContentEdit } from 'roosterjs-editor-plugins/lib/ContentEdit'; -import { ContentModelPastePlugin } from 'roosterjs-content-model-editor'; +import { ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; import { CustomReplace as CustomReplacePlugin } from 'roosterjs-editor-plugins/lib/CustomReplace'; import { CutPasteListChain } from 'roosterjs-editor-plugins/lib/CutPasteListChain'; import { EditorPlugin, KnownAnnounceStrings } from 'roosterjs-editor-types'; diff --git a/demo/scripts/tsconfig.json b/demo/scripts/tsconfig.json index f7754dfecc3..c964dda11ff 100644 --- a/demo/scripts/tsconfig.json +++ b/demo/scripts/tsconfig.json @@ -43,11 +43,11 @@ "roosterjs-content-model-editor/lib/*": [ "packages-content-model/roosterjs-content-model-editor/lib/*" ], - "roosterjs-content-model-adapter": [ - "packages-content-model/roosterjs-content-model-adapter/lib/index" + "roosterjs-content-model-plugins": [ + "packages-content-model/roosterjs-content-model-plugins/lib/index" ], - "roosterjs-content-model-adapter/lib/*": [ - "packages-content-model/roosterjs-content-model-adapter/lib/*" + "roosterjs-content-model-plugins/lib/*": [ + "packages-content-model/roosterjs-content-model-plugins/lib/*" ], "roosterjs-react": ["packages-ui/roosterjs-react/lib/index"], "roosterjs-react/lib/*": ["packages-ui/roosterjs-react/lib/*"] diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts index 071648a0a20..4210d373172 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts @@ -1,4 +1,4 @@ -import { cloneModel } from '../../modelApi/common/cloneModel'; +import { cloneModel } from '../../publicApi/model/cloneModel'; import type { DOMSelection, DomToModelOption } from 'roosterjs-content-model-types'; import { createDomToModelContext, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts index fbf80729f0e..c6212a3361f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts @@ -1,7 +1,7 @@ import paste from '../../publicApi/utils/paste'; import { addRangeToSelection } from '../../domUtils/addRangeToSelection'; import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; -import { cloneModel } from '../../modelApi/common/cloneModel'; +import { cloneModel } from '../../publicApi/model/cloneModel'; import { ColorTransformDirection, PluginEventType } from 'roosterjs-editor-types'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; import { extractClipboardItems } from 'roosterjs-editor-dom'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 3b74a3a7df5..9f55960e261 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -106,10 +106,10 @@ export { default as setParagraphMargin } from './publicApi/block/setParagraphMar export { default as toggleCode } from './publicApi/segment/toggleCode'; export { default as paste } from './publicApi/utils/paste'; export { default as insertEntity } from './publicApi/entity/insertEntity'; +export { CachedElementHandler, CloneModelOptions, cloneModel } from './publicApi/model/cloneModel'; export { default as ContentModelEditor } from './editor/ContentModelEditor'; export { default as isContentModelEditor } from './editor/isContentModelEditor'; -export { default as ContentModelPastePlugin } from './editor/plugins/PastePlugin/ContentModelPastePlugin'; export { default as ContentModelFormatPlugin } from './editor/corePlugins/ContentModelFormatPlugin'; export { default as ContentModelEditPlugin } from './editor/corePlugins/ContentModelEditPlugin'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/model/cloneModel.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts rename to packages-content-model/roosterjs-content-model-editor/lib/publicApi/model/cloneModel.ts index a92d5e1fe58..7488c0e6bb3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/model/cloneModel.ts @@ -28,7 +28,12 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * Function type used for cloneModel API to specify how to handle cached element when clone a model + * @param node The cached node + * @param type Type of the node, it can be + * - general: DOM element of ContentModelGeneralSegment or ContentModelGeneralBlock + * - entity: Wrapper element in ContentModelEntity + * - cache: Cached node in other model element that supports cache */ export type CachedElementHandler = ( node: HTMLElement, @@ -36,7 +41,7 @@ export type CachedElementHandler = ( ) => HTMLElement | undefined; /** - * @internal + * * Options for cloneModel API */ export interface CloneModelOptions { @@ -51,7 +56,9 @@ export interface CloneModelOptions { } /** - * @internal + * Clone a content model + * @param model The content model to clone + * @param options @optional Options to specify customize the clone behavior */ export function cloneModel( model: ContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts index ca87e4d8305..8fc09a36183 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts @@ -1,4 +1,4 @@ -import * as cloneModel from '../../../lib/modelApi/common/cloneModel'; +import * as cloneModel from '../../../lib/publicApi/model/cloneModel'; import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import { ContentModelEditorCore } from '../../../lib/publicTypes/ContentModelEditorCore'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts index 4615bb1d8e6..a56a1f13976 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts @@ -1,5 +1,5 @@ import * as addRangeToSelection from '../../../lib/domUtils/addRangeToSelection'; -import * as cloneModelFile from '../../../lib/modelApi/common/cloneModel'; +import * as cloneModelFile from '../../../lib/publicApi/model/cloneModel'; import * as contentModelToDomFile from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; import * as deleteSelectionsFile from '../../../lib/modelApi/edit/deleteSelection'; import * as extractClipboardItemsFile from 'roosterjs-editor-dom/lib/clipboard/extractClipboardItems'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/cloneModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/model/cloneModelTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/common/cloneModelTest.ts rename to packages-content-model/roosterjs-content-model-editor/test/publicApi/model/cloneModelTest.ts index 017f0e37797..6ba07467196 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/cloneModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/model/cloneModelTest.ts @@ -1,4 +1,4 @@ -import { cloneModel } from '../../../lib/modelApi/common/cloneModel'; +import { cloneModel } from '../../../lib/publicApi/model/cloneModel'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createEntity } from 'roosterjs-content-model-dom'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts index 1b5126950ab..556e68cf733 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts @@ -1,19 +1,22 @@ -import * as addParserF from '../../../lib/editor/plugins/PastePlugin/utils/addParser'; +import * as addParserF from '../../../../roosterjs-content-model-plugins/lib/paste/utils/addParser'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; -import * as ExcelF from '../../../lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel'; -import * as getPasteSourceF from '../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/getPasteSource'; +import * as ExcelF from '../../../../roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel'; +import * as getPasteSourceF from '../../../../roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/getPasteSource'; import * as getSelectedSegmentsF from '../../../lib/publicApi/selection/getSelectedSegments'; import * as mergeModelFile from '../../../lib/modelApi/common/mergeModel'; -import * as PPT from '../../../lib/editor/plugins/PastePlugin/PowerPoint/processPastedContentFromPowerPoint'; -import * as setProcessorF from '../../../lib/editor/plugins/PastePlugin/utils/setProcessor'; -import * as WacComponents from '../../../lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents'; -import * as WordDesktopFile from '../../../lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop'; +import * as PPT from '../../../../roosterjs-content-model-plugins/lib/paste/PowerPoint/processPastedContentFromPowerPoint'; +import * as setProcessorF from '../../../../roosterjs-content-model-plugins/lib/paste/utils/setProcessor'; +import * as WacComponents from '../../../../roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents'; +import * as WordDesktopFile from '../../../../roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop'; import ContentModelEditor from '../../../lib/editor/ContentModelEditor'; -import ContentModelPastePlugin from '../../../lib/editor/plugins/PastePlugin/ContentModelPastePlugin'; import { ContentModelDocument, DomToModelOption } from 'roosterjs-content-model-types'; +import { ContentModelPastePlugin } from '../../../../roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin'; import { createContentModelDocument, tableProcessor } from 'roosterjs-content-model-dom'; -import { expectEqual, initEditor } from '../../editor/plugins/paste/e2e/testUtils'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + expectEqual, + initEditor, +} from '../../../../roosterjs-content-model-plugins/test/paste/e2e/testUtils'; import { ContentModelFormatter, FormatWithContentModelContext, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts new file mode 100644 index 00000000000..d6df2aa5291 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts @@ -0,0 +1 @@ +export { ContentModelPastePlugin } from './paste/ContentModelPastePlugin'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts index 95b51dfa5f2..dde7a01ebfa 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts @@ -10,15 +10,17 @@ import { processPastedContentFromExcel } from './Excel/processPastedContentFromE import { processPastedContentFromPowerPoint } from './PowerPoint/processPastedContentFromPowerPoint'; import { processPastedContentFromWordDesktop } from './WordDesktop/processPastedContentFromWordDesktop'; import { processPastedContentWacComponents } from './WacComponents/processPastedContentWacComponents'; -import type { PasteType } from '../../../publicTypes/parameter/PasteType'; -import type ContentModelBeforePasteEvent from '../../../publicTypes/event/ContentModelBeforePasteEvent'; +import type { + ContentModelBeforePasteEvent, + IContentModelEditor, + PasteType, +} from 'roosterjs-content-model-editor'; import type { BorderFormat, ContentModelBlockFormat, ContentModelTableCellFormat, FormatParser, } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../../publicTypes/IContentModelEditor'; import type { EditorPlugin, HtmlSanitizerOptions, @@ -43,7 +45,7 @@ const PasteTypeMap: Record = { * 4. Content copied from Power Point * (This class is still under development, and may still be changed in the future with some breaking changes) */ -export default class ContentModelPastePlugin implements EditorPlugin { +export class ContentModelPastePlugin implements EditorPlugin { private editor: IContentModelEditor | null = null; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts index 2bd97574f30..86a645b64c1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts @@ -1,8 +1,8 @@ import addParser from '../utils/addParser'; import { isNodeOfType, moveChildNodes } from 'roosterjs-content-model-dom'; import { setProcessor } from '../utils/setProcessor'; -import type ContentModelBeforePasteEvent from '../../../../publicTypes/event/ContentModelBeforePasteEvent'; import type { TrustedHTMLHandler } from 'roosterjs-editor-types'; +import type { ContentModelBeforePasteEvent } from 'roosterjs-content-model-editor'; const LAST_TD_END_REGEX = /<\/\s*td\s*>((?!<\/\s*tr\s*>)[\s\S])*$/i; const LAST_TR_END_REGEX = /<\/\s*tr\s*>((?!<\/\s*table\s*>)[\s\S])*$/i; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/PowerPoint/processPastedContentFromPowerPoint.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/PowerPoint/processPastedContentFromPowerPoint.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/PowerPoint/processPastedContentFromPowerPoint.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/PowerPoint/processPastedContentFromPowerPoint.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts index 24e50482002..6d948f05744 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts @@ -1,6 +1,6 @@ import addParser from '../utils/addParser'; import { setProcessor } from '../utils/setProcessor'; -import type ContentModelBeforePasteEvent from '../../../../publicTypes/event/ContentModelBeforePasteEvent'; +import type { ContentModelBeforePasteEvent } from 'roosterjs-content-model-editor'; import type { ContentModelBlockFormat, ContentModelBlockGroup, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts similarity index 97% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts index 965e55daf60..8e46d06e05f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts @@ -5,7 +5,7 @@ import { moveChildNodes } from 'roosterjs-content-model-dom'; import { processWordComments } from './processWordComments'; import { processWordList } from './processWordLists'; import { setProcessor } from '../utils/setProcessor'; -import type ContentModelBeforePasteEvent from '../../../../publicTypes/event/ContentModelBeforePasteEvent'; +import type { ContentModelBeforePasteEvent } from 'roosterjs-content-model-editor'; import type { ContentModelBlockFormat, ContentModelListItemFormat, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/WordDesktop/processWordComments.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processWordComments.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/WordDesktop/processWordComments.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processWordComments.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/WordDesktop/processWordLists.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processWordLists.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/WordDesktop/processWordLists.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processWordLists.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/constants.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/constants.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/constants.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/constants.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/documentContainWacElements.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/documentContainWacElements.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/documentContainWacElements.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/documentContainWacElements.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/getPasteSource.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/getPasteSource.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/getPasteSource.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/getPasteSource.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/isExcelDesktopDocument.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/isExcelDesktopDocument.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/isExcelDesktopDocument.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/isExcelDesktopDocument.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/isExcelOnlineDocument.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/isExcelOnlineDocument.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/isExcelOnlineDocument.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/isExcelOnlineDocument.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/isGoogleSheetDocument.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/isGoogleSheetDocument.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/isGoogleSheetDocument.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/isGoogleSheetDocument.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/isPowerPointDesktopDocument.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/isPowerPointDesktopDocument.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/isPowerPointDesktopDocument.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/isPowerPointDesktopDocument.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/isWordDesktopDocument.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/isWordDesktopDocument.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/isWordDesktopDocument.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/isWordDesktopDocument.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/shouldConvertToSingleImage.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/shouldConvertToSingleImage.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/pasteSourceValidations/shouldConvertToSingleImage.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/shouldConvertToSingleImage.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/utils/addParser.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/utils/addParser.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/utils/addParser.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/utils/addParser.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/utils/deprecatedColorParser.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/utils/deprecatedColorParser.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/utils/deprecatedColorParser.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/utils/deprecatedColorParser.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/utils/getStyles.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/utils/getStyles.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/utils/getStyles.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/utils/getStyles.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/utils/linkParser.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/utils/linkParser.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/utils/linkParser.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/utils/linkParser.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/utils/setProcessor.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/utils/setProcessor.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/utils/setProcessor.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/paste/utils/setProcessor.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/package.json b/packages-content-model/roosterjs-content-model-plugins/package.json new file mode 100644 index 00000000000..1af019fc5c2 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/package.json @@ -0,0 +1,15 @@ +{ + "name": "roosterjs-content-model-plugins", + "description": "Content Model for roosterjs (Under development)", + "dependencies": { + "tslib": "^2.3.1", + "roosterjs-editor-types": "", + "roosterjs-editor-dom": "", + "roosterjs-editor-core": "", + "roosterjs-content-model-editor": "", + "roosterjs-content-model-dom": "", + "roosterjs-content-model-types": "" + }, + "version": "0.0.0", + "main": "./lib/index.ts" +} diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts similarity index 88% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts index cdf8cf512af..3a19bf2d8b6 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts @@ -1,15 +1,14 @@ -import * as addParser from '../../../lib/editor/plugins/PastePlugin/utils/addParser'; +import * as addParser from '../../lib/paste/utils/addParser'; import * as chainSanitizerCallbackFile from 'roosterjs-editor-dom/lib/htmlSanitizer/chainSanitizerCallback'; -import * as ExcelFile from '../../../lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel'; -import * as getPasteSource from '../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/getPasteSource'; -import * as PowerPointFile from '../../../lib/editor/plugins/PastePlugin/PowerPoint/processPastedContentFromPowerPoint'; -import * as setProcessor from '../../../lib/editor/plugins/PastePlugin/utils/setProcessor'; -import * as WacFile from '../../../lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents'; -import * as WordDesktopFile from '../../../lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop'; -import ContentModelBeforePasteEvent from '../../../lib/publicTypes/event/ContentModelBeforePasteEvent'; -import ContentModelPastePlugin from '../../../lib/editor/plugins/PastePlugin/ContentModelPastePlugin'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { PastePropertyNames } from '../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/constants'; +import * as ExcelFile from '../../lib/paste/Excel/processPastedContentFromExcel'; +import * as getPasteSource from '../../lib/paste/pasteSourceValidations/getPasteSource'; +import * as PowerPointFile from '../../lib/paste/PowerPoint/processPastedContentFromPowerPoint'; +import * as setProcessor from '../../lib/paste/utils/setProcessor'; +import * as WacFile from '../../lib/paste/WacComponents/processPastedContentWacComponents'; +import * as WordDesktopFile from '../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; +import { ContentModelBeforePasteEvent, IContentModelEditor } from 'roosterjs-content-model-editor'; +import { ContentModelPastePlugin } from '../../lib/paste/ContentModelPastePlugin'; +import { PastePropertyNames } from '../../lib/paste/pasteSourceValidations/constants'; import { PasteType, PluginEventType } from 'roosterjs-editor-types'; const trustedHTMLHandler = 'mock'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/deprecatedColorParserTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/deprecatedColorParserTest.ts similarity index 88% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/deprecatedColorParserTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/deprecatedColorParserTest.ts index 23fe6db6960..55e8efa4263 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/deprecatedColorParserTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/deprecatedColorParserTest.ts @@ -1,4 +1,4 @@ -import { deprecatedBorderColorParser } from '../../../../lib/editor/plugins/PastePlugin/utils/deprecatedColorParser'; +import { deprecatedBorderColorParser } from '../../lib/paste/utils/deprecatedColorParser'; const DeprecatedColors: string[] = [ 'activeborder', diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelOnlineTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelOnlineTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts index 786f8263b4a..9b2700ec494 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelOnlineTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts @@ -1,8 +1,7 @@ -import * as processPastedContentFromExcel from '../../../../../lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel'; -import paste from '../../../../../lib/publicApi/utils/paste'; +import * as processPastedContentFromExcel from '../../../lib/paste/Excel/processPastedContentFromExcel'; import { ClipboardData } from 'roosterjs-editor-types'; import { expectEqual, initEditor } from './testUtils'; -import { IContentModelEditor } from '../../../../../lib/publicTypes/IContentModelEditor'; +import { IContentModelEditor, paste } from 'roosterjs-content-model-editor'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; import { tableProcessor } from 'roosterjs-content-model-dom'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts index 3c79c0625bd..c737b5e9012 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts @@ -1,9 +1,8 @@ -import * as processPastedContentFromExcel from '../../../../../lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel'; -import paste from '../../../../../lib/publicApi/utils/paste'; +import * as processPastedContentFromExcel from '../../../lib/paste/Excel/processPastedContentFromExcel'; import { Browser } from 'roosterjs-editor-dom'; import { ClipboardData } from 'roosterjs-editor-types'; import { expectEqual, initEditor } from './testUtils'; -import { IContentModelEditor } from '../../../../../lib/publicTypes/IContentModelEditor'; +import { IContentModelEditor, paste } from 'roosterjs-content-model-editor'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; import { tableProcessor } from 'roosterjs-content-model-dom'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWacTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWacTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts index cc5eddaedb3..36411322f5f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts @@ -1,9 +1,8 @@ -import * as processPastedContentWacComponents from '../../../../../lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents'; -import paste from '../../../../../lib/publicApi/utils/paste'; +import * as processPastedContentWacComponents from '../../../lib/paste/WacComponents/processPastedContentWacComponents'; import { ClipboardData } from 'roosterjs-editor-types'; import { DomToModelOption } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; -import { IContentModelEditor } from '../../../../../lib/publicTypes/IContentModelEditor'; +import { IContentModelEditor, paste } from 'roosterjs-content-model-editor'; import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_From_WORD_Online_E2E'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWordTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWordTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts index 7a44986901e..ec029e34767 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromWordTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts @@ -1,10 +1,8 @@ -import * as wordFile from '../../../../../lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop'; -import paste from '../../../../../lib/publicApi/utils/paste'; +import * as wordFile from '../../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; import { ClipboardData } from 'roosterjs-editor-types'; -import { cloneModel } from '../../../../../lib/modelApi/common/cloneModel'; +import { cloneModel, IContentModelEditor, paste } from 'roosterjs-content-model-editor'; import { DomToModelOption } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; -import { IContentModelEditor } from '../../../../../lib/publicTypes/IContentModelEditor'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; import { tableProcessor } from 'roosterjs-content-model-dom'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts index 0e2de9639cf..efee14a775f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts @@ -1,9 +1,8 @@ -import * as wordFile from '../../../../../lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop'; -import paste from '../../../../../lib/publicApi/utils/paste'; +import * as wordFile from '../../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; import { ClipboardData } from 'roosterjs-editor-types'; import { DomToModelOption } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; -import { IContentModelEditor } from '../../../../../lib/publicTypes/IContentModelEditor'; +import { IContentModelEditor, paste } from 'roosterjs-content-model-editor'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; import { tableProcessor } from 'roosterjs-content-model-dom'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/testUtils.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts similarity index 70% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/testUtils.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts index a1e3f1d7d77..89b2b719981 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/testUtils.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts @@ -1,13 +1,13 @@ -import ContentModelEditor from '../../../../../lib/editor/ContentModelEditor'; -import ContentModelPastePlugin from '../../../../../lib/editor/plugins/PastePlugin/ContentModelPastePlugin'; -import { cloneModel } from '../../../../../lib/modelApi/common/cloneModel'; import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { ContentModelPastePlugin } from '../../../lib/paste/ContentModelPastePlugin'; import { ContentModelEditorOptions, + ContentModelEditor, IContentModelEditor, -} from '../../../../../lib/publicTypes/IContentModelEditor'; + cloneModel, +} from 'roosterjs-content-model-editor'; -export function initEditor(id: string) { +export function initEditor(id: string): IContentModelEditor { let node = document.createElement('div'); node.id = id; document.body.insertBefore(node, document.body.childNodes[0]); @@ -26,7 +26,7 @@ export function initEditor(id: string) { let editor = new ContentModelEditor(node as HTMLDivElement, options); - return editor as IContentModelEditor; + return editor; } export function expectEqual(model1: ContentModelDocument, model2: ContentModelDocument) { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/linkParserTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/linkParserTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/linkParserTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/linkParserTest.ts index f55d8ea8888..a1be1151bf5 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/linkParserTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/linkParserTest.ts @@ -1,6 +1,6 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createBeforePasteEventMock } from './processPastedContentFromWordDesktopTest'; -import { parseLink } from '../../../../lib/editor/plugins/PastePlugin/utils/linkParser'; +import { parseLink } from '../../lib/paste/utils/linkParser'; import { contentModelToDom, createDomToModelContext, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/documentContainWacElementsTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/documentContainWacElementsTest.ts similarity index 91% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/documentContainWacElementsTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/documentContainWacElementsTest.ts index 8075be62b34..e499ab2dc66 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/documentContainWacElementsTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/documentContainWacElementsTest.ts @@ -1,5 +1,5 @@ -import { documentContainWacElements } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/documentContainWacElements'; -import { GetSourceInputParams } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/getPasteSource'; +import { documentContainWacElements } from '../../../lib/paste/pasteSourceValidations/documentContainWacElements'; +import { GetSourceInputParams } from '../../../lib/paste/pasteSourceValidations/getPasteSource'; import { getWacElement } from './pasteTestUtils'; describe('documentContainWacElements |', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/getPasteSourceTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/getPasteSourceTest.ts similarity index 94% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/getPasteSourceTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/getPasteSourceTest.ts index 67baf9f23f2..d1d82fc682f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/getPasteSourceTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/getPasteSourceTest.ts @@ -1,6 +1,6 @@ import { BeforePasteEvent, ClipboardData } from 'roosterjs-editor-types'; -import { getPasteSource } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/getPasteSource'; -import { PastePropertyNames } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/constants'; +import { getPasteSource } from '../../../lib/paste/pasteSourceValidations/getPasteSource'; +import { PastePropertyNames } from '../../../lib/paste/pasteSourceValidations/constants'; import { EXCEL_ATTRIBUTE_VALUE, getWacElement, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/isExcelDesktopDocumentTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/isExcelDesktopDocumentTest.ts similarity index 83% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/isExcelDesktopDocumentTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/isExcelDesktopDocumentTest.ts index 8a338712815..ef93fabee8c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/isExcelDesktopDocumentTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/isExcelDesktopDocumentTest.ts @@ -1,6 +1,6 @@ import { EXCEL_ATTRIBUTE_VALUE } from './pasteTestUtils'; -import { GetSourceInputParams } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/getPasteSource'; -import { isExcelDesktopDocument } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/isExcelDesktopDocument'; +import { GetSourceInputParams } from '../../../lib/paste/pasteSourceValidations/getPasteSource'; +import { isExcelDesktopDocument } from '../../../lib/paste/pasteSourceValidations/isExcelDesktopDocument'; const EXCEL_ONLINE_ATTRIBUTE_VALUE = 'Excel.Sheet'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/isExcelOnlineDocumentTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/isExcelOnlineDocumentTest.ts similarity index 79% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/isExcelOnlineDocumentTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/isExcelOnlineDocumentTest.ts index 50bab7a70ad..f44a719c38a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/isExcelOnlineDocumentTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/isExcelOnlineDocumentTest.ts @@ -1,6 +1,6 @@ import { EXCEL_ATTRIBUTE_VALUE } from './pasteTestUtils'; -import { GetSourceInputParams } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/getPasteSource'; -import { isExcelOnlineDocument } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/isExcelOnlineDocument'; +import { GetSourceInputParams } from '../../../lib/paste/pasteSourceValidations/getPasteSource'; +import { isExcelOnlineDocument } from '../../../lib/paste/pasteSourceValidations/isExcelOnlineDocument'; const EXCEL_ONLINE_ATTRIBUTE_VALUE = 'Excel.Sheet'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/isGoogleSheetDocumentTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/isGoogleSheetDocumentTest.ts similarity index 71% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/isGoogleSheetDocumentTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/isGoogleSheetDocumentTest.ts index 4aec82dae22..16471e31f03 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/isGoogleSheetDocumentTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/isGoogleSheetDocumentTest.ts @@ -1,7 +1,7 @@ -import { GetSourceInputParams } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/getPasteSource'; +import { GetSourceInputParams } from '../../../lib/paste/pasteSourceValidations/getPasteSource'; import { getWacElement } from './pasteTestUtils'; -import { isGoogleSheetDocument } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/isGoogleSheetDocument'; -import { PastePropertyNames } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/constants'; +import { isGoogleSheetDocument } from '../../../lib/paste/pasteSourceValidations/isGoogleSheetDocument'; +import { PastePropertyNames } from '../../../lib/paste/pasteSourceValidations/constants'; describe('isGoogleSheetDocument |', () => { it('Is from Google Sheets', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/isPowerPointDesktopDocumentTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/isPowerPointDesktopDocumentTest.ts similarity index 68% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/isPowerPointDesktopDocumentTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/isPowerPointDesktopDocumentTest.ts index d9f1180db46..194f1d2a539 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/isPowerPointDesktopDocumentTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/isPowerPointDesktopDocumentTest.ts @@ -1,5 +1,5 @@ -import { GetSourceInputParams } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/getPasteSource'; -import { isPowerPointDesktopDocument } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/isPowerPointDesktopDocument'; +import { GetSourceInputParams } from '../../../lib/paste/pasteSourceValidations/getPasteSource'; +import { isPowerPointDesktopDocument } from '../../../lib/paste/pasteSourceValidations/isPowerPointDesktopDocument'; import { POWERPOINT_ATTRIBUTE_VALUE } from './pasteTestUtils'; describe('isPowerPointDesktopDocument |', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/isWordDesktopDocumentTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/isWordDesktopDocumentTest.ts similarity index 82% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/isWordDesktopDocumentTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/isWordDesktopDocumentTest.ts index e6414c4830b..7fe63ffea12 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/isWordDesktopDocumentTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/isWordDesktopDocumentTest.ts @@ -1,5 +1,5 @@ -import { GetSourceInputParams } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/getPasteSource'; -import { isWordDesktopDocument } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/isWordDesktopDocument'; +import { GetSourceInputParams } from '../../../lib/paste/pasteSourceValidations/getPasteSource'; +import { isWordDesktopDocument } from '../../../lib/paste/pasteSourceValidations/isWordDesktopDocument'; import { WORD_ATTRIBUTE_VALUE } from './pasteTestUtils'; const WORD_PROG_ID = 'Word.Document'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/pasteTestUtils.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/pasteTestUtils.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/pasteTestUtils.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/pasteTestUtils.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/shouldConvertToSingleImageTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/shouldConvertToSingleImageTest.ts similarity index 82% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/shouldConvertToSingleImageTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/shouldConvertToSingleImageTest.ts index 5efba972efa..df6b78663c3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/pasteSourceValidations/shouldConvertToSingleImageTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/pasteSourceValidations/shouldConvertToSingleImageTest.ts @@ -1,6 +1,6 @@ import { ClipboardData } from 'roosterjs-editor-types'; -import { GetSourceInputParams } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/getPasteSource'; -import { shouldConvertToSingleImage } from '../../../../../lib/editor/plugins/PastePlugin/pasteSourceValidations/shouldConvertToSingleImage'; +import { GetSourceInputParams } from '../../../lib/paste/pasteSourceValidations/getPasteSource'; +import { shouldConvertToSingleImage } from '../../../lib/paste/pasteSourceValidations/shouldConvertToSingleImage'; describe('shouldConvertToSingleImage |', () => { it('Is Single Image', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromExcelTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromExcelTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts index 8d73b24ae72..ecd95a2316d 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromExcelTest.ts @@ -1,8 +1,8 @@ -import * as PastePluginFile from '../../../../lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel'; +import * as PastePluginFile from '../../lib/paste/Excel/processPastedContentFromExcel'; import { Browser } from 'roosterjs-editor-dom'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createBeforePasteEventMock } from './processPastedContentFromWordDesktopTest'; -import { processPastedContentFromExcel } from '../../../../lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel'; +import { processPastedContentFromExcel } from '../../lib/paste/Excel/processPastedContentFromExcel'; import { contentModelToDom, createDomToModelContext, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromPowerPointTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromPowerPointTest.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromPowerPointTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromPowerPointTest.ts index d945fc8adc5..db4976d67b7 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromPowerPointTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromPowerPointTest.ts @@ -1,6 +1,6 @@ import * as moveChildNodes from 'roosterjs-content-model-dom/lib/domUtils/moveChildNodes'; import { createDefaultHtmlSanitizerOptions } from 'roosterjs-editor-dom'; -import { processPastedContentFromPowerPoint } from '../../../../lib/editor/plugins/PastePlugin/PowerPoint/processPastedContentFromPowerPoint'; +import { processPastedContentFromPowerPoint } from '../../lib/paste/PowerPoint/processPastedContentFromPowerPoint'; import { BeforePasteEvent, ClipboardData, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts index 7f91c7c4c1c..36330c28ba6 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts @@ -2,11 +2,12 @@ import { Browser } from 'roosterjs-editor-dom'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createBeforePasteEventMock } from './processPastedContentFromWordDesktopTest'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; -import { processPastedContentWacComponents } from '../../../../lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents'; +import { processPastedContentWacComponents } from '../../lib/paste/WacComponents/processPastedContentWacComponents'; import { listItemMetadataApplier, listLevelMetadataApplier, -} from '../../../../lib/domUtils/metadata/updateListMetadata'; +} from 'roosterjs-content-model-editor/lib/domUtils/metadata/updateListMetadata'; + import { contentModelToDom, createDomToModelContext, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWordDesktopTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWordDesktopTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts index ee44ce2be0b..b04c44d84da 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWordDesktopTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts @@ -1,12 +1,12 @@ -import ContentModelBeforePasteEvent from '../../../../lib/publicTypes/event/ContentModelBeforePasteEvent'; import { ClipboardData, PluginEventType } from 'roosterjs-editor-types'; +import { ContentModelBeforePasteEvent } from 'roosterjs-content-model-editor'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { expectHtml } from 'roosterjs-editor-api/test/TestHelper'; -import { processPastedContentFromWordDesktop } from '../../../../lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop'; import { listItemMetadataApplier, listLevelMetadataApplier, -} from '../../../../lib/domUtils/metadata/updateListMetadata'; +} from 'roosterjs-content-model-editor/lib/domUtils/metadata/updateListMetadata'; +import { processPastedContentFromWordDesktop } from '../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; import { contentModelToDom, createDomToModelContext, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/utils/getStylesTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/utils/getStylesTest.ts similarity index 92% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/utils/getStylesTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/paste/utils/getStylesTest.ts index 8b684b5742c..921655d20a4 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/utils/getStylesTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/utils/getStylesTest.ts @@ -1,4 +1,4 @@ -import { getStyles } from '../../../../../lib/editor/plugins/PastePlugin/utils/getStyles'; +import { getStyles } from '../../../lib/paste/utils/getStyles'; describe('getStyles', () => { function runTest(style: string, expected: Record) { diff --git a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts index 14d85491728..a29a607d723 100644 --- a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts @@ -1,4 +1,5 @@ -import { ContentModelEditor, ContentModelPastePlugin } from 'roosterjs-content-model-editor'; +import { ContentModelEditor } from 'roosterjs-content-model-editor'; +import { ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; import { getDarkColor } from 'roosterjs-color-utils'; import type { EditorPlugin } from 'roosterjs-editor-types'; import type { diff --git a/packages-content-model/roosterjs-content-model/lib/index.ts b/packages-content-model/roosterjs-content-model/lib/index.ts index 2954c80afd4..85fdf04405e 100644 --- a/packages-content-model/roosterjs-content-model/lib/index.ts +++ b/packages-content-model/roosterjs-content-model/lib/index.ts @@ -2,3 +2,4 @@ export { createContentModelEditor } from './createContentModelEditor'; export * from 'roosterjs-content-model-types'; export * from 'roosterjs-content-model-dom'; export * from 'roosterjs-content-model-editor'; +export * from 'roosterjs-content-model-plugins'; diff --git a/packages-content-model/roosterjs-content-model/package.json b/packages-content-model/roosterjs-content-model/package.json index b1de4e99b1b..afb0cbafea8 100644 --- a/packages-content-model/roosterjs-content-model/package.json +++ b/packages-content-model/roosterjs-content-model/package.json @@ -9,6 +9,7 @@ "roosterjs-content-model-types": "", "roosterjs-content-model-dom": "", "roosterjs-content-model-editor": "", + "roosterjs-content-model-plugins": "", "roosterjs-color-utils": "" }, "version": "0.0.0", From bef8abd6f8e00d87c6afeda35d64a99bb662b387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 7 Nov 2023 18:29:29 -0300 Subject: [PATCH 038/111] image selection plugin --- .../lib/corePlugins/ImageSelection.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts b/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts index 458d58ac8e6..47f5c4a8828 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts @@ -70,19 +70,15 @@ export default class ImageSelection implements EditorPlugin { !rawEvent.metaKey && keyDownSelection.type === SelectionRangeTypes.ImageSelection ) { - if (key === Escape) { + const position = new Position(keyDownSelection.image, PositionType.Before); + if (key === Escape && position) { this.editor.select(keyDownSelection.image, PositionType.Before); this.editor.getSelectionRange()?.collapse(); event.rawEvent.stopPropagation(); } else if (key === Delete) { this.editor.deleteNode(keyDownSelection.image); event.rawEvent.preventDefault(); - } else { - const position = new Position( - keyDownSelection.image, - PositionType.Before - ); - + } else if (position) { this.editor.select(position); } } From 650c769af338db99e89c0092c8e7d270c120f2b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 8 Nov 2023 10:04:36 -0300 Subject: [PATCH 039/111] check image parent node --- .../lib/corePlugins/ImageSelection.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts b/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts index 47f5c4a8828..0041b247fc9 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts @@ -1,5 +1,5 @@ import { PluginEventType, PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { Position, safeInstanceOf } from 'roosterjs-editor-dom'; +import { safeInstanceOf } from 'roosterjs-editor-dom'; import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; const Escape = 'Escape'; @@ -70,16 +70,16 @@ export default class ImageSelection implements EditorPlugin { !rawEvent.metaKey && keyDownSelection.type === SelectionRangeTypes.ImageSelection ) { - const position = new Position(keyDownSelection.image, PositionType.Before); - if (key === Escape && position) { + const imageParent = keyDownSelection.image?.parentNode; + if (key === Escape && imageParent) { this.editor.select(keyDownSelection.image, PositionType.Before); this.editor.getSelectionRange()?.collapse(); event.rawEvent.stopPropagation(); } else if (key === Delete) { this.editor.deleteNode(keyDownSelection.image); event.rawEvent.preventDefault(); - } else if (position) { - this.editor.select(position); + } else if (imageParent) { + this.editor.select(keyDownSelection.image, PositionType.Before); } } break; From 5c204edd0a69a85e2e842621ce4e62fea7bf1c0b Mon Sep 17 00:00:00 2001 From: Keven Arroyo Date: Wed, 8 Nov 2023 15:24:00 -0800 Subject: [PATCH 040/111] Adding module entry to package.json (#2197) --- tools/buildTools/normalize.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/buildTools/normalize.js b/tools/buildTools/normalize.js index 8821c196e99..740489870a3 100644 --- a/tools/buildTools/normalize.js +++ b/tools/buildTools/normalize.js @@ -46,6 +46,7 @@ function normalize() { packageJson.typings = './lib/index.d.ts'; packageJson.main = './lib/index.js'; + packageJson.module = './lib-mjs/index.js'; packageJson.license = 'MIT'; packageJson.repository = { type: 'git', From 3872b42fabad18a7308c735b1eb9d26ce4857373 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 9 Nov 2023 08:56:21 -0800 Subject: [PATCH 041/111] Move ContentModelEdit plugin to plugins package (#2195) * Move ContentModelEdit plugin to plugins package * improve --------- Co-authored-by: Bryan Valverde U --- .../controls/ContentModelEditorMainPane.tsx | 14 +- .../lib/domUtils/eventUtils.ts | 2 - .../lib/domUtils/stringUtil.ts | 3 - .../ContentModelCopyPastePlugin.ts | 2 +- .../editor/createContentModelEditorCore.ts | 4 +- .../lib/index.ts | 13 +- .../lib/modelApi/common/mergeModel.ts | 2 +- .../edit/utils/deleteExpandedSelection.ts | 6 +- .../lib/modelApi/entity/insertEntityModel.ts | 4 +- .../lib/modelApi/format/applyDefaultFormat.ts | 2 +- .../utils => publicApi/block}/deleteBlock.ts | 12 +- .../segment}/deleteSegment.ts | 13 +- .../selection}/deleteSelection.ts | 10 +- .../lib/publicApi/table/insertTable.ts | 2 +- .../parameter}/DeleteSelectionStep.ts | 11 +- .../createContentModelEditorCoreTest.ts | 15 +- .../ContentModelCopyPastePluginTest.ts | 2 +- .../test/modelApi/edit/deleteSelectionTest.ts | 3623 +---------------- .../modelApi/format/applyDefaultFormatTest.ts | 2 +- .../lib/edit}/ContentModelEditPlugin.ts | 17 +- .../deleteSteps/deleteAllSegmentBefore.ts | 4 +- .../deleteSteps/deleteCollapsedSelection.ts | 17 +- .../edit/deleteSteps/deleteWordSelection.ts | 4 +- .../lib/edit}/handleKeyboardEventCommon.ts | 8 +- .../lib/edit}/keyboardDelete.ts | 20 +- .../lib/edit/utils}/getLeafSiblingBlock.ts | 0 .../lib/index.ts | 1 + .../test/edit}/ContentModelEditPluginTest.ts | 8 +- .../deleteCollapsedSelectionTest.ts | 3231 +++++++++++++++ .../deleteSteps/deleteWordSelectionTest.ts | 413 ++ .../test/edit}/editingTestCommon.ts | 4 +- .../edit}/handleKeyboardEventCommonTest.ts | 5 +- .../test/edit}/keyboardDeleteTest.ts | 24 +- .../edit/utils}/getLeafSiblingBlockTest.ts | 2 +- .../lib/createContentModelEditor.ts | 9 +- 35 files changed, 3767 insertions(+), 3742 deletions(-) rename packages-content-model/roosterjs-content-model-editor/lib/{modelApi/edit/utils => publicApi/block}/deleteBlock.ts (77%) rename packages-content-model/roosterjs-content-model-editor/lib/{modelApi/edit/utils => publicApi/segment}/deleteSegment.ts (82%) rename packages-content-model/roosterjs-content-model-editor/lib/{modelApi/edit => publicApi/selection}/deleteSelection.ts (77%) rename packages-content-model/roosterjs-content-model-editor/lib/{modelApi/edit/utils => publicTypes/parameter}/DeleteSelectionStep.ts (83%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/corePlugins => roosterjs-content-model-plugins/lib/edit}/ContentModelEditPlugin.ts (83%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi => roosterjs-content-model-plugins/lib}/edit/deleteSteps/deleteAllSegmentBefore.ts (77%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi => roosterjs-content-model-plugins/lib}/edit/deleteSteps/deleteCollapsedSelection.ts (88%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi => roosterjs-content-model-plugins/lib}/edit/deleteSteps/deleteWordSelection.ts (98%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/utils => roosterjs-content-model-plugins/lib/edit}/handleKeyboardEventCommon.ts (89%) rename packages-content-model/{roosterjs-content-model-editor/lib/publicApi/editing => roosterjs-content-model-plugins/lib/edit}/keyboardDelete.ts (78%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi/block => roosterjs-content-model-plugins/lib/edit/utils}/getLeafSiblingBlock.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/corePlugins => roosterjs-content-model-plugins/test/edit}/ContentModelEditPluginTest.ts (92%) create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteWordSelectionTest.ts rename packages-content-model/{roosterjs-content-model-editor/test/publicApi/editing => roosterjs-content-model-plugins/test/edit}/editingTestCommon.ts (88%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/utils => roosterjs-content-model-plugins/test/edit}/handleKeyboardEventCommonTest.ts (96%) rename packages-content-model/{roosterjs-content-model-editor/test/publicApi/editing => roosterjs-content-model-plugins/test/edit}/keyboardDeleteTest.ts (95%) rename packages-content-model/{roosterjs-content-model-editor/test/modelApi/block => roosterjs-content-model-plugins/test/edit/utils}/getLeafSiblingBlockTest.ts (99%) diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 080a36074cd..704414e9081 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -15,10 +15,11 @@ import SnapshotPlugin from './sidePane/snapshot/SnapshotPlugin'; import TitleBar from './titleBar/TitleBar'; import { arrayPush } from 'roosterjs-editor-dom'; import { ContentModelEditor } from 'roosterjs-content-model-editor'; +import { ContentModelEditPlugin } from 'roosterjs-content-model-plugins'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; +import { createEmojiPlugin, createPasteOptionPlugin, RibbonPlugin } from 'roosterjs-react'; import { EditorOptions, EditorPlugin } from 'roosterjs-editor-types'; import { PartialTheme } from '@fluentui/react/lib/Theme'; -import { RibbonPlugin, createPasteOptionPlugin, createEmojiPlugin } from 'roosterjs-react'; const styles = require('./ContentModelEditorMainPane.scss'); @@ -81,7 +82,8 @@ class ContentModelEditorMainPane extends MainPaneBase { private editorOptionPlugin: ContentModelEditorOptionsPlugin; private eventViewPlugin: ContentModelEventViewPlugin; private apiPlaygroundPlugin: ApiPlaygroundPlugin; - private ContentModelPanePlugin: ContentModelPanePlugin; + private contentModelPanePlugin: ContentModelPanePlugin; + private contentModelEditPlugin: ContentModelEditPlugin; private contentModelRibbonPlugin: RibbonPlugin; private pasteOptionPlugin: EditorPlugin; private emojiPlugin: EditorPlugin; @@ -97,7 +99,8 @@ class ContentModelEditorMainPane extends MainPaneBase { this.eventViewPlugin = new ContentModelEventViewPlugin(); this.apiPlaygroundPlugin = new ApiPlaygroundPlugin(); this.snapshotPlugin = new SnapshotPlugin(); - this.ContentModelPanePlugin = new ContentModelPanePlugin(); + this.contentModelPanePlugin = new ContentModelPanePlugin(); + this.contentModelEditPlugin = new ContentModelEditPlugin(); this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.pasteOptionPlugin = createPasteOptionPlugin(); this.emojiPlugin = createEmojiPlugin(); @@ -159,7 +162,8 @@ class ContentModelEditorMainPane extends MainPaneBase { const plugins = [ ...this.toggleablePlugins, this.contentModelRibbonPlugin, - this.ContentModelPanePlugin.getInnerRibbonPlugin(), + this.contentModelPanePlugin.getInnerRibbonPlugin(), + this.contentModelEditPlugin, this.pasteOptionPlugin, this.emojiPlugin, this.formatPainterPlugin, @@ -197,7 +201,7 @@ class ContentModelEditorMainPane extends MainPaneBase { this.eventViewPlugin, this.apiPlaygroundPlugin, this.snapshotPlugin, - this.ContentModelPanePlugin, + this.contentModelPanePlugin, ]; } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/eventUtils.ts b/packages-content-model/roosterjs-content-model-editor/lib/domUtils/eventUtils.ts index b652765096a..adf658532ca 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/eventUtils.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/domUtils/eventUtils.ts @@ -3,7 +3,6 @@ const ALT_CHAR_CODE = 'Alt'; const META_CHAR_CODE = 'Meta'; /** - * @internal * Returns true when the event was fired from a modifier key, otherwise false * @param event The keyboard event object */ @@ -16,7 +15,6 @@ export function isModifierKey(event: KeyboardEvent): boolean { } /** - * @internal * Returns true when the event was fired from a key that produces a character value, otherwise false * This detection is not 100% accurate. event.key is not fully supported by all browsers, and in some browsers (e.g. IE), * event.key is longer than 1 for num pad input. But here we just want to improve performance as much as possible. diff --git a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/stringUtil.ts b/packages-content-model/roosterjs-content-model-editor/lib/domUtils/stringUtil.ts index 94ebff5742c..ac6d179a37e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/stringUtil.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/domUtils/stringUtil.ts @@ -2,7 +2,6 @@ const SPACES_REGEX = /[\u2000\u2009\u200a​\u200b​\u202f\u205f​\u3000\s\t\r const PUNCTUATIONS = '.,?!:"()[]\\/'; /** - * @internal * Check if the given character is punctuation * @param char The character to check */ @@ -11,7 +10,6 @@ export function isPunctuation(char: string) { } /** - * @internal * Check if the give character is a space. A space can be normal ASCII pace (32) or non-break space (160) or other kinds of spaces * such as ZeroWidthSpace, ... * @param char The character to check @@ -22,7 +20,6 @@ export function isSpace(char: string) { } /** - * @internal * Normalize spaces of the given string. After normalization, all leading (for forward) or trailing (for backward) spaces * will be replaces with non-break space (160) * @param txt The string to normalize diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts index c6212a3361f..690eb8e9b1c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts @@ -3,7 +3,7 @@ import { addRangeToSelection } from '../../domUtils/addRangeToSelection'; import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; import { cloneModel } from '../../publicApi/model/cloneModel'; import { ColorTransformDirection, PluginEventType } from 'roosterjs-editor-types'; -import { deleteSelection } from '../../modelApi/edit/deleteSelection'; +import { deleteSelection } from '../../publicApi/selection/deleteSelection'; import { extractClipboardItems } from 'roosterjs-editor-dom'; import { iterateSelections } from '../../modelApi/selection/iterateSelections'; import { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts index 04682c5965b..e43e5f71e7b 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts @@ -3,7 +3,6 @@ import ContentModelTypeInContainerPlugin from './corePlugins/ContentModelTypeInC import { contentModelDomIndexer } from './utils/contentModelDomIndexer'; import { createContentModel } from './coreApi/createContentModel'; import { createContentModelCachePlugin } from './corePlugins/ContentModelCachePlugin'; -import { createContentModelEditPlugin } from './corePlugins/ContentModelEditPlugin'; import { createContentModelFormatPlugin } from './corePlugins/ContentModelFormatPlugin'; import { createDomToModelConfig, createModelToDomConfig } from 'roosterjs-content-model-dom'; import { createEditorContext } from './coreApi/createEditorContext'; @@ -35,9 +34,8 @@ export const createContentModelEditorCore: CoreCreator< ...options, plugins: [ createContentModelCachePlugin(pluginState.cache), - ...(options.plugins || []), createContentModelFormatPlugin(pluginState.format), - createContentModelEditPlugin(), + ...(options.plugins || []), ], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 9f55960e261..38d92911f3c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -58,6 +58,13 @@ export { TableCellVerticalAlignOperation, } from './publicTypes/parameter/TableOperation'; export { PasteType } from './publicTypes/parameter/PasteType'; +export { + DeleteResult, + DeleteSelectionContext, + DeleteSelectionResult, + DeleteSelectionStep, + ValidDeleteSelectionContext, +} from './publicTypes/parameter/DeleteSelectionStep'; export { default as insertTable } from './publicApi/table/insertTable'; export { default as formatTable } from './publicApi/table/formatTable'; @@ -79,6 +86,7 @@ export { default as setTextColor } from './publicApi/segment/setTextColor'; export { default as changeFontSize } from './publicApi/segment/changeFontSize'; export { default as applySegmentFormat } from './publicApi/segment/applySegmentFormat'; export { default as changeCapitalization } from './publicApi/segment/changeCapitalization'; +export { deleteSegment } from './publicApi/segment/deleteSegment'; export { default as insertImage } from './publicApi/image/insertImage'; export { default as setListStyle } from './publicApi/list/setListStyle'; export { default as setListStartNumber } from './publicApi/list/setListStartNumber'; @@ -91,6 +99,7 @@ export { default as setAlignment } from './publicApi/block/setAlignment'; export { default as setDirection } from './publicApi/block/setDirection'; export { default as setHeadingLevel } from './publicApi/block/setHeadingLevel'; export { default as toggleBlockQuote } from './publicApi/block/toggleBlockQuote'; +export { deleteBlock } from './publicApi/block/deleteBlock'; export { default as setSpacing } from './publicApi/block/setSpacing'; export { default as setImageBorder } from './publicApi/image/setImageBorder'; export { default as setImageBoxShadow } from './publicApi/image/setImageBoxShadow'; @@ -107,12 +116,12 @@ export { default as toggleCode } from './publicApi/segment/toggleCode'; export { default as paste } from './publicApi/utils/paste'; export { default as insertEntity } from './publicApi/entity/insertEntity'; export { CachedElementHandler, CloneModelOptions, cloneModel } from './publicApi/model/cloneModel'; +export { deleteSelection } from './publicApi/selection/deleteSelection'; export { default as ContentModelEditor } from './editor/ContentModelEditor'; export { default as isContentModelEditor } from './editor/isContentModelEditor'; export { default as ContentModelFormatPlugin } from './editor/corePlugins/ContentModelFormatPlugin'; -export { default as ContentModelEditPlugin } from './editor/corePlugins/ContentModelEditPlugin'; export { default as ContentModelTypeInContainerPlugin } from './editor/corePlugins/ContentModelTypeInContainerPlugin'; export { default as ContentModelCopyPastePlugin } from './editor/corePlugins/ContentModelCopyPastePlugin'; export { default as ContentModelCachePlugin } from './editor/corePlugins/ContentModelCachePlugin'; @@ -126,6 +135,8 @@ export { updateImageMetadata } from './domUtils/metadata/updateImageMetadata'; export { updateTableCellMetadata } from './domUtils/metadata/updateTableCellMetadata'; export { updateTableMetadata } from './domUtils/metadata/updateTableMetadata'; export { updateListMetadata } from './domUtils/metadata/updateListMetadata'; +export { isCharacterValue, isModifierKey } from './domUtils/eventUtils'; +export { isPunctuation, isSpace, normalizeText } from './domUtils/stringUtil'; export { ContentModelCachePluginState } from './publicTypes/pluginState/ContentModelCachePluginState'; export { ContentModelPluginState } from './publicTypes/pluginState/ContentModelPluginState'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts index 0a7ffd39d97..92b0cbecdd9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts @@ -1,5 +1,5 @@ import { applyTableFormat } from '../table/applyTableFormat'; -import { deleteSelection } from '../edit/deleteSelection'; +import { deleteSelection } from '../../publicApi/selection/deleteSelection'; import { getClosestAncestorBlockGroupIndex } from './getClosestAncestorBlockGroupIndex'; import { normalizeTable } from '../table/normalizeTable'; import { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts index fa5c322f997..b8bd6c78922 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts @@ -1,9 +1,9 @@ import { createInsertPoint } from '../utils/createInsertPoint'; -import { deleteBlock } from '../utils/deleteBlock'; -import { deleteSegment } from '../utils/deleteSegment'; +import { deleteBlock } from '../../../publicApi/block/deleteBlock'; +import { deleteSegment } from '../../../publicApi/segment/deleteSegment'; import { iterateSelections } from '../../selection/iterateSelections'; +import type { DeleteSelectionContext } from '../../../publicTypes/parameter/DeleteSelectionStep'; import type { ContentModelDocument } from 'roosterjs-content-model-types'; -import type { DeleteSelectionContext } from '../utils/DeleteSelectionStep'; import type { FormatWithContentModelContext } from '../../../publicTypes/parameter/FormatWithContentModelContext'; import type { IterateSelectionsOption } from '../../selection/iterateSelections'; import { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts index ff76454cc9b..71903880ceb 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts @@ -1,4 +1,4 @@ -import { deleteSelection } from '../edit/deleteSelection'; +import { deleteSelection } from '../../publicApi/selection/deleteSelection'; import { getClosestAncestorBlockGroupIndex } from '../common/getClosestAncestorBlockGroupIndex'; import { setSelection } from '../selection/setSelection'; import { @@ -7,7 +7,7 @@ import { createSelectionMarker, normalizeContentModel, } from 'roosterjs-content-model-dom'; -import type { DeleteSelectionResult } from '../edit/utils/DeleteSelectionStep'; +import type { DeleteSelectionResult } from '../../publicTypes/parameter/DeleteSelectionStep'; import type { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; import type { InsertEntityPosition } from '../../publicTypes/parameter/InsertEntityOptions'; import type { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts index d68be3fd827..3df6e54049a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts @@ -1,4 +1,4 @@ -import { deleteSelection } from '../../modelApi/edit/deleteSelection'; +import { deleteSelection } from '../../publicApi/selection/deleteSelection'; import { isBlockElement, isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteBlock.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/deleteBlock.ts similarity index 77% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteBlock.ts rename to packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/deleteBlock.ts index ce50a4ef827..ae69530525c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteBlock.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/deleteBlock.ts @@ -1,11 +1,17 @@ -import type { ContentModelBlock } from 'roosterjs-content-model-types'; import type { EntityRemovalOperation, FormatWithContentModelContext, -} from '../../../publicTypes/parameter/FormatWithContentModelContext'; +} from '../../publicTypes/parameter/FormatWithContentModelContext'; +import type { ContentModelBlock } from 'roosterjs-content-model-types'; /** - * @internal + * Delete a content model block from current selection + * @param blocks Array of the block to delete + * @param blockToDelete The block to delete + * @param replacement @optional If specified, use this block to replace the deleted block + * @param context @optional Context object provided by formatContentModel API + * @param direction @optional Whether this is deleting forward or backward. This is only used for deleting entity. + * If not specified, only selected entity will be deleted */ export function deleteBlock( blocks: ContentModelBlock[], diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/deleteSegment.ts similarity index 82% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts rename to packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/deleteSegment.ts index 26630106e3a..6f882ac7bb6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/deleteSegment.ts @@ -1,14 +1,19 @@ -import { deleteSingleChar } from './deleteSingleChar'; +import { deleteSingleChar } from '../../modelApi/edit/utils/deleteSingleChar'; import { isWhiteSpacePreserved, normalizeSingleSegment } from 'roosterjs-content-model-dom'; -import { normalizeText } from '../../../domUtils/stringUtil'; +import { normalizeText } from '../../domUtils/stringUtil'; import type { ContentModelParagraph, ContentModelSegment } from 'roosterjs-content-model-types'; import type { EntityRemovalOperation, FormatWithContentModelContext, -} from '../../../publicTypes/parameter/FormatWithContentModelContext'; +} from '../../publicTypes/parameter/FormatWithContentModelContext'; /** - * @internal + * Delete a content model segment from current selection + * @param paragraph Parent paragraph of the segment to delete + * @param segmentToDelete The segment to delete + * @param context @optional Context object provided by formatContentModel API + * @param direction @optional Whether this is deleting forward or backward. This is only used for deleting entity. + * If not specified, only selected entity will be deleted */ export function deleteSegment( paragraph: ContentModelParagraph, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/deleteSelection.ts similarity index 77% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSelection.ts rename to packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/deleteSelection.ts index cdca6cee69e..7df0a5b4951 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/deleteSelection.ts @@ -1,4 +1,4 @@ -import { deleteExpandedSelection } from './utils/deleteExpandedSelection'; +import { deleteExpandedSelection } from '../../modelApi/edit/utils/deleteExpandedSelection'; import type { ContentModelDocument } from 'roosterjs-content-model-types'; import type { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; import type { @@ -6,10 +6,14 @@ import type { DeleteSelectionResult, DeleteSelectionStep, ValidDeleteSelectionContext, -} from './utils/DeleteSelectionStep'; +} from '../../publicTypes/parameter/DeleteSelectionStep'; /** - * @internal + * Delete selected content from Content Model + * @param model The model to delete selected content from + * @param additionalSteps @optional Addition delete steps + * @param formatContext @optional A context object provided by formatContentModel API + * @returns A DeleteSelectionResult object to specify the deletion result */ export function deleteSelection( model: ContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts index 810dea9e607..7f2d08f05e6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts @@ -1,7 +1,7 @@ import { applyTableFormat } from '../../modelApi/table/applyTableFormat'; import { createContentModelDocument, createSelectionMarker } from 'roosterjs-content-model-dom'; import { createTableStructure } from '../../modelApi/table/createTableStructure'; -import { deleteSelection } from '../../modelApi/edit/deleteSelection'; +import { deleteSelection } from '../selection/deleteSelection'; import { mergeModel } from '../../modelApi/common/mergeModel'; import { normalizeTable } from '../../modelApi/table/normalizeTable'; import { setSelection } from '../../modelApi/selection/setSelection'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/DeleteSelectionStep.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/DeleteSelectionStep.ts similarity index 83% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/DeleteSelectionStep.ts rename to packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/DeleteSelectionStep.ts index 70719614f96..103f2e48a52 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/DeleteSelectionStep.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/DeleteSelectionStep.ts @@ -1,10 +1,9 @@ +import type { FormatWithContentModelContext } from './FormatWithContentModelContext'; +import type { InsertPoint } from '../selection/InsertPoint'; +import type { TableSelectionContext } from '../selection/TableSelectionContext'; import type { ContentModelParagraph } from 'roosterjs-content-model-types'; -import type { FormatWithContentModelContext } from '../../../publicTypes/parameter/FormatWithContentModelContext'; -import type { InsertPoint } from '../../../publicTypes/selection/InsertPoint'; -import type { TableSelectionContext } from '../../../publicTypes/selection/TableSelectionContext'; /** - * @internal * Delete selection result */ export type DeleteResult = @@ -29,7 +28,6 @@ export type DeleteResult = | 'nothingToDelete'; /** - * @internal * Result of deleteSelection API */ export interface DeleteSelectionResult { @@ -45,7 +43,6 @@ export interface DeleteSelectionResult { } /** - * @internal * A context object used by DeleteSelectionStep */ export interface DeleteSelectionContext extends DeleteSelectionResult { @@ -66,7 +63,6 @@ export interface DeleteSelectionContext extends DeleteSelectionResult { } /** - * @internal * DeleteSelectionContext with a valid insert point that can be handled by next step */ export interface ValidDeleteSelectionContext extends DeleteSelectionContext { @@ -77,7 +73,6 @@ export interface ValidDeleteSelectionContext extends DeleteSelectionContext { } /** - * @internal * Represents a step function for deleteSelection API * @param context The valid delete selection context object returned from previous step */ diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts index 02cc93c65f3..81bf05c5caa 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts @@ -1,5 +1,4 @@ import * as ContentModelCachePlugin from '../../lib/editor/corePlugins/ContentModelCachePlugin'; -import * as ContentModelEditPlugin from '../../lib/editor/corePlugins/ContentModelEditPlugin'; import * as ContentModelFormatPlugin from '../../lib/editor/corePlugins/ContentModelFormatPlugin'; import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as createEditorCore from 'roosterjs-editor-core/lib/editor/createEditorCore'; @@ -29,7 +28,6 @@ const mockedModelToDomConfig = { config: 'mockedModelToDomConfig', } as any; const mockedFormatPlugin = 'FORMATPLUGIN' as any; -const mockedEditPlugin = 'EDITPLUGIN' as any; const mockedCachePlugin = 'CACHPLUGIN' as any; describe('createContentModelEditorCore', () => { @@ -63,9 +61,6 @@ describe('createContentModelEditorCore', () => { spyOn(ContentModelFormatPlugin, 'createContentModelFormatPlugin').and.returnValue( mockedFormatPlugin ); - spyOn(ContentModelEditPlugin, 'createContentModelEditPlugin').and.returnValue( - mockedEditPlugin - ); spyOn(ContentModelCachePlugin, 'createContentModelCachePlugin').and.returnValue( mockedCachePlugin ); @@ -87,7 +82,7 @@ describe('createContentModelEditorCore', () => { const core = createContentModelEditorCore(contentDiv, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { - plugins: [mockedCachePlugin, mockedFormatPlugin, mockedEditPlugin], + plugins: [mockedCachePlugin, mockedFormatPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), copyPaste: copyPastePlugin, @@ -167,7 +162,7 @@ describe('createContentModelEditorCore', () => { expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { defaultDomToModelOptions, defaultModelToDomOptions, - plugins: [mockedCachePlugin, mockedFormatPlugin, mockedEditPlugin], + plugins: [mockedCachePlugin, mockedFormatPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), copyPaste: copyPastePlugin, @@ -253,7 +248,7 @@ describe('createContentModelEditorCore', () => { const core = createContentModelEditorCore(contentDiv, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { - plugins: [mockedCachePlugin, mockedFormatPlugin, mockedEditPlugin], + plugins: [mockedCachePlugin, mockedFormatPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), copyPaste: copyPastePlugin, @@ -336,7 +331,7 @@ describe('createContentModelEditorCore', () => { const core = createContentModelEditorCore(contentDiv, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { - plugins: [mockedCachePlugin, mockedFormatPlugin, mockedEditPlugin], + plugins: [mockedCachePlugin, mockedFormatPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), copyPaste: copyPastePlugin, @@ -412,7 +407,7 @@ describe('createContentModelEditorCore', () => { const core = createContentModelEditorCore(contentDiv, options); expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { - plugins: [mockedCachePlugin, mockedFormatPlugin, mockedEditPlugin], + plugins: [mockedCachePlugin, mockedFormatPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), copyPaste: copyPastePlugin, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts index a56a1f13976..c8363634d8c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts @@ -1,7 +1,7 @@ import * as addRangeToSelection from '../../../lib/domUtils/addRangeToSelection'; import * as cloneModelFile from '../../../lib/publicApi/model/cloneModel'; import * as contentModelToDomFile from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; -import * as deleteSelectionsFile from '../../../lib/modelApi/edit/deleteSelection'; +import * as deleteSelectionsFile from '../../../lib/publicApi/selection/deleteSelection'; import * as extractClipboardItemsFile from 'roosterjs-editor-dom/lib/clipboard/extractClipboardItems'; import * as iterateSelectionsFile from '../../../lib/modelApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts index 6d22c95196f..7f4636b508a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts @@ -1,30 +1,19 @@ -import { ContentModelEntity, ContentModelSelectionMarker } from 'roosterjs-content-model-types'; +import { ContentModelSelectionMarker } from 'roosterjs-content-model-types'; import { DeletedEntity } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; -import { deleteSelection } from '../../../lib/modelApi/edit/deleteSelection'; +import { deleteSelection } from '../../../lib/publicApi/selection/deleteSelection'; import { - createBr, createContentModelDocument, createDivider, createEntity, - createFormatContainer, createGeneralBlock, createGeneralSegment, createImage, - createListItem, createParagraph, createSelectionMarker, createTable, createTableCell, createText, } from 'roosterjs-content-model-dom'; -import { - backwardDeleteWordSelection, - forwardDeleteWordSelection, -} from '../../../lib/modelApi/edit/deleteSteps/deleteWordSelection'; -import { - backwardDeleteCollapsedSelection, - forwardDeleteCollapsedSelection, -} from '../../../lib/modelApi/edit/deleteSteps/deleteCollapsedSelection'; describe('deleteSelection - selectionOnly', () => { it('empty selection', () => { @@ -975,3611 +964,3 @@ describe('deleteSelection - selectionOnly', () => { }); }); }); - -describe('deleteSelection - forward', () => { - it('empty selection', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - - model.blocks.push(para); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [], - }, - ], - }); - - expect(result.deleteResult).toBe('notDeleted'); - expect(result.insertPoint).toBeNull(); - }); - - it('Single selection marker', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - - para.segments.push(marker); - model.blocks.push(para); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('nothingToDelete'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Single selection marker with text after', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const segment = createText('test'); - - para.segments.push(marker, segment); - model.blocks.push(para); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('singleChar'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - { - segmentType: 'Text', - format: {}, - text: 'est', - }, - ], - }, - ], - }); - }); - - it('Single selection marker at end of paragraph', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(); - const para2 = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const text1 = createText('test1'); - const text2 = createText('test2'); - - para1.segments.push(text1, marker); - para2.segments.push(text2); - model.blocks.push(para1, para2); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para1, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'test1', - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - { - segmentType: 'Text', - format: {}, - text: 'test2', - }, - ], - }, - { - blockType: 'Paragraph', - format: {}, - segments: [], - }, - ], - }); - }); - - it('Single selection marker in empty paragraph with BR', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(); - const para2 = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - const text = createText('test'); - - para1.segments.push(marker, br); - para2.segments.push(text); - model.blocks.push(para1, para2); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para1, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - { - segmentType: 'Text', - format: {}, - text: 'test', - }, - ], - }, - { - blockType: 'Paragraph', - format: {}, - segments: [], - }, - ], - }); - }); - - it('Single selection marker in empty paragraph with double BRs', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(); - const para2 = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br1 = createBr(); - const br2 = createBr(); - const text = createText('test'); - - para1.segments.push(marker, br1, br2); - para2.segments.push(text); - model.blocks.push(para1, para2); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('singleChar'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para1, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - }, - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'test', - }, - ], - }, - ], - }); - }); - - it('Double selection marker in 2 paragraphs', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(); - const para2 = createParagraph(); - const marker1 = createSelectionMarker({ fontSize: '10px' }); - const marker2 = createSelectionMarker({ fontSize: '20px' }); - const text1 = createText('test1'); - const text2 = createText('test2'); - - para1.segments.push(text1, marker1); - para2.segments.push(marker2, text2); - model.blocks.push(para1, para2); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para1, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'test1', - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - { - segmentType: 'Text', - format: {}, - text: 'test2', - }, - ], - }, - { - blockType: 'Paragraph', - format: {}, - segments: [], - }, - ], - }); - }); - - it('Single selection marker before image', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const image = createImage(''); - - para.segments.push(marker, image); - model.blocks.push(para); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('singleChar'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Single selection marker before table', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - const table = createTable(1); - - table.rows[0].cells.push(createTableCell()); - para.segments.push(marker, br); - model.blocks.push(para, table); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Single selection marker before divider', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - const divider = createDivider('hr'); - - para.segments.push(marker, br); - model.blocks.push(para, divider); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Single selection marker before entity, no callback', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - const wrapper = 'WRAPPER' as any; - const entity = createEntity(wrapper); - - para.segments.push(marker, br); - model.blocks.push(para, entity); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Single selection marker before entity, with callback returns false', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - const wrapper = 'WRAPPER' as any; - const entity = createEntity(wrapper); - - para.segments.push(marker, br); - model.blocks.push(para, entity); - - const deletedEntities: DeletedEntity[] = []; - const result = deleteSelection(model, [forwardDeleteCollapsedSelection], { - newEntities: [], - deletedEntities, - newImages: [], - }); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - expect(deletedEntities).toEqual([{ entity, operation: 'removeFromStart' }]); - }); - - it('Single selection marker before entity, with callback returns true', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - const wrapper = 'WRAPPER' as any; - const entity = createEntity(wrapper); - - para.segments.push(marker, br); - model.blocks.push(para, entity); - - const deletedEntities: DeletedEntity[] = []; - const result = deleteSelection(model, [forwardDeleteCollapsedSelection], { - newEntities: [], - deletedEntities, - newImages: [], - }); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - expect(deletedEntities).toEqual([{ entity, operation: 'removeFromStart' }]); - }); - - it('Single selection marker before list item', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(); - const para2 = createParagraph(); - const listItem = createListItem([]); - const text = createText('test'); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - - para1.segments.push(marker, br); - para2.segments.push(text); - listItem.blocks.push(para2); - model.blocks.push(para1, listItem); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para1, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - { - segmentType: 'Text', - format: {}, - text: 'test', - }, - ], - }, - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [], - format: {}, - }, - ], - format: {}, - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - levels: [], - }, - ], - }); - }); - - it('Single selection marker before quote', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(); - const para2 = createParagraph(); - const quote = createFormatContainer('blockquote'); - const text = createText('test'); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - - para1.segments.push(marker, br); - para2.segments.push(text); - quote.blocks.push(para2); - model.blocks.push(para1, quote); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para1, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - { - segmentType: 'Text', - format: {}, - text: 'test', - }, - ], - }, - { - blockType: 'BlockGroup', - blockGroupType: 'FormatContainer', - tagName: 'blockquote', - blocks: [ - { - blockType: 'Paragraph', - segments: [], - format: {}, - }, - ], - format: {}, - }, - ], - }); - }); - - it('Single selection marker is under quote', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(); - const para2 = createParagraph(); - const quote = createFormatContainer('blockquote'); - const text = createText('test'); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - - para1.segments.push(marker, br); - para2.segments.push(text); - quote.blocks.push(para1); - model.blocks.push(quote, para2); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para1, - path: [quote, model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'BlockGroup', - blockGroupType: 'FormatContainer', - tagName: 'blockquote', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - { - segmentType: 'Text', - format: {}, - text: 'test', - }, - ], - }, - ], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - }, - ], - }); - }); - - it('Single selection marker is under quote, next block is list', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(); - const para2 = createParagraph(); - const quote = createFormatContainer('blockquote'); - const listItem = createListItem([]); - const text = createText('test'); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - - para1.segments.push(marker, br); - para2.segments.push(text); - quote.blocks.push(para1); - listItem.blocks.push(para2); - model.blocks.push(quote, listItem); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para1, - path: [quote, model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'BlockGroup', - blockGroupType: 'FormatContainer', - tagName: 'blockquote', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - { - segmentType: 'Text', - format: {}, - text: 'test', - }, - ], - }, - ], - format: {}, - }, - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - format: {}, - levels: [], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - blocks: [ - { - blockType: 'Paragraph', - segments: [], - format: {}, - }, - ], - }, - ], - }); - }); - - it('Single text selection', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const text1 = createText('test1', { fontSize: '10px' }); - const text2 = createText('test2', { fontSize: '20px' }); - - text1.isSelected = true; - para.segments.push(text1, text2); - model.blocks.push(para); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - { - segmentType: 'Text', - format: { fontSize: '20px' }, - text: 'test2', - }, - ], - }, - ], - }); - }); - - it('Multiple text selection in multiple paragraphs', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(); - const para2 = createParagraph(); - const text0 = createText('test0', { fontSize: '10px' }); - const text1 = createText('test1', { fontSize: '11px' }); - const text2 = createText('test2', { fontSize: '12px' }); - - text1.isSelected = true; - text2.isSelected = true; - - para1.segments.push(text0); - para1.segments.push(text1); - para2.segments.push(text2); - - model.blocks.push(para1); - model.blocks.push(para2); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '11px' }, - isSelected: true, - }, - paragraph: para1, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test0', - format: { fontSize: '10px' }, - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '11px' }, - isSelected: true, - }, - ], - }, - { - blockType: 'Paragraph', - format: {}, - segments: [], - }, - ], - }); - }); - - it('Divider selection', () => { - const model = createContentModelDocument(); - const divider = createDivider('div'); - - divider.isSelected = true; - model.blocks.push(divider); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - paragraph: { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - format: {}, - isImplicit: false, - }, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - isImplicit: false, - }, - ], - }); - }); - - it('2 Divider selection and paragraph after it', () => { - const model = createContentModelDocument(); - const divider1 = createDivider('div'); - const divider2 = createDivider('hr'); - const para1 = createParagraph(); - const para2 = createParagraph(); - - divider1.isSelected = true; - divider2.isSelected = true; - model.blocks.push(para1, divider1, divider2, para2); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - paragraph: { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - format: {}, - isImplicit: false, - }, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [], - }, - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - isImplicit: false, - }, - { - blockType: 'Paragraph', - format: {}, - segments: [], - isImplicit: true, - }, - { - blockType: 'Paragraph', - format: {}, - segments: [], - }, - ], - }); - }); - - it('Some table cell selection', () => { - const model = createContentModelDocument(); - const table = createTable(1); - const cell1 = createTableCell(); - const cell2 = createTableCell(); - - cell2.isSelected = true; - - table.rows[0].cells.push(cell1, cell2); - model.blocks.push(table); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - paragraph: { - blockType: 'Paragraph', - isImplicit: false, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - format: {}, - }, - path: [cell2, model], - tableContext: { - table: table, - colIndex: 1, - rowIndex: 0, - isWholeTableSelected: false, - }, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Table', - format: {}, - dataset: {}, - widths: [], - rows: [ - { - format: {}, - height: 0, - cells: [ - { - blockGroupType: 'TableCell', - format: {}, - dataset: {}, - spanAbove: false, - spanLeft: false, - isHeader: false, - blocks: [], - }, - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: false, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - isSelected: true, - }, - ], - }, - ], - }, - ], - }); - }); - - it('All table cell selection', () => { - const model = createContentModelDocument(); - const table = createTable(1); - const cell = createTableCell(); - - cell.isSelected = true; - - table.rows[0].cells.push(cell); - model.blocks.push(table); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - paragraph: { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - format: {}, - isImplicit: false, - }, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - isImplicit: false, - }, - ], - }); - }); - - it('delete with default format', () => { - const model = createContentModelDocument({ - fontSize: '10pt', - }); - const divider = createDivider('div'); - - divider.isSelected = true; - model.blocks.push(divider); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - const marker: ContentModelSelectionMarker = { - segmentType: 'SelectionMarker', - format: { fontSize: '10pt' }, - isSelected: true, - }; - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker, - paragraph: { - blockType: 'Paragraph', - segments: [marker], - format: {}, - isImplicit: false, - segmentFormat: { fontSize: '10pt' }, - }, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [marker], - isImplicit: false, - segmentFormat: { fontSize: '10pt' }, - }, - ], - format: { fontSize: '10pt' }, - }); - }); - - it('Delete from general segment, no sibling', () => { - const model = createContentModelDocument(); - const parentParagraph = createParagraph(); - const general = createGeneralSegment(null!); - const para = createParagraph(); - const marker = createSelectionMarker(); - - para.segments.push(marker); - general.blocks.push(para); - parentParagraph.segments.push(general); - model.blocks.push(parentParagraph); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('nothingToDelete'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [general, model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - blockType: 'BlockGroup', - blockGroupType: 'General', - segmentType: 'General', - format: {}, - blocks: [ - { - blockType: 'Paragraph', - segments: [marker], - format: {}, - }, - ], - element: null!, - }, - ], - }, - ], - }); - }); - - it('Delete from general segment, has sibling', () => { - const model = createContentModelDocument(); - const parentParagraph = createParagraph(); - const general = createGeneralSegment(null!); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text = createText('test'); - - para.segments.push(marker); - general.blocks.push(para); - parentParagraph.segments.push(general, text); - model.blocks.push(parentParagraph); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [general, model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - blockType: 'BlockGroup', - blockGroupType: 'General', - segmentType: 'General', - format: {}, - blocks: [ - { - blockType: 'Paragraph', - segments: [marker], - format: {}, - }, - ], - element: null!, - }, - { - segmentType: 'Text', - text: 'est', - format: {}, - }, - ], - }, - ], - }); - }); - - it('Delete text and need to convert space to  ', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text = createText(' test'); - - para.segments.push(marker, text); - model.blocks.push(para); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('singleChar'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Text', - text: '\u00A0test', - format: {}, - }, - ], - }, - ], - }); - }); - - it('Delete text and no need to convert space to   when preserve white space', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text = createText(' test'); - - para.format.whiteSpace = 'pre'; - para.segments.push(marker, text); - model.blocks.push(para); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('singleChar'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: { - whiteSpace: 'pre', - }, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Text', - text: ' test', - format: {}, - }, - ], - }, - ], - }); - }); - - it('Normalize text and space before deleted content', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text1 = createText('test1 '); - const text2 = createText('test2'); - - para.segments.push(text1, marker, text2); - model.blocks.push(para); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('singleChar'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test1\u00A0', - format: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'est2', - format: {}, - }, - ], - }, - ], - }); - }); - - it('Normalize text and space before deleted content, delete empty text', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text1 = createText('test1 '); - const text2 = createText('a'); - - para.segments.push(text1, marker, text2); - model.blocks.push(para); - - const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('singleChar'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test1\u00A0', - format: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Delete word: text+space+text', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text1 = createText('test1'); - const text2 = createText(' '); - const text3 = createText('test2'); - - para.segments.push(marker, text1, text2, text3); - model.blocks.push(para); - - const result = deleteSelection(model, [forwardDeleteWordSelection]); - - expect(result.deleteResult).toBe('range'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test2', - format: {}, - }, - ], - }, - ], - }); - }); - - it('Delete word: space+text+space+text', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text1 = createText(' test1 test2'); - - para.segments.push(marker, text1); - model.blocks.push(para); - - const result = deleteSelection(model, [forwardDeleteWordSelection]); - - expect(result.deleteResult).toBe('range'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test1 test2', - format: {}, - }, - ], - }, - ], - }); - }); - - it('Delete word: text+punc+space+text', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text1 = createText('test1. test2'); - - para.segments.push(marker, text1); - model.blocks.push(para); - - const result = deleteSelection(model, [forwardDeleteWordSelection]); - - expect(result.deleteResult).toBe('range'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Text', - text: '. test2', - format: {}, - }, - ], - }, - ], - }); - }); - - it('Delete word: punc+space+text', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text1 = createText('. test2'); - - para.segments.push(marker, text1); - model.blocks.push(para); - - const result = deleteSelection(model, [forwardDeleteWordSelection]); - - expect(result.deleteResult).toBe('range'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test2', - format: {}, - }, - ], - }, - ], - }); - }); -}); - -describe('deleteSelection - backward', () => { - it('empty selection', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - - model.blocks.push(para); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [], - }, - ], - }); - - expect(result.deleteResult).toBe('notDeleted'); - expect(result.insertPoint).toBeNull(); - }); - - it('Single selection marker', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - - para.segments.push(marker); - model.blocks.push(para); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('nothingToDelete'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Single selection marker with text before', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const segment = createText('test'); - - para.segments.push(segment, marker); - model.blocks.push(para); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('singleChar'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'tes', - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Single selection marker at beginning of paragraph', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(); - const para2 = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const text1 = createText('test1'); - const text2 = createText('test2'); - - para1.segments.push(text1); - para2.segments.push(marker, text2); - model.blocks.push(para1, para2); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para1, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'test1', - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - { - segmentType: 'Text', - format: {}, - text: 'test2', - }, - ], - }, - { - blockType: 'Paragraph', - format: {}, - segments: [], - }, - ], - }); - }); - - it('Single selection marker after empty paragraph with BR', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(false, { lineHeight: '10' }); - const para2 = createParagraph(false, { lineHeight: '12' }); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - const text = createText('test'); - - para1.segments.push(br); - para2.segments.push(marker, text); - model.blocks.push(para1, para2); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para1, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: { lineHeight: '10' }, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - { - segmentType: 'Text', - format: {}, - text: 'test', - }, - ], - }, - { - blockType: 'Paragraph', - format: { lineHeight: '12' }, - segments: [], - }, - ], - }); - }); - - it('Single selection marker after empty paragraph with double BRs', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(false, { lineHeight: '10' }); - const para2 = createParagraph(false, { lineHeight: '11' }); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br1 = createBr(); - const br2 = createBr(); - const text = createText('test'); - - para1.segments.push(br1, br2); - para2.segments.push(marker, text); - model.blocks.push(para1, para2); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para1, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: { lineHeight: '10' }, - segments: [ - { - segmentType: 'Br', - format: {}, - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - { - segmentType: 'Text', - format: {}, - text: 'test', - }, - ], - }, - { - blockType: 'Paragraph', - format: { lineHeight: '11' }, - segments: [], - }, - ], - }); - }); - - it('Double selection marker in 2 paragraphs', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(false, { lineHeight: '10' }); - const para2 = createParagraph(false, { lineHeight: '11' }); - const marker1 = createSelectionMarker({ fontSize: '10px' }); - const marker2 = createSelectionMarker({ fontSize: '20px' }); - const text1 = createText('test1'); - const text2 = createText('test2'); - - para1.segments.push(text1, marker1); - para2.segments.push(marker2, text2); - model.blocks.push(para1, para2); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para1, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: { lineHeight: '10' }, - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'test1', - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - { - segmentType: 'Text', - format: {}, - text: 'test2', - }, - ], - }, - { - blockType: 'Paragraph', - format: { lineHeight: '11' }, - segments: [], - }, - ], - }); - }); - - it('Single selection marker after image', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const image = createImage(''); - - para.segments.push(image, marker); - model.blocks.push(para); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('singleChar'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Single selection marker after table', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - const table = createTable(1); - - table.rows[0].cells.push(createTableCell()); - para.segments.push(marker, br); - model.blocks.push(table, para); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Single selection marker after divider', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - const divider = createDivider('hr'); - - para.segments.push(marker, br); - model.blocks.push(divider, para); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Single selection marker after entity, no callback', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - const wrapper = 'WRAPPER' as any; - const entity = createEntity(wrapper); - - para.segments.push(marker, br); - model.blocks.push(entity, para); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Single selection marker after entity, with callback returns false', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - const wrapper = 'WRAPPER' as any; - const entity = createEntity(wrapper); - - para.segments.push(marker, br); - model.blocks.push(entity, para); - - const deletedEntities: DeletedEntity[] = []; - const result = deleteSelection(model, [backwardDeleteCollapsedSelection], { - newEntities: [], - deletedEntities, - newImages: [], - }); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - expect(deletedEntities).toEqual([{ entity, operation: 'removeFromEnd' }]); - }); - - it('Single selection marker after entity, with callback returns true', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - const wrapper = 'WRAPPER' as any; - const entity = createEntity(wrapper); - - para.segments.push(marker, br); - model.blocks.push(entity, para); - - const deletedEntities: DeletedEntity[] = []; - const newEntities: ContentModelEntity[] = []; - const result = deleteSelection(model, [backwardDeleteCollapsedSelection], { - newEntities, - deletedEntities, - newImages: [], - }); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - }, - ], - }); - expect(deletedEntities).toEqual([{ entity, operation: 'removeFromEnd' }]); - }); - - it('Single selection marker after list item', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(false, { lineHeight: '10' }); - const para2 = createParagraph(false, { lineHeight: '11' }); - const listItem = createListItem([]); - const text = createText('test'); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - - para1.segments.push(marker, br); - para2.segments.push(text); - listItem.blocks.push(para2); - model.blocks.push(listItem, para1); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para2, - path: [listItem, model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'test', - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - format: { lineHeight: '11' }, - }, - ], - format: {}, - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - levels: [], - }, - { - blockType: 'Paragraph', - format: { lineHeight: '10' }, - segments: [], - }, - ], - }); - }); - - it('Single selection marker after quote', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(false, { lineHeight: '10' }); - const para2 = createParagraph(false, { lineHeight: '11' }); - const quote = createFormatContainer('blockquote'); - const text = createText('test'); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - - para1.segments.push(marker, br); - para2.segments.push(text); - quote.blocks.push(para2); - model.blocks.push(quote, para1); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para2, - path: [quote, model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'BlockGroup', - blockGroupType: 'FormatContainer', - tagName: 'blockquote', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'test', - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - format: { lineHeight: '11' }, - }, - ], - format: {}, - }, - { - blockType: 'Paragraph', - format: { lineHeight: '10' }, - segments: [], - }, - ], - }); - }); - - it('Single selection marker is under quote', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(false, { lineHeight: '10' }); - const para2 = createParagraph(false, { lineHeight: '11' }); - const quote = createFormatContainer('blockquote'); - const text = createText('test'); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - - para1.segments.push(marker, br); - para2.segments.push(text); - quote.blocks.push(para1); - model.blocks.push(para2, quote); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para2, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'test', - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - format: { lineHeight: '11' }, - }, - { - blockType: 'BlockGroup', - blockGroupType: 'FormatContainer', - tagName: 'blockquote', - blocks: [ - { - blockType: 'Paragraph', - format: { lineHeight: '10' }, - segments: [], - }, - ], - format: {}, - }, - ], - }); - }); - - it('Single selection marker is under quote, previous block is list', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(false, { lineHeight: '10' }); - const para2 = createParagraph(false, { lineHeight: '11' }); - const quote = createFormatContainer('blockquote'); - const listItem = createListItem([]); - const text = createText('test'); - const marker = createSelectionMarker({ fontSize: '10px' }); - const br = createBr(); - - para1.segments.push(marker, br); - para2.segments.push(text); - quote.blocks.push(para1); - listItem.blocks.push(para2); - model.blocks.push(listItem, quote); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para2, - path: [listItem, model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'BlockGroup', - blockGroupType: 'ListItem', - format: {}, - levels: [], - formatHolder: { - segmentType: 'SelectionMarker', - isSelected: true, - format: {}, - }, - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - format: {}, - text: 'test', - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - ], - format: { lineHeight: '11' }, - }, - ], - }, - { - blockType: 'BlockGroup', - blocks: [ - { - blockType: 'Paragraph', - format: { lineHeight: '10' }, - segments: [], - }, - ], - format: {}, - blockGroupType: 'FormatContainer', - tagName: 'blockquote', - }, - ], - }); - }); - - it('Single text selection', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const text1 = createText('test1', { fontSize: '10px' }); - const text2 = createText('test2', { fontSize: '20px' }); - - text1.isSelected = true; - para.segments.push(text1, text2); - model.blocks.push(para); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - paragraph: para, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: { fontSize: '10px' }, - isSelected: true, - }, - { - segmentType: 'Text', - format: { fontSize: '20px' }, - text: 'test2', - }, - ], - }, - ], - }); - }); - - it('Multiple text selection in multiple paragraphs', () => { - const model = createContentModelDocument(); - const para1 = createParagraph(); - const para2 = createParagraph(); - const text0 = createText('test0', { fontSize: '10px' }); - const text1 = createText('test1', { fontSize: '11px' }); - const text2 = createText('test2', { fontSize: '12px' }); - - text1.isSelected = true; - text2.isSelected = true; - - para1.segments.push(text0); - para1.segments.push(text1); - para2.segments.push(text2); - - model.blocks.push(para1); - model.blocks.push(para2); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: { fontSize: '11px' }, - isSelected: true, - }, - paragraph: para1, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test0', - format: { fontSize: '10px' }, - }, - { - segmentType: 'SelectionMarker', - format: { fontSize: '11px' }, - isSelected: true, - }, - ], - }, - { - blockType: 'Paragraph', - format: {}, - segments: [], - }, - ], - }); - }); - - it('Divider selection', () => { - const model = createContentModelDocument(); - const divider = createDivider('div'); - - divider.isSelected = true; - model.blocks.push(divider); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - paragraph: { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - format: {}, - isImplicit: false, - }, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - isImplicit: false, - }, - ], - }); - }); - - it('2 Divider selection and paragraph after it', () => { - const model = createContentModelDocument(); - const divider1 = createDivider('div'); - const divider2 = createDivider('hr'); - const para1 = createParagraph(); - const para2 = createParagraph(); - - divider1.isSelected = true; - divider2.isSelected = true; - model.blocks.push(para1, divider1, divider2, para2); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - paragraph: { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - format: {}, - isImplicit: false, - }, - path: [model], - tableContext: undefined, - }); - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [], - }, - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - isImplicit: false, - }, - { - blockType: 'Paragraph', - format: {}, - segments: [], - isImplicit: true, - }, - { - blockType: 'Paragraph', - format: {}, - segments: [], - }, - ], - }); - }); - - it('Some table cell selection', () => { - const model = createContentModelDocument(); - const table = createTable(1); - const cell1 = createTableCell(); - const cell2 = createTableCell(); - - cell2.isSelected = true; - - table.rows[0].cells.push(cell1, cell2); - model.blocks.push(table); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - paragraph: { - blockType: 'Paragraph', - isImplicit: false, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - format: {}, - }, - path: [cell2, model], - tableContext: { - table: table, - colIndex: 1, - rowIndex: 0, - isWholeTableSelected: false, - }, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Table', - format: {}, - dataset: {}, - widths: [], - rows: [ - { - format: {}, - height: 0, - cells: [ - { - blockGroupType: 'TableCell', - format: {}, - dataset: {}, - spanAbove: false, - spanLeft: false, - isHeader: false, - blocks: [], - }, - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: false, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Br', - format: {}, - }, - ], - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - isSelected: true, - }, - ], - }, - ], - }, - ], - }); - }); - - it('All table cell selection', () => { - const model = createContentModelDocument(); - const table = createTable(1); - const cell = createTableCell(); - - cell.isSelected = true; - - table.rows[0].cells.push(cell); - model.blocks.push(table); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker: { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - paragraph: { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - format: {}, - isImplicit: false, - }, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - isImplicit: false, - }, - ], - }); - }); - - it('delete with default format', () => { - const model = createContentModelDocument({ - fontSize: '10pt', - }); - const divider = createDivider('div'); - - divider.isSelected = true; - model.blocks.push(divider); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - const marker: ContentModelSelectionMarker = { - segmentType: 'SelectionMarker', - format: { fontSize: '10pt' }, - isSelected: true, - }; - - expect(result.deleteResult).toBe('range'); - expect(result.insertPoint).toEqual({ - marker, - paragraph: { - blockType: 'Paragraph', - segments: [marker], - format: {}, - isImplicit: false, - segmentFormat: { fontSize: '10pt' }, - }, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [marker], - isImplicit: false, - segmentFormat: { fontSize: '10pt' }, - }, - ], - format: { fontSize: '10pt' }, - }); - }); - - it('Delete from general segment, no sibling', () => { - const model = createContentModelDocument(); - const parentParagraph = createParagraph(); - const general = createGeneralSegment(null!); - const para = createParagraph(); - const marker = createSelectionMarker(); - - para.segments.push(marker); - general.blocks.push(para); - parentParagraph.segments.push(general); - model.blocks.push(parentParagraph); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('nothingToDelete'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [general, model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - blockType: 'BlockGroup', - blockGroupType: 'General', - segmentType: 'General', - format: {}, - blocks: [ - { - blockType: 'Paragraph', - segments: [marker], - format: {}, - }, - ], - element: null!, - }, - ], - }, - ], - }); - }); - - it('Delete from general segment, has sibling', () => { - const model = createContentModelDocument(); - const parentParagraph = createParagraph(); - const general = createGeneralSegment(null!); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text = createText('test'); - - para.segments.push(marker); - general.blocks.push(para); - parentParagraph.segments.push(text, general); - model.blocks.push(parentParagraph); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('range'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [general, model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'tes', - format: {}, - }, - { - blockType: 'BlockGroup', - blockGroupType: 'General', - segmentType: 'General', - format: {}, - blocks: [ - { - blockType: 'Paragraph', - segments: [marker], - format: {}, - }, - ], - element: null!, - }, - ], - }, - ], - }); - }); - - it('Delete text and need to convert space to  ', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text = createText('test '); - - para.segments.push(text, marker); - model.blocks.push(para); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('singleChar'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test\u00A0', - format: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Delete text and no need to convert space to   when preserve white space', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text = createText('test '); - - para.format.whiteSpace = 'pre'; - para.segments.push(text, marker); - model.blocks.push(para); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('singleChar'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: { - whiteSpace: 'pre', - }, - segments: [ - { - segmentType: 'Text', - text: 'test ', - format: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Normalize text and space before deleted content', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text1 = createText('test1 '); - const text2 = createText('test2'); - - para.segments.push(text1, text2, marker); - model.blocks.push(para); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('singleChar'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test1\u00A0', - format: {}, - }, - { - segmentType: 'Text', - text: 'test', - format: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Normalize text and space before deleted content, delete empty text', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text1 = createText('test1 '); - const text2 = createText('a'); - - para.segments.push(text1, text2, marker); - model.blocks.push(para); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('singleChar'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test1\u00A0', - format: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Delete word: text+space+text', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text1 = createText('test1'); - const text2 = createText(' '); - const text3 = createText('test2'); - - para.segments.push(text1, text2, text3, marker); - model.blocks.push(para); - - const result = deleteSelection(model, [backwardDeleteWordSelection]); - - expect(result.deleteResult).toBe('range'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test1', - format: {}, - }, - { - segmentType: 'Text', - text: ' ', - format: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Delete word: space+text+space+text', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text1 = createText('\u00A0 \u00A0test1 \u00A0 test2'); - - para.segments.push(text1, marker); - model.blocks.push(para); - - const result = deleteSelection(model, [backwardDeleteWordSelection]); - - expect(result.deleteResult).toBe('range'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: '\u00A0 \u00A0test1 \u00A0\u00A0', - format: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Delete word: text+punc+space+text', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text1 = createText('test1. test2'); - - para.segments.push(text1, marker); - model.blocks.push(para); - - const result = deleteSelection(model, [backwardDeleteWordSelection]); - - expect(result.deleteResult).toBe('range'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: 'test1.\u00A0', - format: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Delete word: punc+space+text', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text1 = createText('. test2'); - - para.segments.push(text1, marker); - model.blocks.push(para); - - const result = deleteSelection(model, [backwardDeleteWordSelection]); - - expect(result.deleteResult).toBe('range'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'Text', - text: '.\u00A0', - format: {}, - }, - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - }); - - it('Delete all before', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text1 = createText('test1'); - const text2 = createText('test2'); - const text3 = createText('test3'); - - para.segments.push(text1, text2, marker, text3); - model.blocks.push(para); - - const result = deleteSelection(model, [backwardDeleteWordSelection]); - - expect(result.deleteResult).toBe('range'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - { - segmentType: 'Text', - text: 'test3', - format: {}, - }, - ], - }, - ], - }); - }); - - it('Delete under an implicit paragraph', () => { - const model = createContentModelDocument(); - const para = createParagraph(); - const marker = createSelectionMarker(); - const text1 = createText('t'); - - para.isImplicit = true; - para.segments.push(text1, marker); - model.blocks.push(para); - - const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); - - expect(result.deleteResult).toBe('singleChar'); - - expect(result.insertPoint).toEqual({ - marker: marker, - paragraph: para, - path: [model], - tableContext: undefined, - }); - - expect(model).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: false, - segments: [ - { - segmentType: 'SelectionMarker', - format: {}, - isSelected: true, - }, - ], - }, - ], - }); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts index f7e9c72280d..603835047df 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts @@ -1,4 +1,4 @@ -import * as deleteSelection from '../../../lib/modelApi/edit/deleteSelection'; +import * as deleteSelection from '../../../lib/publicApi/selection/deleteSelection'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { applyDefaultFormat } from '../../../lib/modelApi/format/applyDefaultFormat'; import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts similarity index 83% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelEditPlugin.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts index 4cec1d433f6..02d347a71b0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts @@ -1,6 +1,6 @@ -import keyboardDelete from '../../publicApi/editing/keyboardDelete'; +import { keyboardDelete } from './keyboardDelete'; import { PluginEventType } from 'roosterjs-editor-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IContentModelEditor } from 'roosterjs-content-model-editor'; import type { EditorPlugin, IEditor, @@ -9,12 +9,12 @@ import type { } from 'roosterjs-editor-types'; /** - * ContentModel plugins helps editor to do editing operation on top of content model. + * ContentModel edit plugins helps editor to do editing operation on top of content model. * This includes: * 1. Delete Key * 2. Backspace Key */ -export default class ContentModelEditPlugin implements EditorPlugin { +export class ContentModelEditPlugin implements EditorPlugin { private editor: IContentModelEditor | null = null; /** @@ -76,12 +76,3 @@ export default class ContentModelEditPlugin implements EditorPlugin { } } } - -/** - * @internal - * Create a new instance of ContentModelEditPlugin class. - * This is mostly for unit test - */ -export function createContentModelEditPlugin() { - return new ContentModelEditPlugin(); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteAllSegmentBefore.ts similarity index 77% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteAllSegmentBefore.ts index 4322d24cc6c..c021c795159 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteAllSegmentBefore.ts @@ -1,5 +1,5 @@ -import { deleteSegment } from '../utils/deleteSegment'; -import type { DeleteSelectionStep } from '../utils/DeleteSelectionStep'; +import { deleteSegment } from 'roosterjs-content-model-editor'; +import type { DeleteSelectionStep } from 'roosterjs-content-model-editor'; /** * @internal diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteCollapsedSelection.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts similarity index 88% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteCollapsedSelection.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts index fc288d874a0..f600fe3433a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteCollapsedSelection.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts @@ -1,11 +1,9 @@ -import { createInsertPoint } from '../utils/createInsertPoint'; -import { deleteBlock } from '../utils/deleteBlock'; -import { deleteSegment } from '../utils/deleteSegment'; -import { getLeafSiblingBlock } from '../../block/getLeafSiblingBlock'; +import { deleteBlock, deleteSegment } from 'roosterjs-content-model-editor'; +import { getLeafSiblingBlock } from '../utils/getLeafSiblingBlock'; import { setParagraphNotImplicit } from 'roosterjs-content-model-dom'; -import type { BlockAndPath } from '../../block/getLeafSiblingBlock'; +import type { BlockAndPath } from '../utils/getLeafSiblingBlock'; import type { ContentModelSegment } from 'roosterjs-content-model-types'; -import type { DeleteSelectionStep } from '../utils/DeleteSelectionStep'; +import type { DeleteSelectionStep } from 'roosterjs-content-model-editor'; function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteSelectionStep { return context => { @@ -44,7 +42,12 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS block.segments.pop(); } - context.insertPoint = createInsertPoint(marker, block, path, tableContext); + context.insertPoint = { + marker, + paragraph: block, + path, + tableContext, + }; context.lastParagraph = paragraph; delete block.cachedElement; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteWordSelection.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteWordSelection.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts index 75e92ebdcc0..d7e3f9da391 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteWordSelection.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts @@ -1,7 +1,7 @@ -import { isPunctuation, isSpace, normalizeText } from '../../../domUtils/stringUtil'; +import { isPunctuation, isSpace, normalizeText } from 'roosterjs-content-model-editor'; import { isWhiteSpacePreserved } from 'roosterjs-content-model-dom'; import type { ContentModelParagraph } from 'roosterjs-content-model-types'; -import type { DeleteSelectionContext, DeleteSelectionStep } from '../utils/DeleteSelectionStep'; +import type { DeleteSelectionContext, DeleteSelectionStep } from 'roosterjs-content-model-editor'; const enum DeleteWordState { Start, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleKeyboardEventCommon.ts similarity index 89% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/edit/handleKeyboardEventCommon.ts index 96d339a3fe3..ff251a82368 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleKeyboardEventCommon.ts @@ -1,9 +1,11 @@ import { normalizeContentModel } from 'roosterjs-content-model-dom'; import { PluginEventType } from 'roosterjs-editor-types'; -import type { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; import type { ContentModelDocument } from 'roosterjs-content-model-types'; -import type { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { + DeleteResult, + FormatWithContentModelContext, + IContentModelEditor, +} from 'roosterjs-content-model-editor'; /** * @internal diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts similarity index 78% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts index e8c03b54440..957dff95b46 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/keyboardDelete.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts @@ -1,23 +1,20 @@ -import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; -import { deleteAllSegmentBefore } from '../../modelApi/edit/deleteSteps/deleteAllSegmentBefore'; -import { deleteSelection } from '../../modelApi/edit/deleteSelection'; -import { isModifierKey } from '../../domUtils/eventUtils'; +import { ChangeSource, deleteSelection, isModifierKey } from 'roosterjs-content-model-editor'; +import { deleteAllSegmentBefore } from './deleteSteps/deleteAllSegmentBefore'; import { isNodeOfType } from 'roosterjs-content-model-dom'; -import type { DeleteSelectionStep } from '../../modelApi/edit/utils/DeleteSelectionStep'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { handleKeyboardEventResult, shouldDeleteAllSegmentsBefore, shouldDeleteWord, -} from '../../editor/utils/handleKeyboardEventCommon'; +} from './handleKeyboardEventCommon'; import { backwardDeleteWordSelection, forwardDeleteWordSelection, -} from '../../modelApi/edit/deleteSteps/deleteWordSelection'; +} from './deleteSteps/deleteWordSelection'; import { backwardDeleteCollapsedSelection, forwardDeleteCollapsedSelection, -} from '../../modelApi/edit/deleteSteps/deleteCollapsedSelection'; +} from './deleteSteps/deleteCollapsedSelection'; +import type { DeleteSelectionStep, IContentModelEditor } from 'roosterjs-content-model-editor'; /** * @internal @@ -26,10 +23,7 @@ import { * @param rawEvent DOM keyboard event * @returns True if the event is handled with this function, otherwise false */ -export default function keyboardDelete( - editor: IContentModelEditor, - rawEvent: KeyboardEvent -): boolean { +export function keyboardDelete(editor: IContentModelEditor, rawEvent: KeyboardEvent): boolean { const selection = editor.getDOMSelection(); const range = selection?.type == 'range' ? selection.range : null; let isDeleted = false; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/getLeafSiblingBlock.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/utils/getLeafSiblingBlock.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/getLeafSiblingBlock.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/edit/utils/getLeafSiblingBlock.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts index d6df2aa5291..373fd4da9eb 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts @@ -1 +1,2 @@ export { ContentModelPastePlugin } from './paste/ContentModelPastePlugin'; +export { ContentModelEditPlugin } from './edit/ContentModelEditPlugin'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelEditPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts similarity index 92% rename from packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelEditPluginTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts index 73b4c9bbbb2..e793fbd99e4 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelEditPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts @@ -1,7 +1,7 @@ -import * as keyboardDelete from '../../../lib/publicApi/editing/keyboardDelete'; -import ContentModelEditPlugin from '../../../lib/editor/corePlugins/ContentModelEditPlugin'; +import * as keyboardDelete from '../../lib/edit/keyboardDelete'; +import { ContentModelEditPlugin } from '../../lib/edit/ContentModelEditPlugin'; import { EntityOperation, PluginEventType } from 'roosterjs-editor-types'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; describe('ContentModelEditPlugin', () => { let editor: IContentModelEditor; @@ -19,7 +19,7 @@ describe('ContentModelEditPlugin', () => { let keyboardDeleteSpy: jasmine.Spy; beforeEach(() => { - keyboardDeleteSpy = spyOn(keyboardDelete, 'default').and.returnValue(true); + keyboardDeleteSpy = spyOn(keyboardDelete, 'keyboardDelete').and.returnValue(true); }); it('Backspace', () => { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts new file mode 100644 index 00000000000..d193fd591e9 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts @@ -0,0 +1,3231 @@ +import { ContentModelEntity, ContentModelSelectionMarker } from 'roosterjs-content-model-types'; +import { DeletedEntity, deleteSelection } from 'roosterjs-content-model-editor'; +import { + createBr, + createContentModelDocument, + createDivider, + createEntity, + createFormatContainer, + createGeneralSegment, + createImage, + createListItem, + createParagraph, + createSelectionMarker, + createTable, + createTableCell, + createText, +} from 'roosterjs-content-model-dom'; +import { + backwardDeleteCollapsedSelection, + forwardDeleteCollapsedSelection, +} from '../../../lib/edit/deleteSteps/deleteCollapsedSelection'; + +describe('deleteSelection - forward', () => { + it('empty selection', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + model.blocks.push(para); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [], + }, + ], + }); + + expect(result.deleteResult).toBe('notDeleted'); + expect(result.insertPoint).toBeNull(); + }); + + it('Single selection marker', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + + para.segments.push(marker); + model.blocks.push(para); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('nothingToDelete'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Single selection marker with text after', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const segment = createText('test'); + + para.segments.push(marker, segment); + model.blocks.push(para); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('singleChar'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: 'est', + }, + ], + }, + ], + }); + }); + + it('Single selection marker at end of paragraph', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(text1, marker); + para2.segments.push(text2); + model.blocks.push(para1, para2); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para1, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test1', + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: 'test2', + }, + ], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [], + }, + ], + }); + }); + + it('Single selection marker in empty paragraph with BR', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + const text = createText('test'); + + para1.segments.push(marker, br); + para2.segments.push(text); + model.blocks.push(para1, para2); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para1, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: 'test', + }, + ], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [], + }, + ], + }); + }); + + it('Single selection marker in empty paragraph with double BRs', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br1 = createBr(); + const br2 = createBr(); + const text = createText('test'); + + para1.segments.push(marker, br1, br2); + para2.segments.push(text); + model.blocks.push(para1, para2); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('singleChar'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para1, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test', + }, + ], + }, + ], + }); + }); + + it('Double selection marker in 2 paragraphs', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const marker1 = createSelectionMarker({ fontSize: '10px' }); + const marker2 = createSelectionMarker({ fontSize: '20px' }); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(text1, marker1); + para2.segments.push(marker2, text2); + model.blocks.push(para1, para2); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para1, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test1', + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: 'test2', + }, + ], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [], + }, + ], + }); + }); + + it('Single selection marker before image', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const image = createImage(''); + + para.segments.push(marker, image); + model.blocks.push(para); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('singleChar'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Single selection marker before table', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + const table = createTable(1); + + table.rows[0].cells.push(createTableCell()); + para.segments.push(marker, br); + model.blocks.push(para, table); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Single selection marker before divider', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + const divider = createDivider('hr'); + + para.segments.push(marker, br); + model.blocks.push(para, divider); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Single selection marker before entity, no callback', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + const wrapper = 'WRAPPER' as any; + const entity = createEntity(wrapper); + + para.segments.push(marker, br); + model.blocks.push(para, entity); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Single selection marker before entity, with callback returns false', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + const wrapper = 'WRAPPER' as any; + const entity = createEntity(wrapper); + + para.segments.push(marker, br); + model.blocks.push(para, entity); + + const deletedEntities: DeletedEntity[] = []; + const result = deleteSelection(model, [forwardDeleteCollapsedSelection], { + newEntities: [], + deletedEntities, + newImages: [], + }); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + expect(deletedEntities).toEqual([{ entity, operation: 'removeFromStart' }]); + }); + + it('Single selection marker before entity, with callback returns true', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + const wrapper = 'WRAPPER' as any; + const entity = createEntity(wrapper); + + para.segments.push(marker, br); + model.blocks.push(para, entity); + + const deletedEntities: DeletedEntity[] = []; + const result = deleteSelection(model, [forwardDeleteCollapsedSelection], { + newEntities: [], + deletedEntities, + newImages: [], + }); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + expect(deletedEntities).toEqual([{ entity, operation: 'removeFromStart' }]); + }); + + it('Single selection marker before list item', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const listItem = createListItem([]); + const text = createText('test'); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + + para1.segments.push(marker, br); + para2.segments.push(text); + listItem.blocks.push(para2); + model.blocks.push(para1, listItem); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para1, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: 'test', + }, + ], + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + levels: [], + }, + ], + }); + }); + + it('Single selection marker before quote', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const quote = createFormatContainer('blockquote'); + const text = createText('test'); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + + para1.segments.push(marker, br); + para2.segments.push(text); + quote.blocks.push(para2); + model.blocks.push(para1, quote); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para1, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: 'test', + }, + ], + }, + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Single selection marker is under quote', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const quote = createFormatContainer('blockquote'); + const text = createText('test'); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + + para1.segments.push(marker, br); + para2.segments.push(text); + quote.blocks.push(para1); + model.blocks.push(quote, para2); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para1, + path: [quote, model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: 'test', + }, + ], + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }); + }); + + it('Single selection marker is under quote, next block is list', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const quote = createFormatContainer('blockquote'); + const listItem = createListItem([]); + const text = createText('test'); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + + para1.segments.push(marker, br); + para2.segments.push(text); + quote.blocks.push(para1); + listItem.blocks.push(para2); + model.blocks.push(quote, listItem); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para1, + path: [quote, model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: 'test', + }, + ], + }, + ], + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + format: {}, + levels: [], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + ], + }); + }); + + it('Single text selection', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const text1 = createText('test1', { fontSize: '10px' }); + const text2 = createText('test2', { fontSize: '20px' }); + + text1.isSelected = true; + para.segments.push(text1, text2); + model.blocks.push(para); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + { + segmentType: 'Text', + format: { fontSize: '20px' }, + text: 'test2', + }, + ], + }, + ], + }); + }); + + it('Multiple text selection in multiple paragraphs', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const text0 = createText('test0', { fontSize: '10px' }); + const text1 = createText('test1', { fontSize: '11px' }); + const text2 = createText('test2', { fontSize: '12px' }); + + text1.isSelected = true; + text2.isSelected = true; + + para1.segments.push(text0); + para1.segments.push(text1); + para2.segments.push(text2); + + model.blocks.push(para1); + model.blocks.push(para2); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '11px' }, + isSelected: true, + }, + paragraph: para1, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test0', + format: { fontSize: '10px' }, + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '11px' }, + isSelected: true, + }, + ], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [], + }, + ], + }); + }); + + it('Divider selection', () => { + const model = createContentModelDocument(); + const divider = createDivider('div'); + + divider.isSelected = true; + model.blocks.push(divider); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + isImplicit: false, + }, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + isImplicit: false, + }, + ], + }); + }); + + it('2 Divider selection and paragraph after it', () => { + const model = createContentModelDocument(); + const divider1 = createDivider('div'); + const divider2 = createDivider('hr'); + const para1 = createParagraph(); + const para2 = createParagraph(); + + divider1.isSelected = true; + divider2.isSelected = true; + model.blocks.push(para1, divider1, divider2, para2); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + isImplicit: false, + }, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + isImplicit: false, + }, + { + blockType: 'Paragraph', + format: {}, + segments: [], + isImplicit: true, + }, + { + blockType: 'Paragraph', + format: {}, + segments: [], + }, + ], + }); + }); + + it('Some table cell selection', () => { + const model = createContentModelDocument(); + const table = createTable(1); + const cell1 = createTableCell(); + const cell2 = createTableCell(); + + cell2.isSelected = true; + + table.rows[0].cells.push(cell1, cell2); + model.blocks.push(table); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + paragraph: { + blockType: 'Paragraph', + isImplicit: false, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + path: [cell2, model], + tableContext: { + table: table, + colIndex: 1, + rowIndex: 0, + isWholeTableSelected: false, + }, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + format: {}, + dataset: {}, + widths: [], + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + format: {}, + dataset: {}, + spanAbove: false, + spanLeft: false, + isHeader: false, + blocks: [], + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: false, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + isSelected: true, + }, + ], + }, + ], + }, + ], + }); + }); + + it('All table cell selection', () => { + const model = createContentModelDocument(); + const table = createTable(1); + const cell = createTableCell(); + + cell.isSelected = true; + + table.rows[0].cells.push(cell); + model.blocks.push(table); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + isImplicit: false, + }, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + isImplicit: false, + }, + ], + }); + }); + + it('delete with default format', () => { + const model = createContentModelDocument({ + fontSize: '10pt', + }); + const divider = createDivider('div'); + + divider.isSelected = true; + model.blocks.push(divider); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + format: { fontSize: '10pt' }, + isSelected: true, + }; + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker, + paragraph: { + blockType: 'Paragraph', + segments: [marker], + format: {}, + isImplicit: false, + segmentFormat: { fontSize: '10pt' }, + }, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [marker], + isImplicit: false, + segmentFormat: { fontSize: '10pt' }, + }, + ], + format: { fontSize: '10pt' }, + }); + }); + + it('Delete from general segment, no sibling', () => { + const model = createContentModelDocument(); + const parentParagraph = createParagraph(); + const general = createGeneralSegment(null!); + const para = createParagraph(); + const marker = createSelectionMarker(); + + para.segments.push(marker); + general.blocks.push(para); + parentParagraph.segments.push(general); + model.blocks.push(parentParagraph); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('nothingToDelete'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [general, model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + blockType: 'BlockGroup', + blockGroupType: 'General', + segmentType: 'General', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + ], + element: null!, + }, + ], + }, + ], + }); + }); + + it('Delete from general segment, has sibling', () => { + const model = createContentModelDocument(); + const parentParagraph = createParagraph(); + const general = createGeneralSegment(null!); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text = createText('test'); + + para.segments.push(marker); + general.blocks.push(para); + parentParagraph.segments.push(general, text); + model.blocks.push(parentParagraph); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [general, model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + blockType: 'BlockGroup', + blockGroupType: 'General', + segmentType: 'General', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + ], + element: null!, + }, + { + segmentType: 'Text', + text: 'est', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Delete text and need to convert space to  ', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text = createText(' test'); + + para.segments.push(marker, text); + model.blocks.push(para); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('singleChar'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: '\u00A0test', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Delete text and no need to convert space to   when preserve white space', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text = createText(' test'); + + para.format.whiteSpace = 'pre'; + para.segments.push(marker, text); + model.blocks.push(para); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('singleChar'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { + whiteSpace: 'pre', + }, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: ' test', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Normalize text and space before deleted content', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1 '); + const text2 = createText('test2'); + + para.segments.push(text1, marker, text2); + model.blocks.push(para); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('singleChar'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1\u00A0', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'est2', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Normalize text and space before deleted content, delete empty text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1 '); + const text2 = createText('a'); + + para.segments.push(text1, marker, text2); + model.blocks.push(para); + + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('singleChar'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1\u00A0', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); +}); + +describe('deleteSelection - backward', () => { + it('empty selection', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [], + }, + ], + }); + + expect(result.deleteResult).toBe('notDeleted'); + expect(result.insertPoint).toBeNull(); + }); + + it('Single selection marker', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + + para.segments.push(marker); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('nothingToDelete'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Single selection marker with text before', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const segment = createText('test'); + + para.segments.push(segment, marker); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('singleChar'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'tes', + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Single selection marker at beginning of paragraph', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(text1); + para2.segments.push(marker, text2); + model.blocks.push(para1, para2); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para1, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test1', + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: 'test2', + }, + ], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [], + }, + ], + }); + }); + + it('Single selection marker after empty paragraph with BR', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(false, { lineHeight: '10' }); + const para2 = createParagraph(false, { lineHeight: '12' }); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + const text = createText('test'); + + para1.segments.push(br); + para2.segments.push(marker, text); + model.blocks.push(para1, para2); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para1, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { lineHeight: '10' }, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: 'test', + }, + ], + }, + { + blockType: 'Paragraph', + format: { lineHeight: '12' }, + segments: [], + }, + ], + }); + }); + + it('Single selection marker after empty paragraph with double BRs', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(false, { lineHeight: '10' }); + const para2 = createParagraph(false, { lineHeight: '11' }); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br1 = createBr(); + const br2 = createBr(); + const text = createText('test'); + + para1.segments.push(br1, br2); + para2.segments.push(marker, text); + model.blocks.push(para1, para2); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para1, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { lineHeight: '10' }, + segments: [ + { + segmentType: 'Br', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: 'test', + }, + ], + }, + { + blockType: 'Paragraph', + format: { lineHeight: '11' }, + segments: [], + }, + ], + }); + }); + + it('Double selection marker in 2 paragraphs', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(false, { lineHeight: '10' }); + const para2 = createParagraph(false, { lineHeight: '11' }); + const marker1 = createSelectionMarker({ fontSize: '10px' }); + const marker2 = createSelectionMarker({ fontSize: '20px' }); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(text1, marker1); + para2.segments.push(marker2, text2); + model.blocks.push(para1, para2); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para1, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { lineHeight: '10' }, + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test1', + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + { + segmentType: 'Text', + format: {}, + text: 'test2', + }, + ], + }, + { + blockType: 'Paragraph', + format: { lineHeight: '11' }, + segments: [], + }, + ], + }); + }); + + it('Single selection marker after image', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const image = createImage(''); + + para.segments.push(image, marker); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('singleChar'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Single selection marker after table', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + const table = createTable(1); + + table.rows[0].cells.push(createTableCell()); + para.segments.push(marker, br); + model.blocks.push(table, para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Single selection marker after divider', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + const divider = createDivider('hr'); + + para.segments.push(marker, br); + model.blocks.push(divider, para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Single selection marker after entity, no callback', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + const wrapper = 'WRAPPER' as any; + const entity = createEntity(wrapper); + + para.segments.push(marker, br); + model.blocks.push(entity, para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Single selection marker after entity, with callback returns false', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + const wrapper = 'WRAPPER' as any; + const entity = createEntity(wrapper); + + para.segments.push(marker, br); + model.blocks.push(entity, para); + + const deletedEntities: DeletedEntity[] = []; + const result = deleteSelection(model, [backwardDeleteCollapsedSelection], { + newEntities: [], + deletedEntities, + newImages: [], + }); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + expect(deletedEntities).toEqual([{ entity, operation: 'removeFromEnd' }]); + }); + + it('Single selection marker after entity, with callback returns true', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + const wrapper = 'WRAPPER' as any; + const entity = createEntity(wrapper); + + para.segments.push(marker, br); + model.blocks.push(entity, para); + + const deletedEntities: DeletedEntity[] = []; + const newEntities: ContentModelEntity[] = []; + const result = deleteSelection(model, [backwardDeleteCollapsedSelection], { + newEntities, + deletedEntities, + newImages: [], + }); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + }, + ], + }); + expect(deletedEntities).toEqual([{ entity, operation: 'removeFromEnd' }]); + }); + + it('Single selection marker after list item', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(false, { lineHeight: '10' }); + const para2 = createParagraph(false, { lineHeight: '11' }); + const listItem = createListItem([]); + const text = createText('test'); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + + para1.segments.push(marker, br); + para2.segments.push(text); + listItem.blocks.push(para2); + model.blocks.push(listItem, para1); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para2, + path: [listItem, model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test', + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + format: { lineHeight: '11' }, + }, + ], + format: {}, + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + levels: [], + }, + { + blockType: 'Paragraph', + format: { lineHeight: '10' }, + segments: [], + }, + ], + }); + }); + + it('Single selection marker after quote', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(false, { lineHeight: '10' }); + const para2 = createParagraph(false, { lineHeight: '11' }); + const quote = createFormatContainer('blockquote'); + const text = createText('test'); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + + para1.segments.push(marker, br); + para2.segments.push(text); + quote.blocks.push(para2); + model.blocks.push(quote, para1); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para2, + path: [quote, model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test', + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + format: { lineHeight: '11' }, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + format: { lineHeight: '10' }, + segments: [], + }, + ], + }); + }); + + it('Single selection marker is under quote', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(false, { lineHeight: '10' }); + const para2 = createParagraph(false, { lineHeight: '11' }); + const quote = createFormatContainer('blockquote'); + const text = createText('test'); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + + para1.segments.push(marker, br); + para2.segments.push(text); + quote.blocks.push(para1); + model.blocks.push(para2, quote); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para2, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test', + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + format: { lineHeight: '11' }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + blocks: [ + { + blockType: 'Paragraph', + format: { lineHeight: '10' }, + segments: [], + }, + ], + format: {}, + }, + ], + }); + }); + + it('Single selection marker is under quote, previous block is list', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(false, { lineHeight: '10' }); + const para2 = createParagraph(false, { lineHeight: '11' }); + const quote = createFormatContainer('blockquote'); + const listItem = createListItem([]); + const text = createText('test'); + const marker = createSelectionMarker({ fontSize: '10px' }); + const br = createBr(); + + para1.segments.push(marker, br); + para2.segments.push(text); + quote.blocks.push(para1); + listItem.blocks.push(para2); + model.blocks.push(listItem, quote); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para2, + path: [listItem, model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + format: {}, + levels: [], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + format: {}, + text: 'test', + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + ], + format: { lineHeight: '11' }, + }, + ], + }, + { + blockType: 'BlockGroup', + blocks: [ + { + blockType: 'Paragraph', + format: { lineHeight: '10' }, + segments: [], + }, + ], + format: {}, + blockGroupType: 'FormatContainer', + tagName: 'blockquote', + }, + ], + }); + }); + + it('Single text selection', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const text1 = createText('test1', { fontSize: '10px' }); + const text2 = createText('test2', { fontSize: '20px' }); + + text1.isSelected = true; + para.segments.push(text1, text2); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + paragraph: para, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: { fontSize: '10px' }, + isSelected: true, + }, + { + segmentType: 'Text', + format: { fontSize: '20px' }, + text: 'test2', + }, + ], + }, + ], + }); + }); + + it('Multiple text selection in multiple paragraphs', () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const text0 = createText('test0', { fontSize: '10px' }); + const text1 = createText('test1', { fontSize: '11px' }); + const text2 = createText('test2', { fontSize: '12px' }); + + text1.isSelected = true; + text2.isSelected = true; + + para1.segments.push(text0); + para1.segments.push(text1); + para2.segments.push(text2); + + model.blocks.push(para1); + model.blocks.push(para2); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: { fontSize: '11px' }, + isSelected: true, + }, + paragraph: para1, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test0', + format: { fontSize: '10px' }, + }, + { + segmentType: 'SelectionMarker', + format: { fontSize: '11px' }, + isSelected: true, + }, + ], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [], + }, + ], + }); + }); + + it('Divider selection', () => { + const model = createContentModelDocument(); + const divider = createDivider('div'); + + divider.isSelected = true; + model.blocks.push(divider); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + isImplicit: false, + }, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + isImplicit: false, + }, + ], + }); + }); + + it('2 Divider selection and paragraph after it', () => { + const model = createContentModelDocument(); + const divider1 = createDivider('div'); + const divider2 = createDivider('hr'); + const para1 = createParagraph(); + const para2 = createParagraph(); + + divider1.isSelected = true; + divider2.isSelected = true; + model.blocks.push(para1, divider1, divider2, para2); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + isImplicit: false, + }, + path: [model], + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [], + }, + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + isImplicit: false, + }, + { + blockType: 'Paragraph', + format: {}, + segments: [], + isImplicit: true, + }, + { + blockType: 'Paragraph', + format: {}, + segments: [], + }, + ], + }); + }); + + it('Some table cell selection', () => { + const model = createContentModelDocument(); + const table = createTable(1); + const cell1 = createTableCell(); + const cell2 = createTableCell(); + + cell2.isSelected = true; + + table.rows[0].cells.push(cell1, cell2); + model.blocks.push(table); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + paragraph: { + blockType: 'Paragraph', + isImplicit: false, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + path: [cell2, model], + tableContext: { + table: table, + colIndex: 1, + rowIndex: 0, + isWholeTableSelected: false, + }, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + format: {}, + dataset: {}, + widths: [], + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + format: {}, + dataset: {}, + spanAbove: false, + spanLeft: false, + isHeader: false, + blocks: [], + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: false, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + isSelected: true, + }, + ], + }, + ], + }, + ], + }); + }); + + it('All table cell selection', () => { + const model = createContentModelDocument(); + const table = createTable(1); + const cell = createTableCell(); + + cell.isSelected = true; + + table.rows[0].cells.push(cell); + model.blocks.push(table); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + format: {}, + isImplicit: false, + }, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + isImplicit: false, + }, + ], + }); + }); + + it('delete with default format', () => { + const model = createContentModelDocument({ + fontSize: '10pt', + }); + const divider = createDivider('div'); + + divider.isSelected = true; + model.blocks.push(divider); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + format: { fontSize: '10pt' }, + isSelected: true, + }; + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker, + paragraph: { + blockType: 'Paragraph', + segments: [marker], + format: {}, + isImplicit: false, + segmentFormat: { fontSize: '10pt' }, + }, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [marker], + isImplicit: false, + segmentFormat: { fontSize: '10pt' }, + }, + ], + format: { fontSize: '10pt' }, + }); + }); + + it('Delete from general segment, no sibling', () => { + const model = createContentModelDocument(); + const parentParagraph = createParagraph(); + const general = createGeneralSegment(null!); + const para = createParagraph(); + const marker = createSelectionMarker(); + + para.segments.push(marker); + general.blocks.push(para); + parentParagraph.segments.push(general); + model.blocks.push(parentParagraph); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('nothingToDelete'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [general, model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + blockType: 'BlockGroup', + blockGroupType: 'General', + segmentType: 'General', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + ], + element: null!, + }, + ], + }, + ], + }); + }); + + it('Delete from general segment, has sibling', () => { + const model = createContentModelDocument(); + const parentParagraph = createParagraph(); + const general = createGeneralSegment(null!); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text = createText('test'); + + para.segments.push(marker); + general.blocks.push(para); + parentParagraph.segments.push(text, general); + model.blocks.push(parentParagraph); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('range'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [general, model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'tes', + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'General', + segmentType: 'General', + format: {}, + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + ], + element: null!, + }, + ], + }, + ], + }); + }); + + it('Delete text and need to convert space to  ', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text = createText('test '); + + para.segments.push(text, marker); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('singleChar'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test\u00A0', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Delete text and no need to convert space to   when preserve white space', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text = createText('test '); + + para.format.whiteSpace = 'pre'; + para.segments.push(text, marker); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('singleChar'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: { + whiteSpace: 'pre', + }, + segments: [ + { + segmentType: 'Text', + text: 'test ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Normalize text and space before deleted content', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1 '); + const text2 = createText('test2'); + + para.segments.push(text1, text2, marker); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('singleChar'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1\u00A0', + format: {}, + }, + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Normalize text and space before deleted content, delete empty text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1 '); + const text2 = createText('a'); + + para.segments.push(text1, text2, marker); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('singleChar'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1\u00A0', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Delete under an implicit paragraph', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('t'); + + para.isImplicit = true; + para.segments.push(text1, marker); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); + + expect(result.deleteResult).toBe('singleChar'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: false, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteWordSelectionTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteWordSelectionTest.ts new file mode 100644 index 00000000000..9e51b199804 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteWordSelectionTest.ts @@ -0,0 +1,413 @@ +import { deleteSelection } from 'roosterjs-content-model-editor'; +import { + createContentModelDocument, + createParagraph, + createSelectionMarker, + createText, +} from 'roosterjs-content-model-dom'; +import { + backwardDeleteWordSelection, + forwardDeleteWordSelection, +} from '../../../lib/edit/deleteSteps/deleteWordSelection'; + +describe('deleteSelection - forward', () => { + it('Delete word: text+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText(' '); + const text3 = createText('test2'); + + para.segments.push(marker, text1, text2, text3); + model.blocks.push(para); + + const result = deleteSelection(model, [forwardDeleteWordSelection]); + + expect(result.deleteResult).toBe('range'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Delete word: space+text+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText(' test1 test2'); + + para.segments.push(marker, text1); + model.blocks.push(para); + + const result = deleteSelection(model, [forwardDeleteWordSelection]); + + expect(result.deleteResult).toBe('range'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test1 test2', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Delete word: text+punc+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1. test2'); + + para.segments.push(marker, text1); + model.blocks.push(para); + + const result = deleteSelection(model, [forwardDeleteWordSelection]); + + expect(result.deleteResult).toBe('range'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: '. test2', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Delete word: punc+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('. test2'); + + para.segments.push(marker, text1); + model.blocks.push(para); + + const result = deleteSelection(model, [forwardDeleteWordSelection]); + + expect(result.deleteResult).toBe('range'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + }, + ], + }); + }); +}); + +describe('deleteSelection - backward', () => { + it('Delete word: text+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText(' '); + const text3 = createText('test2'); + + para.segments.push(text1, text2, text3, marker); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteWordSelection]); + + expect(result.deleteResult).toBe('range'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + { + segmentType: 'Text', + text: ' ', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Delete word: space+text+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('\u00A0 \u00A0test1 \u00A0 test2'); + + para.segments.push(text1, marker); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteWordSelection]); + + expect(result.deleteResult).toBe('range'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: '\u00A0 \u00A0test1 \u00A0\u00A0', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Delete word: text+punc+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1. test2'); + + para.segments.push(text1, marker); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteWordSelection]); + + expect(result.deleteResult).toBe('range'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: 'test1.\u00A0', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Delete word: punc+space+text', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('. test2'); + + para.segments.push(text1, marker); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteWordSelection]); + + expect(result.deleteResult).toBe('range'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'Text', + text: '.\u00A0', + format: {}, + }, + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + ], + }, + ], + }); + }); + + it('Delete all before', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + + para.segments.push(text1, text2, marker, text3); + model.blocks.push(para); + + const result = deleteSelection(model, [backwardDeleteWordSelection]); + + expect(result.deleteResult).toBe('range'); + + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para, + path: [model], + tableContext: undefined, + }); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Text', + text: 'test3', + format: {}, + }, + ], + }, + ], + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts similarity index 88% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts rename to packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts index 38b257941c2..96b6326a39a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts @@ -1,9 +1,9 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { + IContentModelEditor, ContentModelFormatter, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-editor'; export function editingTestCommon( apiName: string, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/handleKeyboardEventCommonTest.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/edit/handleKeyboardEventCommonTest.ts index 471d9ae7f67..7ad988920e9 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/handleKeyboardEventCommonTest.ts @@ -1,12 +1,11 @@ import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import { FormatWithContentModelContext } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { FormatWithContentModelContext, IContentModelEditor } from 'roosterjs-content-model-editor'; import { PluginEventType } from 'roosterjs-editor-types'; import { handleKeyboardEventResult, shouldDeleteAllSegmentsBefore, shouldDeleteWord, -} from '../../../lib/editor/utils/handleKeyboardEventCommon'; +} from '../../lib/edit/handleKeyboardEventCommon'; describe('handleKeyboardEventResult', () => { let mockedEditor: IContentModelEditor; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts index 82302b4469c..eb3cc1c1938 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/keyboardDeleteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts @@ -1,24 +1,24 @@ -import * as deleteSelection from '../../../lib/modelApi/edit/deleteSelection'; -import * as handleKeyboardEventResult from '../../../lib/editor/utils/handleKeyboardEventCommon'; -import keyboardDelete from '../../../lib/publicApi/editing/keyboardDelete'; -import { ChangeSource } from '../../../lib/publicTypes/event/ContentModelContentChangedEvent'; +import * as deleteSelection from 'roosterjs-content-model-editor/lib/publicApi/selection/deleteSelection'; +import * as handleKeyboardEventResult from '../../lib/edit/handleKeyboardEventCommon'; import { ContentModelDocument, DOMSelection } from 'roosterjs-content-model-types'; -import { deleteAllSegmentBefore } from '../../../lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore'; +import { deleteAllSegmentBefore } from '../../lib/edit/deleteSteps/deleteAllSegmentBefore'; import { editingTestCommon } from './editingTestCommon'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { keyboardDelete } from '../../lib/edit/keyboardDelete'; import { Keys } from 'roosterjs-editor-types'; import { - backwardDeleteWordSelection, - forwardDeleteWordSelection, -} from '../../../lib/modelApi/edit/deleteSteps/deleteWordSelection'; -import { + ChangeSource, DeleteResult, DeleteSelectionStep, -} from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; + IContentModelEditor, +} from 'roosterjs-content-model-editor'; +import { + backwardDeleteWordSelection, + forwardDeleteWordSelection, +} from '../../lib/edit/deleteSteps/deleteWordSelection'; import { backwardDeleteCollapsedSelection, forwardDeleteCollapsedSelection, -} from '../../../lib/modelApi/edit/deleteSteps/deleteCollapsedSelection'; +} from '../../lib/edit/deleteSteps/deleteCollapsedSelection'; describe('keyboardDelete', () => { let deleteSelectionSpy: jasmine.Spy; diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/block/getLeafSiblingBlockTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/utils/getLeafSiblingBlockTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/block/getLeafSiblingBlockTest.ts rename to packages-content-model/roosterjs-content-model-plugins/test/edit/utils/getLeafSiblingBlockTest.ts index 0e0498f6dcd..1a8d1154a21 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/block/getLeafSiblingBlockTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/utils/getLeafSiblingBlockTest.ts @@ -1,4 +1,4 @@ -import { getLeafSiblingBlock } from '../../../lib/modelApi/block/getLeafSiblingBlock'; +import { getLeafSiblingBlock } from '../../../lib/edit/utils/getLeafSiblingBlock'; import { createContentModelDocument, createFormatContainer, diff --git a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts index a29a607d723..821d628f9e6 100644 --- a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts @@ -1,5 +1,5 @@ import { ContentModelEditor } from 'roosterjs-content-model-editor'; -import { ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; +import { ContentModelEditPlugin, ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; import { getDarkColor } from 'roosterjs-color-utils'; import type { EditorPlugin } from 'roosterjs-editor-types'; import type { @@ -20,11 +20,8 @@ export function createContentModelEditor( additionalPlugins?: EditorPlugin[], initialContent?: string ): IContentModelEditor { - let plugins: EditorPlugin[] = [new ContentModelPastePlugin()]; - - if (additionalPlugins) { - plugins = plugins.concat(additionalPlugins); - } + const plugins = additionalPlugins ? [...additionalPlugins] : []; + plugins.push(new ContentModelPastePlugin(), new ContentModelEditPlugin()); const options: ContentModelEditorOptions = { plugins: plugins, From 54f901d46ba6195b878c24121e2ab29752a46f07 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 9 Nov 2023 11:00:43 -0800 Subject: [PATCH 042/111] Move type files to roosterjs-content-model-types package (#2196) * Move ContentModelEdit plugin to plugins package * Move types to roosterjs-content-model-types package * improve * Improve * improve * Improve * improve * fix build * fix build --- demo/scripts/controls/MainPaneBase.tsx | 2 +- .../contentModel/ContentModelRibbonPlugin.ts | 7 +- .../contentModel/tableBorderApplyButton.ts | 7 +- .../contentModel/tableEditButtons.ts | 3 +- .../insertEntity/InsertEntityPane.tsx | 7 +- .../eventViewer/ContentModelEventViewPane.tsx | 2 +- .../lib/domUtils/borderValues.ts | 2 +- .../lib/editor/ContentModelEditor.ts | 8 +- .../lib/editor/coreApi/createContentModel.ts | 10 +- .../lib/editor/coreApi/createEditorContext.ts | 3 +- .../lib/editor/coreApi/formatContentModel.ts | 29 ++- .../lib/editor/coreApi/getDOMSelection.ts | 9 +- .../lib/editor/coreApi/setContentModel.ts | 2 +- .../lib/editor/coreApi/setDOMSelection.ts | 2 +- .../lib/editor/coreApi/switchShadowEdit.ts | 6 +- .../corePlugins/ContentModelCachePlugin.ts | 6 +- .../ContentModelCopyPastePlugin.ts | 2 +- .../corePlugins/ContentModelFormatPlugin.ts | 2 +- .../editor/createContentModelEditorCore.ts | 2 +- .../lib/index.ts | 72 +------ .../lib/modelApi/block/setModelAlignment.ts | 7 +- .../lib/modelApi/common/clearModelFormat.ts | 2 +- .../lib/modelApi/common/mergeModel.ts | 4 +- .../common/retrieveModelFormatState.ts | 4 +- .../modelApi/edit/utils/createInsertPoint.ts | 4 +- .../edit/utils/deleteExpandedSelection.ts | 8 +- .../lib/modelApi/entity/insertEntityModel.ts | 6 +- .../modelApi/image/applyImageBorderFormat.ts | 3 +- .../modelApi/selection/collectSelections.ts | 2 +- .../modelApi/selection/iterateSelections.ts | 2 +- .../lib/modelApi/table/alignTable.ts | 3 +- .../lib/modelApi/table/alignTableCell.ts | 5 +- .../lib/modelApi/table/insertTableColumn.ts | 6 +- .../lib/modelApi/table/insertTableRow.ts | 6 +- .../lib/modelApi/table/mergeTableColumn.ts | 6 +- .../lib/modelApi/table/mergeTableRow.ts | 3 +- .../lib/publicApi/block/deleteBlock.ts | 4 +- .../lib/publicApi/entity/insertEntity.ts | 13 +- .../lib/publicApi/format/getFormatState.ts | 7 +- .../lib/publicApi/image/setImageBorder.ts | 3 +- .../lib/publicApi/link/insertLink.ts | 2 +- .../lib/publicApi/segment/deleteSegment.ts | 5 +- .../publicApi/selection/deleteSelection.ts | 6 +- .../publicApi/table/applyTableBorderFormat.ts | 9 +- .../lib/publicApi/table/editTable.ts | 2 +- .../lib/publicApi/utils/paste.ts | 12 +- .../lib/publicTypes/ChangeSource.ts | 59 ++++++ .../lib/publicTypes/ContentModelEditorCore.ts | 152 +-------------- .../lib/publicTypes/IContentModelEditor.ts | 107 +---------- .../event/ContentModelContentChangedEvent.ts | 95 ---------- .../FormatWithContentModelContext.ts | 164 ---------------- .../editor/coreApi/formatContentModelTest.ts | 2 +- .../ContentModelFormatPluginTest.ts | 5 +- .../plugins/ContentModelCachePluginTest.ts | 6 +- .../ContentModelCopyPastePluginTest.ts | 5 +- .../test/modelApi/common/mergeModelTest.ts | 4 +- .../common/retrieveModelFormatStateTest.ts | 3 +- .../test/modelApi/edit/deleteSelectionTest.ts | 3 +- .../modelApi/entity/insertEntityModelTest.ts | 3 +- .../modelApi/format/applyDefaultFormatTest.ts | 15 +- .../modelApi/format/applyPendingFormatTest.ts | 6 +- .../image/applyImageBorderFormatTest.ts | 3 +- .../selection/collectSelectionsTest.ts | 18 +- .../test/modelApi/table/alignTableCellTest.ts | 10 +- .../publicApi/block/paragraphTestCommon.ts | 4 +- .../test/publicApi/block/setAlignmentTest.ts | 6 +- .../publicApi/block/setIndentationTest.ts | 2 +- .../publicApi/block/toggleBlockQuoteTest.ts | 2 +- .../test/publicApi/entity/insertEntityTest.ts | 4 +- .../test/publicApi/format/clearFormatTest.ts | 4 +- .../publicApi/format/getFormatStateTest.ts | 3 +- .../test/publicApi/image/changeImageTest.ts | 4 +- .../test/publicApi/image/insertImageTest.ts | 4 +- .../publicApi/image/setImageBorderTest.ts | 3 +- .../publicApi/link/adjustLinkSelectionTest.ts | 5 +- .../test/publicApi/link/insertLinkTest.ts | 7 +- .../test/publicApi/link/removeLinkTest.ts | 5 +- .../publicApi/list/setListStartNumberTest.ts | 4 +- .../test/publicApi/list/toggleBulletTest.ts | 4 +- .../publicApi/list/toggleNumberingTest.ts | 4 +- .../publicApi/segment/changeFontSizeTest.ts | 5 +- .../publicApi/segment/segmentTestCommon.ts | 4 +- .../selection/getSelectedSegmentsTest.ts | 12 +- .../table/applyTableBorderFormatTest.ts | 9 +- .../publicApi/table/setTableCellShadeTest.ts | 4 +- .../utils/formatImageWithContentModelTest.ts | 5 +- .../formatParagraphWithContentModelTest.ts | 5 +- .../formatSegmentWithContentModelTest.ts | 5 +- .../test/publicApi/utils/pasteTest.ts | 13 +- .../deleteSteps/deleteAllSegmentBefore.ts | 2 +- .../deleteSteps/deleteCollapsedSelection.ts | 3 +- .../edit/deleteSteps/deleteWordSelection.ts | 7 +- .../lib/edit/handleKeyboardEventCommon.ts | 6 +- .../lib/edit/keyboardDelete.ts | 3 +- .../lib/paste/ContentModelPastePlugin.ts | 8 +- .../Excel/processPastedContentFromExcel.ts | 2 +- .../processPastedContentWacComponents.ts | 2 +- .../processPastedContentFromWordDesktop.ts | 2 +- .../deleteCollapsedSelectionTest.ts | 8 +- .../test/edit/editingTestCommon.ts | 6 +- .../edit/handleKeyboardEventCommonTest.ts | 3 +- .../test/edit/keyboardDeleteTest.ts | 8 +- .../test/paste/ContentModelPastePluginTest.ts | 3 +- ...processPastedContentFromWordDesktopTest.ts | 5 +- .../lib/editor/IStandaloneEditor.ts | 77 ++++++++ .../lib/editor/StandaloneEditorCore.ts | 175 ++++++++++++++++++ .../lib/editor/StandaloneEditorOptions.ts | 22 +++ .../lib}/enum/BorderOperations.ts | 0 .../lib/enum/DeleteResult.ts | 23 +++ .../lib/enum/EntityOperation.ts | 52 ++++++ .../lib/enum/InsertEntityPosition.ts | 8 + .../lib/enum}/PasteType.ts | 0 .../lib/enum}/TableOperation.ts | 0 .../event/ContentModelBeforePasteEvent.ts | 5 +- .../event/ContentModelContentChangedEvent.ts | 36 ++++ .../lib/index.ts | 75 ++++++++ .../lib/parameter}/Border.ts | 0 .../lib/parameter}/ContentModelFormatState.ts | 2 +- .../lib}/parameter/DeleteSelectionStep.ts | 27 +-- .../lib/parameter/EditorEnvironment.ts | 14 ++ .../FormatWithContentModelContext.ts | 66 +++++++ .../FormatWithContentModelOptions.ts | 52 ++++++ .../lib/parameter}/ImageFormatState.ts | 0 .../lib}/parameter/InsertEntityOptions.ts | 9 - .../ContentModelCachePluginState.ts | 8 +- .../ContentModelFormatPluginState.ts | 2 +- .../pluginState/ContentModelPluginState.ts | 0 .../lib}/selection/InsertPoint.ts | 8 +- .../lib}/selection/TableSelectionContext.ts | 2 +- .../package.json | 4 +- 130 files changed, 947 insertions(+), 892 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ChangeSource.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelContentChangedEvent.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes => roosterjs-content-model-types/lib}/enum/BorderOperations.ts (100%) create mode 100644 packages-content-model/roosterjs-content-model-types/lib/enum/DeleteResult.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/enum/EntityOperation.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/enum/InsertEntityPosition.ts rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes/parameter => roosterjs-content-model-types/lib/enum}/PasteType.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes/parameter => roosterjs-content-model-types/lib/enum}/TableOperation.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes => roosterjs-content-model-types/lib}/event/ContentModelBeforePasteEvent.ts (85%) create mode 100644 packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes/interface => roosterjs-content-model-types/lib/parameter}/Border.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes/format/formatState => roosterjs-content-model-types/lib/parameter}/ContentModelFormatState.ts (97%) rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes => roosterjs-content-model-types/lib}/parameter/DeleteSelectionStep.ts (72%) create mode 100644 packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelContext.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelOptions.ts rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes/format/formatState => roosterjs-content-model-types/lib/parameter}/ImageFormatState.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes => roosterjs-content-model-types/lib}/parameter/InsertEntityOptions.ts (54%) rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes => roosterjs-content-model-types/lib}/pluginState/ContentModelCachePluginState.ts (70%) rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes => roosterjs-content-model-types/lib}/pluginState/ContentModelFormatPluginState.ts (87%) rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes => roosterjs-content-model-types/lib}/pluginState/ContentModelPluginState.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes => roosterjs-content-model-types/lib}/selection/InsertPoint.ts (71%) rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes => roosterjs-content-model-types/lib}/selection/TableSelectionContext.ts (86%) diff --git a/demo/scripts/controls/MainPaneBase.tsx b/demo/scripts/controls/MainPaneBase.tsx index 014a79f758d..f68d5acf7fb 100644 --- a/demo/scripts/controls/MainPaneBase.tsx +++ b/demo/scripts/controls/MainPaneBase.tsx @@ -3,7 +3,7 @@ import * as ReactDOM from 'react-dom'; import BuildInPluginState from './BuildInPluginState'; import SidePane from './sidePane/SidePane'; import SnapshotPlugin from './sidePane/snapshot/SnapshotPlugin'; -import { Border } from 'roosterjs-content-model-editor'; +import { Border } from 'roosterjs-content-model-types'; import { EditorOptions, EditorPlugin, IEditor } from 'roosterjs-editor-types'; import { getDarkColor } from 'roosterjs-color-utils'; import { PartialTheme, ThemeProvider } from '@fluentui/react/lib/Theme'; diff --git a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts index 7c7cbc5e9d3..d24ef3a8bf4 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts @@ -1,11 +1,8 @@ +import { ContentModelFormatState } from 'roosterjs-content-model-types'; import { FormatState, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; +import { getFormatState, IContentModelEditor } from 'roosterjs-content-model-editor'; import { getObjectKeys } from 'roosterjs-editor-dom'; import { LocalizedStrings, RibbonButton, RibbonPlugin, UIUtilities } from 'roosterjs-react'; -import { - ContentModelFormatState, - getFormatState, - IContentModelEditor, -} from 'roosterjs-content-model-editor'; export class ContentModelRibbonPlugin implements RibbonPlugin { private editor: IContentModelEditor | null = null; diff --git a/demo/scripts/controls/ribbonButtons/contentModel/tableBorderApplyButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/tableBorderApplyButton.ts index bc40a23e91f..86dc32f59f4 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/tableBorderApplyButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/tableBorderApplyButton.ts @@ -1,10 +1,7 @@ import MainPaneBase from '../../MainPaneBase'; +import { applyTableBorderFormat, isContentModelEditor } from 'roosterjs-content-model-editor'; +import { BorderOperations } from 'roosterjs-content-model-types'; import { RibbonButton } from 'roosterjs-react'; -import { - applyTableBorderFormat, - BorderOperations, - isContentModelEditor, -} from 'roosterjs-content-model-editor'; const TABLE_OPERATIONS: Record = { menuNameTableAllBorder: 'allBorders', diff --git a/demo/scripts/controls/ribbonButtons/contentModel/tableEditButtons.ts b/demo/scripts/controls/ribbonButtons/contentModel/tableEditButtons.ts index 851ae1d33e3..2b6d4e86da5 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/tableEditButtons.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/tableEditButtons.ts @@ -1,4 +1,5 @@ -import { editTable, isContentModelEditor, TableOperation } from 'roosterjs-content-model-editor'; +import { editTable, isContentModelEditor } from 'roosterjs-content-model-editor'; +import { TableOperation } from 'roosterjs-content-model-types'; import { RibbonButton, TableEditAlignMenuItemStringKey, diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx index 5fbebf4588d..98ce08e071c 100644 --- a/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx @@ -2,12 +2,9 @@ import * as React from 'react'; import ApiPaneProps from '../ApiPaneProps'; import { Entity } from 'roosterjs-editor-types'; import { getEntityFromElement, getEntitySelector } from 'roosterjs-editor-dom'; +import { IContentModelEditor, insertEntity } from 'roosterjs-content-model-editor'; +import { InsertEntityOptions } from 'roosterjs-content-model-types'; import { trustedHTMLHandler } from '../../../../utils/trustedHTMLHandler'; -import { - IContentModelEditor, - insertEntity, - InsertEntityOptions, -} from 'roosterjs-content-model-editor'; const styles = require('./InsertEntityPane.scss'); diff --git a/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx b/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx index 1e552ff9937..b57357141c0 100644 --- a/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx +++ b/demo/scripts/controls/sidePane/eventViewer/ContentModelEventViewPane.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { ContentModelContentChangedEvent } from 'roosterjs-content-model-editor'; +import { ContentModelContentChangedEvent } from 'roosterjs-content-model-types'; import { EntityOperation, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; import { SidePaneElementProps } from '../SidePaneElement'; import { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/borderValues.ts b/packages-content-model/roosterjs-content-model-editor/lib/domUtils/borderValues.ts index 585742a4e70..11be7401d45 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/borderValues.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/domUtils/borderValues.ts @@ -1,4 +1,4 @@ -import type { Border } from '../publicTypes/interface/Border'; +import type { Border } from 'roosterjs-content-model-types'; const BorderStyles = [ 'none', diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 6ad41f70a6c..bc574b26131 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -1,13 +1,8 @@ import { createContentModelEditorCore } from './createContentModelEditorCore'; import { EditorBase } from 'roosterjs-editor-core'; import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; -import type { - ContentModelFormatter, - FormatWithContentModelOptions, -} from '../publicTypes/parameter/FormatWithContentModelContext'; import type { ContentModelEditorOptions, - EditorEnvironment, IContentModelEditor, } from '../publicTypes/IContentModelEditor'; import type { @@ -17,6 +12,9 @@ import type { DomToModelOption, ModelToDomOption, OnNodeCreated, + ContentModelFormatter, + FormatWithContentModelOptions, + EditorEnvironment, } from 'roosterjs-content-model-types'; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts index 4210d373172..72d1810a9a5 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts @@ -1,14 +1,16 @@ import { cloneModel } from '../../publicApi/model/cloneModel'; -import type { DOMSelection, DomToModelOption } from 'roosterjs-content-model-types'; import { createDomToModelContext, createDomToModelContextWithConfig, domToContentModel, } from 'roosterjs-content-model-dom'; +import type { EditorCore } from 'roosterjs-editor-types'; import type { - ContentModelEditorCore, + DOMSelection, + DomToModelOption, CreateContentModel, -} from '../../publicTypes/ContentModelEditorCore'; + StandaloneEditorCore, +} from 'roosterjs-content-model-types'; /** * @internal @@ -41,7 +43,7 @@ export const createContentModel: CreateContentModel = (core, option, selectionOv }; function internalCreateContentModel( - core: ContentModelEditorCore, + core: StandaloneEditorCore & EditorCore, selection?: DOMSelection, option?: DomToModelOption ) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createEditorContext.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createEditorContext.ts index 08aafeef4b0..8e68e5f3f1e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createEditorContext.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createEditorContext.ts @@ -1,5 +1,4 @@ -import type { CreateEditorContext } from '../../publicTypes/ContentModelEditorCore'; -import type { EditorContext } from 'roosterjs-content-model-types'; +import type { EditorContext, CreateEditorContext } from 'roosterjs-content-model-types'; /** * @internal diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts index 0a8c54014ce..159105c5c81 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts @@ -1,16 +1,14 @@ -import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; +import { ChangeSource } from '../../publicTypes/ChangeSource'; import { ColorTransformDirection, EntityOperation, PluginEventType } from 'roosterjs-editor-types'; -import type ContentModelContentChangedEvent from '../../publicTypes/event/ContentModelContentChangedEvent'; -import type { - ContentModelEditorCore, - FormatContentModel, -} from '../../publicTypes/ContentModelEditorCore'; -import type { Entity } from 'roosterjs-editor-types'; +import type { EditorCore, Entity } from 'roosterjs-editor-types'; import type { + ContentModelContentChangedEvent, + DOMSelection, EntityRemovalOperation, + FormatContentModel, FormatWithContentModelContext, -} from '../../publicTypes/parameter/FormatWithContentModelContext'; -import type { DOMSelection } from 'roosterjs-content-model-types'; + StandaloneEditorCore, +} from 'roosterjs-content-model-types'; /** * @internal @@ -18,7 +16,7 @@ import type { DOMSelection } from 'roosterjs-content-model-types'; * It will grab a Content Model for current editor content, and invoke a callback function * to do format change. Then according to the return value, write back the modified content model into editor. * If there is cached model, it will be used and updated. - * @param core The ContentModelEditorCore object + * @param core The StandaloneEditorCore object * @param formatter Formatter function, see ContentModelFormatter * @param options More options, see FormatWithContentModelOptions */ @@ -83,7 +81,7 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) } }; -function handleNewEntities(core: ContentModelEditorCore, context: FormatWithContentModelContext) { +function handleNewEntities(core: EditorCore, context: FormatWithContentModelContext) { // TODO: Ideally we can trigger NewEntity event here. But to be compatible with original editor code, we don't do it here for now. // Once Content Model Editor can be standalone, we can change this behavior to move triggering NewEntity event code // from EntityPlugin to here @@ -109,10 +107,7 @@ const EntityOperationMap: Record = { removeFromStart: EntityOperation.RemoveFromStart, }; -function handleDeletedEntities( - core: ContentModelEditorCore, - context: FormatWithContentModelContext -) { +function handleDeletedEntities(core: EditorCore, context: FormatWithContentModelContext) { context.deletedEntities.forEach( ({ entity: { @@ -144,7 +139,7 @@ function handleDeletedEntities( ); } -function handleImages(core: ContentModelEditorCore, context: FormatWithContentModelContext) { +function handleImages(core: EditorCore, context: FormatWithContentModelContext) { if (context.newImages.length > 0) { const viewport = core.getVisibleViewport(); @@ -160,7 +155,7 @@ function handleImages(core: ContentModelEditorCore, context: FormatWithContentMo } function handlePendingFormat( - core: ContentModelEditorCore, + core: StandaloneEditorCore, context: FormatWithContentModelContext, selection?: DOMSelection | null ) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/getDOMSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/getDOMSelection.ts index 9b73b537fde..9297df45613 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/getDOMSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/getDOMSelection.ts @@ -1,9 +1,6 @@ import { SelectionRangeTypes } from 'roosterjs-editor-types'; -import type { - ContentModelEditorCore, - GetDOMSelection, -} from '../../publicTypes/ContentModelEditorCore'; -import type { DOMSelection } from 'roosterjs-content-model-types'; +import type { EditorCore } from 'roosterjs-editor-types'; +import type { DOMSelection, GetDOMSelection } from 'roosterjs-content-model-types'; /** * @internal @@ -12,7 +9,7 @@ export const getDOMSelection: GetDOMSelection = core => { return core.cache.cachedSelection ?? getNewSelection(core); }; -function getNewSelection(core: ContentModelEditorCore): DOMSelection | null { +function getNewSelection(core: EditorCore): DOMSelection | null { // TODO: Get rid of getSelectionRangeEx when we have standalone editor const rangeEx = core.api.getSelectionRangeEx(core); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts index 96a4290e5fa..f0cb94159b6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts @@ -1,9 +1,9 @@ -import type { SetContentModel } from '../../publicTypes/ContentModelEditorCore'; import { contentModelToDom, createModelToDomContext, createModelToDomContextWithConfig, } from 'roosterjs-content-model-dom'; +import type { SetContentModel } from 'roosterjs-content-model-types'; /** * @internal diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setDOMSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setDOMSelection.ts index 44273137c3e..be46307b163 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setDOMSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setDOMSelection.ts @@ -1,6 +1,6 @@ import { SelectionRangeTypes } from 'roosterjs-editor-types'; import type { SelectionRangeEx } from 'roosterjs-editor-types'; -import type { SetDOMSelection } from '../../publicTypes/ContentModelEditorCore'; +import type { SetDOMSelection } from 'roosterjs-content-model-types'; /** * @internal diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts index 6d4d56fc95f..0da88bb18e3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts @@ -1,8 +1,8 @@ import { getSelectionPath } from 'roosterjs-editor-dom'; import { iterateSelections } from '../../modelApi/selection/iterateSelections'; import { PluginEventType } from 'roosterjs-editor-types'; -import type { ContentModelEditorCore } from '../../publicTypes/ContentModelEditorCore'; -import type { SwitchShadowEdit } from 'roosterjs-editor-types'; +import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; +import type { EditorCore, SwitchShadowEdit } from 'roosterjs-editor-types'; /** * @internal @@ -12,7 +12,7 @@ import type { SwitchShadowEdit } from 'roosterjs-editor-types'; */ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { // TODO: Use strong-typed editor core object - const core = editorCore as ContentModelEditorCore; + const core = editorCore as StandaloneEditorCore & EditorCore; if (isOn != !!core.lifecycle.shadowEditFragment) { if (isOn) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCachePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCachePlugin.ts index b72c8bce0a2..24238809db8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCachePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCachePlugin.ts @@ -1,8 +1,10 @@ import { areSameRangeEx } from '../../modelApi/selection/areSameRangeEx'; import { isCharacterValue } from '../../domUtils/eventUtils'; import { PluginEventType } from 'roosterjs-editor-types'; -import type ContentModelContentChangedEvent from '../../publicTypes/event/ContentModelContentChangedEvent'; -import type { ContentModelCachePluginState } from '../../publicTypes/pluginState/ContentModelCachePluginState'; +import type { + ContentModelCachePluginState, + ContentModelContentChangedEvent, +} from 'roosterjs-content-model-types'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { IEditor, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts index 690eb8e9b1c..37726ae9227 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts @@ -1,6 +1,6 @@ import paste from '../../publicApi/utils/paste'; import { addRangeToSelection } from '../../domUtils/addRangeToSelection'; -import { ChangeSource } from '../../publicTypes/event/ContentModelContentChangedEvent'; +import { ChangeSource } from '../../publicTypes/ChangeSource'; import { cloneModel } from '../../publicApi/model/cloneModel'; import { ColorTransformDirection, PluginEventType } from 'roosterjs-editor-types'; import { deleteSelection } from '../../publicApi/selection/deleteSelection'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts index 45f8bb0a201..eefe272a2b5 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts @@ -5,7 +5,7 @@ import { isCharacterValue } from '../../domUtils/eventUtils'; import { PluginEventType } from 'roosterjs-editor-types'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { IEditor, PluginEvent, PluginWithState } from 'roosterjs-editor-types'; -import type { ContentModelFormatPluginState } from '../../publicTypes/pluginState/ContentModelFormatPluginState'; +import type { ContentModelFormatPluginState } from 'roosterjs-content-model-types'; // During IME input, KeyDown event will have "Process" as key const ProcessKey = 'Process'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts index e43e5f71e7b..d17046e3a39 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts @@ -19,8 +19,8 @@ import { } from '../domUtils/metadata/updateListMetadata'; import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; -import type { ContentModelPluginState } from '../publicTypes/pluginState/ContentModelPluginState'; import type { CoreCreator, EditorCore } from 'roosterjs-editor-types'; +import type { ContentModelPluginState } from 'roosterjs-content-model-types'; /** * Editor Core creator for Content Model editor diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 38d92911f3c..eebb88ceb0b 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -1,70 +1,9 @@ -export { ContentModelFormatState } from './publicTypes/format/formatState/ContentModelFormatState'; -export { ImageFormatState } from './publicTypes/format/formatState/ImageFormatState'; -export { Border } from './publicTypes/interface/Border'; -export { BorderOperations } from './publicTypes/enum/BorderOperations'; export { - CreateEditorContext, ContentModelCoreApiMap, ContentModelEditorCore, - CreateContentModel, - SetContentModel, - GetDOMSelection, - SetDOMSelection, - FormatContentModel, } from './publicTypes/ContentModelEditorCore'; -export { - default as ContentModelBeforePasteEvent, - ContentModelBeforePasteEventData, - CompatibleContentModelBeforePasteEvent, -} from './publicTypes/event/ContentModelBeforePasteEvent'; -export { - default as ContentModelContentChangedEvent, - CompatibleContentModelContentChangedEvent, - ContentModelContentChangedEventData, - ChangeSource, -} from './publicTypes/event/ContentModelContentChangedEvent'; - -export { - IContentModelEditor, - ContentModelEditorOptions, - EditorEnvironment, -} from './publicTypes/IContentModelEditor'; -export { InsertPoint } from './publicTypes/selection/InsertPoint'; -export { TableSelectionContext } from './publicTypes/selection/TableSelectionContext'; -export { - DeletedEntity, - FormatWithContentModelContext, - FormatWithContentModelOptions, - ContentModelFormatter, - EntityLifecycleOperation, - EntityOperation, - EntityRemovalOperation, -} from './publicTypes/parameter/FormatWithContentModelContext'; -export { - InsertEntityOptions, - InsertEntityPosition, -} from './publicTypes/parameter/InsertEntityOptions'; -export { - TableOperation, - TableVerticalInsertOperation, - TableHorizontalInsertOperation, - TableDeleteOperation, - TableVerticalMergeOperation, - TableHorizontalMergeOperation, - TableCellMergeOperation, - TableSplitOperation, - TableAlignOperation, - TableCellHorizontalAlignOperation, - TableCellVerticalAlignOperation, -} from './publicTypes/parameter/TableOperation'; -export { PasteType } from './publicTypes/parameter/PasteType'; -export { - DeleteResult, - DeleteSelectionContext, - DeleteSelectionResult, - DeleteSelectionStep, - ValidDeleteSelectionContext, -} from './publicTypes/parameter/DeleteSelectionStep'; +export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; +export { ChangeSource } from './publicTypes/ChangeSource'; export { default as insertTable } from './publicApi/table/insertTable'; export { default as formatTable } from './publicApi/table/formatTable'; @@ -137,10 +76,3 @@ export { updateTableMetadata } from './domUtils/metadata/updateTableMetadata'; export { updateListMetadata } from './domUtils/metadata/updateListMetadata'; export { isCharacterValue, isModifierKey } from './domUtils/eventUtils'; export { isPunctuation, isSpace, normalizeText } from './domUtils/stringUtil'; - -export { ContentModelCachePluginState } from './publicTypes/pluginState/ContentModelCachePluginState'; -export { ContentModelPluginState } from './publicTypes/pluginState/ContentModelPluginState'; -export { - ContentModelFormatPluginState, - PendingFormat, -} from './publicTypes/pluginState/ContentModelFormatPluginState'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelAlignment.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelAlignment.ts index 416f8dd6a57..327df31dfa1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelAlignment.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelAlignment.ts @@ -1,7 +1,10 @@ import { alignTable } from '../table/alignTable'; import { getOperationalBlocks } from '../selection/collectSelections'; -import type { TableAlignOperation } from '../../publicTypes/parameter/TableOperation'; -import type { ContentModelDocument, ContentModelListItem } from 'roosterjs-content-model-types'; +import type { + ContentModelDocument, + ContentModelListItem, + TableAlignOperation, +} from 'roosterjs-content-model-types'; const ResultMap: Record< 'left' | 'center' | 'right', diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts index 34f12aa4d77..d811a161638 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts @@ -5,7 +5,6 @@ import { getClosestAncestorBlockGroupIndex } from './getClosestAncestorBlockGrou import { iterateSelections } from '../selection/iterateSelections'; import { updateTableCellMetadata } from '../../domUtils/metadata/updateTableCellMetadata'; import { updateTableMetadata } from '../../domUtils/metadata/updateTableMetadata'; -import type { TableSelectionContext } from '../../publicTypes/selection/TableSelectionContext'; import type { ContentModelBlock, ContentModelBlockGroup, @@ -16,6 +15,7 @@ import type { ContentModelSegmentFormat, ContentModelTable, Selectable, + TableSelectionContext, } from 'roosterjs-content-model-types'; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts index 92b0cbecdd9..d435710efcf 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts @@ -11,8 +11,6 @@ import { getObjectKeys, normalizeContentModel, } from 'roosterjs-content-model-dom'; -import type { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; -import type { InsertPoint } from '../../publicTypes/selection/InsertPoint'; import type { ContentModelBlock, ContentModelBlockFormat, @@ -22,6 +20,8 @@ import type { ContentModelParagraph, ContentModelSegmentFormat, ContentModelTable, + FormatWithContentModelContext, + InsertPoint, } from 'roosterjs-content-model-types'; const HeadingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts index 5e7a33e7c80..ca3fd514c40 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts @@ -3,9 +3,8 @@ import { getClosestAncestorBlockGroupIndex } from './getClosestAncestorBlockGrou import { isBold } from '../../publicApi/segment/toggleBold'; import { iterateSelections } from '../selection/iterateSelections'; import { updateTableMetadata } from '../../domUtils/metadata/updateTableMetadata'; -import type { ContentModelFormatState } from '../../publicTypes/format/formatState/ContentModelFormatState'; -import type { TableSelectionContext } from '../../publicTypes/selection/TableSelectionContext'; import type { + ContentModelFormatState, ContentModelBlock, ContentModelBlockGroup, ContentModelDocument, @@ -14,6 +13,7 @@ import type { ContentModelListItem, ContentModelParagraph, ContentModelSegmentFormat, + TableSelectionContext, } from 'roosterjs-content-model-types'; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/createInsertPoint.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/createInsertPoint.ts index c89e236d120..9790d603fff 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/createInsertPoint.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/createInsertPoint.ts @@ -1,9 +1,9 @@ -import type { InsertPoint } from '../../../publicTypes/selection/InsertPoint'; -import type { TableSelectionContext } from '../../../publicTypes/selection/TableSelectionContext'; import type { ContentModelBlockGroup, ContentModelParagraph, ContentModelSelectionMarker, + InsertPoint, + TableSelectionContext, } from 'roosterjs-content-model-types'; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts index b8bd6c78922..4c603e12271 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts @@ -2,10 +2,12 @@ import { createInsertPoint } from '../utils/createInsertPoint'; import { deleteBlock } from '../../../publicApi/block/deleteBlock'; import { deleteSegment } from '../../../publicApi/segment/deleteSegment'; import { iterateSelections } from '../../selection/iterateSelections'; -import type { DeleteSelectionContext } from '../../../publicTypes/parameter/DeleteSelectionStep'; -import type { ContentModelDocument } from 'roosterjs-content-model-types'; -import type { FormatWithContentModelContext } from '../../../publicTypes/parameter/FormatWithContentModelContext'; import type { IterateSelectionsOption } from '../../selection/iterateSelections'; +import type { + ContentModelDocument, + DeleteSelectionContext, + FormatWithContentModelContext, +} from 'roosterjs-content-model-types'; import { createBr, createParagraph, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts index 71903880ceb..5b28c9ac6a3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts @@ -7,15 +7,15 @@ import { createSelectionMarker, normalizeContentModel, } from 'roosterjs-content-model-dom'; -import type { DeleteSelectionResult } from '../../publicTypes/parameter/DeleteSelectionStep'; -import type { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; -import type { InsertEntityPosition } from '../../publicTypes/parameter/InsertEntityOptions'; import type { ContentModelBlock, ContentModelBlockGroup, ContentModelDocument, ContentModelEntity, ContentModelParagraph, + DeleteSelectionResult, + FormatWithContentModelContext, + InsertEntityPosition, } from 'roosterjs-content-model-types'; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/image/applyImageBorderFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/image/applyImageBorderFormat.ts index d2d8aa8abb5..dbe157d9462 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/image/applyImageBorderFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/image/applyImageBorderFormat.ts @@ -1,7 +1,6 @@ import { extractBorderValues } from '../../domUtils/borderValues'; import { parseValueWithUnit } from 'roosterjs-content-model-dom'; -import type { Border } from '../../publicTypes/interface/Border'; -import type { ContentModelImage } from 'roosterjs-content-model-types'; +import type { Border, ContentModelImage } from 'roosterjs-content-model-types'; /** * @internal diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts index 186dc27b562..14c6abd8874 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts @@ -2,7 +2,6 @@ import { getClosestAncestorBlockGroupIndex } from '../common/getClosestAncestorB import { isBlockGroupOfType } from '../common/isBlockGroupOfType'; import { iterateSelections } from './iterateSelections'; import type { IterateSelectionsOption } from './iterateSelections'; -import type { TableSelectionContext } from '../../publicTypes/selection/TableSelectionContext'; import type { ContentModelBlock, ContentModelBlockGroup, @@ -12,6 +11,7 @@ import type { ContentModelParagraph, ContentModelSegment, ContentModelTable, + TableSelectionContext, } from 'roosterjs-content-model-types'; import type { TypeOfBlockGroup } from '../common/getClosestAncestorBlockGroupIndex'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/iterateSelections.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/iterateSelections.ts index 551eeb8bcb5..0f76fda08b0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/iterateSelections.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/iterateSelections.ts @@ -1,9 +1,9 @@ -import type { TableSelectionContext } from '../../publicTypes/selection/TableSelectionContext'; import type { ContentModelBlock, ContentModelBlockGroup, ContentModelBlockWithCache, ContentModelSegment, + TableSelectionContext, } from 'roosterjs-content-model-types'; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTable.ts index ccf78d77a4a..1d8be6d468e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTable.ts @@ -1,5 +1,4 @@ -import type { TableAlignOperation } from '../../publicTypes/parameter/TableOperation'; -import type { ContentModelTable } from 'roosterjs-content-model-types'; +import type { ContentModelTable, TableAlignOperation } from 'roosterjs-content-model-types'; /** * @internal diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTableCell.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTableCell.ts index 93b972ef13e..ded7e3264af 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTableCell.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTableCell.ts @@ -1,10 +1,11 @@ import { getSelectedCells } from './getSelectedCells'; import { updateTableCellMetadata } from '../../domUtils/metadata/updateTableCellMetadata'; import type { + ContentModelTable, + ContentModelTableCell, TableCellHorizontalAlignOperation, TableCellVerticalAlignOperation, -} from '../../publicTypes/parameter/TableOperation'; -import type { ContentModelTable, ContentModelTableCell } from 'roosterjs-content-model-types'; +} from 'roosterjs-content-model-types'; const TextAlignValueMap: Partial EditorContext; - -/** - * Create Content Model from DOM tree in this editor - * @param core The ContentModelEditorCore object - * @param option The option to customize the behavior of DOM to Content Model conversion - * @param selectionOverride When passed, use this selection range instead of current selection in editor - */ -export type CreateContentModel = ( - core: ContentModelEditorCore, - option?: DomToModelOption, - selectionOverride?: DOMSelection -) => ContentModelDocument; - -/** - * Get current DOM selection from editor - * @param core The ContentModelEditorCore object - */ -export type GetDOMSelection = (core: ContentModelEditorCore) => DOMSelection | null; - -/** - * Set content with content model. This is the replacement of core API getSelectionRangeEx - * @param core The ContentModelEditorCore object - * @param model The content model to set - * @param option Additional options to customize the behavior of Content Model to DOM conversion - * @param onNodeCreated An optional callback that will be called when a DOM node is created - */ -export type SetContentModel = ( - core: ContentModelEditorCore, - model: ContentModelDocument, - option?: ModelToDomOption, - onNodeCreated?: OnNodeCreated -) => DOMSelection | null; - -/** - * Set current DOM selection from editor. This is the replacement of core API select - * @param selection The selection to set - */ -export type SetDOMSelection = (core: ContentModelEditorCore, selection: DOMSelection) => void; - -/** - * The general API to do format change with Content Model - * It will grab a Content Model for current editor content, and invoke a callback function - * to do format change. Then according to the return value, write back the modified content model into editor. - * If there is cached model, it will be used and updated. - * @param core The ContentModelEditorCore object - * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions - */ -export type FormatContentModel = ( - core: ContentModelEditorCore, - formatter: ContentModelFormatter, - options?: FormatWithContentModelOptions -) => void; +import type { StandaloneCoreApiMap, StandaloneEditorCore } from 'roosterjs-content-model-types'; /** * The interface for the map of core API for Content Model editor. * Editor can call call API from this map under ContentModelEditorCore object */ -export interface ContentModelCoreApiMap extends CoreApiMap { - /** - * Create a EditorContext object used by ContentModel API - * @param core The ContentModelEditorCore object - */ - createEditorContext: CreateEditorContext; - - /** - * Create Content Model from DOM tree in this editor - * @param core The ContentModelEditorCore object - * @param option The option to customize the behavior of DOM to Content Model conversion - */ - createContentModel: CreateContentModel; - - /** - * Get current DOM selection from editor - * @param core The ContentModelEditorCore object - */ - getDOMSelection: GetDOMSelection; - - /** - * Set content with content model - * @param core The ContentModelEditorCore object - * @param model The content model to set - * @param option Additional options to customize the behavior of Content Model to DOM conversion - */ - setContentModel: SetContentModel; - - /** - * Set current DOM selection from editor. This is the replacement of core API select - * @param core The ContentModelEditorCore object - * @param selection The selection to set - */ - setDOMSelection: SetDOMSelection; - - /** - * The general API to do format change with Content Model - * It will grab a Content Model for current editor content, and invoke a callback function - * to do format change. Then according to the return value, write back the modified content model into editor. - * If there is cached model, it will be used and updated. - * @param core The ContentModelEditorCore object - * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions - */ - formatContentModel: FormatContentModel; -} +export interface ContentModelCoreApiMap extends CoreApiMap, StandaloneCoreApiMap {} /** * Represents the core data structure of a Content Model editor */ -export interface ContentModelEditorCore extends EditorCore, ContentModelPluginState { +export interface ContentModelEditorCore extends EditorCore, StandaloneEditorCore { /** * Core API map of this editor */ @@ -139,31 +20,4 @@ export interface ContentModelEditorCore extends EditorCore, ContentModelPluginSt * Original API map of this editor. Overridden core API can use API from this map to call the original version of core API. */ readonly originalApi: ContentModelCoreApiMap; - - /** - * Default DOM to Content Model options - */ - defaultDomToModelOptions: (DomToModelOption | undefined)[]; - - /** - * Default Content Model to DOM options - */ - defaultModelToDomOptions: (ModelToDomOption | undefined)[]; - - /** - * Default DOM to Content Model config, calculated from defaultDomToModelOptions, - * will be used for creating content model if there is no other customized options - */ - defaultDomToModelConfig: DomToModelSettings; - - /** - * Default Content Model to DOM config, calculated from defaultModelToDomOptions, - * will be used for setting content model if there is no other customized options - */ - defaultModelToDomConfig: ModelToDomSettings; - - /** - * Editor running environment - */ - environment: EditorEnvironment; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index 8775e2b026c..0eabedea19b 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -1,114 +1,13 @@ import type { EditorOptions, IEditor } from 'roosterjs-editor-types'; -import type { - ContentModelFormatter, - FormatWithContentModelOptions, -} from './parameter/FormatWithContentModelContext'; -import type { - ContentModelDocument, - ContentModelSegmentFormat, - DOMSelection, - DomToModelOption, - ModelToDomOption, - OnNodeCreated, -} from 'roosterjs-content-model-types'; - -/** - * Current running environment - */ -export interface EditorEnvironment { - /** - * Whether editor is running on Mac - */ - isMac?: boolean; - - /** - * Whether editor is running on Android - */ - isAndroid?: boolean; -} +import type { StandaloneEditorOptions, IStandaloneEditor } from 'roosterjs-content-model-types'; /** * An interface of editor with Content Model support. * (This interface is still under development, and may still be changed in the future with some breaking changes) */ -export interface IContentModelEditor extends IEditor { - /** - * Create Content Model from DOM tree in this editor - * @param rootNode Optional start node. If provided, Content Model will be created from this node (including itself), - * otherwise it will create Content Model for the whole content in editor. - * @param option The options to customize the behavior of DOM to Content Model conversion - * @param selectionOverride When specified, use this selection to override existing selection inside editor - */ - createContentModel( - option?: DomToModelOption, - selectionOverride?: DOMSelection - ): ContentModelDocument; - - /** - * Set content with content model - * @param model The content model to set - * @param option Additional options to customize the behavior of Content Model to DOM conversion - * @param onNodeCreated An optional callback that will be called when a DOM node is created - */ - setContentModel( - model: ContentModelDocument, - option?: ModelToDomOption, - onNodeCreated?: OnNodeCreated - ): DOMSelection | null; - - /** - * Get current running environment, such as if editor is running on Mac - */ - getEnvironment(): EditorEnvironment; - - /** - * Get current DOM selection. - * This is the replacement of IEditor.getSelectionRangeEx. - */ - getDOMSelection(): DOMSelection | null; - - /** - * Set DOMSelection into editor content. - * This is the replacement of IEditor.select. - * @param selection The selection to set - */ - setDOMSelection(selection: DOMSelection): void; - - /** - * The general API to do format change with Content Model - * It will grab a Content Model for current editor content, and invoke a callback function - * to do format change. Then according to the return value, write back the modified content model into editor. - * If there is cached model, it will be used and updated. - * @param formatter Formatter function, see ContentModelFormatter - * @param options More options, see FormatWithContentModelOptions - */ - formatContentModel( - formatter: ContentModelFormatter, - options?: FormatWithContentModelOptions - ): void; - - /** - * Get pending format of editor if any, or return null - */ - getPendingFormat(): ContentModelSegmentFormat | null; -} +export interface IContentModelEditor extends IEditor, IStandaloneEditor {} /** * Options for Content Model editor */ -export interface ContentModelEditorOptions extends EditorOptions { - /** - * Default options used for DOM to Content Model conversion - */ - defaultDomToModelOptions?: DomToModelOption; - - /** - * Default options used for Content Model to DOM conversion - */ - defaultModelToDomOptions?: ModelToDomOption; - - /** - * Reuse existing DOM structure if possible, and update the model when content or selection is changed - */ - cacheModel?: boolean; -} +export interface ContentModelEditorOptions extends EditorOptions, StandaloneEditorOptions {} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelContentChangedEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelContentChangedEvent.ts deleted file mode 100644 index f9acb825da6..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelContentChangedEvent.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { ContentModelDocument, DOMSelection } from 'roosterjs-content-model-types'; -import type { - CompatibleContentChangedEvent, - ContentChangedEvent, - ContentChangedEventData, -} from 'roosterjs-editor-types'; - -/** - * Possible change sources. Here are the predefined sources. - * It can also be other string if the change source can't fall into these sources. - */ -export enum ChangeSource { - /** - * Content changed by auto link - */ - AutoLink = 'AutoLink', - /** - * Content changed by create link - */ - CreateLink = 'CreateLink', - /** - * Content changed by format - */ - Format = 'Format', - /** - * Content changed by image resize - */ - ImageResize = 'ImageResize', - /** - * Content changed by paste - */ - Paste = 'Paste', - /** - * Content changed by setContent API - */ - SetContent = 'SetContent', - /** - * Content changed by cut operation - */ - Cut = 'Cut', - /** - * Content changed by drag & drop operation - */ - Drop = 'Drop', - /** - * Insert a new entity into editor - */ - InsertEntity = 'InsertEntity', - /** - * Editor is switched to dark mode, content color is changed - */ - SwitchToDarkMode = 'SwitchToDarkMode', - /** - * Editor is switched to light mode, content color is changed - */ - SwitchToLightMode = 'SwitchToLightMode', - /** - * List chain reorganized numbers of lists - */ - ListChain = 'ListChain', - /** - * Keyboard event, used by Content Model. - * Data of this event will be the key code number - */ - Keyboard = 'Keyboard', -} - -/** - * Data of ContentModelContentChangedEvent - */ -export interface ContentModelContentChangedEventData extends ContentChangedEventData { - /** - * The content model that is applied which causes this content changed event - */ - contentModel?: ContentModelDocument; - - /** - * Selection range applied to the document - */ - selection?: DOMSelection; -} - -/** - * Represents a change to the editor made by another plugin with content model inside - */ -export default interface ContentModelContentChangedEvent - extends ContentChangedEvent, - ContentModelContentChangedEventData {} - -/** - * Represents a change to the editor made by another plugin with content model inside - */ -export interface CompatibleContentModelContentChangedEvent - extends CompatibleContentChangedEvent, - ContentModelContentChangedEventData {} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts deleted file mode 100644 index e907a3d7fae..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts +++ /dev/null @@ -1,164 +0,0 @@ -import type { - ContentModelDocument, - ContentModelEntity, - ContentModelImage, - ContentModelSegmentFormat, - DOMSelection, - OnNodeCreated, -} from 'roosterjs-content-model-types'; - -/** - * Define entity lifecycle related operations - */ -export type EntityLifecycleOperation = - /** - * Notify plugins that there is a new plugin was added into editor. - * Plugin can handle this event to entity hydration. - * This event will be only fired once for each entity DOM node. - * After undo, or copy/paste, since new DOM nodes were added, this event will be fired - * for those entities represented by newly added nodes. - */ - | 'newEntity' - - /** - * Notify plugins that editor is generating HTML content for save. - * Plugin should use this event to remove any temporary content, and only leave DOM nodes that - * should be saved as HTML string. - * This event will provide a cloned DOM tree for each entity, do NOT compare the DOM nodes with cached nodes - * because it will always return false. - */ - | 'replaceTemporaryContent' - /** - * Notify plugins that a new entity state need to be updated to an entity. - * This is normally happened when user undo/redo the content with an entity snapshot added by a plugin that handles entity - */ - | 'UpdateEntityState'; - -/** - * Define entity removal related operations - */ -export type EntityRemovalOperation = - /** - * Notify plugins that user is removing an entity from its start position using DELETE key - */ - | 'removeFromStart' - - /** - * Notify plugins that user is remove an entity from its end position using BACKSPACE key - */ - | 'removeFromEnd' - - /** - * Notify plugins that an entity is being overwritten. - * This can be caused by key in, cut, paste, delete, backspace ... on a selection - * which contains some entities. - */ - | 'overwrite'; - -/** - * Define possible operations to an entity - */ -export type EntityOperation = EntityLifecycleOperation | EntityRemovalOperation; - -/** - * Represents an entity that is deleted by a specified entity operation - */ -export interface DeletedEntity { - entity: ContentModelEntity; - operation: EntityRemovalOperation; -} - -/** - * Context object for API formatWithContentModel - */ -export interface FormatWithContentModelContext { - /** - * New entities added during the format process - */ - readonly newEntities: ContentModelEntity[]; - - /** - * Entities got deleted during formatting. Need to be set by the formatter function - */ - readonly deletedEntities: DeletedEntity[]; - - /** - * Images inserted in the editor that needs to have their size adjusted - */ - readonly newImages: ContentModelImage[]; - - /** - * Raw Event that triggers this format call - */ - readonly rawEvent?: Event; - - /** - * @optional - * When pass true, skip adding undo snapshot when write Content Model back to DOM. - * Need to be set by the formatter function - */ - skipUndoSnapshot?: boolean; - - /** - * @optional - * When set to true, formatWithContentModel API will not keep cached Content Model. Next time when we need a Content Model, a new one will be created - */ - clearModelCache?: boolean; - - /** - * @optional - * Specify new pending format. - * To keep current format event selection position is changed, set this value to "preserved", editor will update pending format position to the new position - * To set a new pending format, set this property to the format object - * Otherwise, leave it there and editor will automatically decide if the original pending format is still available - */ - newPendingFormat?: ContentModelSegmentFormat | 'preserve'; -} - -/** - * Options for API formatWithContentModel - */ -export interface FormatWithContentModelOptions { - /** - * Name of the format API - */ - apiName?: string; - - /** - * Raw event object that triggers this call - */ - rawEvent?: Event; - - /** - * Change source used for triggering a ContentChanged event. @default ChangeSource.Format. - */ - changeSource?: string; - - /** - * An optional callback that will be called when a DOM node is created - * @param modelElement The related Content Model element - * @param node The node created for this model element - */ - onNodeCreated?: OnNodeCreated; - - /** - * Optional callback to get an object used for change data in ContentChangedEvent - */ - getChangeData?: () => any; - - /** - * When specified, use this selection range to override current selection inside editor - */ - selectionOverride?: DOMSelection; -} - -/** - * Type of formatter used for format Content Model. - * @param model The source Content Model to format - * @param context A context object used for pass in and out more parameters - * @returns True means the model is changed and need to write back to editor, otherwise false - */ -export type ContentModelFormatter = ( - model: ContentModelDocument, - context: FormatWithContentModelContext -) => boolean; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts index 1e4c49a28a9..7fcfbf2638a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts @@ -1,4 +1,4 @@ -import { ChangeSource } from '../../../lib/publicTypes/event/ContentModelContentChangedEvent'; +import { ChangeSource } from '../../../lib/publicTypes/ChangeSource'; import { ColorTransformDirection, EntityOperation, PluginEventType } from 'roosterjs-editor-types'; import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { ContentModelEditorCore } from '../../../lib/publicTypes/ContentModelEditorCore'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts index d25378e2fd5..b86aa9d0444 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts @@ -2,10 +2,7 @@ import * as applyPendingFormat from '../../../lib/modelApi/format/applyPendingFo import ContentModelFormatPlugin from '../../../lib/editor/corePlugins/ContentModelFormatPlugin'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { PluginEventType } from 'roosterjs-editor-types'; -import { - ContentModelFormatPluginState, - PendingFormat, -} from '../../../lib/publicTypes/pluginState/ContentModelFormatPluginState'; +import { ContentModelFormatPluginState, PendingFormat } from 'roosterjs-content-model-types'; import { addSegment, createContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCachePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCachePluginTest.ts index 1379c66c658..08ec77166cf 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCachePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCachePluginTest.ts @@ -1,8 +1,10 @@ -import { ContentModelCachePluginState } from '../../../lib/publicTypes/pluginState/ContentModelCachePluginState'; -import { ContentModelDomIndexer } from 'roosterjs-content-model-types'; import { default as ContentModelCachePlugin } from '../../../lib/editor/corePlugins/ContentModelCachePlugin'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { PluginEventType } from 'roosterjs-editor-types'; +import { + ContentModelCachePluginState, + ContentModelDomIndexer, +} from 'roosterjs-content-model-types'; describe('ContentModelCachePlugin', () => { let plugin: ContentModelCachePlugin; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts index c8363634d8c..ff1cd7a4bf5 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts @@ -6,15 +6,16 @@ import * as extractClipboardItemsFile from 'roosterjs-editor-dom/lib/clipboard/e import * as iterateSelectionsFile from '../../../lib/modelApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import * as PasteFile from '../../../lib/publicApi/utils/paste'; -import { ContentModelDocument, DOMSelection } from 'roosterjs-content-model-types'; import { createModelToDomContext } from 'roosterjs-content-model-dom'; import { createRange } from 'roosterjs-editor-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { setEntityElementClasses } from 'roosterjs-content-model-dom/test/domUtils/entityUtilTest'; import { + ContentModelDocument, + DOMSelection, ContentModelFormatter, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; import ContentModelCopyPastePlugin, { onNodeCreated, } from '../../../lib/editor/corePlugins/ContentModelCopyPastePlugin'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts index b83ad88b109..64a0154c34f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts @@ -1,7 +1,5 @@ import * as applyTableFormat from '../../../lib/modelApi/table/applyTableFormat'; import * as normalizeTable from '../../../lib/modelApi/table/normalizeTable'; -import { FormatWithContentModelContext } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; -import { mergeModel } from '../../../lib/modelApi/common/mergeModel'; import { ContentModelDocument, ContentModelImage, @@ -10,7 +8,9 @@ import { ContentModelSelectionMarker, ContentModelTable, ContentModelTableCell, + FormatWithContentModelContext, } from 'roosterjs-content-model-types'; +import { mergeModel } from '../../../lib/modelApi/common/mergeModel'; import { createBr, createContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts index 21dabcc1371..1b916ed32c8 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts @@ -1,7 +1,6 @@ import * as iterateSelections from '../../../lib/modelApi/selection/iterateSelections'; import { applyTableFormat } from '../../../lib/modelApi/table/applyTableFormat'; -import { ContentModelFormatState } from '../../../lib/publicTypes/format/formatState/ContentModelFormatState'; -import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import { ContentModelFormatState, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { retrieveModelFormatState } from '../../../lib/modelApi/common/retrieveModelFormatState'; import { addCode, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts index 7f4636b508a..e8c0cef5414 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts @@ -1,5 +1,4 @@ -import { ContentModelSelectionMarker } from 'roosterjs-content-model-types'; -import { DeletedEntity } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +import { ContentModelSelectionMarker, DeletedEntity } from 'roosterjs-content-model-types'; import { deleteSelection } from '../../../lib/publicApi/selection/deleteSelection'; import { createContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/entity/insertEntityModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/entity/insertEntityModelTest.ts index 1c59a012554..49cf8bb1a6b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/entity/insertEntityModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/entity/insertEntityModelTest.ts @@ -1,6 +1,5 @@ -import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { ContentModelDocument, InsertEntityPosition } from 'roosterjs-content-model-types'; import { insertEntityModel } from '../../../lib/modelApi/entity/insertEntityModel'; -import { InsertEntityPosition } from '../../../lib/publicTypes/parameter/InsertEntityOptions'; import { createBr, createContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts index 603835047df..2941729c95f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts @@ -1,8 +1,14 @@ import * as deleteSelection from '../../../lib/publicApi/selection/deleteSelection'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { applyDefaultFormat } from '../../../lib/modelApi/format/applyDefaultFormat'; -import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import { InsertPoint } from '../../../lib/publicTypes/selection/InsertPoint'; +import { + ContentModelDocument, + ContentModelFormatter, + ContentModelSegmentFormat, + FormatWithContentModelContext, + FormatWithContentModelOptions, + InsertPoint, +} from 'roosterjs-content-model-types'; import { createContentModelDocument, createDivider, @@ -11,11 +17,6 @@ import { createSelectionMarker, createText, } from 'roosterjs-content-model-dom'; -import { - ContentModelFormatter, - FormatWithContentModelContext, - FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import type { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; describe('applyDefaultFormat', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts index 5faa0d91c22..d827fe32175 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts @@ -2,15 +2,13 @@ import * as iterateSelections from '../../../lib/modelApi/selection/iterateSelec import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { applyPendingFormat } from '../../../lib/modelApi/format/applyPendingFormat'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { - ContentModelFormatter, - FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { ContentModelDocument, ContentModelParagraph, ContentModelSelectionMarker, ContentModelText, + ContentModelFormatter, + FormatWithContentModelOptions, } from 'roosterjs-content-model-types'; import { createContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/image/applyImageBorderFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/image/applyImageBorderFormatTest.ts index 4290a0cf78a..acf4bd0038a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/image/applyImageBorderFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/image/applyImageBorderFormatTest.ts @@ -1,6 +1,5 @@ import applyImageBorderFormat from '../../../lib/modelApi/image/applyImageBorderFormat'; -import { Border } from '../../../lib/publicTypes/interface/Border'; -import { ContentModelImage } from 'roosterjs-content-model-types'; +import { Border, ContentModelImage } from 'roosterjs-content-model-types'; describe('applyImageBorderFormat', () => { function createImage(border?: string): ContentModelImage { diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts index a9080dc1aa4..4bf8312389f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts @@ -1,5 +1,13 @@ import * as iterateSelections from '../../../lib/modelApi/selection/iterateSelections'; -import { TableSelectionContext } from '../../../lib/publicTypes/selection/TableSelectionContext'; +import { + ContentModelBlock, + ContentModelBlockGroup, + ContentModelBlockGroupType, + ContentModelParagraph, + ContentModelSegment, + ContentModelTable, + TableSelectionContext, +} from 'roosterjs-content-model-types'; import { createContentModelDocument, createDivider, @@ -13,14 +21,6 @@ import { createTableCell, createText, } from 'roosterjs-content-model-dom'; -import { - ContentModelBlock, - ContentModelBlockGroup, - ContentModelBlockGroupType, - ContentModelParagraph, - ContentModelSegment, - ContentModelTable, -} from 'roosterjs-content-model-types'; import { getSelectedParagraphs, getFirstSelectedListItem, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/alignTableCellTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/alignTableCellTest.ts index d0773936966..5b987c81476 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/alignTableCellTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/alignTableCellTest.ts @@ -1,13 +1,13 @@ -import { ContentModelTableCellFormat } from 'roosterjs-content-model-types'; import { createTable, createTableCell } from 'roosterjs-content-model-dom'; +import { + ContentModelTableCellFormat, + TableCellHorizontalAlignOperation, + TableCellVerticalAlignOperation, +} from 'roosterjs-content-model-types'; import { alignTableCellHorizontally, alignTableCellVertically, } from '../../../lib/modelApi/table/alignTableCell'; -import { - TableCellHorizontalAlignOperation, - TableCellVerticalAlignOperation, -} from '../../../lib/publicTypes/parameter/TableOperation'; describe('alignTableCellHorizontally', () => { function runTest( diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts index 5e8f711202a..c595d5a0320 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts @@ -1,9 +1,9 @@ -import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { + ContentModelDocument, ContentModelFormatter, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; export function paragraphTestCommon( apiName: string, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts index 7d77ab5bdc9..e6763b7d32f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts @@ -3,14 +3,12 @@ import setAlignment from '../../../lib/publicApi/block/setAlignment'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { paragraphTestCommon } from './paragraphTestCommon'; -import { - ContentModelFormatter, - FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { ContentModelDocument, ContentModelListItem, ContentModelTable, + ContentModelFormatter, + FormatWithContentModelOptions, } from 'roosterjs-content-model-types'; describe('setAlignment', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts index 83aba0f0690..d4162033dc2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts @@ -4,7 +4,7 @@ import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEdito import { ContentModelFormatter, FormatWithContentModelContext, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; describe('setIndentation', () => { const fakeModel: any = { a: 'b' }; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts index 9d39f25aca2..690c4dd8f2e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts @@ -4,7 +4,7 @@ import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEdito import { ContentModelFormatter, FormatWithContentModelContext, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; describe('toggleBlockQuote', () => { const fakeModel: any = { a: 'b' }; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts index a1350441569..d9217e1c383 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts @@ -1,12 +1,12 @@ import * as insertEntityModel from '../../../lib/modelApi/entity/insertEntityModel'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import insertEntity from '../../../lib/publicApi/entity/insertEntity'; -import { ChangeSource } from '../../../lib/publicTypes/event/ContentModelContentChangedEvent'; +import { ChangeSource } from '../../../lib/publicTypes/ChangeSource'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { FormatWithContentModelContext, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; describe('insertEntity', () => { let editor: IContentModelEditor; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts index 93e76a725ab..67e1c179af2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts @@ -1,12 +1,12 @@ import * as clearModelFormat from '../../../lib/modelApi/common/clearModelFormat'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import clearFormat from '../../../lib/publicApi/format/clearFormat'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { + ContentModelDocument, ContentModelFormatter, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; describe('clearFormat', () => { it('Clear format', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts index f943577ef10..05057a68839 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts @@ -1,7 +1,6 @@ import * as getSelectionRootNode from '../../../lib/modelApi/selection/getSelectionRootNode'; import * as retrieveModelFormatState from '../../../lib/modelApi/common/retrieveModelFormatState'; -import { ContentModelFormatState } from '../../../lib/publicTypes/format/formatState/ContentModelFormatState'; -import { DomToModelContext } from 'roosterjs-content-model-types'; +import { ContentModelFormatState, DomToModelContext } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import getFormatState, { reducedModelChildProcessor, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts index ebac050117a..dab3091f466 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts @@ -1,12 +1,12 @@ import * as readFile from '../../../lib/domUtils/readFile'; import changeImage from '../../../lib/publicApi/image/changeImage'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { PluginEventType } from 'roosterjs-editor-types'; import { + ContentModelDocument, ContentModelFormatter, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; import { addSegment, createContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts index 4d458dbd18c..f99a8cc9fb0 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts @@ -1,11 +1,11 @@ import * as readFile from '../../../lib/domUtils/readFile'; import insertImage from '../../../lib/publicApi/image/insertImage'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { + ContentModelDocument, ContentModelFormatter, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; import { addSegment, createContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/setImageBorderTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/setImageBorderTest.ts index 32dac75e5ea..2110db87731 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/setImageBorderTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/setImageBorderTest.ts @@ -1,6 +1,5 @@ import setImageBorder from '../../../lib/publicApi/image/setImageBorder'; -import { Border } from '../../../lib/publicTypes/interface/Border'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { Border, ContentModelDocument } from 'roosterjs-content-model-types'; import { segmentTestCommon } from '../segment/segmentTestCommon'; import { addSegment, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts index a9ad11d1bc3..83e590e2946 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts @@ -1,10 +1,11 @@ import adjustLinkSelection from '../../../lib/publicApi/link/adjustLinkSelection'; -import { ContentModelDocument, ContentModelLink } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { + ContentModelDocument, + ContentModelLink, ContentModelFormatter, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; import { addLink, addSegment, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts index 146642963b6..f627da244a7 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts @@ -1,13 +1,14 @@ import ContentModelEditor from '../../../lib/editor/ContentModelEditor'; import insertLink from '../../../lib/publicApi/link/insertLink'; -import { ChangeSource } from '../../../lib/publicTypes/event/ContentModelContentChangedEvent'; -import { ContentModelDocument, ContentModelLink } from 'roosterjs-content-model-types'; +import { ChangeSource } from '../../../lib/publicTypes/ChangeSource'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { PluginEventType } from 'roosterjs-editor-types'; import { + ContentModelDocument, + ContentModelLink, ContentModelFormatter, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; import { addSegment, createContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts index 6d9679db5cc..08db1193703 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts @@ -1,10 +1,11 @@ import removeLink from '../../../lib/publicApi/link/removeLink'; -import { ContentModelDocument, ContentModelLink } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { + ContentModelDocument, + ContentModelLink, ContentModelFormatter, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; import { addLink, addSegment, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts index 42e92628e14..a049ab7ad31 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts @@ -1,9 +1,9 @@ import setListStartNumber from '../../../lib/publicApi/list/setListStartNumber'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; import { + ContentModelDocument, ContentModelFormatter, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; describe('setListStartNumber', () => { function runTest( diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts index f71a75b3d0b..1d6d6039852 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts @@ -1,12 +1,12 @@ import * as setListType from '../../../lib/modelApi/list/setListType'; import toggleBullet from '../../../lib/publicApi/list/toggleBullet'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { + ContentModelDocument, ContentModelFormatter, FormatWithContentModelContext, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; describe('toggleBullet', () => { let editor = ({} as any) as IContentModelEditor; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts index 134276ddbef..310f9e6cee9 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts @@ -1,12 +1,12 @@ import * as setListType from '../../../lib/modelApi/list/setListType'; import toggleNumbering from '../../../lib/publicApi/list/toggleNumbering'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { + ContentModelDocument, ContentModelFormatter, FormatWithContentModelContext, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; describe('toggleNumbering', () => { let editor = ({} as any) as IContentModelEditor; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts index d8c68162f43..b6a19e46e5a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts @@ -1,13 +1,14 @@ import changeFontSize from '../../../lib/publicApi/segment/changeFontSize'; -import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; import { createRange } from 'roosterjs-editor-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { segmentTestCommon } from './segmentTestCommon'; import { + ContentModelDocument, + ContentModelSegmentFormat, ContentModelFormatter, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; describe('changeFontSize', () => { function runTest( diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts index c2983dca8f9..0cb32c53568 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts @@ -1,10 +1,10 @@ -import { ContentModelDocument } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { NodePosition } from 'roosterjs-editor-types'; import { + ContentModelDocument, ContentModelFormatter, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; export function segmentTestCommon( apiName: string, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/selection/getSelectedSegmentsTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/selection/getSelectedSegmentsTest.ts index e091fbf6812..0fb7ee2c42b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/selection/getSelectedSegmentsTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/selection/getSelectedSegmentsTest.ts @@ -1,6 +1,11 @@ import * as iterateSelections from '../../../lib/modelApi/selection/iterateSelections'; import getSelectedSegments from '../../../lib/publicApi/selection/getSelectedSegments'; -import { TableSelectionContext } from '../../../lib/publicTypes/selection/TableSelectionContext'; +import { + ContentModelBlock, + ContentModelBlockGroup, + ContentModelSegment, + TableSelectionContext, +} from 'roosterjs-content-model-types'; import { createDivider, createEntity, @@ -8,11 +13,6 @@ import { createSelectionMarker, createText, } from 'roosterjs-content-model-dom'; -import { - ContentModelBlock, - ContentModelBlockGroup, - ContentModelSegment, -} from 'roosterjs-content-model-types'; interface SelectionInfo { path: ContentModelBlockGroup[]; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts index 0eac239a422..b8f47004e43 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts @@ -1,15 +1,16 @@ import * as normalizeTable from '../../../lib/modelApi/table/normalizeTable'; import applyTableBorderFormat from '../../../lib/publicApi/table/applyTableBorderFormat'; -import { Border } from '../../../lib/publicTypes/interface/Border'; -import { BorderOperations } from '../../../lib/publicTypes/enum/BorderOperations'; -import { ContentModelTable, ContentModelTableCell } from 'roosterjs-content-model-types'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { createTable, createTableCell } from 'roosterjs-content-model-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { + Border, + BorderOperations, + ContentModelTable, + ContentModelTableCell, ContentModelFormatter, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; describe('applyTableBorderFormat', () => { let editor: IContentModelEditor; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts index c277589dbdc..130616747eb 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts @@ -1,12 +1,12 @@ import * as normalizeTable from '../../../lib/modelApi/table/normalizeTable'; import setTableCellShade from '../../../lib/publicApi/table/setTableCellShade'; -import { ContentModelTable } from 'roosterjs-content-model-types'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { + ContentModelTable, ContentModelFormatter, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; describe('setTableCellShade', () => { let editor: IContentModelEditor; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts index f2c92f3e5ca..ee4d6c7c373 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts @@ -1,10 +1,11 @@ import formatImageWithContentModel from '../../../lib/publicApi/utils/formatImageWithContentModel'; -import { ContentModelDocument, ContentModelImage } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { + ContentModelDocument, + ContentModelImage, ContentModelFormatter, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; import { addSegment, createContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts index dbc2e3e8a35..7b3610db256 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts @@ -1,11 +1,12 @@ -import { ContentModelDocument, ContentModelParagraph } from 'roosterjs-content-model-types'; import { formatParagraphWithContentModel } from '../../../lib/publicApi/utils/formatParagraphWithContentModel'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { + ContentModelDocument, + ContentModelParagraph, ContentModelFormatter, FormatWithContentModelContext, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; import { createContentModelDocument, createParagraph, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts index 18859242ca3..57d18d37583 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts @@ -1,11 +1,12 @@ -import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { formatSegmentWithContentModel } from '../../../lib/publicApi/utils/formatSegmentWithContentModel'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { + ContentModelDocument, + ContentModelSegmentFormat, ContentModelFormatter, FormatWithContentModelContext, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; import { createContentModelDocument, createParagraph, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts index 556e68cf733..c97ea3bf19c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts @@ -9,19 +9,20 @@ import * as setProcessorF from '../../../../roosterjs-content-model-plugins/lib/ import * as WacComponents from '../../../../roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents'; import * as WordDesktopFile from '../../../../roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop'; import ContentModelEditor from '../../../lib/editor/ContentModelEditor'; -import { ContentModelDocument, DomToModelOption } from 'roosterjs-content-model-types'; import { ContentModelPastePlugin } from '../../../../roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin'; import { createContentModelDocument, tableProcessor } from 'roosterjs-content-model-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { - expectEqual, - initEditor, -} from '../../../../roosterjs-content-model-plugins/test/paste/e2e/testUtils'; -import { + ContentModelDocument, + DomToModelOption, ContentModelFormatter, FormatWithContentModelContext, FormatWithContentModelOptions, -} from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +} from 'roosterjs-content-model-types'; +import { + expectEqual, + initEditor, +} from '../../../../roosterjs-content-model-plugins/test/paste/e2e/testUtils'; import paste, * as pasteF from '../../../lib/publicApi/utils/paste'; import { BeforePasteEvent, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteAllSegmentBefore.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteAllSegmentBefore.ts index c021c795159..931e3e3b3ec 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteAllSegmentBefore.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteAllSegmentBefore.ts @@ -1,5 +1,5 @@ import { deleteSegment } from 'roosterjs-content-model-editor'; -import type { DeleteSelectionStep } from 'roosterjs-content-model-editor'; +import type { DeleteSelectionStep } from 'roosterjs-content-model-types'; /** * @internal diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts index f600fe3433a..ce066048f33 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts @@ -2,8 +2,7 @@ import { deleteBlock, deleteSegment } from 'roosterjs-content-model-editor'; import { getLeafSiblingBlock } from '../utils/getLeafSiblingBlock'; import { setParagraphNotImplicit } from 'roosterjs-content-model-dom'; import type { BlockAndPath } from '../utils/getLeafSiblingBlock'; -import type { ContentModelSegment } from 'roosterjs-content-model-types'; -import type { DeleteSelectionStep } from 'roosterjs-content-model-editor'; +import type { ContentModelSegment, DeleteSelectionStep } from 'roosterjs-content-model-types'; function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteSelectionStep { return context => { diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts index d7e3f9da391..65376862dd7 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts @@ -1,7 +1,10 @@ import { isPunctuation, isSpace, normalizeText } from 'roosterjs-content-model-editor'; import { isWhiteSpacePreserved } from 'roosterjs-content-model-dom'; -import type { ContentModelParagraph } from 'roosterjs-content-model-types'; -import type { DeleteSelectionContext, DeleteSelectionStep } from 'roosterjs-content-model-editor'; +import type { + ContentModelParagraph, + DeleteSelectionContext, + DeleteSelectionStep, +} from 'roosterjs-content-model-types'; const enum DeleteWordState { Start, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleKeyboardEventCommon.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleKeyboardEventCommon.ts index ff251a82368..da3f811049e 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleKeyboardEventCommon.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/handleKeyboardEventCommon.ts @@ -1,11 +1,11 @@ import { normalizeContentModel } from 'roosterjs-content-model-dom'; import { PluginEventType } from 'roosterjs-editor-types'; -import type { ContentModelDocument } from 'roosterjs-content-model-types'; +import type { IContentModelEditor } from 'roosterjs-content-model-editor'; import type { + ContentModelDocument, DeleteResult, FormatWithContentModelContext, - IContentModelEditor, -} from 'roosterjs-content-model-editor'; +} from 'roosterjs-content-model-types'; /** * @internal diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts index 957dff95b46..a68e2f8140f 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts @@ -14,7 +14,8 @@ import { backwardDeleteCollapsedSelection, forwardDeleteCollapsedSelection, } from './deleteSteps/deleteCollapsedSelection'; -import type { DeleteSelectionStep, IContentModelEditor } from 'roosterjs-content-model-editor'; +import type { IContentModelEditor } from 'roosterjs-content-model-editor'; +import type { DeleteSelectionStep } from 'roosterjs-content-model-types'; /** * @internal diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts index dde7a01ebfa..a4ff2e111e7 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts @@ -10,16 +10,14 @@ import { processPastedContentFromExcel } from './Excel/processPastedContentFromE import { processPastedContentFromPowerPoint } from './PowerPoint/processPastedContentFromPowerPoint'; import { processPastedContentFromWordDesktop } from './WordDesktop/processPastedContentFromWordDesktop'; import { processPastedContentWacComponents } from './WacComponents/processPastedContentWacComponents'; -import type { - ContentModelBeforePasteEvent, - IContentModelEditor, - PasteType, -} from 'roosterjs-content-model-editor'; +import type { IContentModelEditor } from 'roosterjs-content-model-editor'; import type { BorderFormat, + ContentModelBeforePasteEvent, ContentModelBlockFormat, ContentModelTableCellFormat, FormatParser, + PasteType, } from 'roosterjs-content-model-types'; import type { EditorPlugin, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts index 86a645b64c1..08fd346901c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts @@ -2,7 +2,7 @@ import addParser from '../utils/addParser'; import { isNodeOfType, moveChildNodes } from 'roosterjs-content-model-dom'; import { setProcessor } from '../utils/setProcessor'; import type { TrustedHTMLHandler } from 'roosterjs-editor-types'; -import type { ContentModelBeforePasteEvent } from 'roosterjs-content-model-editor'; +import type { ContentModelBeforePasteEvent } from 'roosterjs-content-model-types'; const LAST_TD_END_REGEX = /<\/\s*td\s*>((?!<\/\s*tr\s*>)[\s\S])*$/i; const LAST_TR_END_REGEX = /<\/\s*tr\s*>((?!<\/\s*table\s*>)[\s\S])*$/i; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts index 6d948f05744..4d382fa7172 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents.ts @@ -1,7 +1,7 @@ import addParser from '../utils/addParser'; import { setProcessor } from '../utils/setProcessor'; -import type { ContentModelBeforePasteEvent } from 'roosterjs-content-model-editor'; import type { + ContentModelBeforePasteEvent, ContentModelBlockFormat, ContentModelBlockGroup, ContentModelListItemLevelFormat, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts index 8e46d06e05f..ef69d8b74d1 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop.ts @@ -5,8 +5,8 @@ import { moveChildNodes } from 'roosterjs-content-model-dom'; import { processWordComments } from './processWordComments'; import { processWordList } from './processWordLists'; import { setProcessor } from '../utils/setProcessor'; -import type { ContentModelBeforePasteEvent } from 'roosterjs-content-model-editor'; import type { + ContentModelBeforePasteEvent, ContentModelBlockFormat, ContentModelListItemFormat, ContentModelListItemLevelFormat, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts index d193fd591e9..be02c187733 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts @@ -1,5 +1,9 @@ -import { ContentModelEntity, ContentModelSelectionMarker } from 'roosterjs-content-model-types'; -import { DeletedEntity, deleteSelection } from 'roosterjs-content-model-editor'; +import { deleteSelection } from 'roosterjs-content-model-editor'; +import { + ContentModelEntity, + ContentModelSelectionMarker, + DeletedEntity, +} from 'roosterjs-content-model-types'; import { createBr, createContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts index 96b6326a39a..6d637e5f62f 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/editingTestCommon.ts @@ -1,9 +1,9 @@ -import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { - IContentModelEditor, + ContentModelDocument, ContentModelFormatter, FormatWithContentModelOptions, -} from 'roosterjs-content-model-editor'; +} from 'roosterjs-content-model-types'; export function editingTestCommon( apiName: string, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/handleKeyboardEventCommonTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/handleKeyboardEventCommonTest.ts index 7ad988920e9..cde253d34d3 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/handleKeyboardEventCommonTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/handleKeyboardEventCommonTest.ts @@ -1,5 +1,6 @@ import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import { FormatWithContentModelContext, IContentModelEditor } from 'roosterjs-content-model-editor'; +import { FormatWithContentModelContext } from 'roosterjs-content-model-types'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { PluginEventType } from 'roosterjs-editor-types'; import { handleKeyboardEventResult, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts index eb3cc1c1938..403e30d55af 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts @@ -1,16 +1,12 @@ import * as deleteSelection from 'roosterjs-content-model-editor/lib/publicApi/selection/deleteSelection'; import * as handleKeyboardEventResult from '../../lib/edit/handleKeyboardEventCommon'; +import { ChangeSource, IContentModelEditor } from 'roosterjs-content-model-editor'; import { ContentModelDocument, DOMSelection } from 'roosterjs-content-model-types'; import { deleteAllSegmentBefore } from '../../lib/edit/deleteSteps/deleteAllSegmentBefore'; +import { DeleteResult, DeleteSelectionStep } from 'roosterjs-content-model-types'; import { editingTestCommon } from './editingTestCommon'; import { keyboardDelete } from '../../lib/edit/keyboardDelete'; import { Keys } from 'roosterjs-editor-types'; -import { - ChangeSource, - DeleteResult, - DeleteSelectionStep, - IContentModelEditor, -} from 'roosterjs-content-model-editor'; import { backwardDeleteWordSelection, forwardDeleteWordSelection, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts index 3a19bf2d8b6..b9d35c35a6d 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts @@ -6,8 +6,9 @@ import * as PowerPointFile from '../../lib/paste/PowerPoint/processPastedContent import * as setProcessor from '../../lib/paste/utils/setProcessor'; import * as WacFile from '../../lib/paste/WacComponents/processPastedContentWacComponents'; import * as WordDesktopFile from '../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; -import { ContentModelBeforePasteEvent, IContentModelEditor } from 'roosterjs-content-model-editor'; +import { ContentModelBeforePasteEvent } from 'roosterjs-content-model-types'; import { ContentModelPastePlugin } from '../../lib/paste/ContentModelPastePlugin'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { PastePropertyNames } from '../../lib/paste/pasteSourceValidations/constants'; import { PasteType, PluginEventType } from 'roosterjs-editor-types'; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts index b04c44d84da..1e47fe03eb2 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts @@ -1,12 +1,11 @@ import { ClipboardData, PluginEventType } from 'roosterjs-editor-types'; -import { ContentModelBeforePasteEvent } from 'roosterjs-content-model-editor'; -import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { ContentModelBeforePasteEvent, ContentModelDocument } from 'roosterjs-content-model-types'; import { expectHtml } from 'roosterjs-editor-api/test/TestHelper'; +import { processPastedContentFromWordDesktop } from '../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; import { listItemMetadataApplier, listLevelMetadataApplier, } from 'roosterjs-content-model-editor/lib/domUtils/metadata/updateListMetadata'; -import { processPastedContentFromWordDesktop } from '../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; import { contentModelToDom, createDomToModelContext, diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts new file mode 100644 index 00000000000..12d1ffdf951 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts @@ -0,0 +1,77 @@ +import type { ContentModelDocument } from '../group/ContentModelDocument'; +import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; +import type { DOMSelection } from '../selection/DOMSelection'; +import type { DomToModelOption } from '../context/DomToModelOption'; +import type { EditorEnvironment } from '../parameter/EditorEnvironment'; +import type { ModelToDomOption } from '../context/ModelToDomOption'; +import type { OnNodeCreated } from '../context/ModelToDomSettings'; +import type { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../parameter/FormatWithContentModelOptions'; + +/** + * An interface of standalone Content Model editor. + * (This interface is still under development, and may still be changed in the future with some breaking changes) + */ +export interface IStandaloneEditor { + /** + * Create Content Model from DOM tree in this editor + * @param rootNode Optional start node. If provided, Content Model will be created from this node (including itself), + * otherwise it will create Content Model for the whole content in editor. + * @param option The options to customize the behavior of DOM to Content Model conversion + * @param selectionOverride When specified, use this selection to override existing selection inside editor + */ + createContentModel( + option?: DomToModelOption, + selectionOverride?: DOMSelection + ): ContentModelDocument; + + /** + * Set content with content model + * @param model The content model to set + * @param option Additional options to customize the behavior of Content Model to DOM conversion + * @param onNodeCreated An optional callback that will be called when a DOM node is created + */ + setContentModel( + model: ContentModelDocument, + option?: ModelToDomOption, + onNodeCreated?: OnNodeCreated + ): DOMSelection | null; + + /** + * Get current running environment, such as if editor is running on Mac + */ + getEnvironment(): EditorEnvironment; + + /** + * Get current DOM selection. + * This is the replacement of IEditor.getSelectionRangeEx. + */ + getDOMSelection(): DOMSelection | null; + + /** + * Set DOMSelection into editor content. + * This is the replacement of IEditor.select. + * @param selection The selection to set + */ + setDOMSelection(selection: DOMSelection): void; + + /** + * The general API to do format change with Content Model + * It will grab a Content Model for current editor content, and invoke a callback function + * to do format change. Then according to the return value, write back the modified content model into editor. + * If there is cached model, it will be used and updated. + * @param formatter Formatter function, see ContentModelFormatter + * @param options More options, see FormatWithContentModelOptions + */ + formatContentModel( + formatter: ContentModelFormatter, + options?: FormatWithContentModelOptions + ): void; + + /** + * Get pending format of editor if any, or return null + */ + getPendingFormat(): ContentModelSegmentFormat | null; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts new file mode 100644 index 00000000000..51b2f142d30 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts @@ -0,0 +1,175 @@ +import type { EditorCore } from 'roosterjs-editor-types'; +import type { ContentModelDocument } from '../group/ContentModelDocument'; +import type { ContentModelPluginState } from '../pluginState/ContentModelPluginState'; +import type { DOMSelection } from '../selection/DOMSelection'; +import type { DomToModelOption } from '../context/DomToModelOption'; +import type { DomToModelSettings } from '../context/DomToModelSettings'; +import type { EditorContext } from '../context/EditorContext'; +import type { EditorEnvironment } from '../parameter/EditorEnvironment'; +import type { ModelToDomOption } from '../context/ModelToDomOption'; +import type { ModelToDomSettings, OnNodeCreated } from '../context/ModelToDomSettings'; +import type { + ContentModelFormatter, + FormatWithContentModelOptions, +} from '../parameter/FormatWithContentModelOptions'; + +/** + * Create a EditorContext object used by ContentModel API + * @param core The StandaloneEditorCore object + */ +export type CreateEditorContext = (core: StandaloneEditorCore & EditorCore) => EditorContext; + +/** + * Create Content Model from DOM tree in this editor + * @param core The StandaloneEditorCore object + * @param option The option to customize the behavior of DOM to Content Model conversion + * @param selectionOverride When passed, use this selection range instead of current selection in editor + */ +export type CreateContentModel = ( + core: StandaloneEditorCore & EditorCore, + option?: DomToModelOption, + selectionOverride?: DOMSelection +) => ContentModelDocument; + +/** + * Get current DOM selection from editor + * @param core The StandaloneEditorCore object + */ +export type GetDOMSelection = (core: StandaloneEditorCore & EditorCore) => DOMSelection | null; + +/** + * Set content with content model. This is the replacement of core API getSelectionRangeEx + * @param core The StandaloneEditorCore object + * @param model The content model to set + * @param option Additional options to customize the behavior of Content Model to DOM conversion + * @param onNodeCreated An optional callback that will be called when a DOM node is created + */ +export type SetContentModel = ( + core: StandaloneEditorCore & EditorCore, + model: ContentModelDocument, + option?: ModelToDomOption, + onNodeCreated?: OnNodeCreated +) => DOMSelection | null; + +/** + * Set current DOM selection from editor. This is the replacement of core API select + * @param core The StandaloneEditorCore object + * @param selection The selection to set + */ +export type SetDOMSelection = ( + core: StandaloneEditorCore & EditorCore, + selection: DOMSelection +) => void; + +/** + * The general API to do format change with Content Model + * It will grab a Content Model for current editor content, and invoke a callback function + * to do format change. Then according to the return value, write back the modified content model into editor. + * If there is cached model, it will be used and updated. + * @param core The StandaloneEditorCore object + * @param formatter Formatter function, see ContentModelFormatter + * @param options More options, see FormatWithContentModelOptions + */ +export type FormatContentModel = ( + core: StandaloneEditorCore & EditorCore, + formatter: ContentModelFormatter, + options?: FormatWithContentModelOptions +) => void; + +/** + * The interface for the map of core API for Content Model editor. + * Editor can call call API from this map under StandaloneEditorCore object + */ +export interface StandaloneCoreApiMap { + /** + * Create a EditorContext object used by ContentModel API + * @param core The StandaloneEditorCore object + */ + createEditorContext: CreateEditorContext; + + /** + * Create Content Model from DOM tree in this editor + * @param core The StandaloneEditorCore object + * @param option The option to customize the behavior of DOM to Content Model conversion + */ + createContentModel: CreateContentModel; + + /** + * Get current DOM selection from editor + * @param core The StandaloneEditorCore object + */ + getDOMSelection: GetDOMSelection; + + /** + * Set content with content model + * @param core The StandaloneEditorCore object + * @param model The content model to set + * @param option Additional options to customize the behavior of Content Model to DOM conversion + */ + setContentModel: SetContentModel; + + /** + * Set current DOM selection from editor. This is the replacement of core API select + * @param core The StandaloneEditorCore object + * @param selection The selection to set + */ + setDOMSelection: SetDOMSelection; + + /** + * The general API to do format change with Content Model + * It will grab a Content Model for current editor content, and invoke a callback function + * to do format change. Then according to the return value, write back the modified content model into editor. + * If there is cached model, it will be used and updated. + * @param core The StandaloneEditorCore object + * @param formatter Formatter function, see ContentModelFormatter + * @param options More options, see FormatWithContentModelOptions + */ + formatContentModel: FormatContentModel; +} + +/** + * Represents the core data structure of a Content Model editor + */ +export interface StandaloneEditorCore extends ContentModelPluginState { + /** + * The content DIV element of this editor + */ + readonly contentDiv: HTMLDivElement; + + /** + * Core API map of this editor + */ + readonly api: StandaloneCoreApiMap; + + /** + * Original API map of this editor. Overridden core API can use API from this map to call the original version of core API. + */ + readonly originalApi: StandaloneCoreApiMap; + + /** + * Default DOM to Content Model options + */ + defaultDomToModelOptions: (DomToModelOption | undefined)[]; + + /** + * Default Content Model to DOM options + */ + defaultModelToDomOptions: (ModelToDomOption | undefined)[]; + + /** + * Default DOM to Content Model config, calculated from defaultDomToModelOptions, + * will be used for creating content model if there is no other customized options + */ + defaultDomToModelConfig: DomToModelSettings; + + /** + * Default Content Model to DOM config, calculated from defaultModelToDomOptions, + * will be used for setting content model if there is no other customized options + */ + defaultModelToDomConfig: ModelToDomSettings; + + /** + * Editor running environment + */ + environment: EditorEnvironment; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts new file mode 100644 index 00000000000..3f17c706d55 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts @@ -0,0 +1,22 @@ +import type { DomToModelOption } from '../context/DomToModelOption'; +import type { ModelToDomOption } from '../context/ModelToDomOption'; + +/** + * Options for Content Model editor + */ +export interface StandaloneEditorOptions { + /** + * Default options used for DOM to Content Model conversion + */ + defaultDomToModelOptions?: DomToModelOption; + + /** + * Default options used for Content Model to DOM conversion + */ + defaultModelToDomOptions?: ModelToDomOption; + + /** + * Reuse existing DOM structure if possible, and update the model when content or selection is changed + */ + cacheModel?: boolean; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/enum/BorderOperations.ts b/packages-content-model/roosterjs-content-model-types/lib/enum/BorderOperations.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/enum/BorderOperations.ts rename to packages-content-model/roosterjs-content-model-types/lib/enum/BorderOperations.ts diff --git a/packages-content-model/roosterjs-content-model-types/lib/enum/DeleteResult.ts b/packages-content-model/roosterjs-content-model-types/lib/enum/DeleteResult.ts new file mode 100644 index 00000000000..50c4e68abdb --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/enum/DeleteResult.ts @@ -0,0 +1,23 @@ +/** + * Delete selection result + */ +export type DeleteResult = + /** + * Content Model could not finish deletion, need to let browser handle it + */ + | 'notDeleted' + + /** + * Deleted a single char, no need to let browser keep handling + */ + | 'singleChar' + + /** + * Deleted a range, no need to let browser keep handling + */ + | 'range' + + /** + * There is nothing to delete, no need to let browser keep handling + */ + | 'nothingToDelete'; diff --git a/packages-content-model/roosterjs-content-model-types/lib/enum/EntityOperation.ts b/packages-content-model/roosterjs-content-model-types/lib/enum/EntityOperation.ts new file mode 100644 index 00000000000..d6a768f4dfc --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/enum/EntityOperation.ts @@ -0,0 +1,52 @@ +/** + * Define entity lifecycle related operations + */ +export type EntityLifecycleOperation = + /** + * Notify plugins that there is a new plugin was added into editor. + * Plugin can handle this event to entity hydration. + * This event will be only fired once for each entity DOM node. + * After undo, or copy/paste, since new DOM nodes were added, this event will be fired + * for those entities represented by newly added nodes. + */ + | 'newEntity' + + /** + * Notify plugins that editor is generating HTML content for save. + * Plugin should use this event to remove any temporary content, and only leave DOM nodes that + * should be saved as HTML string. + * This event will provide a cloned DOM tree for each entity, do NOT compare the DOM nodes with cached nodes + * because it will always return false. + */ + | 'replaceTemporaryContent' + /** + * Notify plugins that a new entity state need to be updated to an entity. + * This is normally happened when user undo/redo the content with an entity snapshot added by a plugin that handles entity + */ + | 'UpdateEntityState'; + +/** + * Define entity removal related operations + */ +export type EntityRemovalOperation = + /** + * Notify plugins that user is removing an entity from its start position using DELETE key + */ + | 'removeFromStart' + + /** + * Notify plugins that user is remove an entity from its end position using BACKSPACE key + */ + | 'removeFromEnd' + + /** + * Notify plugins that an entity is being overwritten. + * This can be caused by key in, cut, paste, delete, backspace ... on a selection + * which contains some entities. + */ + | 'overwrite'; + +/** + * Define possible operations to an entity + */ +export type EntityOperation = EntityLifecycleOperation | EntityRemovalOperation; diff --git a/packages-content-model/roosterjs-content-model-types/lib/enum/InsertEntityPosition.ts b/packages-content-model/roosterjs-content-model-types/lib/enum/InsertEntityPosition.ts new file mode 100644 index 00000000000..e590ad6090b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/enum/InsertEntityPosition.ts @@ -0,0 +1,8 @@ +/** + * Define the position of the entity to insert. It can be: + * "focus": insert at current focus. If insert a block entity, it will be inserted under the paragraph where focus is + * "begin": insert at beginning of content. When insert an inline entity, it will be wrapped with a paragraph. + * "end": insert at end of content. When insert an inline entity, it will be wrapped with a paragraph. + * "root": insert at the root level of current region + */ +export type InsertEntityPosition = 'focus' | 'begin' | 'end' | 'root'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/PasteType.ts b/packages-content-model/roosterjs-content-model-types/lib/enum/PasteType.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/PasteType.ts rename to packages-content-model/roosterjs-content-model-types/lib/enum/PasteType.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/TableOperation.ts b/packages-content-model/roosterjs-content-model-types/lib/enum/TableOperation.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/TableOperation.ts rename to packages-content-model/roosterjs-content-model-types/lib/enum/TableOperation.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelBeforePasteEvent.ts b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelBeforePasteEvent.ts similarity index 85% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelBeforePasteEvent.ts rename to packages-content-model/roosterjs-content-model-types/lib/event/ContentModelBeforePasteEvent.ts index 742850f1489..5e28cc48be8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/event/ContentModelBeforePasteEvent.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelBeforePasteEvent.ts @@ -1,5 +1,6 @@ +import type { DomToModelOption } from '../context/DomToModelOption'; +import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { InsertPoint } from '../selection/InsertPoint'; -import type { ContentModelDocument, DomToModelOption } from 'roosterjs-content-model-types'; import type { BeforePasteEvent, BeforePasteEventData, @@ -26,7 +27,7 @@ export interface ContentModelBeforePasteEventData extends BeforePasteEventData { /** * Provides a chance for plugin to change the content before it is pasted into editor. */ -export default interface ContentModelBeforePasteEvent +export interface ContentModelBeforePasteEvent extends ContentModelBeforePasteEventData, BeforePasteEvent {} diff --git a/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts new file mode 100644 index 00000000000..7c425792fcc --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts @@ -0,0 +1,36 @@ +import type { ContentModelDocument } from '../group/ContentModelDocument'; +import type { DOMSelection } from '../selection/DOMSelection'; +import type { + CompatibleContentChangedEvent, + ContentChangedEvent, + ContentChangedEventData, +} from 'roosterjs-editor-types'; + +/** + * Data of ContentModelContentChangedEvent + */ +export interface ContentModelContentChangedEventData extends ContentChangedEventData { + /** + * The content model that is applied which causes this content changed event + */ + contentModel?: ContentModelDocument; + + /** + * Selection range applied to the document + */ + selection?: DOMSelection; +} + +/** + * Represents a change to the editor made by another plugin with content model inside + */ +export interface ContentModelContentChangedEvent + extends ContentChangedEvent, + ContentModelContentChangedEventData {} + +/** + * Represents a change to the editor made by another plugin with content model inside + */ +export interface CompatibleContentModelContentChangedEvent + extends CompatibleContentChangedEvent, + ContentModelContentChangedEventData {} diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index e7d36c0362e..851d8cdd922 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -67,6 +67,28 @@ export { TableCellMetadataFormat } from './format/metadata/TableCellMetadataForm export { ContentModelBlockGroupType } from './enum/BlockGroupType'; export { ContentModelBlockType } from './enum/BlockType'; export { ContentModelSegmentType } from './enum/SegmentType'; +export { + EntityLifecycleOperation, + EntityOperation, + EntityRemovalOperation, +} from './enum/EntityOperation'; +export { + TableOperation, + TableVerticalInsertOperation, + TableHorizontalInsertOperation, + TableDeleteOperation, + TableVerticalMergeOperation, + TableHorizontalMergeOperation, + TableCellMergeOperation, + TableSplitOperation, + TableAlignOperation, + TableCellHorizontalAlignOperation, + TableCellVerticalAlignOperation, +} from './enum/TableOperation'; +export { PasteType } from './enum/PasteType'; +export { BorderOperations } from './enum/BorderOperations'; +export { DeleteResult } from './enum/DeleteResult'; +export { InsertEntityPosition } from './enum/InsertEntityPosition'; export { ContentModelBlock } from './block/ContentModelBlock'; export { ContentModelParagraph } from './block/ContentModelParagraph'; @@ -109,6 +131,8 @@ export { RangeSelection, TableSelection, } from './selection/DOMSelection'; +export { InsertPoint } from './selection/InsertPoint'; +export { TableSelectionContext } from './selection/TableSelectionContext'; export { ContentModelHandlerMap, @@ -172,3 +196,54 @@ export { Definition, } from './metadata/Definition'; export { ColorManager, Colors } from './context/ColorManager'; + +export { IStandaloneEditor } from './editor/IStandaloneEditor'; +export { StandaloneEditorOptions } from './editor/StandaloneEditorOptions'; +export { + CreateContentModel, + CreateEditorContext, + GetDOMSelection, + SetContentModel, + SetDOMSelection, + FormatContentModel, + StandaloneCoreApiMap, + StandaloneEditorCore, +} from './editor/StandaloneEditorCore'; + +export { ContentModelCachePluginState } from './pluginState/ContentModelCachePluginState'; +export { ContentModelPluginState } from './pluginState/ContentModelPluginState'; +export { + ContentModelFormatPluginState, + PendingFormat, +} from './pluginState/ContentModelFormatPluginState'; + +export { EditorEnvironment } from './parameter/EditorEnvironment'; +export { + DeletedEntity, + FormatWithContentModelContext, +} from './parameter/FormatWithContentModelContext'; +export { + FormatWithContentModelOptions, + ContentModelFormatter, +} from './parameter/FormatWithContentModelOptions'; +export { ContentModelFormatState } from './parameter/ContentModelFormatState'; +export { ImageFormatState } from './parameter/ImageFormatState'; +export { Border } from './parameter/Border'; +export { InsertEntityOptions } from './parameter/InsertEntityOptions'; +export { + DeleteSelectionContext, + DeleteSelectionResult, + DeleteSelectionStep, + ValidDeleteSelectionContext, +} from './parameter/DeleteSelectionStep'; + +export { + ContentModelBeforePasteEvent, + ContentModelBeforePasteEventData, + CompatibleContentModelBeforePasteEvent, +} from './event/ContentModelBeforePasteEvent'; +export { + ContentModelContentChangedEvent, + CompatibleContentModelContentChangedEvent, + ContentModelContentChangedEventData, +} from './event/ContentModelContentChangedEvent'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/interface/Border.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/Border.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/interface/Border.ts rename to packages-content-model/roosterjs-content-model-types/lib/parameter/Border.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/format/formatState/ContentModelFormatState.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/ContentModelFormatState.ts similarity index 97% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/format/formatState/ContentModelFormatState.ts rename to packages-content-model/roosterjs-content-model-types/lib/parameter/ContentModelFormatState.ts index 09d910cd73f..e3ae48f49ef 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/format/formatState/ContentModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/ContentModelFormatState.ts @@ -1,4 +1,4 @@ -import type { TableMetadataFormat } from 'roosterjs-content-model-types'; +import type { TableMetadataFormat } from '../format/metadata/TableMetadataFormat'; import type { ImageFormatState } from './ImageFormatState'; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/DeleteSelectionStep.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts similarity index 72% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/DeleteSelectionStep.ts rename to packages-content-model/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts index 103f2e48a52..84a0976f326 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/DeleteSelectionStep.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/DeleteSelectionStep.ts @@ -1,31 +1,8 @@ +import type { ContentModelParagraph } from '../block/ContentModelParagraph'; +import type { DeleteResult } from '../enum/DeleteResult'; import type { FormatWithContentModelContext } from './FormatWithContentModelContext'; import type { InsertPoint } from '../selection/InsertPoint'; import type { TableSelectionContext } from '../selection/TableSelectionContext'; -import type { ContentModelParagraph } from 'roosterjs-content-model-types'; - -/** - * Delete selection result - */ -export type DeleteResult = - /** - * Content Model could not finish deletion, need to let browser handle it - */ - | 'notDeleted' - - /** - * Deleted a single char, no need to let browser keep handling - */ - | 'singleChar' - - /** - * Deleted a range, no need to let browser keep handling - */ - | 'range' - - /** - * There is nothing to delete, no need to let browser keep handling - */ - | 'nothingToDelete'; /** * Result of deleteSelection API diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts new file mode 100644 index 00000000000..242c2145bf8 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts @@ -0,0 +1,14 @@ +/** + * Current running environment + */ +export interface EditorEnvironment { + /** + * Whether editor is running on Mac + */ + isMac?: boolean; + + /** + * Whether editor is running on Android + */ + isAndroid?: boolean; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelContext.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelContext.ts new file mode 100644 index 00000000000..8278725ae51 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelContext.ts @@ -0,0 +1,66 @@ +import type { ContentModelEntity } from '../entity/ContentModelEntity'; +import type { ContentModelImage } from '../segment/ContentModelImage'; +import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; +import type { EntityRemovalOperation } from '../enum/EntityOperation'; + +/** + * Represents an entity that is deleted by a specified entity operation + */ +export interface DeletedEntity { + /** + * The deleted entity + */ + entity: ContentModelEntity; + + /** + * The operation that causes this entity to be deleted + */ + operation: EntityRemovalOperation; +} + +/** + * Context object for API formatWithContentModel + */ +export interface FormatWithContentModelContext { + /** + * New entities added during the format process + */ + readonly newEntities: ContentModelEntity[]; + + /** + * Entities got deleted during formatting. Need to be set by the formatter function + */ + readonly deletedEntities: DeletedEntity[]; + + /** + * Images inserted in the editor that needs to have their size adjusted + */ + readonly newImages: ContentModelImage[]; + + /** + * Raw Event that triggers this format call + */ + readonly rawEvent?: Event; + + /** + * @optional + * When pass true, skip adding undo snapshot when write Content Model back to DOM. + * Need to be set by the formatter function + */ + skipUndoSnapshot?: boolean; + + /** + * @optional + * When set to true, formatWithContentModel API will not keep cached Content Model. Next time when we need a Content Model, a new one will be created + */ + clearModelCache?: boolean; + + /** + * @optional + * Specify new pending format. + * To keep current format event selection position is changed, set this value to "preserved", editor will update pending format position to the new position + * To set a new pending format, set this property to the format object + * Otherwise, leave it there and editor will automatically decide if the original pending format is still available + */ + newPendingFormat?: ContentModelSegmentFormat | 'preserve'; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelOptions.ts new file mode 100644 index 00000000000..cfcb8736a7c --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelOptions.ts @@ -0,0 +1,52 @@ +import type { ContentModelDocument } from '../group/ContentModelDocument'; +import type { DOMSelection } from '../selection/DOMSelection'; +import type { FormatWithContentModelContext } from './FormatWithContentModelContext'; +import type { OnNodeCreated } from '../context/ModelToDomSettings'; + +/** + * Options for API formatWithContentModel + */ +export interface FormatWithContentModelOptions { + /** + * Name of the format API + */ + apiName?: string; + + /** + * Raw event object that triggers this call + */ + rawEvent?: Event; + + /** + * Change source used for triggering a ContentChanged event. @default ChangeSource.Format. + */ + changeSource?: string; + + /** + * An optional callback that will be called when a DOM node is created + * @param modelElement The related Content Model element + * @param node The node created for this model element + */ + onNodeCreated?: OnNodeCreated; + + /** + * Optional callback to get an object used for change data in ContentChangedEvent + */ + getChangeData?: () => any; + + /** + * When specified, use this selection range to override current selection inside editor + */ + selectionOverride?: DOMSelection; +} + +/** + * Type of formatter used for format Content Model. + * @param model The source Content Model to format + * @param context A context object used for pass in and out more parameters + * @returns True means the model is changed and need to write back to editor, otherwise false + */ +export type ContentModelFormatter = ( + model: ContentModelDocument, + context: FormatWithContentModelContext +) => boolean; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/format/formatState/ImageFormatState.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/ImageFormatState.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/format/formatState/ImageFormatState.ts rename to packages-content-model/roosterjs-content-model-types/lib/parameter/ImageFormatState.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/InsertEntityOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/InsertEntityOptions.ts similarity index 54% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/InsertEntityOptions.ts rename to packages-content-model/roosterjs-content-model-types/lib/parameter/InsertEntityOptions.ts index bac29b89210..e936fd88255 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/InsertEntityOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/InsertEntityOptions.ts @@ -22,12 +22,3 @@ export interface InsertEntityOptions { */ skipUndoSnapshot?: boolean; } - -/** - * Define the position of the entity to insert. It can be: - * "focus": insert at current focus. If insert a block entity, it will be inserted under the paragraph where focus is - * "begin": insert at beginning of content. When insert an inline entity, it will be wrapped with a paragraph. - * "end": insert at end of content. When insert an inline entity, it will be wrapped with a paragraph. - * "root": insert at the root level of current region - */ -export type InsertEntityPosition = 'focus' | 'begin' | 'end' | 'root'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelCachePluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts similarity index 70% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelCachePluginState.ts rename to packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts index 807d2bb7de2..b9fbc4befcb 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelCachePluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelCachePluginState.ts @@ -1,8 +1,6 @@ -import type { - ContentModelDocument, - ContentModelDomIndexer, - DOMSelection, -} from 'roosterjs-content-model-types'; +import type { ContentModelDocument } from '../group/ContentModelDocument'; +import type { ContentModelDomIndexer } from '../context/ContentModelDomIndexer'; +import type { DOMSelection } from '../selection/DOMSelection'; /** * Plugin state for ContentModelEditPlugin diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelFormatPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelFormatPluginState.ts similarity index 87% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelFormatPluginState.ts rename to packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelFormatPluginState.ts index 743dfefc555..13a058373ab 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelFormatPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelFormatPluginState.ts @@ -1,4 +1,4 @@ -import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; /** * Pending format holder interface diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/pluginState/ContentModelPluginState.ts rename to packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/selection/InsertPoint.ts b/packages-content-model/roosterjs-content-model-types/lib/selection/InsertPoint.ts similarity index 71% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/selection/InsertPoint.ts rename to packages-content-model/roosterjs-content-model-types/lib/selection/InsertPoint.ts index 6b740d2278d..e7de09ee7d2 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/selection/InsertPoint.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/selection/InsertPoint.ts @@ -1,9 +1,7 @@ +import type { ContentModelBlockGroup } from '../group/ContentModelBlockGroup'; +import type { ContentModelParagraph } from '../block/ContentModelParagraph'; +import type { ContentModelSelectionMarker } from '../segment/ContentModelSelectionMarker'; import type { TableSelectionContext } from './TableSelectionContext'; -import type { - ContentModelBlockGroup, - ContentModelParagraph, - ContentModelSelectionMarker, -} from 'roosterjs-content-model-types'; /** * Represent all related information of an insert point diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/selection/TableSelectionContext.ts b/packages-content-model/roosterjs-content-model-types/lib/selection/TableSelectionContext.ts similarity index 86% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/selection/TableSelectionContext.ts rename to packages-content-model/roosterjs-content-model-types/lib/selection/TableSelectionContext.ts index 0cfc059f5ba..af2befb6bd8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/selection/TableSelectionContext.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/selection/TableSelectionContext.ts @@ -1,4 +1,4 @@ -import type { ContentModelTable } from 'roosterjs-content-model-types'; +import type { ContentModelTable } from '../block/ContentModelTable'; /** * Context object for table in a selection diff --git a/packages-content-model/roosterjs-content-model-types/package.json b/packages-content-model/roosterjs-content-model-types/package.json index 632c7495fda..83f2dc53f2b 100644 --- a/packages-content-model/roosterjs-content-model-types/package.json +++ b/packages-content-model/roosterjs-content-model-types/package.json @@ -1,7 +1,9 @@ { "name": "roosterjs-content-model-types", "description": "Types for Content Model for roosterjs (Under development)", - "dependencies": {}, + "dependencies": { + "roosterjs-editor-types": "" + }, "version": "0.0.0", "main": "./lib/index.ts" } From c4f3b130127fe699546cccc715fa0ca6ae393ef1 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 9 Nov 2023 14:30:00 -0800 Subject: [PATCH 043/111] Move core API to roosterjs-content-model-core package (#2198) * Move ContentModelEdit plugin to plugins package * Move types to roosterjs-content-model-types package * improve * Improve * improve * Improve * improve * Move core API to core package * fix build * improve * fix build * fix build --- .../model/ContentModelImageView.tsx | 2 +- .../model/ContentModelListLevelView.tsx | 2 +- .../model/ContentModelTableCellView.tsx | 3 +- .../model/ContentModelTableView.tsx | 3 +- demo/scripts/tsconfig.json | 6 + .../lib/constants}/ChangeSource.ts | 0 .../lib}/coreApi/createContentModel.ts | 2 +- .../lib}/coreApi/createEditorContext.ts | 0 .../lib}/coreApi/formatContentModel.ts | 2 +- .../lib}/coreApi/getDOMSelection.ts | 0 .../lib}/coreApi/setContentModel.ts | 0 .../lib}/coreApi/setDOMSelection.ts | 0 .../lib}/coreApi/switchShadowEdit.ts | 11 +- .../editor/promoteToContentModelEditorCore.ts | 87 ++++ .../roosterjs-content-model-core/lib/index.ts | 15 + .../lib}/metadata/definitionCreators.ts | 0 .../lib}/metadata/updateImageMetadata.ts | 0 .../lib}/metadata/updateListMetadata.ts | 0 .../lib}/metadata/updateTableCellMetadata.ts | 0 .../lib}/metadata/updateTableMetadata.ts | 0 .../lib/override}/tablePreProcessor.ts | 2 +- .../lib/publicApi/model/cloneModel.ts | 0 .../selection/getSelectionRootNode.ts | 6 +- .../publicApi}/selection/iterateSelections.ts | 3 - .../roosterjs-content-model-core/package.json | 12 + .../test}/coreApi/createContentModelTest.ts | 11 +- .../test}/coreApi/createEditorContextTest.ts | 17 +- .../test}/coreApi/formatContentModelTest.ts | 24 +- .../test}/coreApi/setContentModelTest.ts | 9 +- .../test}/coreApi/switchShadowEditTest.ts | 16 +- .../promoteToContentModelEditorCoreTest.ts | 180 +++++++++ .../handleListItemWithMetadataTest.ts | 2 +- .../metadata/handleListWithMetadataTest.ts | 2 +- .../test}/metadata/updateImageMetadataTest.ts | 2 +- .../test}/metadata/updateListMetadataTest.ts | 2 +- .../metadata/updateTableCellMetadataTest.ts | 2 +- .../test}/metadata/updateTableMetadataTest.ts | 2 +- .../test}/overrides/tablePreProcessorTest.ts | 2 +- .../test/publicApi/model/cloneModelTest.ts | 0 .../selection/getSelectionRootNodeTest.ts | 41 ++ .../selection/iterateSelectionsTest.ts | 2 +- .../ContentModelCopyPastePlugin.ts | 13 +- .../editor/createContentModelEditorCore.ts | 94 +---- .../lib/index.ts | 11 +- .../lib/modelApi/common/clearModelFormat.ts | 8 +- .../common/retrieveModelFormatState.ts | 3 +- .../edit/utils/deleteExpandedSelection.ts | 4 +- .../lib/modelApi/format/applyPendingFormat.ts | 2 +- .../modelApi/selection/adjustWordSelection.ts | 2 +- .../modelApi/selection/collectSelections.ts | 4 +- .../lib/modelApi/table/alignTableCell.ts | 2 +- .../lib/modelApi/table/applyTableFormat.ts | 3 +- .../table/setTableCellBackgroundColor.ts | 2 +- .../lib/publicApi/entity/insertEntity.ts | 2 +- .../lib/publicApi/format/getFormatState.ts | 2 +- .../lib/publicApi/image/changeImage.ts | 2 +- .../lib/publicApi/link/insertLink.ts | 2 +- .../lib/publicApi/list/setListStyle.ts | 2 +- .../publicApi/table/applyTableBorderFormat.ts | 2 +- .../lib/publicApi/table/formatTable.ts | 2 +- .../lib/publicApi/utils/paste.ts | 2 +- .../package.json | 1 + .../createContentModelEditorCoreTest.ts | 372 ++++-------------- .../ContentModelCopyPastePluginTest.ts | 4 +- .../common/retrieveModelFormatStateTest.ts | 2 +- .../modelApi/format/applyPendingFormatTest.ts | 2 +- .../selection/collectSelectionsTest.ts | 2 +- .../test/publicApi/entity/insertEntityTest.ts | 2 +- .../publicApi/format/getFormatStateTest.ts | 2 +- .../test/publicApi/link/insertLinkTest.ts | 2 +- .../selection/getSelectedSegmentsTest.ts | 2 +- .../lib/edit/keyboardDelete.ts | 3 +- .../package.json | 1 + .../test/edit/keyboardDeleteTest.ts | 3 +- .../test/paste/e2e/cmPasteFromWordTest.ts | 3 +- .../test/paste/e2e/testUtils.ts | 2 +- .../paste/processPastedContentFromWacTest.ts | 2 +- ...processPastedContentFromWordDesktopTest.ts | 2 +- .../lib/editor/StandaloneEditorCore.ts | 5 +- 79 files changed, 542 insertions(+), 504 deletions(-) rename packages-content-model/{roosterjs-content-model-editor/lib/publicTypes => roosterjs-content-model-core/lib/constants}/ChangeSource.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor => roosterjs-content-model-core/lib}/coreApi/createContentModel.ts (96%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor => roosterjs-content-model-core/lib}/coreApi/createEditorContext.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor => roosterjs-content-model-core/lib}/coreApi/formatContentModel.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor => roosterjs-content-model-core/lib}/coreApi/getDOMSelection.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor => roosterjs-content-model-core/lib}/coreApi/setContentModel.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor => roosterjs-content-model-core/lib}/coreApi/setDOMSelection.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor => roosterjs-content-model-core/lib}/coreApi/switchShadowEdit.ts (86%) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/editor/promoteToContentModelEditorCore.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/index.ts rename packages-content-model/{roosterjs-content-model-editor/lib/domUtils => roosterjs-content-model-core/lib}/metadata/definitionCreators.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/domUtils => roosterjs-content-model-core/lib}/metadata/updateImageMetadata.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/domUtils => roosterjs-content-model-core/lib}/metadata/updateListMetadata.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/domUtils => roosterjs-content-model-core/lib}/metadata/updateTableCellMetadata.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/domUtils => roosterjs-content-model-core/lib}/metadata/updateTableMetadata.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/overrides => roosterjs-content-model-core/lib/override}/tablePreProcessor.ts (92%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-core}/lib/publicApi/model/cloneModel.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi => roosterjs-content-model-core/lib/publicApi}/selection/getSelectionRootNode.ts (59%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi => roosterjs-content-model-core/lib/publicApi}/selection/iterateSelections.ts (99%) create mode 100644 packages-content-model/roosterjs-content-model-core/package.json rename packages-content-model/{roosterjs-content-model-editor/test/editor => roosterjs-content-model-core/test}/coreApi/createContentModelTest.ts (94%) rename packages-content-model/{roosterjs-content-model-editor/test/editor => roosterjs-content-model-core/test}/coreApi/createEditorContextTest.ts (92%) rename packages-content-model/{roosterjs-content-model-editor/test/editor => roosterjs-content-model-core/test}/coreApi/formatContentModelTest.ts (97%) rename packages-content-model/{roosterjs-content-model-editor/test/editor => roosterjs-content-model-core/test}/coreApi/setContentModelTest.ts (93%) rename packages-content-model/{roosterjs-content-model-editor/test/editor => roosterjs-content-model-core/test}/coreApi/switchShadowEditTest.ts (90%) create mode 100644 packages-content-model/roosterjs-content-model-core/test/editor/promoteToContentModelEditorCoreTest.ts rename packages-content-model/{roosterjs-content-model-editor/test/domUtils => roosterjs-content-model-core/test}/metadata/handleListItemWithMetadataTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/test/domUtils => roosterjs-content-model-core/test}/metadata/handleListWithMetadataTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/test/domUtils => roosterjs-content-model-core/test}/metadata/updateImageMetadataTest.ts (98%) rename packages-content-model/{roosterjs-content-model-editor/test/domUtils => roosterjs-content-model-core/test}/metadata/updateListMetadataTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/test/domUtils => roosterjs-content-model-core/test}/metadata/updateTableCellMetadataTest.ts (98%) rename packages-content-model/{roosterjs-content-model-editor/test/domUtils => roosterjs-content-model-core/test}/metadata/updateTableMetadataTest.ts (98%) rename packages-content-model/{roosterjs-content-model-editor/test/editor => roosterjs-content-model-core/test}/overrides/tablePreProcessorTest.ts (98%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-core}/test/publicApi/model/cloneModelTest.ts (100%) create mode 100644 packages-content-model/roosterjs-content-model-core/test/publicApi/selection/getSelectionRootNodeTest.ts rename packages-content-model/{roosterjs-content-model-editor/test/modelApi => roosterjs-content-model-core/test/publicApi}/selection/iterateSelectionsTest.ts (99%) diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelImageView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelImageView.tsx index 948a6390a1a..4d2db4664d1 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelImageView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelImageView.tsx @@ -13,7 +13,7 @@ import { MetadataView } from '../format/MetadataView'; import { PaddingFormatRenderer } from '../format/formatPart/PaddingFormatRenderer'; import { SegmentFormatView } from '../format/SegmentFormatView'; import { SizeFormatRenderers } from '../format/formatPart/SizeFormatRenderers'; -import { updateImageMetadata } from 'roosterjs-content-model-editor'; +import { updateImageMetadata } from 'roosterjs-content-model-core'; import { useProperty } from '../../hooks/useProperty'; const styles = require('./ContentModelImageView.scss'); diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelListLevelView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelListLevelView.tsx index 96f9eb7f9db..e0c3523979a 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelListLevelView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelListLevelView.tsx @@ -10,7 +10,7 @@ import { MarginFormatRenderer } from '../format/formatPart/MarginFormatRenderer' import { MetadataView } from '../format/MetadataView'; import { PaddingFormatRenderer } from '../format/formatPart/PaddingFormatRenderer'; import { TextAlignFormatRenderer } from '../format/formatPart/TextAlignFormatRenderer'; -import { updateListMetadata } from 'roosterjs-content-model-editor'; +import { updateListMetadata } from 'roosterjs-content-model-core'; import { useProperty } from '../../hooks/useProperty'; import { ContentModelListItemLevelFormat, diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelTableCellView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelTableCellView.tsx index c620b954528..f0a3ca81e4b 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelTableCellView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelTableCellView.tsx @@ -8,7 +8,7 @@ import { ContentModelView } from '../ContentModelView'; import { DirectionFormatRenderer } from '../format/formatPart/DirectionFormatRenderer'; import { FormatRenderer } from '../format/utils/FormatRenderer'; import { FormatView } from '../format/FormatView'; -import { hasSelectionInBlockGroup, updateTableCellMetadata } from 'roosterjs-content-model-editor'; +import { hasSelectionInBlockGroup } from 'roosterjs-content-model-editor'; import { HtmlAlignFormatRenderer } from '../format/formatPart/HtmlAlignFormatRenderer'; import { MetadataView } from '../format/MetadataView'; import { PaddingFormatRenderer } from '../format/formatPart/PaddingFormatRenderer'; @@ -16,6 +16,7 @@ import { SizeFormatRenderers } from '../format/formatPart/SizeFormatRenderers'; import { TableCellMetadataFormatRenders } from '../format/formatPart/TableCellMetadataFormatRenders'; import { TextAlignFormatRenderer } from '../format/formatPart/TextAlignFormatRenderer'; import { TextColorFormatRenderer } from '../format/formatPart/TextColorFormatRenderer'; +import { updateTableCellMetadata } from 'roosterjs-content-model-core'; import { useProperty } from '../../hooks/useProperty'; import { VerticalAlignFormatRenderer } from '../format/formatPart/VerticalAlignFormatRenderer'; import { WordBreakFormatRenderer } from '../format/formatPart/WordBreakFormatRenderer'; diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelTableView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelTableView.tsx index bbee53af187..acd847ac78d 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelTableView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelTableView.tsx @@ -8,13 +8,14 @@ import { ContentModelView } from '../ContentModelView'; import { DisplayFormatRenderer } from '../format/formatPart/DisplayFormatRenderer'; import { FormatRenderer } from '../format/utils/FormatRenderer'; import { FormatView } from '../format/FormatView'; -import { hasSelectionInBlock, updateTableMetadata } from 'roosterjs-content-model-editor'; +import { hasSelectionInBlock } from 'roosterjs-content-model-editor'; import { IdFormatRenderer } from '../format/formatPart/IdFormatRenderer'; import { MarginFormatRenderer } from '../format/formatPart/MarginFormatRenderer'; import { MetadataView } from '../format/MetadataView'; import { SpacingFormatRenderer } from '../format/formatPart/SpacingFormatRenderer'; import { TableLayoutFormatRenderer } from '../format/formatPart/TableLayoutFormatRenderer'; import { TableMetadataFormatRenders } from '../format/formatPart/TableMetadataFormatRenders'; +import { updateTableMetadata } from 'roosterjs-content-model-core'; import { useProperty } from '../../hooks/useProperty'; const styles = require('./ContentModelTableView.scss'); diff --git a/demo/scripts/tsconfig.json b/demo/scripts/tsconfig.json index c964dda11ff..349e0655b2a 100644 --- a/demo/scripts/tsconfig.json +++ b/demo/scripts/tsconfig.json @@ -37,6 +37,12 @@ "roosterjs-content-model-dom/lib/*": [ "packages-content-model/roosterjs-content-model-dom/lib/*" ], + "roosterjs-content-model-core": [ + "packages-content-model/roosterjs-content-model-core/lib/index" + ], + "roosterjs-content-model-core/lib/*": [ + "packages-content-model/roosterjs-content-model-core/lib/*" + ], "roosterjs-content-model-editor": [ "packages-content-model/roosterjs-content-model-editor/lib/index" ], diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ChangeSource.ts b/packages-content-model/roosterjs-content-model-core/lib/constants/ChangeSource.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ChangeSource.ts rename to packages-content-model/roosterjs-content-model-core/lib/constants/ChangeSource.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts rename to packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts index 72d1810a9a5..a4428a172ac 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts @@ -1,4 +1,4 @@ -import { cloneModel } from '../../publicApi/model/cloneModel'; +import { cloneModel } from '../publicApi/model/cloneModel'; import { createDomToModelContext, createDomToModelContextWithConfig, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createEditorContext.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createEditorContext.ts rename to packages-content-model/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts rename to packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts index 159105c5c81..86e3365d913 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/formatContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts @@ -1,4 +1,4 @@ -import { ChangeSource } from '../../publicTypes/ChangeSource'; +import { ChangeSource } from '../constants/ChangeSource'; import { ColorTransformDirection, EntityOperation, PluginEventType } from 'roosterjs-editor-types'; import type { EditorCore, Entity } from 'roosterjs-editor-types'; import type { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/getDOMSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/getDOMSelection.ts rename to packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setContentModel.ts rename to packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setDOMSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/setDOMSelection.ts rename to packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts similarity index 86% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts rename to packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts index 0da88bb18e3..42329e710a2 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/switchShadowEdit.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts @@ -1,8 +1,7 @@ -import { getSelectionPath } from 'roosterjs-editor-dom'; -import { iterateSelections } from '../../modelApi/selection/iterateSelections'; +import { iterateSelections } from '../publicApi/selection/iterateSelections'; import { PluginEventType } from 'roosterjs-editor-types'; import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; -import type { EditorCore, SwitchShadowEdit } from 'roosterjs-editor-types'; +import type { EditorCore, SelectionPath, SwitchShadowEdit } from 'roosterjs-editor-types'; /** * @internal @@ -17,12 +16,14 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { if (isOn != !!core.lifecycle.shadowEditFragment) { if (isOn) { const model = !core.cache.cachedModel ? core.api.createContentModel(core) : null; - const range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); // Fake object, not used in Content Model Editor, just to satisfy original editor code // TODO: we can remove them once we have standalone Content Model Editor const fragment = core.contentDiv.ownerDocument.createDocumentFragment(); - const selectionPath = range && getSelectionPath(core.contentDiv, range); + const selectionPath: SelectionPath = { + start: [], + end: [], + }; core.api.triggerEvent( core, diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/promoteToContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/promoteToContentModelEditorCore.ts new file mode 100644 index 00000000000..99902794182 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/promoteToContentModelEditorCore.ts @@ -0,0 +1,87 @@ +import { createContentModel } from '../coreApi/createContentModel'; +import { createDomToModelConfig, createModelToDomConfig } from 'roosterjs-content-model-dom'; +import { createEditorContext } from '../coreApi/createEditorContext'; +import { formatContentModel } from '../coreApi/formatContentModel'; +import { getDOMSelection } from '../coreApi/getDOMSelection'; +import { listItemMetadataApplier, listLevelMetadataApplier } from '../metadata/updateListMetadata'; +import { setContentModel } from '../coreApi/setContentModel'; +import { setDOMSelection } from '../coreApi/setDOMSelection'; +import { switchShadowEdit } from '../coreApi/switchShadowEdit'; +import { tablePreProcessor } from '../override/tablePreProcessor'; +import type { + ContentModelPluginState, + StandaloneEditorCore, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; +import type { EditorCore, EditorOptions } from 'roosterjs-editor-types'; + +/** + * Creator Content Model Editor Core from Editor Core + * @param core The original EditorCore object + * @param options Options of this editor + */ +export function promoteToContentModelEditorCore( + core: EditorCore, + options: EditorOptions & StandaloneEditorOptions, + pluginState: ContentModelPluginState +) { + const cmCore = core as EditorCore & StandaloneEditorCore; + + promoteCorePluginState(cmCore, pluginState); + promoteContentModelInfo(cmCore, options); + promoteCoreApi(cmCore); + promoteEnvironment(cmCore); +} + +function promoteCorePluginState( + cmCore: StandaloneEditorCore, + pluginState: ContentModelPluginState +) { + Object.assign(cmCore, pluginState); +} + +function promoteContentModelInfo(cmCore: StandaloneEditorCore, options: StandaloneEditorOptions) { + cmCore.defaultDomToModelOptions = [ + { + processorOverride: { + table: tablePreProcessor, + }, + }, + options.defaultDomToModelOptions, + ]; + cmCore.defaultModelToDomOptions = [ + { + metadataAppliers: { + listItem: listItemMetadataApplier, + listLevel: listLevelMetadataApplier, + }, + }, + options.defaultModelToDomOptions, + ]; + cmCore.defaultDomToModelConfig = createDomToModelConfig(cmCore.defaultDomToModelOptions); + cmCore.defaultModelToDomConfig = createModelToDomConfig(cmCore.defaultModelToDomOptions); +} + +function promoteCoreApi(cmCore: StandaloneEditorCore) { + cmCore.api.createEditorContext = createEditorContext; + cmCore.api.createContentModel = createContentModel; + cmCore.api.setContentModel = setContentModel; + cmCore.api.switchShadowEdit = switchShadowEdit; + cmCore.api.getDOMSelection = getDOMSelection; + cmCore.api.setDOMSelection = setDOMSelection; + cmCore.api.formatContentModel = formatContentModel; + cmCore.originalApi.createEditorContext = createEditorContext; + cmCore.originalApi.createContentModel = createContentModel; + cmCore.originalApi.setContentModel = setContentModel; + cmCore.originalApi.getDOMSelection = getDOMSelection; + cmCore.originalApi.setDOMSelection = setDOMSelection; + cmCore.originalApi.formatContentModel = formatContentModel; +} + +function promoteEnvironment(cmCore: StandaloneEditorCore) { + cmCore.environment = {}; + + // It is ok to use global window here since the environment should always be the same for all windows in one session + cmCore.environment.isMac = window.navigator.appVersion.indexOf('Mac') != -1; + cmCore.environment.isAndroid = /android/i.test(window.navigator.userAgent); +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/index.ts b/packages-content-model/roosterjs-content-model-core/lib/index.ts new file mode 100644 index 00000000000..16ec47baf8c --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/index.ts @@ -0,0 +1,15 @@ +export { CachedElementHandler, CloneModelOptions, cloneModel } from './publicApi/model/cloneModel'; +export { + iterateSelections, + IterateSelectionsCallback, + IterateSelectionsOption, +} from './publicApi/selection/iterateSelections'; +export { getSelectionRootNode } from './publicApi/selection/getSelectionRootNode'; + +export { updateImageMetadata } from './metadata/updateImageMetadata'; +export { updateTableCellMetadata } from './metadata/updateTableCellMetadata'; +export { updateTableMetadata } from './metadata/updateTableMetadata'; +export { updateListMetadata } from './metadata/updateListMetadata'; + +export { promoteToContentModelEditorCore } from './editor/promoteToContentModelEditorCore'; +export { ChangeSource } from './constants/ChangeSource'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/metadata/definitionCreators.ts b/packages-content-model/roosterjs-content-model-core/lib/metadata/definitionCreators.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/domUtils/metadata/definitionCreators.ts rename to packages-content-model/roosterjs-content-model-core/lib/metadata/definitionCreators.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/metadata/updateImageMetadata.ts b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateImageMetadata.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/domUtils/metadata/updateImageMetadata.ts rename to packages-content-model/roosterjs-content-model-core/lib/metadata/updateImageMetadata.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/metadata/updateListMetadata.ts b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/domUtils/metadata/updateListMetadata.ts rename to packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/metadata/updateTableCellMetadata.ts b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateTableCellMetadata.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/domUtils/metadata/updateTableCellMetadata.ts rename to packages-content-model/roosterjs-content-model-core/lib/metadata/updateTableCellMetadata.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/metadata/updateTableMetadata.ts b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateTableMetadata.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/domUtils/metadata/updateTableMetadata.ts rename to packages-content-model/roosterjs-content-model-core/lib/metadata/updateTableMetadata.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/overrides/tablePreProcessor.ts b/packages-content-model/roosterjs-content-model-core/lib/override/tablePreProcessor.ts similarity index 92% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/overrides/tablePreProcessor.ts rename to packages-content-model/roosterjs-content-model-core/lib/override/tablePreProcessor.ts index 08c09a384d4..3122c2d84fb 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/overrides/tablePreProcessor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/override/tablePreProcessor.ts @@ -1,5 +1,5 @@ import { entityProcessor, hasMetadata, tableProcessor } from 'roosterjs-content-model-dom'; -import { getSelectionRootNode } from '../../modelApi/selection/getSelectionRootNode'; +import { getSelectionRootNode } from '../publicApi/selection/getSelectionRootNode'; import type { DomToModelContext, ElementProcessor } from 'roosterjs-content-model-types'; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/model/cloneModel.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/cloneModel.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/model/cloneModel.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/model/cloneModel.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/getSelectionRootNode.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/getSelectionRootNode.ts similarity index 59% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/getSelectionRootNode.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/getSelectionRootNode.ts index bc7115d0006..185edb6164f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/getSelectionRootNode.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/getSelectionRootNode.ts @@ -1,7 +1,11 @@ import type { DOMSelection } from 'roosterjs-content-model-types'; /** - * @internal + * Get root node of a given DOM selection + * For table selection, root node is the selected table + * For image selection, root node is the selected image + * For range selection, root node is the common ancestor container node of the selection range + * @param selection The selection to get root node from */ export function getSelectionRootNode(selection: DOMSelection | undefined): Node | undefined { return !selection diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/iterateSelections.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/iterateSelections.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/iterateSelections.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/iterateSelections.ts index 0f76fda08b0..33f39d2a987 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/iterateSelections.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/iterateSelections.ts @@ -7,7 +7,6 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal * Options for iterateSelections API */ export interface IterateSelectionsOption { @@ -42,7 +41,6 @@ export interface IterateSelectionsOption { } /** - * @internal * The callback function type for iterateSelections * @param path The block group path of current selection * @param tableContext Table context of current selection @@ -58,7 +56,6 @@ export type IterateSelectionsCallback = ( ) => void | boolean; /** - * @internal * Iterate all selected elements in a given model * @param group The given Content Model to iterate selection from * @param callback The callback function to access the selected element diff --git a/packages-content-model/roosterjs-content-model-core/package.json b/packages-content-model/roosterjs-content-model-core/package.json new file mode 100644 index 00000000000..ed1e640d214 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/package.json @@ -0,0 +1,12 @@ +{ + "name": "roosterjs-content-model-core", + "description": "Content Model for roosterjs (Under development)", + "dependencies": { + "tslib": "^2.3.1", + "roosterjs-editor-types": "", + "roosterjs-content-model-dom": "", + "roosterjs-content-model-types": "" + }, + "version": "0.0.0", + "main": "./lib/index.ts" +} diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts similarity index 94% rename from packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts rename to packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts index 8fc09a36183..16e33c508a5 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts @@ -1,8 +1,9 @@ -import * as cloneModel from '../../../lib/publicApi/model/cloneModel'; +import * as cloneModel from '../../lib/publicApi/model/cloneModel'; import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; -import { ContentModelEditorCore } from '../../../lib/publicTypes/ContentModelEditorCore'; -import { createContentModel } from '../../../lib/editor/coreApi/createContentModel'; +import { createContentModel } from '../../lib/coreApi/createContentModel'; +import { EditorCore } from 'roosterjs-editor-types'; +import { StandaloneEditorCore } from 'roosterjs-content-model-types'; const mockedEditorContext = 'EDITORCONTEXT' as any; const mockedContext = 'CONTEXT' as any; @@ -12,7 +13,7 @@ const mockedCachedMode = 'CACHEDMODEL' as any; const mockedClonedModel = 'CLONEDMODEL' as any; describe('createContentModel', () => { - let core: ContentModelEditorCore; + let core: StandaloneEditorCore & EditorCore; let createEditorContext: jasmine.Spy; let getDOMSelection: jasmine.Spy; let domToContentModelSpy: jasmine.Spy; @@ -43,7 +44,7 @@ describe('createContentModel', () => { cachedModel: mockedCachedMode, }, lifecycle: {}, - } as any) as ContentModelEditorCore; + } as any) as StandaloneEditorCore & EditorCore; }); it('Reuse model, no cache, no shadow edit', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createEditorContextTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts similarity index 92% rename from packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createEditorContextTest.ts rename to packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts index 6a4fbc1bfa7..f9fd8365aac 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createEditorContextTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts @@ -1,5 +1,6 @@ -import { ContentModelEditorCore } from '../../../lib/publicTypes/ContentModelEditorCore'; -import { createEditorContext } from '../../../lib/editor/coreApi/createEditorContext'; +import { createEditorContext } from '../../lib/coreApi/createEditorContext'; +import { EditorCore } from 'roosterjs-editor-types'; +import { StandaloneEditorCore } from 'roosterjs-content-model-types'; describe('createEditorContext', () => { it('create a normal context', () => { @@ -28,7 +29,7 @@ describe('createEditorContext', () => { }, darkColorHandler, cache: {}, - } as any) as ContentModelEditorCore; + } as any) as StandaloneEditorCore & EditorCore; const context = createEditorContext(core); @@ -71,7 +72,7 @@ describe('createEditorContext', () => { cache: { domIndexer, }, - } as any) as ContentModelEditorCore; + } as any) as StandaloneEditorCore & EditorCore; const context = createEditorContext(core); @@ -87,7 +88,7 @@ describe('createEditorContext', () => { }); describe('createEditorContext - checkZoomScale', () => { - let core: ContentModelEditorCore; + let core: StandaloneEditorCore & EditorCore; let div: any; let getComputedStyleSpy: jasmine.Spy; let getBoundingClientRectSpy: jasmine.Spy; @@ -117,7 +118,7 @@ describe('createEditorContext - checkZoomScale', () => { }, darkColorHandler, cache: {}, - } as any) as ContentModelEditorCore; + } as any) as StandaloneEditorCore & EditorCore; }); it('Zoom scale = 1', () => { @@ -179,7 +180,7 @@ describe('createEditorContext - checkZoomScale', () => { }); describe('createEditorContext - checkRootDir', () => { - let core: ContentModelEditorCore; + let core: StandaloneEditorCore & EditorCore; let div: any; let getComputedStyleSpy: jasmine.Spy; let getBoundingClientRectSpy: jasmine.Spy; @@ -209,7 +210,7 @@ describe('createEditorContext - checkRootDir', () => { }, darkColorHandler, cache: {}, - } as any) as ContentModelEditorCore; + } as any) as StandaloneEditorCore & EditorCore; }); it('LTR CSS', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts similarity index 97% rename from packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts rename to packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts index 7fcfbf2638a..b518e96e7e2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/formatContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts @@ -1,12 +1,20 @@ -import { ChangeSource } from '../../../lib/publicTypes/ChangeSource'; -import { ColorTransformDirection, EntityOperation, PluginEventType } from 'roosterjs-editor-types'; -import { ContentModelDocument, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import { ContentModelEditorCore } from '../../../lib/publicTypes/ContentModelEditorCore'; +import { ChangeSource } from '../../lib/constants/ChangeSource'; import { createImage } from 'roosterjs-content-model-dom'; -import { formatContentModel } from '../../../lib/editor/coreApi/formatContentModel'; +import { formatContentModel } from '../../lib/coreApi/formatContentModel'; +import { + ColorTransformDirection, + EditorCore, + EntityOperation, + PluginEventType, +} from 'roosterjs-editor-types'; +import { + ContentModelDocument, + ContentModelSegmentFormat, + StandaloneEditorCore, +} from 'roosterjs-content-model-types'; describe('formatContentModel', () => { - let core: ContentModelEditorCore; + let core: StandaloneEditorCore & EditorCore; let addUndoSnapshot: jasmine.Spy; let createContentModel: jasmine.Spy; let setContentModel: jasmine.Spy; @@ -48,7 +56,7 @@ describe('formatContentModel', () => { }, lifecycle: {}, cache: {}, - } as any) as ContentModelEditorCore; + } as any) as StandaloneEditorCore & EditorCore; }); it('Callback return false', () => { @@ -512,7 +520,7 @@ describe('formatContentModel', () => { }); }); - describe('Pending foramt', () => { + describe('Pending format', () => { const mockedStartContainer1 = 'CONTAINER1' as any; const mockedStartOffset1 = 'OFFSET1' as any; const mockedFormat1: ContentModelSegmentFormat = { fontSize: '10pt' }; diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts similarity index 93% rename from packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts rename to packages-content-model/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts index c7d5198daef..f65e179a403 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/setContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts @@ -1,7 +1,8 @@ import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; -import { ContentModelEditorCore } from '../../../lib/publicTypes/ContentModelEditorCore'; -import { setContentModel } from '../../../lib/editor/coreApi/setContentModel'; +import { EditorCore } from 'roosterjs-editor-types'; +import { setContentModel } from '../../lib/coreApi/setContentModel'; +import { StandaloneEditorCore } from 'roosterjs-content-model-types'; const mockedRange = 'RANGE' as any; const mockedDoc = 'DOCUMENT' as any; @@ -12,7 +13,7 @@ const mockedDiv = { ownerDocument: mockedDoc } as any; const mockedConfig = 'CONFIG' as any; describe('setContentModel', () => { - let core: ContentModelEditorCore; + let core: StandaloneEditorCore & EditorCore; let contentModelToDomSpy: jasmine.Spy; let createEditorContext: jasmine.Spy; let createModelToDomContextSpy: jasmine.Spy; @@ -48,7 +49,7 @@ describe('setContentModel', () => { lifecycle: {}, defaultModelToDomConfig: mockedConfig, cache: {}, - } as any) as ContentModelEditorCore; + } as any) as StandaloneEditorCore & EditorCore; }); it('no default option, no shadow edit', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/switchShadowEditTest.ts similarity index 90% rename from packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts rename to packages-content-model/roosterjs-content-model-core/test/coreApi/switchShadowEditTest.ts index 4d679d7b833..effd9f25284 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/switchShadowEditTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/switchShadowEditTest.ts @@ -1,13 +1,13 @@ -import * as iterateSelections from '../../../lib/modelApi/selection/iterateSelections'; -import { ContentModelEditorCore } from '../../../lib/publicTypes/ContentModelEditorCore'; -import { PluginEventType } from 'roosterjs-editor-types'; -import { switchShadowEdit } from '../../../lib/editor/coreApi/switchShadowEdit'; +import * as iterateSelections from '../../lib/publicApi/selection/iterateSelections'; +import { EditorCore, PluginEventType } from 'roosterjs-editor-types'; +import { StandaloneEditorCore } from 'roosterjs-content-model-types'; +import { switchShadowEdit } from '../../lib/coreApi/switchShadowEdit'; const mockedModel = 'MODEL' as any; const mockedCachedModel = 'CACHEMODEL' as any; describe('switchShadowEdit', () => { - let core: ContentModelEditorCore; + let core: StandaloneEditorCore & EditorCore; let createContentModel: jasmine.Spy; let setContentModel: jasmine.Spy; let getSelectionRange: jasmine.Spy; @@ -29,7 +29,7 @@ describe('switchShadowEdit', () => { lifecycle: {}, contentDiv: document.createElement('div'), cache: {}, - } as any) as ContentModelEditorCore; + } as any) as StandaloneEditorCore & EditorCore; }); describe('was off', () => { @@ -46,7 +46,7 @@ describe('switchShadowEdit', () => { { eventType: PluginEventType.EnteredShadowEdit, fragment: document.createDocumentFragment(), - selectionPath: undefined, + selectionPath: { start: [], end: [] }, }, false ); @@ -67,7 +67,7 @@ describe('switchShadowEdit', () => { { eventType: PluginEventType.EnteredShadowEdit, fragment: document.createDocumentFragment(), - selectionPath: undefined, + selectionPath: { start: [], end: [] }, }, false ); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/promoteToContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/promoteToContentModelEditorCoreTest.ts new file mode 100644 index 00000000000..3b9d35f8840 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/editor/promoteToContentModelEditorCoreTest.ts @@ -0,0 +1,180 @@ +import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; +import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; +import { ContentModelPluginState } from 'roosterjs-content-model-types'; +import { createContentModel } from '../../lib/coreApi/createContentModel'; +import { createEditorContext } from '../../lib/coreApi/createEditorContext'; +import { EditorCore } from 'roosterjs-editor-types'; +import { formatContentModel } from '../../lib/coreApi/formatContentModel'; +import { getDOMSelection } from '../../lib/coreApi/getDOMSelection'; +import { promoteToContentModelEditorCore } from '../../lib/editor/promoteToContentModelEditorCore'; +import { setContentModel } from '../../lib/coreApi/setContentModel'; +import { setDOMSelection } from '../../lib/coreApi/setDOMSelection'; +import { switchShadowEdit } from '../../lib/coreApi/switchShadowEdit'; +import { tablePreProcessor } from '../../lib/override/tablePreProcessor'; +import { + listItemMetadataApplier, + listLevelMetadataApplier, +} from '../../lib/metadata/updateListMetadata'; + +describe('promoteToContentModelEditorCore', () => { + let pluginState: ContentModelPluginState; + let core: EditorCore; + const mockedSwitchShadowEdit = 'SHADOWEDIT' as any; + const mockedDomToModelConfig = { + config: 'mockedDomToModelConfig', + } as any; + const mockedModelToDomConfig = { + config: 'mockedModelToDomConfig', + } as any; + + const baseResult: any = { + contentDiv: null!, + darkColorHandler: null!, + domEvent: null!, + edit: null!, + entity: null!, + getVisibleViewport: null!, + lifecycle: null!, + pendingFormatState: null!, + trustedHTMLHandler: null!, + undo: null!, + sizeTransformer: null!, + zoomScale: 1, + plugins: [], + }; + + beforeEach(() => { + pluginState = { + cache: {}, + copyPaste: { allowedCustomPasteType: [] }, + format: { + defaultFormat: {}, + pendingFormat: null, + }, + }; + core = { + ...baseResult, + api: { + switchShadowEdit: mockedSwitchShadowEdit, + } as any, + originalApi: { + switchShadowEdit: mockedSwitchShadowEdit, + } as any, + copyPaste: { allowedCustomPasteType: [] }, + }; + + spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue( + mockedDomToModelConfig + ); + spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue( + mockedModelToDomConfig + ); + }); + + it('No additional option', () => { + promoteToContentModelEditorCore(core, {}, pluginState); + + expect(core).toEqual({ + ...baseResult, + api: { + switchShadowEdit, + createEditorContext, + createContentModel, + setContentModel, + getDOMSelection, + setDOMSelection, + formatContentModel, + }, + originalApi: { + switchShadowEdit: mockedSwitchShadowEdit, + createEditorContext, + createContentModel, + setContentModel, + getDOMSelection, + setDOMSelection, + formatContentModel, + }, + defaultDomToModelOptions: [ + { processorOverride: { table: tablePreProcessor } }, + undefined, + ], + defaultModelToDomOptions: [ + { + metadataAppliers: { + listItem: listItemMetadataApplier, + listLevel: listLevelMetadataApplier, + }, + }, + undefined, + ], + defaultDomToModelConfig: mockedDomToModelConfig, + defaultModelToDomConfig: mockedModelToDomConfig, + format: { + defaultFormat: {}, + pendingFormat: null, + }, + cache: {}, + copyPaste: { allowedCustomPasteType: [] }, + environment: { isMac: false, isAndroid: false }, + } as any); + }); + + it('With additional option', () => { + const defaultDomToModelOptions = { a: '1' } as any; + const defaultModelToDomOptions = { b: '2' } as any; + const mockedPlugin = 'PLUGIN' as any; + const options = { + defaultDomToModelOptions, + defaultModelToDomOptions, + corePluginOverride: { + copyPaste: mockedPlugin, + }, + }; + + promoteToContentModelEditorCore(core, options, pluginState); + + expect(core).toEqual({ + ...baseResult, + api: { + switchShadowEdit, + createEditorContext, + createContentModel, + setContentModel, + getDOMSelection, + setDOMSelection, + formatContentModel, + }, + originalApi: { + switchShadowEdit: mockedSwitchShadowEdit, + createEditorContext, + createContentModel, + setContentModel, + getDOMSelection, + setDOMSelection, + formatContentModel, + }, + defaultDomToModelOptions: [ + { processorOverride: { table: tablePreProcessor } }, + defaultDomToModelOptions, + ], + defaultModelToDomOptions: [ + { + metadataAppliers: { + listItem: listItemMetadataApplier, + listLevel: listLevelMetadataApplier, + }, + }, + defaultModelToDomOptions, + ], + defaultDomToModelConfig: mockedDomToModelConfig, + defaultModelToDomConfig: mockedModelToDomConfig, + format: { + defaultFormat: {}, + pendingFormat: null, + }, + cache: {}, + copyPaste: { allowedCustomPasteType: [] }, + environment: { isMac: false, isAndroid: false }, + } as any); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/handleListItemWithMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/handleListItemWithMetadataTest.ts rename to packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts index d86b1dc0ee0..3069b2e9100 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/handleListItemWithMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListItemWithMetadataTest.ts @@ -18,7 +18,7 @@ import { import { listItemMetadataApplier, listLevelMetadataApplier, -} from '../../../lib/domUtils/metadata/updateListMetadata'; +} from '../../lib/metadata/updateListMetadata'; describe('handleListItem with metadata', () => { let context: ModelToDomContext; diff --git a/packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/handleListWithMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/handleListWithMetadataTest.ts rename to packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts index 4936cfec9a6..cce69e71cfb 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/handleListWithMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/handleListWithMetadataTest.ts @@ -9,7 +9,7 @@ import { import { listItemMetadataApplier, listLevelMetadataApplier, -} from '../../../lib/domUtils/metadata/updateListMetadata'; +} from '../../lib/metadata/updateListMetadata'; describe('handleList with metadata', () => { let context: ModelToDomContext; diff --git a/packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/updateImageMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/updateImageMetadataTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/updateImageMetadataTest.ts rename to packages-content-model/roosterjs-content-model-core/test/metadata/updateImageMetadataTest.ts index 7350d97afc7..d3f003d0991 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/updateImageMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/updateImageMetadataTest.ts @@ -1,5 +1,5 @@ import { ContentModelImage, ImageMetadataFormat } from 'roosterjs-content-model-types'; -import { updateImageMetadata } from '../../../lib/domUtils/metadata/updateImageMetadata'; +import { updateImageMetadata } from '../../lib/metadata/updateImageMetadata'; describe('updateImageMetadataTest', () => { it('No value', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/updateListMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/updateListMetadataTest.ts rename to packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts index d5279e200a8..939fec274ed 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/updateListMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts @@ -11,7 +11,7 @@ import { listItemMetadataApplier, listLevelMetadataApplier, updateListMetadata, -} from '../../../lib/domUtils/metadata/updateListMetadata'; +} from '../../lib/metadata/updateListMetadata'; describe('updateListMetadata', () => { it('No value', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/updateTableCellMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/updateTableCellMetadataTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/updateTableCellMetadataTest.ts rename to packages-content-model/roosterjs-content-model-core/test/metadata/updateTableCellMetadataTest.ts index c3a177e62c9..d5ecf9822ea 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/updateTableCellMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/updateTableCellMetadataTest.ts @@ -1,5 +1,5 @@ import { ContentModelTableCell, TableCellMetadataFormat } from 'roosterjs-content-model-types'; -import { updateTableCellMetadata } from '../../../lib/domUtils/metadata/updateTableCellMetadata'; +import { updateTableCellMetadata } from '../../lib/metadata/updateTableCellMetadata'; describe('updateTableCellMetadata', () => { it('No value', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/updateTableMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/updateTableMetadataTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/updateTableMetadataTest.ts rename to packages-content-model/roosterjs-content-model-core/test/metadata/updateTableMetadataTest.ts index 9afb2b357a3..a4ecc891d51 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/domUtils/metadata/updateTableMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/updateTableMetadataTest.ts @@ -1,4 +1,4 @@ -import { updateTableMetadata } from '../../../lib/domUtils/metadata/updateTableMetadata'; +import { updateTableMetadata } from '../../lib/metadata/updateTableMetadata'; import { ContentModelTable, TableBorderFormat, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/overrides/tablePreProcessorTest.ts b/packages-content-model/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/test/editor/overrides/tablePreProcessorTest.ts rename to packages-content-model/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts index 4dd8d30ef19..e759c7b4a19 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/overrides/tablePreProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/overrides/tablePreProcessorTest.ts @@ -1,6 +1,6 @@ import * as tableProcessor from 'roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor'; import { createContentModelDocument, createDomToModelContext } from 'roosterjs-content-model-dom'; -import { tablePreProcessor } from '../../../lib/editor/overrides/tablePreProcessor'; +import { tablePreProcessor } from '../../lib/override/tablePreProcessor'; describe('tablePreProcessor', () => { it('Table without metadata, use Entity', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/model/cloneModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/cloneModelTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/model/cloneModelTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/model/cloneModelTest.ts diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/getSelectionRootNodeTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/getSelectionRootNodeTest.ts new file mode 100644 index 00000000000..cbb1d37affa --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/getSelectionRootNodeTest.ts @@ -0,0 +1,41 @@ +import { getSelectionRootNode } from '../../../lib/publicApi/selection/getSelectionRootNode'; + +describe('getSelectionRootNode', () => { + it('undefined input', () => { + const root = getSelectionRootNode(undefined); + + expect(root).toBeUndefined(); + }); + + it('range input', () => { + const mockedRoot = 'ROOT' as any; + const root = getSelectionRootNode({ + type: 'range', + range: { + commonAncestorContainer: mockedRoot, + } as any, + }); + + expect(root).toBe(mockedRoot); + }); + + it('table input', () => { + const mockedTable = 'TABLE' as any; + const root = getSelectionRootNode({ + type: 'table', + table: mockedTable, + } as any); + + expect(root).toBe(mockedTable); + }); + + it('image input', () => { + const mockedImage = 'IMAGE' as any; + const root = getSelectionRootNode({ + type: 'image', + image: mockedImage, + } as any); + + expect(root).toBe(mockedImage); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/iterateSelectionsTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/iterateSelectionsTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/iterateSelectionsTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/selection/iterateSelectionsTest.ts index a790c394993..9bb7a27769c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/iterateSelectionsTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/iterateSelectionsTest.ts @@ -17,7 +17,7 @@ import { import { iterateSelections, IterateSelectionsCallback, -} from '../../../lib/modelApi/selection/iterateSelections'; +} from '../../../lib/publicApi/selection/iterateSelections'; describe('iterateSelections', () => { let callback: jasmine.Spy; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts index 37726ae9227..a9138f480f1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts @@ -1,11 +1,9 @@ import paste from '../../publicApi/utils/paste'; import { addRangeToSelection } from '../../domUtils/addRangeToSelection'; -import { ChangeSource } from '../../publicTypes/ChangeSource'; -import { cloneModel } from '../../publicApi/model/cloneModel'; +import { ChangeSource, cloneModel, iterateSelections } from 'roosterjs-content-model-core'; import { ColorTransformDirection, PluginEventType } from 'roosterjs-editor-types'; import { deleteSelection } from '../../publicApi/selection/deleteSelection'; import { extractClipboardItems } from 'roosterjs-editor-dom'; -import { iterateSelections } from '../../modelApi/selection/iterateSelections'; import { contentModelToDom, createModelToDomContext, @@ -286,3 +284,12 @@ export const onNodeCreated: OnNodeCreated = (_, node): void => { node.removeAttribute('contenteditable'); } }; + +/** + * @internal + * Create a new instance of ContentModelCopyPastePlugin + * @param state The plugin state object + */ +export function createContentModelCopyPastePlugin(state: CopyPastePluginState) { + return new ContentModelCopyPastePlugin(state); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts index d17046e3a39..9f9aa638c80 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts @@ -1,25 +1,13 @@ -import ContentModelCopyPastePlugin from './corePlugins/ContentModelCopyPastePlugin'; import ContentModelTypeInContainerPlugin from './corePlugins/ContentModelTypeInContainerPlugin'; import { contentModelDomIndexer } from './utils/contentModelDomIndexer'; -import { createContentModel } from './coreApi/createContentModel'; import { createContentModelCachePlugin } from './corePlugins/ContentModelCachePlugin'; +import { createContentModelCopyPastePlugin } from './corePlugins/ContentModelCopyPastePlugin'; import { createContentModelFormatPlugin } from './corePlugins/ContentModelFormatPlugin'; -import { createDomToModelConfig, createModelToDomConfig } from 'roosterjs-content-model-dom'; -import { createEditorContext } from './coreApi/createEditorContext'; import { createEditorCore } from 'roosterjs-editor-core'; -import { formatContentModel } from './coreApi/formatContentModel'; -import { getDOMSelection } from './coreApi/getDOMSelection'; -import { setContentModel } from './coreApi/setContentModel'; -import { setDOMSelection } from './coreApi/setDOMSelection'; -import { switchShadowEdit } from './coreApi/switchShadowEdit'; -import { tablePreProcessor } from './overrides/tablePreProcessor'; -import { - listItemMetadataApplier, - listLevelMetadataApplier, -} from '../domUtils/metadata/updateListMetadata'; +import { promoteToContentModelEditorCore } from 'roosterjs-content-model-core'; import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; -import type { CoreCreator, EditorCore } from 'roosterjs-editor-types'; +import type { CoreCreator } from 'roosterjs-editor-types'; import type { ContentModelPluginState } from 'roosterjs-content-model-types'; /** @@ -39,92 +27,18 @@ export const createContentModelEditorCore: CoreCreator< ], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: new ContentModelCopyPastePlugin(pluginState.copyPaste), + copyPaste: createContentModelCopyPastePlugin(pluginState.copyPaste), ...options.corePluginOverride, }, }; const core = createEditorCore(contentDiv, modifiedOptions) as ContentModelEditorCore; - core.environment = {}; - promoteToContentModelEditorCore(core, modifiedOptions, pluginState); return core; }; -/** - * Creator Content Model Editor Core from Editor Core - * @param core The original EditorCore object - * @param options Options of this editor - */ -export function promoteToContentModelEditorCore( - core: EditorCore, - options: ContentModelEditorOptions, - pluginState: ContentModelPluginState -) { - const cmCore = core as ContentModelEditorCore; - - promoteCorePluginState(cmCore, pluginState); - promoteContentModelInfo(cmCore, options); - promoteCoreApi(cmCore); - promoteEnvironment(cmCore); -} - -function promoteCorePluginState( - cmCore: ContentModelEditorCore, - pluginState: ContentModelPluginState -) { - Object.assign(cmCore, pluginState); -} - -function promoteContentModelInfo( - cmCore: ContentModelEditorCore, - options: ContentModelEditorOptions -) { - cmCore.defaultDomToModelOptions = [ - { - processorOverride: { - table: tablePreProcessor, - }, - }, - options.defaultDomToModelOptions, - ]; - cmCore.defaultModelToDomOptions = [ - { - metadataAppliers: { - listItem: listItemMetadataApplier, - listLevel: listLevelMetadataApplier, - }, - }, - options.defaultModelToDomOptions, - ]; - cmCore.defaultDomToModelConfig = createDomToModelConfig(cmCore.defaultDomToModelOptions); - cmCore.defaultModelToDomConfig = createModelToDomConfig(cmCore.defaultModelToDomOptions); -} - -function promoteCoreApi(cmCore: ContentModelEditorCore) { - cmCore.api.createEditorContext = createEditorContext; - cmCore.api.createContentModel = createContentModel; - cmCore.api.setContentModel = setContentModel; - cmCore.api.switchShadowEdit = switchShadowEdit; - cmCore.api.getDOMSelection = getDOMSelection; - cmCore.api.setDOMSelection = setDOMSelection; - cmCore.api.formatContentModel = formatContentModel; - cmCore.originalApi.createEditorContext = createEditorContext; - cmCore.originalApi.createContentModel = createContentModel; - cmCore.originalApi.setContentModel = setContentModel; - cmCore.originalApi.getDOMSelection = getDOMSelection; - cmCore.originalApi.setDOMSelection = setDOMSelection; - cmCore.originalApi.formatContentModel = formatContentModel; -} - -function promoteEnvironment(cmCore: ContentModelEditorCore) { - // It is ok to use global window here since the environment should always be the same for all windows in one session - cmCore.environment.isMac = window.navigator.appVersion.indexOf('Mac') != -1; - cmCore.environment.isAndroid = /android/i.test(window.navigator.userAgent); -} - function getPluginState(options: ContentModelEditorOptions): ContentModelPluginState { const format = options.defaultFormat || {}; return { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index eebb88ceb0b..75eef39b208 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -3,7 +3,6 @@ export { ContentModelEditorCore, } from './publicTypes/ContentModelEditorCore'; export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; -export { ChangeSource } from './publicTypes/ChangeSource'; export { default as insertTable } from './publicApi/table/insertTable'; export { default as formatTable } from './publicApi/table/formatTable'; @@ -54,7 +53,6 @@ export { default as setParagraphMargin } from './publicApi/block/setParagraphMar export { default as toggleCode } from './publicApi/segment/toggleCode'; export { default as paste } from './publicApi/utils/paste'; export { default as insertEntity } from './publicApi/entity/insertEntity'; -export { CachedElementHandler, CloneModelOptions, cloneModel } from './publicApi/model/cloneModel'; export { deleteSelection } from './publicApi/selection/deleteSelection'; export { default as ContentModelEditor } from './editor/ContentModelEditor'; @@ -65,14 +63,7 @@ export { default as ContentModelTypeInContainerPlugin } from './editor/corePlugi export { default as ContentModelCopyPastePlugin } from './editor/corePlugins/ContentModelCopyPastePlugin'; export { default as ContentModelCachePlugin } from './editor/corePlugins/ContentModelCachePlugin'; -export { - createContentModelEditorCore, - promoteToContentModelEditorCore, -} from './editor/createContentModelEditorCore'; +export { createContentModelEditorCore } from './editor/createContentModelEditorCore'; export { combineBorderValue, extractBorderValues } from './domUtils/borderValues'; -export { updateImageMetadata } from './domUtils/metadata/updateImageMetadata'; -export { updateTableCellMetadata } from './domUtils/metadata/updateTableCellMetadata'; -export { updateTableMetadata } from './domUtils/metadata/updateTableMetadata'; -export { updateListMetadata } from './domUtils/metadata/updateListMetadata'; export { isCharacterValue, isModifierKey } from './domUtils/eventUtils'; export { isPunctuation, isSpace, normalizeText } from './domUtils/stringUtil'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts index d811a161638..31fdcac6e3d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts @@ -2,9 +2,11 @@ import { adjustWordSelection } from '../selection/adjustWordSelection'; import { applyTableFormat } from '../table/applyTableFormat'; import { createFormatContainer } from 'roosterjs-content-model-dom'; import { getClosestAncestorBlockGroupIndex } from './getClosestAncestorBlockGroupIndex'; -import { iterateSelections } from '../selection/iterateSelections'; -import { updateTableCellMetadata } from '../../domUtils/metadata/updateTableCellMetadata'; -import { updateTableMetadata } from '../../domUtils/metadata/updateTableMetadata'; +import { + iterateSelections, + updateTableCellMetadata, + updateTableMetadata, +} from 'roosterjs-content-model-core'; import type { ContentModelBlock, ContentModelBlockGroup, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts index ca3fd514c40..8d9503034c2 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts @@ -1,8 +1,7 @@ import { extractBorderValues } from '../../domUtils/borderValues'; import { getClosestAncestorBlockGroupIndex } from './getClosestAncestorBlockGroupIndex'; import { isBold } from '../../publicApi/segment/toggleBold'; -import { iterateSelections } from '../selection/iterateSelections'; -import { updateTableMetadata } from '../../domUtils/metadata/updateTableMetadata'; +import { iterateSelections, updateTableMetadata } from 'roosterjs-content-model-core'; import type { ContentModelFormatState, ContentModelBlock, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts index 4c603e12271..6685245d6f8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts @@ -1,8 +1,8 @@ import { createInsertPoint } from '../utils/createInsertPoint'; import { deleteBlock } from '../../../publicApi/block/deleteBlock'; import { deleteSegment } from '../../../publicApi/segment/deleteSegment'; -import { iterateSelections } from '../../selection/iterateSelections'; -import type { IterateSelectionsOption } from '../../selection/iterateSelections'; +import { iterateSelections } from 'roosterjs-content-model-core'; +import type { IterateSelectionsOption } from 'roosterjs-content-model-core'; import type { ContentModelDocument, DeleteSelectionContext, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyPendingFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyPendingFormat.ts index 7fa99123c90..093ac7b93f6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyPendingFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyPendingFormat.ts @@ -1,4 +1,4 @@ -import { iterateSelections } from '../../modelApi/selection/iterateSelections'; +import { iterateSelections } from 'roosterjs-content-model-core'; import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts index 9d4d3e2f8cf..741ce3e48bd 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts @@ -1,6 +1,6 @@ import { createText } from 'roosterjs-content-model-dom'; import { isPunctuation, isSpace } from '../../domUtils/stringUtil'; -import { iterateSelections } from '../../modelApi/selection/iterateSelections'; +import { iterateSelections } from 'roosterjs-content-model-core'; import type { ContentModelDocument, ContentModelParagraph, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts index 14c6abd8874..7180f208819 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts @@ -1,7 +1,7 @@ import { getClosestAncestorBlockGroupIndex } from '../common/getClosestAncestorBlockGroupIndex'; import { isBlockGroupOfType } from '../common/isBlockGroupOfType'; -import { iterateSelections } from './iterateSelections'; -import type { IterateSelectionsOption } from './iterateSelections'; +import { iterateSelections } from 'roosterjs-content-model-core'; +import type { IterateSelectionsOption } from 'roosterjs-content-model-core'; import type { ContentModelBlock, ContentModelBlockGroup, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTableCell.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTableCell.ts index ded7e3264af..296ccb034d5 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTableCell.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTableCell.ts @@ -1,5 +1,5 @@ import { getSelectedCells } from './getSelectedCells'; -import { updateTableCellMetadata } from '../../domUtils/metadata/updateTableCellMetadata'; +import { updateTableCellMetadata } from 'roosterjs-content-model-core'; import type { ContentModelTable, ContentModelTableCell, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/applyTableFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/applyTableFormat.ts index b41efeef019..dcc44fcdfe3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/applyTableFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/applyTableFormat.ts @@ -2,8 +2,7 @@ import { BorderKeys } from 'roosterjs-content-model-dom'; import { combineBorderValue, extractBorderValues } from '../../domUtils/borderValues'; import { setTableCellBackgroundColor } from './setTableCellBackgroundColor'; import { TableBorderFormat } from 'roosterjs-content-model-types'; -import { updateTableCellMetadata } from '../../domUtils/metadata/updateTableCellMetadata'; -import { updateTableMetadata } from '../../domUtils/metadata/updateTableMetadata'; +import { updateTableCellMetadata, updateTableMetadata } from 'roosterjs-content-model-core'; import type { BorderFormat, ContentModelTable, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts index 9307566a3cd..895940744b5 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts @@ -1,4 +1,4 @@ -import { updateTableCellMetadata } from '../../domUtils/metadata/updateTableCellMetadata'; +import { updateTableCellMetadata } from 'roosterjs-content-model-core'; import type { ContentModelTableCell } from 'roosterjs-content-model-types'; // Using the HSL (hue, saturation and lightness) representation for RGB color values. diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts index 408be04505e..964c4e59837 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts @@ -1,4 +1,4 @@ -import { ChangeSource } from '../../publicTypes/ChangeSource'; +import { ChangeSource } from 'roosterjs-content-model-core'; import { createEntity, normalizeContentModel } from 'roosterjs-content-model-dom'; import { insertEntityModel } from '../../modelApi/entity/insertEntityModel'; import type { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts index 5ee2fec8d02..deebd5dac76 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts @@ -1,4 +1,4 @@ -import { getSelectionRootNode } from '../../modelApi/selection/getSelectionRootNode'; +import { getSelectionRootNode } from 'roosterjs-content-model-core'; import { retrieveModelFormatState } from '../../modelApi/common/retrieveModelFormatState'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/changeImage.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/changeImage.ts index 20187fd008b..fcad8e95237 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/changeImage.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/changeImage.ts @@ -1,7 +1,7 @@ import formatImageWithContentModel from '../utils/formatImageWithContentModel'; import { PluginEventType } from 'roosterjs-editor-types'; import { readFile } from '../../domUtils/readFile'; -import { updateImageMetadata } from '../../domUtils/metadata/updateImageMetadata'; +import { updateImageMetadata } from 'roosterjs-content-model-core'; import type { ContentModelImage } from 'roosterjs-content-model-types'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts index dd41b78dc34..2e1df2c3605 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts @@ -1,5 +1,5 @@ import getSelectedSegments from '../selection/getSelectedSegments'; -import { ChangeSource } from '../../publicTypes/ChangeSource'; +import { ChangeSource } from 'roosterjs-content-model-core'; import { HtmlSanitizer, matchLink } from 'roosterjs-editor-dom'; import { mergeModel } from '../../modelApi/common/mergeModel'; import type { ContentModelLink } from 'roosterjs-content-model-types'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStyle.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStyle.ts index 15a101351b0..f9d9d7beee4 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStyle.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStyle.ts @@ -1,6 +1,6 @@ import { findListItemsInSameThread } from '../../modelApi/list/findListItemsInSameThread'; import { getFirstSelectedListItem } from '../../modelApi/selection/collectSelections'; -import { updateListMetadata } from '../../domUtils/metadata/updateListMetadata'; +import { updateListMetadata } from 'roosterjs-content-model-core'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { ListMetadataFormat } from 'roosterjs-content-model-types'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts index 43d7fb5cb71..46a4b063a3d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts @@ -2,7 +2,7 @@ import { extractBorderValues } from '../../domUtils/borderValues'; import { getFirstSelectedTable } from '../../modelApi/selection/collectSelections'; import { getSelectedCells } from '../../modelApi/table/getSelectedCells'; import { parseValueWithUnit } from 'roosterjs-content-model-dom'; -import { updateTableCellMetadata } from '../../domUtils/metadata/updateTableCellMetadata'; +import { updateTableCellMetadata } from 'roosterjs-content-model-core'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { Border, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/formatTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/formatTable.ts index 92a5e2b4044..7e3446851ab 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/formatTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/formatTable.ts @@ -1,6 +1,6 @@ import { applyTableFormat } from '../../modelApi/table/applyTableFormat'; import { getFirstSelectedTable } from '../../modelApi/selection/collectSelections'; -import { updateTableCellMetadata } from '../../domUtils/metadata/updateTableCellMetadata'; +import { updateTableCellMetadata } from 'roosterjs-content-model-core'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { TableMetadataFormat } from 'roosterjs-content-model-types'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts index a9363711383..f66cf488248 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts @@ -1,5 +1,5 @@ import getSelectedSegments from '../selection/getSelectedSegments'; -import { ChangeSource } from '../../publicTypes/ChangeSource'; +import { ChangeSource } from 'roosterjs-content-model-core'; import { GetContentMode, PasteType as OldPasteType, PluginEventType } from 'roosterjs-editor-types'; import { mergeModel } from '../../modelApi/common/mergeModel'; import type { diff --git a/packages-content-model/roosterjs-content-model-editor/package.json b/packages-content-model/roosterjs-content-model-editor/package.json index 00f04bf6314..6debbfeb1cb 100644 --- a/packages-content-model/roosterjs-content-model-editor/package.json +++ b/packages-content-model/roosterjs-content-model-editor/package.json @@ -6,6 +6,7 @@ "roosterjs-editor-types": "", "roosterjs-editor-dom": "", "roosterjs-editor-core": "", + "roosterjs-content-model-core": "", "roosterjs-content-model-dom": "", "roosterjs-content-model-types": "" }, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts index 81bf05c5caa..c14a760dda6 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts @@ -1,42 +1,25 @@ import * as ContentModelCachePlugin from '../../lib/editor/corePlugins/ContentModelCachePlugin'; +import * as ContentModelCopyPastePlugin from '../../lib/editor/corePlugins/ContentModelCopyPastePlugin'; import * as ContentModelFormatPlugin from '../../lib/editor/corePlugins/ContentModelFormatPlugin'; -import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as createEditorCore from 'roosterjs-editor-core/lib/editor/createEditorCore'; -import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; +import * as promoteToContentModelEditorCore from 'roosterjs-content-model-core/lib/editor/promoteToContentModelEditorCore'; import ContentModelTypeInContainerPlugin from '../../lib/editor/corePlugins/ContentModelTypeInContainerPlugin'; import { contentModelDomIndexer } from '../../lib/editor/utils/contentModelDomIndexer'; import { ContentModelEditorOptions } from '../../lib/publicTypes/IContentModelEditor'; -import { createContentModel } from '../../lib/editor/coreApi/createContentModel'; import { createContentModelEditorCore } from '../../lib/editor/createContentModelEditorCore'; -import { createEditorContext } from '../../lib/editor/coreApi/createEditorContext'; -import { formatContentModel } from '../../lib/editor/coreApi/formatContentModel'; -import { getDOMSelection } from '../../lib/editor/coreApi/getDOMSelection'; -import { setContentModel } from '../../lib/editor/coreApi/setContentModel'; -import { setDOMSelection } from '../../lib/editor/coreApi/setDOMSelection'; -import { switchShadowEdit } from '../../lib/editor/coreApi/switchShadowEdit'; -import { tablePreProcessor } from '../../lib/editor/overrides/tablePreProcessor'; -import { - listItemMetadataApplier, - listLevelMetadataApplier, -} from '../../lib/domUtils/metadata/updateListMetadata'; const mockedSwitchShadowEdit = 'SHADOWEDIT' as any; -const mockedDomToModelConfig = { - config: 'mockedDomToModelConfig', -} as any; -const mockedModelToDomConfig = { - config: 'mockedModelToDomConfig', -} as any; const mockedFormatPlugin = 'FORMATPLUGIN' as any; const mockedCachePlugin = 'CACHPLUGIN' as any; +const mockedCopyPastePlugin = 'COPYPASTE' as any; +const mockedCopyPastePlugin2 = 'COPYPASTE2' as any; describe('createContentModelEditorCore', () => { let createEditorCoreSpy: jasmine.Spy; + let promoteToContentModelEditorCoreSpy: jasmine.Spy; let mockedCore: any; let contentDiv: any; - let copyPastePlugin = 'copyPastePlugin' as any; - beforeEach(() => { contentDiv = { style: {}, @@ -58,73 +41,34 @@ describe('createContentModelEditorCore', () => { createEditorCoreSpy = spyOn(createEditorCore, 'createEditorCore').and.returnValue( mockedCore ); + promoteToContentModelEditorCoreSpy = spyOn( + promoteToContentModelEditorCore, + 'promoteToContentModelEditorCore' + ); spyOn(ContentModelFormatPlugin, 'createContentModelFormatPlugin').and.returnValue( mockedFormatPlugin ); spyOn(ContentModelCachePlugin, 'createContentModelCachePlugin').and.returnValue( mockedCachePlugin ); - - spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue( - mockedDomToModelConfig - ); - spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue( - mockedModelToDomConfig + spyOn(ContentModelCopyPastePlugin, 'createContentModelCopyPastePlugin').and.returnValue( + mockedCopyPastePlugin ); }); it('No additional option', () => { - const options = { - corePluginOverride: { - copyPaste: copyPastePlugin, - }, - }; - const core = createContentModelEditorCore(contentDiv, options); + const core = createContentModelEditorCore(contentDiv, {}); - expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { + const expectedOptions = { plugins: [mockedCachePlugin, mockedFormatPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: copyPastePlugin, - }, - }); - expect(core).toEqual({ - lifecycle: { - experimentalFeatures: [], - }, - api: { - switchShadowEdit, - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - originalApi: { - a: 'b', - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, + copyPaste: mockedCopyPastePlugin, }, - defaultDomToModelOptions: [ - { processorOverride: { table: tablePreProcessor } }, - undefined, - ], - defaultModelToDomOptions: [ - { - metadataAppliers: { - listItem: listItemMetadataApplier, - listLevel: listLevelMetadataApplier, - }, - }, - undefined, - ], - defaultDomToModelConfig: mockedDomToModelConfig, - defaultModelToDomConfig: mockedModelToDomConfig, + }; + const expectedPluginState: any = { + cache: { domIndexer: undefined }, + copyPaste: { allowedCustomPasteType: [] }, format: { defaultFormat: { fontWeight: undefined, @@ -137,13 +81,14 @@ describe('createContentModelEditorCore', () => { }, pendingFormat: null, }, - contentDiv: { - style: {}, - }, - cache: { domIndexer: undefined }, - copyPaste: { allowedCustomPasteType: [] }, - environment: { isMac: false, isAndroid: false }, - } as any); + }; + + expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, expectedOptions); + expect(promoteToContentModelEditorCoreSpy).toHaveBeenCalledWith( + core, + expectedOptions, + expectedPluginState + ); }); it('With additional option', () => { @@ -154,58 +99,23 @@ describe('createContentModelEditorCore', () => { defaultDomToModelOptions, defaultModelToDomOptions, corePluginOverride: { - copyPaste: copyPastePlugin, + copyPaste: mockedCopyPastePlugin2, }, }; const core = createContentModelEditorCore(contentDiv, options); - expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { + const expectedOptions = { defaultDomToModelOptions, defaultModelToDomOptions, plugins: [mockedCachePlugin, mockedFormatPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: copyPastePlugin, - }, - }); - - expect(core).toEqual({ - lifecycle: { - experimentalFeatures: [], - }, - api: { - switchShadowEdit, - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - originalApi: { - a: 'b', - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, + copyPaste: mockedCopyPastePlugin2, }, - defaultDomToModelOptions: [ - { processorOverride: { table: tablePreProcessor } }, - defaultDomToModelOptions, - ], - defaultModelToDomOptions: [ - { - metadataAppliers: { - listItem: listItemMetadataApplier, - listLevel: listLevelMetadataApplier, - }, - }, - defaultModelToDomOptions, - ], - defaultDomToModelConfig: mockedDomToModelConfig, - defaultModelToDomConfig: mockedModelToDomConfig, + }; + const expectedPluginState: any = { + cache: { domIndexer: undefined }, + copyPaste: { allowedCustomPasteType: [] }, format: { defaultFormat: { fontWeight: undefined, @@ -218,22 +128,18 @@ describe('createContentModelEditorCore', () => { }, pendingFormat: null, }, - contentDiv: { - style: {}, - }, - cache: { - domIndexer: undefined, - }, - copyPaste: { allowedCustomPasteType: [] }, - environment: { isMac: false, isAndroid: false }, - } as any); + }; + + expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, expectedOptions); + expect(promoteToContentModelEditorCoreSpy).toHaveBeenCalledWith( + core, + expectedOptions, + expectedPluginState + ); }); it('With default format', () => { const options = { - corePluginOverride: { - copyPaste: copyPastePlugin, - }, defaultFormat: { bold: true, italic: true, @@ -247,11 +153,11 @@ describe('createContentModelEditorCore', () => { const core = createContentModelEditorCore(contentDiv, options); - expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { + const expectedOptions = { plugins: [mockedCachePlugin, mockedFormatPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: copyPastePlugin, + copyPaste: mockedCopyPastePlugin, }, defaultFormat: { bold: true, @@ -262,44 +168,10 @@ describe('createContentModelEditorCore', () => { textColor: 'red', backgroundColor: 'blue', }, - }); - expect(core).toEqual({ - lifecycle: { - experimentalFeatures: [], - }, - api: { - switchShadowEdit, - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - originalApi: { - a: 'b', - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - defaultDomToModelOptions: [ - { processorOverride: { table: tablePreProcessor } }, - undefined, - ], - defaultModelToDomOptions: [ - { - metadataAppliers: { - listItem: listItemMetadataApplier, - listLevel: listLevelMetadataApplier, - }, - }, - undefined, - ], - defaultDomToModelConfig: mockedDomToModelConfig, - defaultModelToDomConfig: mockedModelToDomConfig, + }; + const expectedPluginState: any = { + cache: { domIndexer: undefined }, + copyPaste: { allowedCustomPasteType: [] }, format: { defaultFormat: { fontWeight: 'bold', @@ -312,145 +184,34 @@ describe('createContentModelEditorCore', () => { }, pendingFormat: null, }, - contentDiv: { - style: {}, - }, - cache: { domIndexer: undefined }, - copyPaste: { allowedCustomPasteType: [] }, - environment: { isMac: false, isAndroid: false }, - } as any); - }); - - it('Reuse model', () => { - const options = { - corePluginOverride: { - copyPaste: copyPastePlugin, - }, }; - const core = createContentModelEditorCore(contentDiv, options); - - expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { - plugins: [mockedCachePlugin, mockedFormatPlugin], - corePluginOverride: { - typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: copyPastePlugin, - }, - }); - expect(core).toEqual({ - lifecycle: { - experimentalFeatures: [], - }, - api: { - switchShadowEdit: switchShadowEdit, - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - originalApi: { - a: 'b', - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - defaultDomToModelOptions: [ - { processorOverride: { table: tablePreProcessor } }, - undefined, - ], - defaultModelToDomOptions: [ - { - metadataAppliers: { - listItem: listItemMetadataApplier, - listLevel: listLevelMetadataApplier, - }, - }, - undefined, - ], - format: { - defaultFormat: { - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, - }, - pendingFormat: null, - }, - defaultDomToModelConfig: mockedDomToModelConfig, - defaultModelToDomConfig: mockedModelToDomConfig, - - contentDiv: { - style: {}, - }, - cache: { domIndexer: undefined }, - copyPaste: { allowedCustomPasteType: [] }, - environment: { isMac: false, isAndroid: false }, - } as any); + expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, expectedOptions); + expect(promoteToContentModelEditorCoreSpy).toHaveBeenCalledWith( + core, + expectedOptions, + expectedPluginState + ); }); it('Allow dom indexer', () => { const options: ContentModelEditorOptions = { - corePluginOverride: { - copyPaste: copyPastePlugin, - }, cacheModel: true, }; const core = createContentModelEditorCore(contentDiv, options); - expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, { + const expectedOptions = { plugins: [mockedCachePlugin, mockedFormatPlugin], corePluginOverride: { typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: copyPastePlugin, + copyPaste: mockedCopyPastePlugin, }, cacheModel: true, - }); - expect(core).toEqual({ - lifecycle: { - experimentalFeatures: [], - }, - api: { - switchShadowEdit, - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - originalApi: { - a: 'b', - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - defaultDomToModelOptions: [ - { processorOverride: { table: tablePreProcessor } }, - undefined, - ], - defaultModelToDomOptions: [ - { - metadataAppliers: { - listItem: listItemMetadataApplier, - listLevel: listLevelMetadataApplier, - }, - }, - undefined, - ], - defaultDomToModelConfig: mockedDomToModelConfig, - defaultModelToDomConfig: mockedModelToDomConfig, + }; + const expectedPluginState: any = { + cache: { domIndexer: contentModelDomIndexer }, + copyPaste: { allowedCustomPasteType: [] }, format: { defaultFormat: { fontWeight: undefined, @@ -463,12 +224,13 @@ describe('createContentModelEditorCore', () => { }, pendingFormat: null, }, - contentDiv: { - style: {}, - }, - cache: { domIndexer: contentModelDomIndexer }, - copyPaste: { allowedCustomPasteType: [] }, - environment: { isMac: false, isAndroid: false }, - } as any); + }; + + expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, expectedOptions); + expect(promoteToContentModelEditorCoreSpy).toHaveBeenCalledWith( + core, + expectedOptions, + expectedPluginState + ); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts index ff1cd7a4bf5..8fa3ddd0c3f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts @@ -1,9 +1,9 @@ import * as addRangeToSelection from '../../../lib/domUtils/addRangeToSelection'; -import * as cloneModelFile from '../../../lib/publicApi/model/cloneModel'; +import * as cloneModelFile from 'roosterjs-content-model-core/lib/publicApi/model/cloneModel'; import * as contentModelToDomFile from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; import * as deleteSelectionsFile from '../../../lib/publicApi/selection/deleteSelection'; import * as extractClipboardItemsFile from 'roosterjs-editor-dom/lib/clipboard/extractClipboardItems'; -import * as iterateSelectionsFile from '../../../lib/modelApi/selection/iterateSelections'; +import * as iterateSelectionsFile from 'roosterjs-content-model-core/lib/publicApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import * as PasteFile from '../../../lib/publicApi/utils/paste'; import { createModelToDomContext } from 'roosterjs-content-model-dom'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts index 1b916ed32c8..ef22256bc4f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts @@ -1,4 +1,4 @@ -import * as iterateSelections from '../../../lib/modelApi/selection/iterateSelections'; +import * as iterateSelections from 'roosterjs-content-model-core/lib/publicApi/selection/iterateSelections'; import { applyTableFormat } from '../../../lib/modelApi/table/applyTableFormat'; import { ContentModelFormatState, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { retrieveModelFormatState } from '../../../lib/modelApi/common/retrieveModelFormatState'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts index d827fe32175..e6a9d10837a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts @@ -1,4 +1,4 @@ -import * as iterateSelections from '../../../lib/modelApi/selection/iterateSelections'; +import * as iterateSelections from 'roosterjs-content-model-core/lib/publicApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { applyPendingFormat } from '../../../lib/modelApi/format/applyPendingFormat'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts index 4bf8312389f..8f31a71a522 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts @@ -1,4 +1,4 @@ -import * as iterateSelections from '../../../lib/modelApi/selection/iterateSelections'; +import * as iterateSelections from 'roosterjs-content-model-core/lib/publicApi/selection/iterateSelections'; import { ContentModelBlock, ContentModelBlockGroup, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts index d9217e1c383..95bfbf5d206 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts @@ -1,7 +1,7 @@ import * as insertEntityModel from '../../../lib/modelApi/entity/insertEntityModel'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import insertEntity from '../../../lib/publicApi/entity/insertEntity'; -import { ChangeSource } from '../../../lib/publicTypes/ChangeSource'; +import { ChangeSource } from 'roosterjs-content-model-core'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { FormatWithContentModelContext, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts index 05057a68839..b1800c8e780 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts @@ -1,4 +1,4 @@ -import * as getSelectionRootNode from '../../../lib/modelApi/selection/getSelectionRootNode'; +import * as getSelectionRootNode from 'roosterjs-content-model-core/lib/publicApi/selection/getSelectionRootNode'; import * as retrieveModelFormatState from '../../../lib/modelApi/common/retrieveModelFormatState'; import { ContentModelFormatState, DomToModelContext } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts index f627da244a7..dafb43b835b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts @@ -1,6 +1,6 @@ import ContentModelEditor from '../../../lib/editor/ContentModelEditor'; import insertLink from '../../../lib/publicApi/link/insertLink'; -import { ChangeSource } from '../../../lib/publicTypes/ChangeSource'; +import { ChangeSource } from 'roosterjs-content-model-core'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { PluginEventType } from 'roosterjs-editor-types'; import { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/selection/getSelectedSegmentsTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/selection/getSelectedSegmentsTest.ts index 0fb7ee2c42b..2e9fa06beba 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/selection/getSelectedSegmentsTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/selection/getSelectedSegmentsTest.ts @@ -1,4 +1,4 @@ -import * as iterateSelections from '../../../lib/modelApi/selection/iterateSelections'; +import * as iterateSelections from 'roosterjs-content-model-core/lib/publicApi/selection/iterateSelections'; import getSelectedSegments from '../../../lib/publicApi/selection/getSelectedSegments'; import { ContentModelBlock, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts index a68e2f8140f..7a72552d91c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts @@ -1,5 +1,6 @@ -import { ChangeSource, deleteSelection, isModifierKey } from 'roosterjs-content-model-editor'; +import { ChangeSource } from 'roosterjs-content-model-core'; import { deleteAllSegmentBefore } from './deleteSteps/deleteAllSegmentBefore'; +import { deleteSelection, isModifierKey } from 'roosterjs-content-model-editor'; import { isNodeOfType } from 'roosterjs-content-model-dom'; import { handleKeyboardEventResult, diff --git a/packages-content-model/roosterjs-content-model-plugins/package.json b/packages-content-model/roosterjs-content-model-plugins/package.json index 1af019fc5c2..e17cccbee0c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/package.json +++ b/packages-content-model/roosterjs-content-model-plugins/package.json @@ -6,6 +6,7 @@ "roosterjs-editor-types": "", "roosterjs-editor-dom": "", "roosterjs-editor-core": "", + "roosterjs-content-model-core": "", "roosterjs-content-model-editor": "", "roosterjs-content-model-dom": "", "roosterjs-content-model-types": "" diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts index 403e30d55af..868f433ab1c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts @@ -1,10 +1,11 @@ import * as deleteSelection from 'roosterjs-content-model-editor/lib/publicApi/selection/deleteSelection'; import * as handleKeyboardEventResult from '../../lib/edit/handleKeyboardEventCommon'; -import { ChangeSource, IContentModelEditor } from 'roosterjs-content-model-editor'; +import { ChangeSource } from 'roosterjs-content-model-core'; import { ContentModelDocument, DOMSelection } from 'roosterjs-content-model-types'; import { deleteAllSegmentBefore } from '../../lib/edit/deleteSteps/deleteAllSegmentBefore'; import { DeleteResult, DeleteSelectionStep } from 'roosterjs-content-model-types'; import { editingTestCommon } from './editingTestCommon'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { keyboardDelete } from '../../lib/edit/keyboardDelete'; import { Keys } from 'roosterjs-editor-types'; import { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts index ec029e34767..c364736e4b8 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts @@ -1,8 +1,9 @@ import * as wordFile from '../../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; import { ClipboardData } from 'roosterjs-editor-types'; -import { cloneModel, IContentModelEditor, paste } from 'roosterjs-content-model-editor'; +import { cloneModel } from 'roosterjs-content-model-core'; import { DomToModelOption } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; +import { IContentModelEditor, paste } from 'roosterjs-content-model-editor'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; import { tableProcessor } from 'roosterjs-content-model-dom'; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts index 89b2b719981..025c0d7882e 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts @@ -1,10 +1,10 @@ +import { cloneModel } from 'roosterjs-content-model-core'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { ContentModelPastePlugin } from '../../../lib/paste/ContentModelPastePlugin'; import { ContentModelEditorOptions, ContentModelEditor, IContentModelEditor, - cloneModel, } from 'roosterjs-content-model-editor'; export function initEditor(id: string): IContentModelEditor { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts index 36330c28ba6..e505f48d5a0 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts @@ -6,7 +6,7 @@ import { processPastedContentWacComponents } from '../../lib/paste/WacComponents import { listItemMetadataApplier, listLevelMetadataApplier, -} from 'roosterjs-content-model-editor/lib/domUtils/metadata/updateListMetadata'; +} from 'roosterjs-content-model-core/lib/metadata/updateListMetadata'; import { contentModelToDom, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts index 1e47fe03eb2..8c063b74d74 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWordDesktopTest.ts @@ -5,7 +5,7 @@ import { processPastedContentFromWordDesktop } from '../../lib/paste/WordDesktop import { listItemMetadataApplier, listLevelMetadataApplier, -} from 'roosterjs-content-model-editor/lib/domUtils/metadata/updateListMetadata'; +} from 'roosterjs-content-model-core/lib/metadata/updateListMetadata'; import { contentModelToDom, createDomToModelContext, diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts index 51b2f142d30..8be1e5cf3d1 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts @@ -1,4 +1,4 @@ -import type { EditorCore } from 'roosterjs-editor-types'; +import type { EditorCore, SwitchShadowEdit } from 'roosterjs-editor-types'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { ContentModelPluginState } from '../pluginState/ContentModelPluginState'; import type { DOMSelection } from '../selection/DOMSelection'; @@ -125,6 +125,9 @@ export interface StandaloneCoreApiMap { * @param options More options, see FormatWithContentModelOptions */ formatContentModel: FormatContentModel; + + // TODO: This is copied from legacy editor core, will be ported to use new types later + switchShadowEdit: SwitchShadowEdit; } /** From 741394dca8a4a2db0673f4799e128555d1c3f8da Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 9 Nov 2023 21:33:43 -0800 Subject: [PATCH 044/111] Move corePlugins to roosterjs-content-model-core package (#2199) * Move ContentModelEdit plugin to plugins package * Move types to roosterjs-content-model-types package * improve * Improve * improve * Improve * improve * Move core API to core package * fix build * improve * Move corePlugins to roosterjs-content-model-core package * fix build * improve * fix build * fix build --- .../formatPart/BorderFormatRenderers.ts | 4 +- .../corePlugin}/ContentModelCachePlugin.ts | 17 +++--- .../ContentModelCopyPastePlugin.ts | 21 ++++---- .../corePlugin}/ContentModelFormatPlugin.ts | 19 +++---- .../ContentModelTypeInContainerPlugin.ts | 2 +- .../corePlugin/utils}/addRangeToSelection.ts | 0 .../corePlugin/utils}/applyDefaultFormat.ts | 8 +-- .../corePlugin/utils}/applyPendingFormat.ts | 7 ++- .../lib/corePlugin/utils/areSameSelection.ts} | 2 +- .../utils/contentModelDomIndexer.ts | 2 +- .../editor/createContentModelEditorCore.ts | 32 +++++------ .../roosterjs-content-model-core/lib/index.ts | 35 ++++++++++++ .../modelApi/edit}/deleteExpandedSelection.ts | 28 ++++++++-- .../lib/modelApi/edit}/deleteSingleChar.ts | 0 .../lib/publicApi}/domUtils/borderValues.ts | 0 .../lib/publicApi}/domUtils/eventUtils.ts | 0 .../lib/publicApi}/domUtils/stringUtil.ts | 0 .../getClosestAncestorBlockGroupIndex.ts | 7 ++- .../publicApi/model}/isBlockGroupOfType.ts | 4 +- .../lib/publicApi/model}/mergeModel.ts | 2 - .../lib/publicApi/model}/paste.ts | 18 +++---- .../publicApi}/selection/collectSelections.ts | 50 +++++++++++++---- .../lib/publicApi/selection}/deleteBlock.ts | 0 .../lib/publicApi/selection}/deleteSegment.ts | 4 +- .../publicApi/selection/deleteSelection.ts | 2 +- .../lib/publicApi}/selection/setSelection.ts | 5 +- .../lib/publicApi}/table/applyTableFormat.ts | 10 ++-- .../lib/publicApi}/table/normalizeTable.ts | 10 +++- .../table/setTableCellBackgroundColor.ts | 8 ++- .../roosterjs-content-model-core/package.json | 2 + .../ContentModelCachePluginTest.ts | 10 ++-- .../ContentModelCopyPastePluginTest.ts | 23 ++++---- .../ContentModelFormatPluginTest.ts | 33 ++++++------ .../utils}/applyDefaultFormatTest.ts | 7 +-- .../utils}/applyPendingFormatTest.ts | 17 +++--- .../corePlugin/utils}/areSameRangeExTest.ts | 6 +-- .../utils/contentModelDomIndexerTest.ts | 4 +- .../createContentModelEditorCoreTest.ts | 17 +++--- .../modelApi/edit}/deleteSingleCharTest.ts | 2 +- .../publicApi}/domUtils/borderValuesTest.ts | 5 +- .../getClosestAncestorBlockGroupIndexTest.ts | 2 +- .../model}/isBlockGroupOfTypeTest.ts | 2 +- .../test/publicApi/model}/mergeModelTest.ts | 6 +-- .../test/publicApi/model}/pasteTest.ts | 53 ++++++++++--------- .../selection/collectSelectionsTest.ts | 4 +- .../selection}/deleteSelectionTest.ts | 0 .../selection/getSelectedSegmentsTest.ts | 4 +- .../publicApi}/selection/setSelectionTest.ts | 2 +- .../publicApi}/table/applyTableFormatTest.ts | 2 +- .../publicApi}/table/normalizeTableTest.ts | 2 +- .../table/setTableCellBackgroundColorTest.ts | 2 +- .../lib/editor/ContentModelEditor.ts | 2 +- .../lib/index.ts | 15 ------ .../lib/modelApi/block/setModelAlignment.ts | 2 +- .../lib/modelApi/block/setModelDirection.ts | 3 +- .../lib/modelApi/block/setModelIndentation.ts | 3 +- .../modelApi/block/toggleModelBlockQuote.ts | 5 +- .../lib/modelApi/common/clearModelFormat.ts | 4 +- .../common/retrieveModelFormatState.ts | 9 ++-- .../modelApi/edit/utils/createInsertPoint.ts | 24 --------- .../lib/modelApi/entity/insertEntityModel.ts | 8 +-- .../modelApi/image/applyImageBorderFormat.ts | 2 +- .../lib/modelApi/list/setListType.ts | 3 +- .../selection/adjustSegmentSelection.ts | 3 +- .../modelApi/selection/adjustWordSelection.ts | 3 +- .../lib/publicApi/image/insertImage.ts | 2 +- .../lib/publicApi/link/adjustLinkSelection.ts | 3 +- .../lib/publicApi/link/insertLink.ts | 4 +- .../lib/publicApi/link/removeLink.ts | 2 +- .../lib/publicApi/list/setListStartNumber.ts | 2 +- .../lib/publicApi/list/setListStyle.ts | 3 +- .../publicApi/segment/setBackgroundColor.ts | 2 +- .../selection/getSelectedSegments.ts | 12 ----- .../publicApi/table/applyTableBorderFormat.ts | 8 +-- .../lib/publicApi/table/editTable.ts | 10 ++-- .../lib/publicApi/table/formatTable.ts | 8 +-- .../lib/publicApi/table/insertTable.ts | 12 +++-- .../lib/publicApi/table/setTableCellShade.ts | 8 +-- .../utils/formatParagraphWithContentModel.ts | 2 +- .../utils/formatSegmentWithContentModel.ts | 2 +- .../common/retrieveModelFormatStateTest.ts | 2 +- .../test/publicApi/block/setAlignmentTest.ts | 2 +- .../table/applyTableBorderFormatTest.ts | 2 +- .../publicApi/table/setTableCellShadeTest.ts | 2 +- .../deleteSteps/deleteAllSegmentBefore.ts | 2 +- .../deleteSteps/deleteCollapsedSelection.ts | 2 +- .../edit/deleteSteps/deleteWordSelection.ts | 2 +- .../lib/edit/keyboardDelete.ts | 3 +- .../deleteCollapsedSelectionTest.ts | 2 +- .../deleteSteps/deleteWordSelectionTest.ts | 2 +- .../test/edit/keyboardDeleteTest.ts | 2 +- .../paste/e2e/cmPasteFromExcelOnlineTest.ts | 3 +- .../test/paste/e2e/cmPasteFromExcelTest.ts | 3 +- .../test/paste/e2e/cmPasteFromWacTest.ts | 3 +- .../test/paste/e2e/cmPasteFromWordTest.ts | 4 +- .../test/paste/e2e/cmPasteTest.ts | 3 +- 96 files changed, 391 insertions(+), 316 deletions(-) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/corePlugins => roosterjs-content-model-core/lib/corePlugin}/ContentModelCachePlugin.ts (91%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/corePlugins => roosterjs-content-model-core/lib/corePlugin}/ContentModelCopyPastePlugin.ts (92%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/corePlugins => roosterjs-content-model-core/lib/corePlugin}/ContentModelFormatPlugin.ts (89%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor/corePlugins => roosterjs-content-model-core/lib/corePlugin}/ContentModelTypeInContainerPlugin.ts (91%) rename packages-content-model/{roosterjs-content-model-editor/lib/domUtils => roosterjs-content-model-core/lib/corePlugin/utils}/addRangeToSelection.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi/format => roosterjs-content-model-core/lib/corePlugin/utils}/applyDefaultFormat.ts (94%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi/format => roosterjs-content-model-core/lib/corePlugin/utils}/applyPendingFormat.ts (90%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi/selection/areSameRangeEx.ts => roosterjs-content-model-core/lib/corePlugin/utils/areSameSelection.ts} (92%) rename packages-content-model/{roosterjs-content-model-editor/lib/editor => roosterjs-content-model-core/lib/corePlugin}/utils/contentModelDomIndexer.ts (99%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-core}/lib/editor/createContentModelEditorCore.ts (66%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi/edit/utils => roosterjs-content-model-core/lib/modelApi/edit}/deleteExpandedSelection.ts (87%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi/edit/utils => roosterjs-content-model-core/lib/modelApi/edit}/deleteSingleChar.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib => roosterjs-content-model-core/lib/publicApi}/domUtils/borderValues.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib => roosterjs-content-model-core/lib/publicApi}/domUtils/eventUtils.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib => roosterjs-content-model-core/lib/publicApi}/domUtils/stringUtil.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi/common => roosterjs-content-model-core/lib/publicApi/model}/getClosestAncestorBlockGroupIndex.ts (76%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi/common => roosterjs-content-model-core/lib/publicApi/model}/isBlockGroupOfType.ts (74%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi/common => roosterjs-content-model-core/lib/publicApi/model}/mergeModel.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/lib/publicApi/utils => roosterjs-content-model-core/lib/publicApi/model}/paste.ts (94%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi => roosterjs-content-model-core/lib/publicApi}/selection/collectSelections.ts (77%) rename packages-content-model/{roosterjs-content-model-editor/lib/publicApi/block => roosterjs-content-model-core/lib/publicApi/selection}/deleteBlock.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib/publicApi/segment => roosterjs-content-model-core/lib/publicApi/selection}/deleteSegment.ts (96%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-core}/lib/publicApi/selection/deleteSelection.ts (95%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi => roosterjs-content-model-core/lib/publicApi}/selection/setSelection.ts (92%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi => roosterjs-content-model-core/lib/publicApi}/table/applyTableFormat.ts (93%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi => roosterjs-content-model-core/lib/publicApi}/table/normalizeTable.ts (91%) rename packages-content-model/{roosterjs-content-model-editor/lib/modelApi => roosterjs-content-model-core/lib/publicApi}/table/setTableCellBackgroundColor.ts (89%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-core/test/corePlugin}/ContentModelCachePluginTest.ts (97%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/plugins => roosterjs-content-model-core/test/corePlugin}/ContentModelCopyPastePluginTest.ts (96%) rename packages-content-model/{roosterjs-content-model-editor/test/editor/corePlugins => roosterjs-content-model-core/test/corePlugin}/ContentModelFormatPluginTest.ts (95%) rename packages-content-model/{roosterjs-content-model-editor/test/modelApi/format => roosterjs-content-model-core/test/corePlugin/utils}/applyDefaultFormatTest.ts (98%) rename packages-content-model/{roosterjs-content-model-editor/test/modelApi/format => roosterjs-content-model-core/test/corePlugin/utils}/applyPendingFormatTest.ts (95%) rename packages-content-model/{roosterjs-content-model-editor/test/modelApi/selection => roosterjs-content-model-core/test/corePlugin/utils}/areSameRangeExTest.ts (97%) rename packages-content-model/{roosterjs-content-model-editor/test/editor => roosterjs-content-model-core/test/corePlugin}/utils/contentModelDomIndexerTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-core}/test/editor/createContentModelEditorCoreTest.ts (90%) rename packages-content-model/{roosterjs-content-model-editor/test/modelApi/edit/utils => roosterjs-content-model-core/test/modelApi/edit}/deleteSingleCharTest.ts (94%) rename packages-content-model/{roosterjs-content-model-editor/test => roosterjs-content-model-core/test/publicApi}/domUtils/borderValuesTest.ts (94%) rename packages-content-model/{roosterjs-content-model-editor/test/modelApi/common => roosterjs-content-model-core/test/publicApi/model}/getClosestAncestorBlockGroupIndexTest.ts (98%) rename packages-content-model/{roosterjs-content-model-editor/test/modelApi/common => roosterjs-content-model-core/test/publicApi/model}/isBlockGroupOfTypeTest.ts (92%) rename packages-content-model/{roosterjs-content-model-editor/test/modelApi/common => roosterjs-content-model-core/test/publicApi/model}/mergeModelTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/test/publicApi/utils => roosterjs-content-model-core/test/publicApi/model}/pasteTest.ts (95%) rename packages-content-model/{roosterjs-content-model-editor/test/modelApi => roosterjs-content-model-core/test/publicApi}/selection/collectSelectionsTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/test/modelApi/edit => roosterjs-content-model-core/test/publicApi/selection}/deleteSelectionTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-core}/test/publicApi/selection/getSelectedSegmentsTest.ts (95%) rename packages-content-model/{roosterjs-content-model-editor/test/modelApi => roosterjs-content-model-core/test/publicApi}/selection/setSelectionTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/test/modelApi => roosterjs-content-model-core/test/publicApi}/table/applyTableFormatTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/test/modelApi => roosterjs-content-model-core/test/publicApi}/table/normalizeTableTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor/test/modelApi => roosterjs-content-model-core/test/publicApi}/table/setTableCellBackgroundColorTest.ts (98%) delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/createInsertPoint.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/getSelectedSegments.ts diff --git a/demo/scripts/controls/contentModel/components/format/formatPart/BorderFormatRenderers.ts b/demo/scripts/controls/contentModel/components/format/formatPart/BorderFormatRenderers.ts index 40148b7f1e5..14091d480e0 100644 --- a/demo/scripts/controls/contentModel/components/format/formatPart/BorderFormatRenderers.ts +++ b/demo/scripts/controls/contentModel/components/format/formatPart/BorderFormatRenderers.ts @@ -1,8 +1,8 @@ +import { BorderFormat } from 'roosterjs-content-model-types'; +import { combineBorderValue, extractBorderValues } from 'roosterjs-content-model-core'; import { createDropDownFormatRenderer } from '../utils/createDropDownFormatRenderer'; import { createTextFormatRenderer } from '../utils/createTextFormatRenderer'; import { FormatRenderer } from '../utils/FormatRenderer'; -import { BorderFormat } from 'roosterjs-content-model-types'; -import { combineBorderValue, extractBorderValues } from 'roosterjs-content-model-editor'; type BorderStyle = | 'dashed' diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCachePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts similarity index 91% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCachePlugin.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts index 24238809db8..107e36a6b15 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCachePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts @@ -1,11 +1,11 @@ -import { areSameRangeEx } from '../../modelApi/selection/areSameRangeEx'; -import { isCharacterValue } from '../../domUtils/eventUtils'; +import { areSameSelection } from './utils/areSameSelection'; +import { isCharacterValue } from '../publicApi/domUtils/eventUtils'; import { PluginEventType } from 'roosterjs-editor-types'; import type { ContentModelCachePluginState, ContentModelContentChangedEvent, + IStandaloneEditor, } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { IEditor, PluginEvent, @@ -16,9 +16,8 @@ import type { /** * ContentModel cache plugin manages cached Content Model, and refresh the cache when necessary */ -export default class ContentModelCachePlugin - implements PluginWithState { - private editor: IContentModelEditor | null = null; +export class ContentModelCachePlugin implements PluginWithState { + private editor: (IEditor & IStandaloneEditor) | null = null; /** * Construct a new instance of ContentModelEditPlugin class @@ -43,7 +42,7 @@ export default class ContentModelCachePlugin */ initialize(editor: IEditor) { // TODO: Later we may need a different interface for Content Model editor plugin - this.editor = editor as IContentModelEditor; + this.editor = editor as IEditor & IStandaloneEditor; this.editor.getDocument().addEventListener('selectionchange', this.onNativeSelectionChange); } @@ -125,7 +124,7 @@ export default class ContentModelCachePlugin } } - private updateCachedModel(editor: IContentModelEditor, forceUpdate?: boolean) { + private updateCachedModel(editor: IStandaloneEditor, forceUpdate?: boolean) { const cachedSelection = this.state.cachedSelection; this.state.cachedSelection = undefined; // Clear it to force getDOMSelection() retrieve the latest selection range @@ -135,7 +134,7 @@ export default class ContentModelCachePlugin forceUpdate || !cachedSelection || !newRangeEx || - !areSameRangeEx(newRangeEx, cachedSelection); + !areSameSelection(newRangeEx, cachedSelection); if (isSelectionChanged) { if ( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts similarity index 92% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts index a9138f480f1..c0ce1892653 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts @@ -1,9 +1,11 @@ -import paste from '../../publicApi/utils/paste'; -import { addRangeToSelection } from '../../domUtils/addRangeToSelection'; -import { ChangeSource, cloneModel, iterateSelections } from 'roosterjs-content-model-core'; +import { addRangeToSelection } from './utils/addRangeToSelection'; +import { ChangeSource } from '../constants/ChangeSource'; +import { cloneModel } from '../publicApi/model/cloneModel'; import { ColorTransformDirection, PluginEventType } from 'roosterjs-editor-types'; -import { deleteSelection } from '../../publicApi/selection/deleteSelection'; +import { deleteSelection } from '../publicApi/selection/deleteSelection'; import { extractClipboardItems } from 'roosterjs-editor-dom'; +import { iterateSelections } from '../publicApi/selection/iterateSelections'; +import { paste } from '../publicApi/model/paste'; import { contentModelToDom, createModelToDomContext, @@ -14,8 +16,7 @@ import { toArray, wrap, } from 'roosterjs-content-model-dom'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import type { DOMSelection, OnNodeCreated } from 'roosterjs-content-model-types'; +import type { DOMSelection, IStandaloneEditor, OnNodeCreated } from 'roosterjs-content-model-types'; import type { CopyPastePluginState, IEditor, @@ -26,8 +27,8 @@ import type { /** * Copy and paste plugin for handling onCopy and onPaste event */ -export default class ContentModelCopyPastePlugin implements PluginWithState { - private editor: IContentModelEditor | null = null; +export class ContentModelCopyPastePlugin implements PluginWithState { + private editor: (IStandaloneEditor & IEditor) | null = null; private disposer: (() => void) | null = null; /** @@ -48,7 +49,7 @@ export default class ContentModelCopyPastePlugin implements PluginWithState this.onPaste(e), copy: e => this.onCutCopy(e, false /*isCut*/), @@ -149,7 +150,7 @@ export default class ContentModelCopyPastePlugin implements PluginWithState { - const editor = e as IContentModelEditor; + const editor = e as IStandaloneEditor & IEditor; cleanUpAndRestoreSelection(tempDiv); editor.focus(); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts similarity index 89% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts index eefe272a2b5..c5f516e1d9a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts @@ -1,11 +1,13 @@ -import { applyDefaultFormat } from '../../modelApi/format/applyDefaultFormat'; -import { applyPendingFormat } from '../../modelApi/format/applyPendingFormat'; +import { applyDefaultFormat } from './utils/applyDefaultFormat'; +import { applyPendingFormat } from './utils/applyPendingFormat'; import { getObjectKeys } from 'roosterjs-content-model-dom'; -import { isCharacterValue } from '../../domUtils/eventUtils'; +import { isCharacterValue } from '../publicApi/domUtils/eventUtils'; import { PluginEventType } from 'roosterjs-editor-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { IEditor, PluginEvent, PluginWithState } from 'roosterjs-editor-types'; -import type { ContentModelFormatPluginState } from 'roosterjs-content-model-types'; +import type { + ContentModelFormatPluginState, + IStandaloneEditor, +} from 'roosterjs-content-model-types'; // During IME input, KeyDown event will have "Process" as key const ProcessKey = 'Process'; @@ -25,9 +27,8 @@ const CursorMovingKeys = new Set([ * This includes: * 1. Handle pending format changes when selection is collapsed */ -export default class ContentModelFormatPlugin - implements PluginWithState { - private editor: IContentModelEditor | null = null; +export class ContentModelFormatPlugin implements PluginWithState { + private editor: (IStandaloneEditor & IEditor) | null = null; private hasDefaultFormat = false; /** @@ -53,7 +54,7 @@ export default class ContentModelFormatPlugin */ initialize(editor: IEditor) { // TODO: Later we may need a different interface for Content Model editor plugin - this.editor = editor as IContentModelEditor; + this.editor = editor as IStandaloneEditor & IEditor; this.hasDefaultFormat = getObjectKeys(this.state.defaultFormat).filter( x => typeof this.state.defaultFormat[x] !== 'undefined' diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelTypeInContainerPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelTypeInContainerPlugin.ts similarity index 91% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelTypeInContainerPlugin.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelTypeInContainerPlugin.ts index db7747187be..a128c862f50 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelTypeInContainerPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelTypeInContainerPlugin.ts @@ -3,7 +3,7 @@ import type { EditorPlugin } from 'roosterjs-editor-types'; /** * Dummy plugin, just to skip original TypeInContainerPlugin's behavior */ -export default class ContentModelTypeInContainerPlugin implements EditorPlugin { +export class ContentModelTypeInContainerPlugin implements EditorPlugin { /** * Get name of this plugin */ diff --git a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/addRangeToSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/addRangeToSelection.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/domUtils/addRangeToSelection.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/addRangeToSelection.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts similarity index 94% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts index 3df6e54049a..3ced34cc1fb 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyDefaultFormat.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts @@ -1,7 +1,7 @@ import { deleteSelection } from '../../publicApi/selection/deleteSelection'; import { isBlockElement, isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; -import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IEditor } from 'roosterjs-editor-types'; +import type { ContentModelSegmentFormat, IStandaloneEditor } from 'roosterjs-content-model-types'; /** * @internal @@ -10,7 +10,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' * @param defaultFormat The default segment format to apply */ export function applyDefaultFormat( - editor: IContentModelEditor, + editor: IStandaloneEditor & IEditor, defaultFormat: ContentModelSegmentFormat ) { const selection = editor.getDOMSelection(); @@ -92,7 +92,7 @@ export function applyDefaultFormat( } function getNewPendingFormat( - editor: IContentModelEditor, + editor: IStandaloneEditor, defaultFormat: ContentModelSegmentFormat, markerFormat: ContentModelSegmentFormat ): ContentModelSegmentFormat { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyPendingFormat.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyPendingFormat.ts similarity index 90% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyPendingFormat.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyPendingFormat.ts index 093ac7b93f6..d16e0c501f8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/format/applyPendingFormat.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/applyPendingFormat.ts @@ -1,6 +1,5 @@ -import { iterateSelections } from 'roosterjs-content-model-core'; -import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import { iterateSelections } from '../../publicApi/selection/iterateSelections'; +import type { ContentModelSegmentFormat, IStandaloneEditor } from 'roosterjs-content-model-types'; import { createText, normalizeContentModel, @@ -17,7 +16,7 @@ const NON_BREAK_SPACE = '\u00A0'; * @param data The text user just input */ export function applyPendingFormat( - editor: IContentModelEditor, + editor: IStandaloneEditor, data: string, format: ContentModelSegmentFormat ) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/areSameRangeEx.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/areSameSelection.ts similarity index 92% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/areSameRangeEx.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/areSameSelection.ts index 7cdd8dca1ef..e4ca1ad1965 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/areSameRangeEx.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/areSameSelection.ts @@ -4,7 +4,7 @@ import type { DOMSelection } from 'roosterjs-content-model-types'; * @internal * Check if the given selections are the same */ -export function areSameRangeEx(sel1: DOMSelection, sel2: DOMSelection): boolean { +export function areSameSelection(sel1: DOMSelection, sel2: DOMSelection): boolean { if (sel1 == sel2) { return true; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/contentModelDomIndexer.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/contentModelDomIndexer.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/utils/contentModelDomIndexer.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/contentModelDomIndexer.ts index cea02e14a44..4b15da73392 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/contentModelDomIndexer.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/contentModelDomIndexer.ts @@ -1,5 +1,5 @@ import { createSelectionMarker, createText, isNodeOfType } from 'roosterjs-content-model-dom'; -import { setSelection } from '../../modelApi/selection/setSelection'; +import { setSelection } from '../../publicApi/selection/setSelection'; import type { ContentModelDocument, ContentModelDomIndexer, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createContentModelEditorCore.ts similarity index 66% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts rename to packages-content-model/roosterjs-content-model-core/lib/editor/createContentModelEditorCore.ts index 9f9aa638c80..e847a849fd9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createContentModelEditorCore.ts @@ -1,24 +1,26 @@ -import ContentModelTypeInContainerPlugin from './corePlugins/ContentModelTypeInContainerPlugin'; -import { contentModelDomIndexer } from './utils/contentModelDomIndexer'; -import { createContentModelCachePlugin } from './corePlugins/ContentModelCachePlugin'; -import { createContentModelCopyPastePlugin } from './corePlugins/ContentModelCopyPastePlugin'; -import { createContentModelFormatPlugin } from './corePlugins/ContentModelFormatPlugin'; +import { contentModelDomIndexer } from '../corePlugin/utils/contentModelDomIndexer'; +import { ContentModelTypeInContainerPlugin } from '../corePlugin/ContentModelTypeInContainerPlugin'; +import { createContentModelCachePlugin } from '../corePlugin/ContentModelCachePlugin'; +import { createContentModelCopyPastePlugin } from '../corePlugin/ContentModelCopyPastePlugin'; +import { createContentModelFormatPlugin } from '../corePlugin/ContentModelFormatPlugin'; import { createEditorCore } from 'roosterjs-editor-core'; -import { promoteToContentModelEditorCore } from 'roosterjs-content-model-core'; -import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; -import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; -import type { CoreCreator } from 'roosterjs-editor-types'; -import type { ContentModelPluginState } from 'roosterjs-content-model-types'; +import { promoteToContentModelEditorCore } from './promoteToContentModelEditorCore'; +import type { CoreCreator, EditorCore, EditorOptions } from 'roosterjs-editor-types'; +import type { + ContentModelPluginState, + StandaloneEditorCore, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; /** * Editor Core creator for Content Model editor */ export const createContentModelEditorCore: CoreCreator< - ContentModelEditorCore, - ContentModelEditorOptions + EditorCore & StandaloneEditorCore, + EditorOptions & StandaloneEditorOptions > = (contentDiv, options) => { const pluginState = getPluginState(options); - const modifiedOptions: ContentModelEditorOptions = { + const modifiedOptions: EditorOptions & StandaloneEditorOptions = { ...options, plugins: [ createContentModelCachePlugin(pluginState.cache), @@ -32,14 +34,14 @@ export const createContentModelEditorCore: CoreCreator< }, }; - const core = createEditorCore(contentDiv, modifiedOptions) as ContentModelEditorCore; + const core = createEditorCore(contentDiv, modifiedOptions) as EditorCore & StandaloneEditorCore; promoteToContentModelEditorCore(core, modifiedOptions, pluginState); return core; }; -function getPluginState(options: ContentModelEditorOptions): ContentModelPluginState { +function getPluginState(options: EditorOptions & StandaloneEditorOptions): ContentModelPluginState { const format = options.defaultFormat || {}; return { cache: { diff --git a/packages-content-model/roosterjs-content-model-core/lib/index.ts b/packages-content-model/roosterjs-content-model-core/lib/index.ts index 16ec47baf8c..a22614c4e03 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/index.ts @@ -1,10 +1,39 @@ export { CachedElementHandler, CloneModelOptions, cloneModel } from './publicApi/model/cloneModel'; +export { paste } from './publicApi/model/paste'; +export { mergeModel, MergeModelOption } from './publicApi/model/mergeModel'; +export { isBlockGroupOfType } from './publicApi/model/isBlockGroupOfType'; +export { + getClosestAncestorBlockGroupIndex, + TypeOfBlockGroup, +} from './publicApi/model/getClosestAncestorBlockGroupIndex'; + export { iterateSelections, IterateSelectionsCallback, IterateSelectionsOption, } from './publicApi/selection/iterateSelections'; export { getSelectionRootNode } from './publicApi/selection/getSelectionRootNode'; +export { deleteSelection } from './publicApi/selection/deleteSelection'; +export { deleteSegment } from './publicApi/selection/deleteSegment'; +export { deleteBlock } from './publicApi/selection/deleteBlock'; +export { + OperationalBlocks, + getFirstSelectedListItem, + getFirstSelectedTable, + getOperationalBlocks, + getSelectedParagraphs, + getSelectedSegments, + getSelectedSegmentsAndParagraphs, +} from './publicApi/selection/collectSelections'; +export { setSelection } from './publicApi/selection/setSelection'; + +export { applyTableFormat } from './publicApi/table/applyTableFormat'; +export { normalizeTable } from './publicApi/table/normalizeTable'; +export { setTableCellBackgroundColor } from './publicApi/table/setTableCellBackgroundColor'; + +export { isCharacterValue, isModifierKey } from './publicApi/domUtils/eventUtils'; +export { combineBorderValue, extractBorderValues } from './publicApi/domUtils/borderValues'; +export { isPunctuation, isSpace, normalizeText } from './publicApi/domUtils/stringUtil'; export { updateImageMetadata } from './metadata/updateImageMetadata'; export { updateTableCellMetadata } from './metadata/updateTableCellMetadata'; @@ -12,4 +41,10 @@ export { updateTableMetadata } from './metadata/updateTableMetadata'; export { updateListMetadata } from './metadata/updateListMetadata'; export { promoteToContentModelEditorCore } from './editor/promoteToContentModelEditorCore'; +export { createContentModelEditorCore } from './editor/createContentModelEditorCore'; export { ChangeSource } from './constants/ChangeSource'; + +export { ContentModelCachePlugin } from './corePlugin/ContentModelCachePlugin'; +export { ContentModelCopyPastePlugin } from './corePlugin/ContentModelCopyPastePlugin'; +export { ContentModelFormatPlugin } from './corePlugin/ContentModelFormatPlugin'; +export { ContentModelTypeInContainerPlugin } from './corePlugin/ContentModelTypeInContainerPlugin'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/modelApi/edit/deleteExpandedSelection.ts similarity index 87% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts rename to packages-content-model/roosterjs-content-model-core/lib/modelApi/edit/deleteExpandedSelection.ts index 6685245d6f8..36191b7b502 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/modelApi/edit/deleteExpandedSelection.ts @@ -1,12 +1,16 @@ -import { createInsertPoint } from '../utils/createInsertPoint'; -import { deleteBlock } from '../../../publicApi/block/deleteBlock'; -import { deleteSegment } from '../../../publicApi/segment/deleteSegment'; -import { iterateSelections } from 'roosterjs-content-model-core'; -import type { IterateSelectionsOption } from 'roosterjs-content-model-core'; +import { deleteBlock } from '../../publicApi/selection/deleteBlock'; +import { deleteSegment } from '../../publicApi/selection/deleteSegment'; +import { iterateSelections } from '../../publicApi/selection/iterateSelections'; +import type { IterateSelectionsOption } from '../../publicApi/selection/iterateSelections'; import type { + ContentModelBlockGroup, ContentModelDocument, + ContentModelParagraph, + ContentModelSelectionMarker, DeleteSelectionContext, FormatWithContentModelContext, + InsertPoint, + TableSelectionContext, } from 'roosterjs-content-model-types'; import { createBr, @@ -123,3 +127,17 @@ export function deleteExpandedSelection( return context; } + +function createInsertPoint( + marker: ContentModelSelectionMarker, + paragraph: ContentModelParagraph, + path: ContentModelBlockGroup[], + tableContext: TableSelectionContext | undefined +): InsertPoint { + return { + marker, + paragraph, + path, + tableContext, + }; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSingleChar.ts b/packages-content-model/roosterjs-content-model-core/lib/modelApi/edit/deleteSingleChar.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSingleChar.ts rename to packages-content-model/roosterjs-content-model-core/lib/modelApi/edit/deleteSingleChar.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/borderValues.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/borderValues.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/domUtils/borderValues.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/borderValues.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/eventUtils.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/eventUtils.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/domUtils/eventUtils.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/eventUtils.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/stringUtil.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/stringUtil.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/domUtils/stringUtil.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/stringUtil.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/getClosestAncestorBlockGroupIndex.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/getClosestAncestorBlockGroupIndex.ts similarity index 76% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/getClosestAncestorBlockGroupIndex.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/model/getClosestAncestorBlockGroupIndex.ts index 2180768f52d..19823cdc894 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/getClosestAncestorBlockGroupIndex.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/getClosestAncestorBlockGroupIndex.ts @@ -5,14 +5,17 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * Retrieve block group type string from a given block group */ export type TypeOfBlockGroup< T extends ContentModelBlockGroup > = T extends ContentModelBlockGroupBase ? U : never; /** - * @internal + * Get index of closest ancestor block group of the expected block group type. If not found, return -1 + * @param path The block group path, from the closest one to root + * @param blockGroupTypes The expected block group types + * @param stopTypes @optional Block group types that will cause stop searching */ export function getClosestAncestorBlockGroupIndex( path: ContentModelBlockGroup[], diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/isBlockGroupOfType.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/isBlockGroupOfType.ts similarity index 74% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/isBlockGroupOfType.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/model/isBlockGroupOfType.ts index da24115d6dc..8c4c61003ee 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/isBlockGroupOfType.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/isBlockGroupOfType.ts @@ -2,7 +2,9 @@ import type { ContentModelBlock, ContentModelBlockGroup } from 'roosterjs-conten import type { TypeOfBlockGroup } from './getClosestAncestorBlockGroupIndex'; /** - * @internal + * Check if the given content model block or block group is of the expected block group type + * @param input The object to check + * @param type The expected type */ export function isBlockGroupOfType( input: ContentModelBlock | ContentModelBlockGroup | null | undefined, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/mergeModel.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/model/mergeModel.ts index d435710efcf..abb7648dd89 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/mergeModel.ts @@ -27,7 +27,6 @@ import type { const HeadingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; /** - * @internal * Options to specify how to merge models */ export interface MergeModelOption { @@ -57,7 +56,6 @@ export interface MergeModelOption { } /** - * @internal * Merge source model into target mode * @param target Target Content Model that will merge content into * @param source Source Content Model will be merged to target model diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts similarity index 94% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts index f66cf488248..3a9804013c4 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts @@ -1,7 +1,7 @@ -import getSelectedSegments from '../selection/getSelectedSegments'; -import { ChangeSource } from 'roosterjs-content-model-core'; +import { ChangeSource } from '../../constants/ChangeSource'; import { GetContentMode, PasteType as OldPasteType, PluginEventType } from 'roosterjs-editor-types'; -import { mergeModel } from '../../modelApi/common/mergeModel'; +import { getSelectedSegments } from '../selection/collectSelections'; +import { mergeModel } from './mergeModel'; import type { ContentModelDocument, ContentModelSegmentFormat, @@ -10,9 +10,9 @@ import type { PasteType, ContentModelBeforePasteEventData, ContentModelBeforePasteEvent, + IStandaloneEditor, } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import type { ClipboardData } from 'roosterjs-editor-types'; +import type { ClipboardData, IEditor } from 'roosterjs-editor-types'; import { applySegmentFormatToElement, createDomToModelContext, @@ -55,8 +55,8 @@ const EmptySegmentFormat: Required = { * @param clipboardData Clipboard data retrieved from clipboard * @param pasteType Type of content to paste. @default normal */ -export default function paste( - editor: IContentModelEditor, +export function paste( + editor: IStandaloneEditor & IEditor, clipboardData: ClipboardData, pasteType: PasteType = 'normal' ) { @@ -157,7 +157,7 @@ function shouldMergeTable(pasteModel: ContentModelDocument): boolean | undefined } function createBeforePasteEventData( - editor: IContentModelEditor, + editor: IEditor, clipboardData: ClipboardData, pasteType: PasteType ): ContentModelBeforePasteEventData { @@ -183,7 +183,7 @@ function createBeforePasteEventData( * This function will also create a DocumentFragment for paste. */ function triggerPluginEventAndCreatePasteFragment( - editor: IContentModelEditor, + editor: IEditor, clipboardData: ClipboardData, pasteType: PasteType, eventData: ContentModelBeforePasteEventData, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/collectSelections.ts similarity index 77% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/collectSelections.ts index 7180f208819..bf9ce4cd9b8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/collectSelections.ts @@ -1,7 +1,7 @@ -import { getClosestAncestorBlockGroupIndex } from '../common/getClosestAncestorBlockGroupIndex'; -import { isBlockGroupOfType } from '../common/isBlockGroupOfType'; -import { iterateSelections } from 'roosterjs-content-model-core'; -import type { IterateSelectionsOption } from 'roosterjs-content-model-core'; +import { getClosestAncestorBlockGroupIndex } from '../model/getClosestAncestorBlockGroupIndex'; +import { isBlockGroupOfType } from '../model/isBlockGroupOfType'; +import { iterateSelections } from './iterateSelections'; +import type { IterateSelectionsOption } from './iterateSelections'; import type { ContentModelBlock, ContentModelBlockGroup, @@ -13,18 +13,27 @@ import type { ContentModelTable, TableSelectionContext, } from 'roosterjs-content-model-types'; -import type { TypeOfBlockGroup } from '../common/getClosestAncestorBlockGroupIndex'; +import type { TypeOfBlockGroup } from '../model/getClosestAncestorBlockGroupIndex'; /** - * @internal + * Represent a pair of parent block group and child block */ export type OperationalBlocks = { + /** + * The parent block group + */ parent: ContentModelBlockGroup; + + /** + * The child block + */ block: ContentModelBlock | T; }; /** - * @internal + * Get an array of selected parent paragraph and child segment pair + * @param model The Content Model to get selection from + * @param includingFormatHolder True means also include format holder as segment from list item, in that case paragraph will be null */ export function getSelectedSegmentsAndParagraphs( model: ContentModelDocument, @@ -49,7 +58,20 @@ export function getSelectedSegmentsAndParagraphs( } /** - * @internal + * Get an array of selected segments from a content model + * @param model The Content Model to get selection from + * @param includingFormatHolder True means also include format holder as segment from list item + */ +export function getSelectedSegments( + model: ContentModelDocument, + includingFormatHolder: boolean +): ContentModelSegment[] { + return getSelectedSegmentsAndParagraphs(model, includingFormatHolder).map(x => x[0]); +} + +/** + * Get any array of selected paragraphs from a content model + * @param model The Content Model to get selection from */ export function getSelectedParagraphs(model: ContentModelDocument): ContentModelParagraph[] { const selections = collectSelections(model, { includeListFormatHolder: 'never' }); @@ -67,7 +89,11 @@ export function getSelectedParagraphs(model: ContentModelDocument): ContentModel } /** - * @internal + * Get an array of block group - block pair that is of the expected block group type from selection + * @param model The Content Model to get selection from + * @param blockGroupTypes The expected block group types + * @param stopTypes Block group types that will stop searching when hit + * @param deepFirst True means search in deep first, otherwise wide first */ export function getOperationalBlocks( model: ContentModelDocument, @@ -110,7 +136,8 @@ export function getOperationalBlocks( } /** - * @internal + * Get the first selected table from content model + * @param model The Content Model to get selection from */ export function getFirstSelectedTable( model: ContentModelDocument @@ -142,7 +169,8 @@ export function getFirstSelectedTable( } /** - * @internal + * Get the first selected list item from content model + * @param model The Content Model to get selection from */ export function getFirstSelectedListItem( model: ContentModelDocument diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/deleteBlock.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/deleteBlock.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteBlock.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/deleteSegment.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/deleteSegment.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts index 42d503c1350..95ce0c57bba 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/deleteSegment.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSegment.ts @@ -1,6 +1,6 @@ -import { deleteSingleChar } from '../../modelApi/edit/utils/deleteSingleChar'; +import { deleteSingleChar } from '../../modelApi/edit/deleteSingleChar'; import { isWhiteSpacePreserved, normalizeSingleSegment } from 'roosterjs-content-model-dom'; -import { normalizeText } from '../../domUtils/stringUtil'; +import { normalizeText } from '../domUtils/stringUtil'; import type { ContentModelParagraph, ContentModelSegment, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/deleteSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/deleteSelection.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts index 2bce5527639..8a734613210 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/deleteSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts @@ -1,4 +1,4 @@ -import { deleteExpandedSelection } from '../../modelApi/edit/utils/deleteExpandedSelection'; +import { deleteExpandedSelection } from '../../modelApi/edit/deleteExpandedSelection'; import type { ContentModelDocument, DeleteSelectionContext, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/setSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/setSelection.ts similarity index 92% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/setSelection.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/setSelection.ts index 06a3371f7f8..e0e445fe92b 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/setSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/setSelection.ts @@ -8,7 +8,10 @@ import type { } from 'roosterjs-content-model-types'; /** - * @internal + * Set selection into Content Model. If the Content Model already has selection, existing selection will be overwritten by the new one. + * @param group The root level group of Content Model + * @param start The start selected element. If not passed, existing selection of content model will be cleared + * @param end The end selected element. If not passed, only the start element will be selected. If passed, all elements between start and end elements will be selected */ export function setSelection(group: ContentModelBlockGroup, start?: Selectable, end?: Selectable) { setSelectionToBlockGroup(group, false /*isInSelection*/, start || null, end || null); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/applyTableFormat.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/applyTableFormat.ts similarity index 93% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/applyTableFormat.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/table/applyTableFormat.ts index dcc44fcdfe3..ef4455ff0c0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/applyTableFormat.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/applyTableFormat.ts @@ -1,8 +1,9 @@ import { BorderKeys } from 'roosterjs-content-model-dom'; -import { combineBorderValue, extractBorderValues } from '../../domUtils/borderValues'; +import { combineBorderValue, extractBorderValues } from '../domUtils/borderValues'; import { setTableCellBackgroundColor } from './setTableCellBackgroundColor'; import { TableBorderFormat } from 'roosterjs-content-model-types'; -import { updateTableCellMetadata, updateTableMetadata } from 'roosterjs-content-model-core'; +import { updateTableCellMetadata } from '../../metadata/updateTableCellMetadata'; +import { updateTableMetadata } from '../../metadata/updateTableMetadata'; import type { BorderFormat, ContentModelTable, @@ -32,7 +33,10 @@ type MetaOverrides = { }; /** - * @internal + * Apply table format from table metadata and the passed in new format + * @param table The table to apply format to + * @param newFormat @optional New format to apply. When passed, this value will be merged into existing metadata format and default format + * @param keepCellShade @optional When pass true, table cells with customized shade color will not be overwritten. @default false */ export function applyTableFormat( table: ContentModelTable, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/normalizeTable.ts similarity index 91% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/table/normalizeTable.ts index 7a278a3589f..3a43078f9cd 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/normalizeTable.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/normalizeTable.ts @@ -9,7 +9,15 @@ import type { const MIN_HEIGHT = 22; /** - * @internal + * Normalize a Content Model table, make sure: + * 1. Fist cells are not spanned + * 2. Inner cells are not header + * 3. All cells have content and width + * 4. Table and table row have correct width/height + * 5. Spanned cell has no child blocks + * 6. default format is correctly applied + * @param table The table to normalize + * @param defaultSegmentFormat @optional Default segment format to apply to cell */ export function normalizeTable( table: ContentModelTable, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts similarity index 89% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts rename to packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts index 895940744b5..816ac4e5505 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/setTableCellBackgroundColor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts @@ -1,4 +1,4 @@ -import { updateTableCellMetadata } from 'roosterjs-content-model-core'; +import { updateTableCellMetadata } from '../../metadata/updateTableCellMetadata'; import type { ContentModelTableCell } from 'roosterjs-content-model-types'; // Using the HSL (hue, saturation and lightness) representation for RGB color values. @@ -10,7 +10,11 @@ const White = '#ffffff'; const Black = '#000000'; /** - * @internal + * Set shade color of table cell + * @param cell The cell to set shade color to + * @param color The color to set + * @param isColorOverride @optional When pass true, it means this shade color is not part of table format, so it can be preserved when apply table format + * @param applyToSegments @optional When pass true, we will also apply text color from table cell to its child blocks and segments */ export function setTableCellBackgroundColor( cell: ContentModelTableCell, diff --git a/packages-content-model/roosterjs-content-model-core/package.json b/packages-content-model/roosterjs-content-model-core/package.json index ed1e640d214..c932d7dfbde 100644 --- a/packages-content-model/roosterjs-content-model-core/package.json +++ b/packages-content-model/roosterjs-content-model-core/package.json @@ -4,6 +4,8 @@ "dependencies": { "tslib": "^2.3.1", "roosterjs-editor-types": "", + "roosterjs-editor-dom": "", + "roosterjs-editor-core": "", "roosterjs-content-model-dom": "", "roosterjs-content-model-types": "" }, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCachePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts similarity index 97% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCachePluginTest.ts rename to packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts index 08ec77166cf..ca503a4feaf 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCachePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts @@ -1,15 +1,15 @@ -import { default as ContentModelCachePlugin } from '../../../lib/editor/corePlugins/ContentModelCachePlugin'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { PluginEventType } from 'roosterjs-editor-types'; +import { ContentModelCachePlugin } from '../../lib/corePlugin/ContentModelCachePlugin'; +import { IEditor, PluginEventType } from 'roosterjs-editor-types'; import { ContentModelCachePluginState, ContentModelDomIndexer, + IStandaloneEditor, } from 'roosterjs-content-model-types'; describe('ContentModelCachePlugin', () => { let plugin: ContentModelCachePlugin; let state: ContentModelCachePluginState; - let editor: IContentModelEditor; + let editor: IStandaloneEditor & IEditor; let addEventListenerSpy: jasmine.Spy; let removeEventListenerSpy: jasmine.Spy; @@ -39,7 +39,7 @@ describe('ContentModelCachePlugin', () => { removeEventListener: removeEventListenerSpy, }; }, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; plugin = new ContentModelCachePlugin(state); plugin.initialize(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts rename to packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts index 8fa3ddd0c3f..40ae5ce1799 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts @@ -1,24 +1,25 @@ -import * as addRangeToSelection from '../../../lib/domUtils/addRangeToSelection'; -import * as cloneModelFile from 'roosterjs-content-model-core/lib/publicApi/model/cloneModel'; +import * as addRangeToSelection from '../../lib/corePlugin/utils/addRangeToSelection'; +import * as cloneModelFile from '../../lib/publicApi/model/cloneModel'; import * as contentModelToDomFile from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; -import * as deleteSelectionsFile from '../../../lib/publicApi/selection/deleteSelection'; +import * as deleteSelectionsFile from '../../lib/publicApi/selection/deleteSelection'; import * as extractClipboardItemsFile from 'roosterjs-editor-dom/lib/clipboard/extractClipboardItems'; -import * as iterateSelectionsFile from 'roosterjs-content-model-core/lib/publicApi/selection/iterateSelections'; +import * as iterateSelectionsFile from '../../lib/publicApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import * as PasteFile from '../../../lib/publicApi/utils/paste'; +import * as PasteFile from '../../lib/publicApi/model/paste'; import { createModelToDomContext } from 'roosterjs-content-model-dom'; import { createRange } from 'roosterjs-editor-dom'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { setEntityElementClasses } from 'roosterjs-content-model-dom/test/domUtils/entityUtilTest'; import { ContentModelDocument, DOMSelection, ContentModelFormatter, FormatWithContentModelOptions, + IStandaloneEditor, } from 'roosterjs-content-model-types'; -import ContentModelCopyPastePlugin, { +import { + ContentModelCopyPastePlugin, onNodeCreated, -} from '../../../lib/editor/corePlugins/ContentModelCopyPastePlugin'; +} from '../../lib/corePlugin/ContentModelCopyPastePlugin'; import { ClipboardData, ColorTransformDirection, @@ -95,7 +96,7 @@ describe('ContentModelCopyPastePlugin |', () => { plugin = new ContentModelCopyPastePlugin({ allowedCustomPasteType, }); - editor = ({ + editor = ({ addDomEventHandler: ( nameOrMap: string | Record, handler?: DOMEventHandlerFunction @@ -545,7 +546,7 @@ describe('ContentModelCopyPastePlugin |', () => { let clipboardData = {}; it('Handle', () => { - spyOn(PasteFile, 'default').and.callFake(() => {}); + spyOn(PasteFile, 'paste').and.callFake(() => {}); const preventDefaultSpy = jasmine.createSpy('preventDefaultPaste'); let clipboardEvent = { clipboardData: ({ @@ -565,7 +566,7 @@ describe('ContentModelCopyPastePlugin |', () => { domEvents.paste?.(clipboardEvent); expect(pasteSpy).not.toHaveBeenCalledWith(clipboardData); - expect(PasteFile.default).toHaveBeenCalled(); + expect(PasteFile.paste).toHaveBeenCalled(); expect(extractClipboardItemsFile.default).toHaveBeenCalledWith( Array.from(clipboardEvent.clipboardData!.items), { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts rename to packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts index b86aa9d0444..ba2bfeb59fb 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/corePlugins/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts @@ -1,8 +1,11 @@ -import * as applyPendingFormat from '../../../lib/modelApi/format/applyPendingFormat'; -import ContentModelFormatPlugin from '../../../lib/editor/corePlugins/ContentModelFormatPlugin'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { PluginEventType } from 'roosterjs-editor-types'; -import { ContentModelFormatPluginState, PendingFormat } from 'roosterjs-content-model-types'; +import * as applyPendingFormat from '../../lib/corePlugin/utils/applyPendingFormat'; +import { ContentModelFormatPlugin } from '../../lib/corePlugin/ContentModelFormatPlugin'; +import { IEditor, PluginEventType } from 'roosterjs-editor-types'; +import { + ContentModelFormatPluginState, + IStandaloneEditor, + PendingFormat, +} from 'roosterjs-content-model-types'; import { addSegment, createContentModelDocument, @@ -22,7 +25,7 @@ describe('ContentModelFormatPlugin', () => { const editor = ({ cacheContentModel: () => {}, isDarkMode: () => false, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; const state = { defaultFormat: {}, pendingFormat: ({} as any) as PendingFormat, @@ -48,7 +51,7 @@ describe('ContentModelFormatPlugin', () => { isInIME: () => false, cacheContentModel: () => {}, getEnvironment: () => ({}), - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; const state = { defaultFormat: {}, pendingFormat: { @@ -86,7 +89,7 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, cacheContentModel: () => {}, getEnvironment: () => ({}), - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; const state = { defaultFormat: {}, pendingFormat: { @@ -120,7 +123,7 @@ describe('ContentModelFormatPlugin', () => { isDarkMode: () => false, triggerPluginEvent, getVisibleViewport, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; const state = { defaultFormat: {}, pendingFormat: { @@ -150,7 +153,7 @@ describe('ContentModelFormatPlugin', () => { const editor = ({ createContentModel: () => model, cacheContentModel: () => {}, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; const state = { defaultFormat: {}, pendingFormat: { @@ -180,7 +183,7 @@ describe('ContentModelFormatPlugin', () => { callback(); }, cacheContentModel: () => {}, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; const state = { defaultFormat: {}, pendingFormat: { @@ -209,7 +212,7 @@ describe('ContentModelFormatPlugin', () => { const editor = ({ createContentModel: () => model, cacheContentModel: () => {}, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; const state = { defaultFormat: {}, pendingFormat: { @@ -239,7 +242,7 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, cacheContentModel: () => {}, getEnvironment: () => ({}), - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; const state = { defaultFormat: {}, pendingFormat: { @@ -266,7 +269,7 @@ describe('ContentModelFormatPlugin', () => { }); describe('ContentModelFormatPlugin for default format', () => { - let editor: IContentModelEditor; + let editor: IStandaloneEditor & IEditor; let contentDiv: HTMLDivElement; let getDOMSelection: jasmine.Spy; let getPendingFormatSpy: jasmine.Spy; @@ -290,7 +293,7 @@ describe('ContentModelFormatPlugin for default format', () => { cacheContentModel: cacheContentModelSpy, addUndoSnapshot: addUndoSnapshotSpy, formatContentModel: formatContentModelSpy, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; }); it('Collapsed range, text input, under editor directly', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts rename to packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts index 2941729c95f..a48c4ad531f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyDefaultFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts @@ -1,12 +1,14 @@ import * as deleteSelection from '../../../lib/publicApi/selection/deleteSelection'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import { applyDefaultFormat } from '../../../lib/modelApi/format/applyDefaultFormat'; +import { applyDefaultFormat } from '../../../lib/corePlugin/utils/applyDefaultFormat'; +import { IEditor } from 'roosterjs-editor-types'; import { ContentModelDocument, ContentModelFormatter, ContentModelSegmentFormat, FormatWithContentModelContext, FormatWithContentModelOptions, + IStandaloneEditor, InsertPoint, } from 'roosterjs-content-model-types'; import { @@ -17,10 +19,9 @@ import { createSelectionMarker, createText, } from 'roosterjs-content-model-dom'; -import type { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; describe('applyDefaultFormat', () => { - let editor: IContentModelEditor; + let editor: IStandaloneEditor & IEditor; let getDOMSelectionSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; let deleteSelectionSpy: jasmine.Spy; diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts rename to packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts index e6a9d10837a..d05da7ffa3e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/format/applyPendingFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts @@ -1,7 +1,7 @@ -import * as iterateSelections from 'roosterjs-content-model-core/lib/publicApi/selection/iterateSelections'; +import * as iterateSelections from '../../../lib/publicApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import { applyPendingFormat } from '../../../lib/modelApi/format/applyPendingFormat'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { applyPendingFormat } from '../../../lib/corePlugin/utils/applyPendingFormat'; +import { IEditor } from 'roosterjs-editor-types'; import { ContentModelDocument, ContentModelParagraph, @@ -9,6 +9,7 @@ import { ContentModelText, ContentModelFormatter, FormatWithContentModelOptions, + IStandaloneEditor, } from 'roosterjs-content-model-types'; import { createContentModelDocument, @@ -54,7 +55,7 @@ describe('applyPendingFormat', () => { const editor = ({ formatContentModel: formatContentModelSpy, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); @@ -128,7 +129,7 @@ describe('applyPendingFormat', () => { const editor = ({ formatContentModel: formatContentModelSpy, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); @@ -187,7 +188,7 @@ describe('applyPendingFormat', () => { const formatContentModelSpy = jasmine.createSpy('formatContentModel'); const editor = ({ formatContentModel: formatContentModelSpy, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); @@ -247,7 +248,7 @@ describe('applyPendingFormat', () => { const editor = ({ formatContentModel: formatContentModelSpy, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [text]); @@ -298,7 +299,7 @@ describe('applyPendingFormat', () => { const editor = ({ formatContentModel: formatContentModelSpy, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { callback([model], undefined, paragraph, [marker]); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/areSameRangeExTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/areSameRangeExTest.ts similarity index 97% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/areSameRangeExTest.ts rename to packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/areSameRangeExTest.ts index 63f5cdbcac7..a97c0de20db 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/areSameRangeExTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/areSameRangeExTest.ts @@ -1,7 +1,7 @@ -import { areSameRangeEx } from '../../../lib/modelApi/selection/areSameRangeEx'; +import { areSameSelection } from '../../../lib/corePlugin/utils/areSameSelection'; import { DOMSelection } from 'roosterjs-content-model-types'; -describe('areSameRangeEx', () => { +describe('areSameSelection', () => { const startContainer = 'MockedStartContainer' as any; const endContainer = 'MockedEndContainer' as any; const startOffset = 1; @@ -10,7 +10,7 @@ describe('areSameRangeEx', () => { const image = 'MockedImage' as any; function runTest(r1: DOMSelection, r2: DOMSelection, result: boolean) { - expect(areSameRangeEx(r1, r2)).toBe(result); + expect(areSameSelection(r1, r2)).toBe(result); } it('Same object', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/contentModelDomIndexerTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/editor/utils/contentModelDomIndexerTest.ts rename to packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts index 469a80ffce4..a6d1c33c4a2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/contentModelDomIndexerTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/contentModelDomIndexerTest.ts @@ -1,5 +1,5 @@ -import * as setSelection from '../../../lib/modelApi/selection/setSelection'; -import { contentModelDomIndexer } from '../../../lib/editor/utils/contentModelDomIndexer'; +import * as setSelection from '../../../lib/publicApi/selection/setSelection'; +import { contentModelDomIndexer } from '../../../lib/corePlugin/utils/contentModelDomIndexer'; import { createRange } from 'roosterjs-editor-dom'; import { ContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/createContentModelEditorCoreTest.ts similarity index 90% rename from packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts rename to packages-content-model/roosterjs-content-model-core/test/editor/createContentModelEditorCoreTest.ts index c14a760dda6..fb1cb935557 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createContentModelEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/createContentModelEditorCoreTest.ts @@ -1,12 +1,13 @@ -import * as ContentModelCachePlugin from '../../lib/editor/corePlugins/ContentModelCachePlugin'; -import * as ContentModelCopyPastePlugin from '../../lib/editor/corePlugins/ContentModelCopyPastePlugin'; -import * as ContentModelFormatPlugin from '../../lib/editor/corePlugins/ContentModelFormatPlugin'; +import * as ContentModelCachePlugin from '../../lib/corePlugin/ContentModelCachePlugin'; +import * as ContentModelCopyPastePlugin from '../../lib/corePlugin/ContentModelCopyPastePlugin'; +import * as ContentModelFormatPlugin from '../../lib/corePlugin/ContentModelFormatPlugin'; import * as createEditorCore from 'roosterjs-editor-core/lib/editor/createEditorCore'; -import * as promoteToContentModelEditorCore from 'roosterjs-content-model-core/lib/editor/promoteToContentModelEditorCore'; -import ContentModelTypeInContainerPlugin from '../../lib/editor/corePlugins/ContentModelTypeInContainerPlugin'; -import { contentModelDomIndexer } from '../../lib/editor/utils/contentModelDomIndexer'; -import { ContentModelEditorOptions } from '../../lib/publicTypes/IContentModelEditor'; +import * as promoteToContentModelEditorCore from '../../lib/editor/promoteToContentModelEditorCore'; +import { contentModelDomIndexer } from '../../lib/corePlugin/utils/contentModelDomIndexer'; +import { ContentModelTypeInContainerPlugin } from '../../lib/corePlugin/ContentModelTypeInContainerPlugin'; import { createContentModelEditorCore } from '../../lib/editor/createContentModelEditorCore'; +import { EditorOptions } from 'roosterjs-editor-types'; +import { StandaloneEditorOptions } from 'roosterjs-content-model-types'; const mockedSwitchShadowEdit = 'SHADOWEDIT' as any; const mockedFormatPlugin = 'FORMATPLUGIN' as any; @@ -195,7 +196,7 @@ describe('createContentModelEditorCore', () => { }); it('Allow dom indexer', () => { - const options: ContentModelEditorOptions = { + const options: StandaloneEditorOptions & EditorOptions = { cacheModel: true, }; diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/utils/deleteSingleCharTest.ts b/packages-content-model/roosterjs-content-model-core/test/modelApi/edit/deleteSingleCharTest.ts similarity index 94% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/utils/deleteSingleCharTest.ts rename to packages-content-model/roosterjs-content-model-core/test/modelApi/edit/deleteSingleCharTest.ts index 873f4d24147..d39aac74970 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/utils/deleteSingleCharTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/modelApi/edit/deleteSingleCharTest.ts @@ -1,4 +1,4 @@ -import { deleteSingleChar } from '../../../../lib/modelApi/edit/utils/deleteSingleChar'; +import { deleteSingleChar } from '../../../lib/modelApi/edit/deleteSingleChar'; describe('deleteSingleChar', () => { const tests = ['', 'a', '\u200b', '好', '👩‍💻', '🎉', '🛏', '🎉', '👩‍❤️‍💋‍👩']; diff --git a/packages-content-model/roosterjs-content-model-editor/test/domUtils/borderValuesTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/domUtils/borderValuesTest.ts similarity index 94% rename from packages-content-model/roosterjs-content-model-editor/test/domUtils/borderValuesTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/domUtils/borderValuesTest.ts index 2dd23043397..16d895fb2f8 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/domUtils/borderValuesTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/domUtils/borderValuesTest.ts @@ -1,4 +1,7 @@ -import { combineBorderValue, extractBorderValues } from '../../lib/domUtils/borderValues'; +import { + combineBorderValue, + extractBorderValues, +} from '../../../lib/publicApi/domUtils/borderValues'; describe('extractBorderValues', () => { it('undefined string', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/getClosestAncestorBlockGroupIndexTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/getClosestAncestorBlockGroupIndexTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/common/getClosestAncestorBlockGroupIndexTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/model/getClosestAncestorBlockGroupIndexTest.ts index a2434b552ce..c8a7199c1cf 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/getClosestAncestorBlockGroupIndexTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/getClosestAncestorBlockGroupIndexTest.ts @@ -1,4 +1,4 @@ -import { getClosestAncestorBlockGroupIndex } from '../../../lib/modelApi/common/getClosestAncestorBlockGroupIndex'; +import { getClosestAncestorBlockGroupIndex } from '../../../lib/publicApi/model/getClosestAncestorBlockGroupIndex'; describe('getClosestAncestorBlockGroupIndex', () => { it('Empty path', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/isBlockGroupOfTypeTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/isBlockGroupOfTypeTest.ts similarity index 92% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/common/isBlockGroupOfTypeTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/model/isBlockGroupOfTypeTest.ts index 5eb54d00f70..23c524a6860 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/isBlockGroupOfTypeTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/isBlockGroupOfTypeTest.ts @@ -1,4 +1,4 @@ -import { isBlockGroupOfType } from '../../../lib/modelApi/common/isBlockGroupOfType'; +import { isBlockGroupOfType } from '../../../lib/publicApi/model/isBlockGroupOfType'; describe('isBlockGroupOfType', () => { it('null input', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/mergeModelTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/model/mergeModelTest.ts index 64a0154c34f..90401cf5b54 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/mergeModelTest.ts @@ -1,5 +1,6 @@ -import * as applyTableFormat from '../../../lib/modelApi/table/applyTableFormat'; -import * as normalizeTable from '../../../lib/modelApi/table/normalizeTable'; +import * as applyTableFormat from '../../../lib/publicApi/table/applyTableFormat'; +import * as normalizeTable from '../../../lib/publicApi/table/normalizeTable'; +import { mergeModel } from '../../../lib/publicApi/model/mergeModel'; import { ContentModelDocument, ContentModelImage, @@ -10,7 +11,6 @@ import { ContentModelTableCell, FormatWithContentModelContext, } from 'roosterjs-content-model-types'; -import { mergeModel } from '../../../lib/modelApi/common/mergeModel'; import { createBr, createContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/pasteTest.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/model/pasteTest.ts index c97ea3bf19c..b0e61a6a330 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/pasteTest.ts @@ -2,31 +2,32 @@ import * as addParserF from '../../../../roosterjs-content-model-plugins/lib/pas import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; import * as ExcelF from '../../../../roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel'; import * as getPasteSourceF from '../../../../roosterjs-content-model-plugins/lib/paste/pasteSourceValidations/getPasteSource'; -import * as getSelectedSegmentsF from '../../../lib/publicApi/selection/getSelectedSegments'; -import * as mergeModelFile from '../../../lib/modelApi/common/mergeModel'; +import * as getSelectedSegmentsF from '../../../lib/publicApi/selection/collectSelections'; +import * as mergeModelFile from '../../../lib/publicApi/model/mergeModel'; +import * as pasteF from '../../../lib/publicApi/model/paste'; import * as PPT from '../../../../roosterjs-content-model-plugins/lib/paste/PowerPoint/processPastedContentFromPowerPoint'; import * as setProcessorF from '../../../../roosterjs-content-model-plugins/lib/paste/utils/setProcessor'; import * as WacComponents from '../../../../roosterjs-content-model-plugins/lib/paste/WacComponents/processPastedContentWacComponents'; import * as WordDesktopFile from '../../../../roosterjs-content-model-plugins/lib/paste/WordDesktop/processPastedContentFromWordDesktop'; -import ContentModelEditor from '../../../lib/editor/ContentModelEditor'; +import { ContentModelEditor } from 'roosterjs-content-model-editor'; import { ContentModelPastePlugin } from '../../../../roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin'; import { createContentModelDocument, tableProcessor } from 'roosterjs-content-model-dom'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { ContentModelDocument, DomToModelOption, ContentModelFormatter, FormatWithContentModelContext, FormatWithContentModelOptions, + IStandaloneEditor, } from 'roosterjs-content-model-types'; import { expectEqual, initEditor, } from '../../../../roosterjs-content-model-plugins/test/paste/e2e/testUtils'; -import paste, * as pasteF from '../../../lib/publicApi/utils/paste'; import { BeforePasteEvent, ClipboardData, + IEditor, PasteType, PluginEvent, PluginEventType, @@ -37,7 +38,7 @@ let clipboardData: ClipboardData; const DEFAULT_TIMES_ADD_PARSER_CALLED = 4; describe('Paste ', () => { - let editor: IContentModelEditor; + let editor: IStandaloneEditor & IEditor; let createContentModel: jasmine.Spy; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; @@ -108,7 +109,7 @@ describe('Paste ', () => { mockedModel = mockedMergeModel; return null; }); - spyOn(getSelectedSegmentsF, 'default').and.returnValue([ + spyOn(getSelectedSegmentsF, 'getSelectedSegments').and.returnValue([ { format: { fontSize: '1pt', @@ -144,7 +145,7 @@ describe('Paste ', () => { getVisibleViewport, isDarkMode: () => false, formatContentModel, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor & IEditor; }); afterEach(() => { @@ -153,7 +154,7 @@ describe('Paste ', () => { }); it('Execute', () => { - pasteF.default(editor, clipboardData); + pasteF.paste(editor, clipboardData); expect(formatResult).toBeTrue(); expect(focus).toHaveBeenCalled(); @@ -165,7 +166,7 @@ describe('Paste ', () => { }); it('Execute | As plain text', () => { - pasteF.default(editor, clipboardData, 'asPlainText'); + pasteF.paste(editor, clipboardData, 'asPlainText'); expect(formatResult).toBeTrue(); expect(focus).toHaveBeenCalled(); @@ -193,7 +194,7 @@ describe('Paste ', () => { }, }); - paste(editor, clipboardData); + pasteF.paste(editor, clipboardData); editor.createContentModel({ processorOverride: { @@ -255,7 +256,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wordDesktop'); spyOn(WordDesktopFile, 'processPastedContentFromWordDesktop').and.callThrough(); - pasteF.default(editor!, clipboardData); + pasteF.paste(editor!, clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 3); @@ -266,7 +267,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wacComponents'); spyOn(WacComponents, 'processPastedContentWacComponents').and.callThrough(); - pasteF.default(editor!, clipboardData); + pasteF.paste(editor!, clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(4); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 4); @@ -277,7 +278,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelOnline'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - pasteF.default(editor!, clipboardData); + pasteF.paste(editor!, clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); @@ -288,7 +289,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelDesktop'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - pasteF.default(editor!, clipboardData); + pasteF.paste(editor!, clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); @@ -299,7 +300,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('powerPointDesktop'); spyOn(PPT, 'processPastedContentFromPowerPoint').and.callThrough(); - pasteF.default(editor!, clipboardData); + pasteF.paste(editor!, clipboardData); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); @@ -311,7 +312,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wordDesktop'); spyOn(WordDesktopFile, 'processPastedContentFromWordDesktop').and.callThrough(); - pasteF.default(editor!, clipboardData, 'asPlainText'); + pasteF.paste(editor!, clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -322,7 +323,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('wacComponents'); spyOn(WacComponents, 'processPastedContentWacComponents').and.callThrough(); - pasteF.default(editor!, clipboardData, 'asPlainText'); + pasteF.paste(editor!, clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -333,7 +334,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelOnline'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - pasteF.default(editor!, clipboardData, 'asPlainText'); + pasteF.paste(editor!, clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -344,7 +345,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('excelDesktop'); spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); - pasteF.default(editor!, clipboardData, 'asPlainText'); + pasteF.paste(editor!, clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -355,7 +356,7 @@ describe('paste with content model & paste plugin', () => { spyOn(getPasteSourceF, 'getPasteSource').and.returnValue('powerPointDesktop'); spyOn(PPT, 'processPastedContentFromPowerPoint').and.callThrough(); - pasteF.default(editor!, clipboardData, 'asPlainText'); + pasteF.paste(editor!, clipboardData, 'asPlainText'); expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); expect(addParserF.default).toHaveBeenCalledTimes(0); @@ -391,7 +392,7 @@ describe('paste with content model & paste plugin', () => { ], }); - pasteF.default(editor!, clipboardData); + pasteF.paste(editor!, clipboardData); expect(eventChecker?.clipboardData).toEqual(clipboardData); expect(eventChecker?.htmlBefore).toBeTruthy(); @@ -628,7 +629,7 @@ describe('mergePasteContent', () => { }); describe('Paste with clipboardData', () => { - let editor: IContentModelEditor = undefined!; + let editor: IEditor & IStandaloneEditor = undefined!; const ID = 'EDITOR_ID'; beforeEach(() => { @@ -654,7 +655,7 @@ describe('Paste with clipboardData', () => { clipboardData.rawHtml = '

                                                          Test

                                                          '; - paste(editor, clipboardData); + pasteF.paste(editor, clipboardData); const model = editor.createContentModel({ processorOverride: { @@ -697,7 +698,7 @@ describe('Paste with clipboardData', () => { clipboardData.rawHtml = 'Link'; - paste(editor, clipboardData); + pasteF.paste(editor, clipboardData); const model = editor.createContentModel({ processorOverride: { @@ -729,7 +730,7 @@ describe('Paste with clipboardData', () => { clipboardData.rawHtml = 'Link'; - paste(editor, clipboardData); + pasteF.paste(editor, clipboardData); const model = editor.createContentModel({ processorOverride: { diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/collectSelectionsTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/selection/collectSelectionsTest.ts index 8f31a71a522..e1d56510259 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/collectSelectionsTest.ts @@ -1,4 +1,4 @@ -import * as iterateSelections from 'roosterjs-content-model-core/lib/publicApi/selection/iterateSelections'; +import * as iterateSelections from '../../../lib/publicApi/selection/iterateSelections'; import { ContentModelBlock, ContentModelBlockGroup, @@ -28,7 +28,7 @@ import { getOperationalBlocks, OperationalBlocks, getSelectedSegmentsAndParagraphs, -} from '../../../lib/modelApi/selection/collectSelections'; +} from '../../../lib/publicApi/selection/collectSelections'; interface SelectionInfo { path: ContentModelBlockGroup[]; diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/selection/getSelectedSegmentsTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/getSelectedSegmentsTest.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/selection/getSelectedSegmentsTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/selection/getSelectedSegmentsTest.ts index 2e9fa06beba..288cf8fa146 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/selection/getSelectedSegmentsTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/getSelectedSegmentsTest.ts @@ -1,5 +1,5 @@ -import * as iterateSelections from 'roosterjs-content-model-core/lib/publicApi/selection/iterateSelections'; -import getSelectedSegments from '../../../lib/publicApi/selection/getSelectedSegments'; +import * as iterateSelections from '../../../lib/publicApi/selection/iterateSelections'; +import { getSelectedSegments } from '../../../lib/publicApi/selection/collectSelections'; import { ContentModelBlock, ContentModelBlockGroup, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/setSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/setSelectionTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/setSelectionTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/selection/setSelectionTest.ts index c7c5d7fcefe..b1d555bf331 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/setSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/setSelectionTest.ts @@ -1,4 +1,4 @@ -import { setSelection } from '../../../lib/modelApi/selection/setSelection'; +import { setSelection } from '../../../lib/publicApi/selection/setSelection'; import { createBr, createContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/applyTableFormatTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/table/applyTableFormatTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/applyTableFormatTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/table/applyTableFormatTest.ts index 549a6e9bb85..419354eb2fd 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/applyTableFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/table/applyTableFormatTest.ts @@ -1,4 +1,4 @@ -import { applyTableFormat } from '../../../lib/modelApi/table/applyTableFormat'; +import { applyTableFormat } from '../../../lib/publicApi/table/applyTableFormat'; import { ContentModelTable, ContentModelTableCell, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/table/normalizeTableTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/table/normalizeTableTest.ts index f2379aa2f28..3d8b0282527 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/normalizeTableTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/table/normalizeTableTest.ts @@ -1,4 +1,4 @@ -import { normalizeTable } from '../../../lib/modelApi/table/normalizeTable'; +import { normalizeTable } from '../../../lib/publicApi/table/normalizeTable'; import { ContentModelParagraph, ContentModelSegmentFormat, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/setTableCellBackgroundColorTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/table/setTableCellBackgroundColorTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/setTableCellBackgroundColorTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/table/setTableCellBackgroundColorTest.ts index 886ebca7659..efb0d3bcb4d 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/setTableCellBackgroundColorTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/table/setTableCellBackgroundColorTest.ts @@ -3,7 +3,7 @@ import { createTableCell as originalCreateTableCell } from 'roosterjs-content-mo import { parseColor, setTableCellBackgroundColor, -} from '../../../lib/modelApi/table/setTableCellBackgroundColor'; +} from '../../../lib/publicApi/table/setTableCellBackgroundColor'; function createTableCell( spanLeftOrColSpan?: boolean | number, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index bc574b26131..7ea629c336e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -1,4 +1,4 @@ -import { createContentModelEditorCore } from './createContentModelEditorCore'; +import { createContentModelEditorCore } from 'roosterjs-content-model-core'; import { EditorBase } from 'roosterjs-editor-core'; import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; import type { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 75eef39b208..0694938d05e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -24,20 +24,17 @@ export { default as setTextColor } from './publicApi/segment/setTextColor'; export { default as changeFontSize } from './publicApi/segment/changeFontSize'; export { default as applySegmentFormat } from './publicApi/segment/applySegmentFormat'; export { default as changeCapitalization } from './publicApi/segment/changeCapitalization'; -export { deleteSegment } from './publicApi/segment/deleteSegment'; export { default as insertImage } from './publicApi/image/insertImage'; export { default as setListStyle } from './publicApi/list/setListStyle'; export { default as setListStartNumber } from './publicApi/list/setListStartNumber'; export { default as hasSelectionInBlock } from './publicApi/selection/hasSelectionInBlock'; export { default as hasSelectionInSegment } from './publicApi/selection/hasSelectionInSegment'; export { default as hasSelectionInBlockGroup } from './publicApi/selection/hasSelectionInBlockGroup'; -export { default as getSelectedSegments } from './publicApi/selection/getSelectedSegments'; export { default as setIndentation } from './publicApi/block/setIndentation'; export { default as setAlignment } from './publicApi/block/setAlignment'; export { default as setDirection } from './publicApi/block/setDirection'; export { default as setHeadingLevel } from './publicApi/block/setHeadingLevel'; export { default as toggleBlockQuote } from './publicApi/block/toggleBlockQuote'; -export { deleteBlock } from './publicApi/block/deleteBlock'; export { default as setSpacing } from './publicApi/block/setSpacing'; export { default as setImageBorder } from './publicApi/image/setImageBorder'; export { default as setImageBoxShadow } from './publicApi/image/setImageBoxShadow'; @@ -51,19 +48,7 @@ export { default as setImageAltText } from './publicApi/image/setImageAltText'; export { default as adjustImageSelection } from './publicApi/image/adjustImageSelection'; export { default as setParagraphMargin } from './publicApi/block/setParagraphMargin'; export { default as toggleCode } from './publicApi/segment/toggleCode'; -export { default as paste } from './publicApi/utils/paste'; export { default as insertEntity } from './publicApi/entity/insertEntity'; -export { deleteSelection } from './publicApi/selection/deleteSelection'; export { default as ContentModelEditor } from './editor/ContentModelEditor'; export { default as isContentModelEditor } from './editor/isContentModelEditor'; - -export { default as ContentModelFormatPlugin } from './editor/corePlugins/ContentModelFormatPlugin'; -export { default as ContentModelTypeInContainerPlugin } from './editor/corePlugins/ContentModelTypeInContainerPlugin'; -export { default as ContentModelCopyPastePlugin } from './editor/corePlugins/ContentModelCopyPastePlugin'; -export { default as ContentModelCachePlugin } from './editor/corePlugins/ContentModelCachePlugin'; - -export { createContentModelEditorCore } from './editor/createContentModelEditorCore'; -export { combineBorderValue, extractBorderValues } from './domUtils/borderValues'; -export { isCharacterValue, isModifierKey } from './domUtils/eventUtils'; -export { isPunctuation, isSpace, normalizeText } from './domUtils/stringUtil'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelAlignment.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelAlignment.ts index 327df31dfa1..7195148b4f1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelAlignment.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelAlignment.ts @@ -1,5 +1,5 @@ import { alignTable } from '../table/alignTable'; -import { getOperationalBlocks } from '../selection/collectSelections'; +import { getOperationalBlocks } from 'roosterjs-content-model-core'; import type { ContentModelDocument, ContentModelListItem, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelDirection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelDirection.ts index f8d6f9497a2..3a984275972 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelDirection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelDirection.ts @@ -1,6 +1,5 @@ import { findListItemsInSameThread } from '../list/findListItemsInSameThread'; -import { getOperationalBlocks } from '../selection/collectSelections'; -import { isBlockGroupOfType } from '../common/isBlockGroupOfType'; +import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; import type { ContentModelBlockFormat, ContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelIndentation.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelIndentation.ts index 4f5b1776a2b..97fa93cc7a8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelIndentation.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelIndentation.ts @@ -1,6 +1,5 @@ import { createListLevel, parseValueWithUnit } from 'roosterjs-content-model-dom'; -import { getOperationalBlocks } from '../selection/collectSelections'; -import { isBlockGroupOfType } from '../common/isBlockGroupOfType'; +import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; import type { ContentModelDocument, ContentModelListItem, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/toggleModelBlockQuote.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/toggleModelBlockQuote.ts index 5bef7411040..f3f25a977f4 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/toggleModelBlockQuote.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/toggleModelBlockQuote.ts @@ -1,8 +1,7 @@ import { areSameFormats, createFormatContainer, unwrapBlock } from 'roosterjs-content-model-dom'; -import { getOperationalBlocks } from '../selection/collectSelections'; -import { isBlockGroupOfType } from '../common/isBlockGroupOfType'; +import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; import { wrapBlockStep1, wrapBlockStep2 } from '../common/wrapBlock'; -import type { OperationalBlocks } from '../selection/collectSelections'; +import type { OperationalBlocks } from 'roosterjs-content-model-core'; import type { WrapBlockStep1Result } from '../common/wrapBlock'; import type { ContentModelBlock, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts index 31fdcac6e3d..3cfdf9ce3f1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts @@ -1,9 +1,9 @@ import { adjustWordSelection } from '../selection/adjustWordSelection'; -import { applyTableFormat } from '../table/applyTableFormat'; import { createFormatContainer } from 'roosterjs-content-model-dom'; -import { getClosestAncestorBlockGroupIndex } from './getClosestAncestorBlockGroupIndex'; import { iterateSelections, + applyTableFormat, + getClosestAncestorBlockGroupIndex, updateTableCellMetadata, updateTableMetadata, } from 'roosterjs-content-model-core'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts index 8d9503034c2..ff557da027d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts @@ -1,7 +1,10 @@ -import { extractBorderValues } from '../../domUtils/borderValues'; -import { getClosestAncestorBlockGroupIndex } from './getClosestAncestorBlockGroupIndex'; import { isBold } from '../../publicApi/segment/toggleBold'; -import { iterateSelections, updateTableMetadata } from 'roosterjs-content-model-core'; +import { + extractBorderValues, + getClosestAncestorBlockGroupIndex, + iterateSelections, + updateTableMetadata, +} from 'roosterjs-content-model-core'; import type { ContentModelFormatState, ContentModelBlock, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/createInsertPoint.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/createInsertPoint.ts deleted file mode 100644 index 9790d603fff..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/createInsertPoint.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { - ContentModelBlockGroup, - ContentModelParagraph, - ContentModelSelectionMarker, - InsertPoint, - TableSelectionContext, -} from 'roosterjs-content-model-types'; - -/** - * @internal - */ -export function createInsertPoint( - marker: ContentModelSelectionMarker, - paragraph: ContentModelParagraph, - path: ContentModelBlockGroup[], - tableContext: TableSelectionContext | undefined -): InsertPoint { - return { - marker, - paragraph, - path, - tableContext, - }; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts index 5b28c9ac6a3..5a52bfb959e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts @@ -1,6 +1,8 @@ -import { deleteSelection } from '../../publicApi/selection/deleteSelection'; -import { getClosestAncestorBlockGroupIndex } from '../common/getClosestAncestorBlockGroupIndex'; -import { setSelection } from '../selection/setSelection'; +import { + deleteSelection, + getClosestAncestorBlockGroupIndex, + setSelection, +} from 'roosterjs-content-model-core'; import { createBr, createParagraph, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/image/applyImageBorderFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/image/applyImageBorderFormat.ts index dbe157d9462..6d7ef1a5898 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/image/applyImageBorderFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/image/applyImageBorderFormat.ts @@ -1,4 +1,4 @@ -import { extractBorderValues } from '../../domUtils/borderValues'; +import { extractBorderValues } from 'roosterjs-content-model-core'; import { parseValueWithUnit } from 'roosterjs-content-model-dom'; import type { Border, ContentModelImage } from 'roosterjs-content-model-types'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/setListType.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/setListType.ts index 9d6427994c4..6e83bedbf44 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/setListType.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/setListType.ts @@ -1,5 +1,4 @@ -import { getOperationalBlocks } from '../selection/collectSelections'; -import { isBlockGroupOfType } from '../common/isBlockGroupOfType'; +import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; import { createListItem, createListLevel, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustSegmentSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustSegmentSelection.ts index 1af7eb1ccbc..40a73ae3468 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustSegmentSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustSegmentSelection.ts @@ -1,5 +1,4 @@ -import { getSelectedParagraphs } from './collectSelections'; -import { setSelection } from './setSelection'; +import { getSelectedParagraphs, setSelection } from 'roosterjs-content-model-core'; import type { ContentModelDocument, ContentModelSegment } from 'roosterjs-content-model-types'; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts index 741ce3e48bd..a80dbf0c3b1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts @@ -1,6 +1,5 @@ import { createText } from 'roosterjs-content-model-dom'; -import { isPunctuation, isSpace } from '../../domUtils/stringUtil'; -import { iterateSelections } from 'roosterjs-content-model-core'; +import { isPunctuation, isSpace, iterateSelections } from 'roosterjs-content-model-core'; import type { ContentModelDocument, ContentModelParagraph, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts index e6f69e12c15..1b0b814e942 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts @@ -1,5 +1,5 @@ import { addSegment, createContentModelDocument, createImage } from 'roosterjs-content-model-dom'; -import { mergeModel } from '../../modelApi/common/mergeModel'; +import { mergeModel } from 'roosterjs-content-model-core'; import { readFile } from '../../domUtils/readFile'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/adjustLinkSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/adjustLinkSelection.ts index ef80f2661f8..17ff9066661 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/adjustLinkSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/adjustLinkSelection.ts @@ -1,7 +1,6 @@ -import getSelectedSegments from '../selection/getSelectedSegments'; import { adjustSegmentSelection } from '../../modelApi/selection/adjustSegmentSelection'; import { adjustWordSelection } from '../../modelApi/selection/adjustWordSelection'; -import { setSelection } from '../../modelApi/selection/setSelection'; +import { getSelectedSegments, setSelection } from 'roosterjs-content-model-core'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts index 2e1df2c3605..f61e29a619d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts @@ -1,7 +1,5 @@ -import getSelectedSegments from '../selection/getSelectedSegments'; -import { ChangeSource } from 'roosterjs-content-model-core'; +import { ChangeSource, getSelectedSegments, mergeModel } from 'roosterjs-content-model-core'; import { HtmlSanitizer, matchLink } from 'roosterjs-editor-dom'; -import { mergeModel } from '../../modelApi/common/mergeModel'; import type { ContentModelLink } from 'roosterjs-content-model-types'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/removeLink.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/removeLink.ts index 730998a4a4b..b26e40da2c8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/removeLink.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/removeLink.ts @@ -1,5 +1,5 @@ -import getSelectedSegments from '../selection/getSelectedSegments'; import { adjustSegmentSelection } from '../../modelApi/selection/adjustSegmentSelection'; +import { getSelectedSegments } from 'roosterjs-content-model-core'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStartNumber.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStartNumber.ts index 509912dffe7..7de0ef9ff52 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStartNumber.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStartNumber.ts @@ -1,4 +1,4 @@ -import { getFirstSelectedListItem } from '../../modelApi/selection/collectSelections'; +import { getFirstSelectedListItem } from 'roosterjs-content-model-core'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStyle.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStyle.ts index f9d9d7beee4..83bee9865ff 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStyle.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStyle.ts @@ -1,6 +1,5 @@ import { findListItemsInSameThread } from '../../modelApi/list/findListItemsInSameThread'; -import { getFirstSelectedListItem } from '../../modelApi/selection/collectSelections'; -import { updateListMetadata } from 'roosterjs-content-model-core'; +import { getFirstSelectedListItem, updateListMetadata } from 'roosterjs-content-model-core'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { ListMetadataFormat } from 'roosterjs-content-model-types'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setBackgroundColor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setBackgroundColor.ts index 3f8e769b69d..7bd4d0609f1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setBackgroundColor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setBackgroundColor.ts @@ -1,6 +1,6 @@ import { createSelectionMarker } from 'roosterjs-content-model-dom'; import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import { setSelection } from '../../modelApi/selection/setSelection'; +import { setSelection } from 'roosterjs-content-model-core'; import type { ContentModelParagraph } from 'roosterjs-content-model-types'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/getSelectedSegments.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/getSelectedSegments.ts deleted file mode 100644 index fceae7b5a91..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/getSelectedSegments.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { getSelectedSegmentsAndParagraphs } from '../../modelApi/selection/collectSelections'; -import type { ContentModelDocument, ContentModelSegment } from 'roosterjs-content-model-types'; - -/** - * Get selected segments from a content model - */ -export default function getSelectedSegments( - model: ContentModelDocument, - includingFormatHolder: boolean -): ContentModelSegment[] { - return getSelectedSegmentsAndParagraphs(model, includingFormatHolder).map(x => x[0]); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts index 46a4b063a3d..52e9dbe5508 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts @@ -1,8 +1,10 @@ -import { extractBorderValues } from '../../domUtils/borderValues'; -import { getFirstSelectedTable } from '../../modelApi/selection/collectSelections'; import { getSelectedCells } from '../../modelApi/table/getSelectedCells'; import { parseValueWithUnit } from 'roosterjs-content-model-dom'; -import { updateTableCellMetadata } from 'roosterjs-content-model-core'; +import { + extractBorderValues, + getFirstSelectedTable, + updateTableCellMetadata, +} from 'roosterjs-content-model-core'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { Border, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts index f92f30802b5..5a439ee67ae 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts @@ -1,20 +1,22 @@ import hasSelectionInBlock from '../selection/hasSelectionInBlock'; import { alignTable } from '../../modelApi/table/alignTable'; -import { applyTableFormat } from '../../modelApi/table/applyTableFormat'; import { deleteTable } from '../../modelApi/table/deleteTable'; import { deleteTableColumn } from '../../modelApi/table/deleteTableColumn'; import { deleteTableRow } from '../../modelApi/table/deleteTableRow'; import { ensureFocusableParagraphForTable } from '../../modelApi/table/ensureFocusableParagraphForTable'; -import { getFirstSelectedTable } from '../../modelApi/selection/collectSelections'; import { insertTableColumn } from '../../modelApi/table/insertTableColumn'; import { insertTableRow } from '../../modelApi/table/insertTableRow'; import { mergeTableCells } from '../../modelApi/table/mergeTableCells'; import { mergeTableColumn } from '../../modelApi/table/mergeTableColumn'; import { mergeTableRow } from '../../modelApi/table/mergeTableRow'; -import { normalizeTable } from '../../modelApi/table/normalizeTable'; -import { setSelection } from '../../modelApi/selection/setSelection'; import { splitTableCellHorizontally } from '../../modelApi/table/splitTableCellHorizontally'; import { splitTableCellVertically } from '../../modelApi/table/splitTableCellVertically'; +import { + applyTableFormat, + getFirstSelectedTable, + normalizeTable, + setSelection, +} from 'roosterjs-content-model-core'; import type { TableOperation } from 'roosterjs-content-model-types'; import { alignTableCellHorizontally, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/formatTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/formatTable.ts index 7e3446851ab..5da4bd5be59 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/formatTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/formatTable.ts @@ -1,6 +1,8 @@ -import { applyTableFormat } from '../../modelApi/table/applyTableFormat'; -import { getFirstSelectedTable } from '../../modelApi/selection/collectSelections'; -import { updateTableCellMetadata } from 'roosterjs-content-model-core'; +import { + applyTableFormat, + getFirstSelectedTable, + updateTableCellMetadata, +} from 'roosterjs-content-model-core'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { TableMetadataFormat } from 'roosterjs-content-model-types'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts index 7f2d08f05e6..12640af2a55 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts @@ -1,10 +1,12 @@ -import { applyTableFormat } from '../../modelApi/table/applyTableFormat'; import { createContentModelDocument, createSelectionMarker } from 'roosterjs-content-model-dom'; import { createTableStructure } from '../../modelApi/table/createTableStructure'; -import { deleteSelection } from '../selection/deleteSelection'; -import { mergeModel } from '../../modelApi/common/mergeModel'; -import { normalizeTable } from '../../modelApi/table/normalizeTable'; -import { setSelection } from '../../modelApi/selection/setSelection'; +import { + applyTableFormat, + deleteSelection, + mergeModel, + normalizeTable, + setSelection, +} from 'roosterjs-content-model-core'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { TableMetadataFormat } from 'roosterjs-content-model-types'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/setTableCellShade.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/setTableCellShade.ts index 4f77eb14519..43b9f7efbcb 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/setTableCellShade.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/setTableCellShade.ts @@ -1,7 +1,9 @@ import hasSelectionInBlockGroup from '../selection/hasSelectionInBlockGroup'; -import { getFirstSelectedTable } from '../../modelApi/selection/collectSelections'; -import { normalizeTable } from '../../modelApi/table/normalizeTable'; -import { setTableCellBackgroundColor } from '../../modelApi/table/setTableCellBackgroundColor'; +import { + getFirstSelectedTable, + normalizeTable, + setTableCellBackgroundColor, +} from 'roosterjs-content-model-core'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts index be73c4cdd32..39d0b385dc1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts @@ -1,4 +1,4 @@ -import { getSelectedParagraphs } from '../../modelApi/selection/collectSelections'; +import { getSelectedParagraphs } from 'roosterjs-content-model-core'; import type { ContentModelParagraph } from 'roosterjs-content-model-types'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts index 6c3ba558be6..64bb3292af5 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts @@ -1,5 +1,5 @@ import { adjustWordSelection } from '../../modelApi/selection/adjustWordSelection'; -import { getSelectedSegmentsAndParagraphs } from '../../modelApi/selection/collectSelections'; +import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core'; import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { ContentModelDocument, diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts index ef22256bc4f..45d9439fe76 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts @@ -1,5 +1,5 @@ import * as iterateSelections from 'roosterjs-content-model-core/lib/publicApi/selection/iterateSelections'; -import { applyTableFormat } from '../../../lib/modelApi/table/applyTableFormat'; +import { applyTableFormat } from 'roosterjs-content-model-core'; import { ContentModelFormatState, ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { retrieveModelFormatState } from '../../../lib/modelApi/common/retrieveModelFormatState'; import { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts index e6763b7d32f..fa2b9c84006 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts @@ -1,4 +1,4 @@ -import * as normalizeTable from '../../../lib/modelApi/table/normalizeTable'; +import * as normalizeTable from 'roosterjs-content-model-core/lib/publicApi/table/normalizeTable'; import setAlignment from '../../../lib/publicApi/block/setAlignment'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts index b8f47004e43..c8a1865d0be 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts @@ -1,4 +1,4 @@ -import * as normalizeTable from '../../../lib/modelApi/table/normalizeTable'; +import * as normalizeTable from 'roosterjs-content-model-core/lib/publicApi/table/normalizeTable'; import applyTableBorderFormat from '../../../lib/publicApi/table/applyTableBorderFormat'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { createTable, createTableCell } from 'roosterjs-content-model-dom'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts index 130616747eb..7f6ffaec4d9 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts @@ -1,4 +1,4 @@ -import * as normalizeTable from '../../../lib/modelApi/table/normalizeTable'; +import * as normalizeTable from 'roosterjs-content-model-core/lib/publicApi/table/normalizeTable'; import setTableCellShade from '../../../lib/publicApi/table/setTableCellShade'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteAllSegmentBefore.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteAllSegmentBefore.ts index 931e3e3b3ec..9039d67b664 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteAllSegmentBefore.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteAllSegmentBefore.ts @@ -1,4 +1,4 @@ -import { deleteSegment } from 'roosterjs-content-model-editor'; +import { deleteSegment } from 'roosterjs-content-model-core'; import type { DeleteSelectionStep } from 'roosterjs-content-model-types'; /** diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts index ce066048f33..e1c4af0340b 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteCollapsedSelection.ts @@ -1,4 +1,4 @@ -import { deleteBlock, deleteSegment } from 'roosterjs-content-model-editor'; +import { deleteBlock, deleteSegment } from 'roosterjs-content-model-core'; import { getLeafSiblingBlock } from '../utils/getLeafSiblingBlock'; import { setParagraphNotImplicit } from 'roosterjs-content-model-dom'; import type { BlockAndPath } from '../utils/getLeafSiblingBlock'; diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts index 65376862dd7..6435a2e1c1b 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/deleteSteps/deleteWordSelection.ts @@ -1,4 +1,4 @@ -import { isPunctuation, isSpace, normalizeText } from 'roosterjs-content-model-editor'; +import { isPunctuation, isSpace, normalizeText } from 'roosterjs-content-model-core'; import { isWhiteSpacePreserved } from 'roosterjs-content-model-dom'; import type { ContentModelParagraph, diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts index 7a72552d91c..8c8a279edd3 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts @@ -1,6 +1,5 @@ -import { ChangeSource } from 'roosterjs-content-model-core'; +import { ChangeSource, deleteSelection, isModifierKey } from 'roosterjs-content-model-core'; import { deleteAllSegmentBefore } from './deleteSteps/deleteAllSegmentBefore'; -import { deleteSelection, isModifierKey } from 'roosterjs-content-model-editor'; import { isNodeOfType } from 'roosterjs-content-model-dom'; import { handleKeyboardEventResult, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts index be02c187733..9a8dcd7d7fd 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteCollapsedSelectionTest.ts @@ -1,4 +1,4 @@ -import { deleteSelection } from 'roosterjs-content-model-editor'; +import { deleteSelection } from 'roosterjs-content-model-core'; import { ContentModelEntity, ContentModelSelectionMarker, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteWordSelectionTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteWordSelectionTest.ts index 9e51b199804..88531728bdf 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteWordSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/deleteSteps/deleteWordSelectionTest.ts @@ -1,4 +1,4 @@ -import { deleteSelection } from 'roosterjs-content-model-editor'; +import { deleteSelection } from 'roosterjs-content-model-core'; import { createContentModelDocument, createParagraph, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts index 868f433ab1c..902d3502c8b 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts @@ -1,4 +1,4 @@ -import * as deleteSelection from 'roosterjs-content-model-editor/lib/publicApi/selection/deleteSelection'; +import * as deleteSelection from 'roosterjs-content-model-core/lib/publicApi/selection/deleteSelection'; import * as handleKeyboardEventResult from '../../lib/edit/handleKeyboardEventCommon'; import { ChangeSource } from 'roosterjs-content-model-core'; import { ContentModelDocument, DOMSelection } from 'roosterjs-content-model-types'; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts index 9b2700ec494..55e5a6a68fd 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelOnlineTest.ts @@ -1,8 +1,9 @@ import * as processPastedContentFromExcel from '../../../lib/paste/Excel/processPastedContentFromExcel'; import { ClipboardData } from 'roosterjs-editor-types'; import { expectEqual, initEditor } from './testUtils'; -import { IContentModelEditor, paste } from 'roosterjs-content-model-editor'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { paste } from 'roosterjs-content-model-core'; import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_From_ExcelOnline_E2E'; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts index c737b5e9012..dfd1cc6ad7b 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts @@ -2,8 +2,9 @@ import * as processPastedContentFromExcel from '../../../lib/paste/Excel/process import { Browser } from 'roosterjs-editor-dom'; import { ClipboardData } from 'roosterjs-editor-types'; import { expectEqual, initEditor } from './testUtils'; -import { IContentModelEditor, paste } from 'roosterjs-content-model-editor'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { paste } from 'roosterjs-content-model-core'; import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_From_Excel_E2E'; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts index 36411322f5f..9f2f0391918 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWacTest.ts @@ -2,7 +2,8 @@ import * as processPastedContentWacComponents from '../../../lib/paste/WacCompon import { ClipboardData } from 'roosterjs-editor-types'; import { DomToModelOption } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; -import { IContentModelEditor, paste } from 'roosterjs-content-model-editor'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; +import { paste } from 'roosterjs-content-model-core'; import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_From_WORD_Online_E2E'; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts index c364736e4b8..ba61bd45854 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts @@ -1,9 +1,9 @@ import * as wordFile from '../../../lib/paste/WordDesktop/processPastedContentFromWordDesktop'; import { ClipboardData } from 'roosterjs-editor-types'; -import { cloneModel } from 'roosterjs-content-model-core'; +import { cloneModel, paste } from 'roosterjs-content-model-core'; import { DomToModelOption } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; -import { IContentModelEditor, paste } from 'roosterjs-content-model-editor'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; import { tableProcessor } from 'roosterjs-content-model-dom'; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts index efee14a775f..387cfe53e97 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteTest.ts @@ -2,8 +2,9 @@ import * as wordFile from '../../../lib/paste/WordDesktop/processPastedContentFr import { ClipboardData } from 'roosterjs-editor-types'; import { DomToModelOption } from 'roosterjs-content-model-types'; import { expectEqual, initEditor } from './testUtils'; -import { IContentModelEditor, paste } from 'roosterjs-content-model-editor'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { itChromeOnly } from 'roosterjs-editor-dom/test/DomTestHelper'; +import { paste } from 'roosterjs-content-model-core'; import { tableProcessor } from 'roosterjs-content-model-dom'; const ID = 'CM_Paste_E2E'; From 705e7413bed8046f2e02b3ce0d59450088aefe60 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 10 Nov 2023 09:51:26 -0800 Subject: [PATCH 045/111] Move format API to roosterjs-content-model-api package (#2200) * Move ContentModelEdit plugin to plugins package * Move types to roosterjs-content-model-types package * improve * Improve * improve * Improve * improve * Move core API to core package * fix build * improve * Move corePlugins to roosterjs-content-model-core package * fix build * improve * fix build * Move format API to roosterjs-content-model-api package * fix build * Improve --- .../model/ContentModelDocumentView.tsx | 2 +- .../model/ContentModelFormatContainerView.tsx | 2 +- .../model/ContentModelGeneralView.tsx | 2 +- .../model/ContentModelListItemView.tsx | 2 +- .../model/ContentModelParagraphView.tsx | 2 +- .../model/ContentModelTableCellView.tsx | 2 +- .../model/ContentModelTableRowView.tsx | 2 +- .../model/ContentModelTableView.tsx | 2 +- .../ContentModelFormatPainterPlugin.ts | 7 +-- .../contentModel/ContentModelRibbonPlugin.ts | 3 +- .../contentModel/alignCenterButton.ts | 3 +- .../contentModel/alignLeftButton.ts | 3 +- .../contentModel/alignRightButton.ts | 3 +- .../contentModel/backgroundColorButton.ts | 3 +- .../contentModel/blockQuoteButton.ts | 3 +- .../ribbonButtons/contentModel/boldButton.ts | 3 +- .../contentModel/bulletedListButton.ts | 3 +- .../contentModel/changeImageButton.ts | 3 +- .../contentModel/clearFormatButton.ts | 3 +- .../ribbonButtons/contentModel/codeButton.ts | 3 +- .../contentModel/decreaseFontSizeButton.ts | 3 +- .../contentModel/decreaseIndentButton.ts | 3 +- .../ribbonButtons/contentModel/fontButton.ts | 3 +- .../contentModel/fontSizeButton.ts | 3 +- .../contentModel/formatTableButton.ts | 3 +- .../contentModel/imageBorderColorButton.ts | 3 +- .../contentModel/imageBorderRemoveButton.ts | 3 +- .../contentModel/imageBorderStyleButton.ts | 3 +- .../contentModel/imageBorderWidthButton.ts | 3 +- .../contentModel/imageBoxShadowButton.ts | 3 +- .../contentModel/increaseFontSizeButton.ts | 3 +- .../contentModel/increaseIndentButton.ts | 3 +- .../contentModel/insertImageButton.ts | 3 +- .../contentModel/insertLinkButton.ts | 7 +-- .../contentModel/insertTableButton.ts | 3 +- .../contentModel/italicButton.ts | 3 +- .../contentModel/listStartNumberButton.ts | 3 +- .../ribbonButtons/contentModel/ltrButton.ts | 3 +- .../contentModel/numberedListButton.ts | 3 +- .../contentModel/removeLinkButton.ts | 3 +- .../ribbonButtons/contentModel/rtlButton.ts | 3 +- .../setBulletedListStyleButton.ts | 3 +- .../contentModel/setHeadingLevelButton.ts | 3 +- .../setNumberedListStyleButton.ts | 3 +- .../contentModel/setTableCellShadeButton.ts | 3 +- .../contentModel/setTableHeaderButton.ts | 3 +- .../contentModel/spaceBeforeAfterButtons.ts | 7 +-- .../contentModel/spacingButton.ts | 3 +- .../contentModel/strikethroughButton.ts | 3 +- .../contentModel/subscriptButton.ts | 3 +- .../contentModel/superscriptButton.ts | 3 +- .../contentModel/tableBorderApplyButton.ts | 3 +- .../contentModel/tableEditButtons.ts | 3 +- .../contentModel/textColorButton.ts | 3 +- .../contentModel/underlineButton.ts | 3 +- .../insertEntity/InsertEntityPane.tsx | 3 +- .../ContentModelFormatStatePlugin.ts | 3 +- demo/scripts/tsconfig.json | 6 ++ .../roosterjs-content-model-api/lib/index.ts | 45 ++++++++++++++ .../lib/modelApi/block/setModelAlignment.ts | 0 .../lib/modelApi/block/setModelDirection.ts | 0 .../lib/modelApi/block/setModelIndentation.ts | 0 .../modelApi/block/toggleModelBlockQuote.ts | 0 .../lib/modelApi/common/clearModelFormat.ts | 0 .../common/retrieveModelFormatState.ts | 0 .../lib/modelApi/common/wrapBlock.ts | 0 .../lib/modelApi}/domUtils/readFile.ts | 0 .../lib/modelApi/entity/insertEntityModel.ts | 0 .../modelApi/image/applyImageBorderFormat.ts | 0 .../list/findListItemsInSameThread.ts | 0 .../lib/modelApi/list/setListType.ts | 0 .../selection/adjustSegmentSelection.ts | 0 .../modelApi/selection/adjustWordSelection.ts | 0 .../selection/collapseTableSelection.ts | 0 .../lib/modelApi/table/alignTable.ts | 0 .../lib/modelApi/table/alignTableCell.ts | 0 .../lib/modelApi/table/canMergeCells.ts | 0 .../modelApi/table/createTableStructure.ts | 0 .../lib/modelApi/table/deleteTable.ts | 0 .../lib/modelApi/table/deleteTableColumn.ts | 0 .../lib/modelApi/table/deleteTableRow.ts | 0 .../table/ensureFocusableParagraphForTable.ts | 0 .../lib/modelApi/table/getSelectedCells.ts | 0 .../lib/modelApi/table/insertTableColumn.ts | 0 .../lib/modelApi/table/insertTableRow.ts | 0 .../lib/modelApi/table/mergeTableCells.ts | 0 .../lib/modelApi/table/mergeTableColumn.ts | 0 .../lib/modelApi/table/mergeTableRow.ts | 0 .../table/splitTableCellHorizontally.ts | 0 .../table/splitTableCellVertically.ts | 0 .../lib/publicApi/block/setAlignment.ts | 4 +- .../lib/publicApi/block/setDirection.ts | 4 +- .../lib/publicApi/block/setHeadingLevel.ts | 8 ++- .../lib/publicApi/block/setIndentation.ts | 4 +- .../lib/publicApi/block/setParagraphMargin.ts | 4 +- .../lib/publicApi/block/setSpacing.ts | 4 +- .../lib/publicApi/block/toggleBlockQuote.ts | 8 ++- .../lib/publicApi/entity/insertEntity.ts | 8 +-- .../lib/publicApi/format/clearFormat.ts | 4 +- .../lib/publicApi/format/getFormatState.ts | 5 +- .../publicApi/image/adjustImageSelection.ts | 7 +-- .../lib/publicApi/image/changeImage.ts | 7 +-- .../lib/publicApi/image/insertImage.ts | 8 +-- .../lib/publicApi/image/setImageAltText.ts | 5 +- .../lib/publicApi/image/setImageBorder.ts | 5 +- .../lib/publicApi/image/setImageBoxShadow.ts | 5 +- .../lib/publicApi/link/adjustLinkSelection.ts | 4 +- .../lib/publicApi/link/insertLink.ts | 5 +- .../lib/publicApi/link/removeLink.ts | 4 +- .../lib/publicApi/list/setListStartNumber.ts | 4 +- .../lib/publicApi/list/setListStyle.ts | 5 +- .../lib/publicApi/list/toggleBullet.ts | 4 +- .../lib/publicApi/list/toggleNumbering.ts | 4 +- .../publicApi/segment/applySegmentFormat.ts | 5 +- .../publicApi/segment/changeCapitalization.ts | 4 +- .../lib/publicApi/segment/changeFontSize.ts | 7 +-- .../publicApi/segment/setBackgroundColor.ts | 5 +- .../lib/publicApi/segment/setFontName.ts | 4 +- .../lib/publicApi/segment/setFontSize.ts | 4 +- .../lib/publicApi/segment/setTextColor.ts | 4 +- .../lib/publicApi/segment/toggleBold.ts | 4 +- .../lib/publicApi/segment/toggleCode.ts | 5 +- .../lib/publicApi/segment/toggleItalic.ts | 4 +- .../publicApi/segment/toggleStrikethrough.ts | 4 +- .../lib/publicApi/segment/toggleSubscript.ts | 4 +- .../publicApi/segment/toggleSuperscript.ts | 4 +- .../lib/publicApi/segment/toggleUnderline.ts | 4 +- .../selection/hasSelectionInBlock.ts | 0 .../selection/hasSelectionInBlockGroup.ts | 0 .../selection/hasSelectionInSegment.ts | 0 .../publicApi/table/applyTableBorderFormat.ts | 4 +- .../lib/publicApi/table/editTable.ts | 5 +- .../lib/publicApi/table/formatTable.ts | 5 +- .../lib/publicApi/table/insertTable.ts | 5 +- .../lib/publicApi/table/setTableCellShade.ts | 4 +- .../utils/formatImageWithContentModel.ts | 5 +- .../utils/formatParagraphWithContentModel.ts | 5 +- .../utils/formatSegmentWithContentModel.ts | 4 +- .../roosterjs-content-model-api/package.json | 14 +++++ .../modelApi/block/setModelAlignmentTest.ts | 0 .../modelApi/block/setModelDirectionTest.ts | 0 .../modelApi/block/setModelIndentationTest.ts | 0 .../block/toggleModelBlockQuoteTest.ts | 0 .../modelApi/common/clearModelFormatTest.ts | 0 .../common/retrieveModelFormatStateTest.ts | 0 .../test/modelApi/common/wrapBlockTest.ts | 0 .../modelApi/entity/insertEntityModelTest.ts | 0 .../image/applyImageBorderFormatTest.ts | 0 .../list/findListItemsInSameThreadTest.ts | 0 .../test/modelApi/list/setListTypeTest.ts | 0 .../selection/adjustSegmentSelectionTest.ts | 0 .../selection/adjustWordSelectionTest.ts | 0 .../selection/collapseTableSelectionTest.ts | 0 .../test/modelApi/table/alignTableCellTest.ts | 0 .../test/modelApi/table/alignTableTest.ts | 0 .../test/modelApi/table/canMergeCellsTest.ts | 0 .../table/createTableStructureTest.ts | 0 .../modelApi/table/deleteTableColumnTest.ts | 0 .../test/modelApi/table/deleteTableRowTest.ts | 0 .../test/modelApi/table/deleteTableTest.ts | 0 .../ensureFocusableParagraphForTableTest.ts | 0 .../modelApi/table/getSelectedCellsTest.ts | 0 .../modelApi/table/insertTableColumnTest.ts | 0 .../test/modelApi/table/insertTableRowTest.ts | 0 .../modelApi/table/mergeTableCellsTest.ts | 0 .../modelApi/table/mergeTableColumnTest.ts | 0 .../test/modelApi/table/mergeTableRowTest.ts | 0 .../table/splitTableCellHorizontallyTest.ts | 0 .../table/splitTableCellVerticallyTest.ts | 0 .../publicApi/block/paragraphTestCommon.ts | 6 +- .../test/publicApi/block/setAlignmentTest.ts | 16 ++--- .../test/publicApi/block/setDirectionTest.ts | 0 .../publicApi/block/setHeadingLevelTest.ts | 0 .../publicApi/block/setIndentationTest.ts | 6 +- .../publicApi/block/setParagraphMarginTest.ts | 0 .../test/publicApi/block/setSpacingTest.ts | 0 .../publicApi/block/toggleBlockQuoteTest.ts | 6 +- .../test/publicApi/entity/insertEntityTest.ts | 4 +- .../test/publicApi/format/clearFormatTest.ts | 4 +- .../publicApi/format/getFormatStateTest.ts | 4 +- .../image/adjustImageSelectionTest.ts | 0 .../test/publicApi/image/changeImageTest.ts | 8 +-- .../test/publicApi/image/insertImageTest.ts | 8 +-- .../publicApi/image/setImageAltTextTest.ts | 0 .../publicApi/image/setImageBorderTest.ts | 0 .../publicApi/image/setImageBoxShadowTest.ts | 0 .../publicApi/link/adjustLinkSelectionTest.ts | 8 +-- .../test/publicApi/link/insertLinkTest.ts | 8 +-- .../test/publicApi/link/removeLinkTest.ts | 6 +- .../publicApi/list/setListStartNumberTest.ts | 0 .../test/publicApi/list/setListStyleTest.ts | 0 .../test/publicApi/list/toggleBulletTest.ts | 6 +- .../publicApi/list/toggleNumberingTest.ts | 6 +- .../segment/applySegmentFormatTest.ts | 0 .../segment/changeCapitalizationTest.ts | 0 .../publicApi/segment/changeFontSizeTest.ts | 4 +- .../publicApi/segment/segmentTestCommon.ts | 6 +- .../segment/setBackgroundColorTest.ts | 0 .../test/publicApi/segment/setFontNameTest.ts | 0 .../test/publicApi/segment/setFontSizeTest.ts | 0 .../publicApi/segment/setTextColorTest.ts | 0 .../test/publicApi/segment/toggleBoldTest.ts | 0 .../test/publicApi/segment/toggleCodeTest.ts | 0 .../publicApi/segment/toggleItalicTest.ts | 0 .../segment/toggleStrikethroughTest.ts | 0 .../publicApi/segment/toggleSubscriptTest.ts | 0 .../segment/toggleSuperscriptTest.ts | 0 .../publicApi/segment/toggleUnderlineTest.ts | 0 .../selection/hasSelectionInBlockTest.ts | 0 .../selection/hasSelectionInSegmentTest.ts | 0 .../table/applyTableBorderFormatTest.ts | 6 +- .../publicApi/table/setTableCellShadeTest.ts | 6 +- .../utils/formatImageWithContentModelTest.ts | 6 +- .../formatParagraphWithContentModelTest.ts | 6 +- .../formatSegmentWithContentModelTest.ts | 11 +--- .../lib/index.ts | 46 -------------- .../package.json | 1 - .../lib/editor/IStandaloneEditor.ts | 62 +++++++++++++++++++ .../roosterjs-content-model/lib/index.ts | 2 + .../roosterjs-content-model/package.json | 4 +- 220 files changed, 407 insertions(+), 308 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-api/lib/index.ts rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/block/setModelAlignment.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/block/setModelDirection.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/block/setModelIndentation.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/block/toggleModelBlockQuote.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/common/clearModelFormat.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/common/retrieveModelFormatState.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/common/wrapBlock.ts (100%) rename packages-content-model/{roosterjs-content-model-editor/lib => roosterjs-content-model-api/lib/modelApi}/domUtils/readFile.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/entity/insertEntityModel.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/image/applyImageBorderFormat.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/list/findListItemsInSameThread.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/list/setListType.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/selection/adjustSegmentSelection.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/selection/adjustWordSelection.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/selection/collapseTableSelection.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/alignTable.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/alignTableCell.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/canMergeCells.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/createTableStructure.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/deleteTable.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/deleteTableColumn.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/deleteTableRow.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/ensureFocusableParagraphForTable.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/getSelectedCells.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/insertTableColumn.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/insertTableRow.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/mergeTableCells.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/mergeTableColumn.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/mergeTableRow.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/splitTableCellHorizontally.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/modelApi/table/splitTableCellVertically.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/block/setAlignment.ts (80%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/block/setDirection.ts (70%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/block/setHeadingLevel.ts (90%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/block/setIndentation.ts (88%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/block/setParagraphMargin.ts (90%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/block/setSpacing.ts (78%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/block/toggleBlockQuote.ts (86%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/entity/insertEntity.ts (95%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/format/clearFormat.ts (87%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/format/getFormatState.ts (95%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/image/adjustImageSelection.ts (75%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/image/changeImage.ts (80%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/image/insertImage.ts (77%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/image/setImageAltText.ts (63%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/image/setImageBorder.ts (81%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/image/setImageBoxShadow.ts (84%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/link/adjustLinkSelection.ts (89%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/link/insertLink.ts (96%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/link/removeLink.ts (89%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/list/setListStartNumber.ts (80%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/list/setListStyle.ts (81%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/list/toggleBullet.ts (79%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/list/toggleNumbering.ts (79%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/segment/applySegmentFormat.ts (84%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/segment/changeCapitalization.ts (95%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/segment/changeFontSize.ts (93%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/segment/setBackgroundColor.ts (89%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/segment/setFontName.ts (77%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/segment/setFontSize.ts (88%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/segment/setTextColor.ts (83%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/segment/toggleBold.ts (84%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/segment/toggleCode.ts (76%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/segment/toggleItalic.ts (72%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/segment/toggleStrikethrough.ts (72%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/segment/toggleSubscript.ts (75%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/segment/toggleSuperscript.ts (75%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/segment/toggleUnderline.ts (78%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/selection/hasSelectionInBlock.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/selection/hasSelectionInBlockGroup.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/selection/hasSelectionInSegment.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/table/applyTableBorderFormat.ts (99%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/table/editTable.ts (95%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/table/formatTable.ts (87%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/table/insertTable.ts (91%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/table/setTableCellShade.ts (85%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/utils/formatImageWithContentModel.ts (74%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/utils/formatParagraphWithContentModel.ts (74%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/lib/publicApi/utils/formatSegmentWithContentModel.ts (96%) create mode 100644 packages-content-model/roosterjs-content-model-api/package.json rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/block/setModelAlignmentTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/block/setModelDirectionTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/block/setModelIndentationTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/block/toggleModelBlockQuoteTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/common/clearModelFormatTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/common/retrieveModelFormatStateTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/common/wrapBlockTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/entity/insertEntityModelTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/image/applyImageBorderFormatTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/list/findListItemsInSameThreadTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/list/setListTypeTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/selection/adjustSegmentSelectionTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/selection/adjustWordSelectionTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/selection/collapseTableSelectionTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/alignTableCellTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/alignTableTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/canMergeCellsTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/createTableStructureTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/deleteTableColumnTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/deleteTableRowTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/deleteTableTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/ensureFocusableParagraphForTableTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/getSelectedCellsTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/insertTableColumnTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/insertTableRowTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/mergeTableCellsTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/mergeTableColumnTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/mergeTableRowTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/splitTableCellHorizontallyTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/modelApi/table/splitTableCellVerticallyTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/block/paragraphTestCommon.ts (85%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/block/setAlignmentTest.ts (98%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/block/setDirectionTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/block/setHeadingLevelTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/block/setIndentationTest.ts (93%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/block/setParagraphMarginTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/block/setSpacingTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/block/toggleBlockQuoteTest.ts (94%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/entity/insertEntityTest.ts (98%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/format/clearFormatTest.ts (92%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/format/getFormatStateTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/image/adjustImageSelectionTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/image/changeImageTest.ts (96%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/image/insertImageTest.ts (96%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/image/setImageAltTextTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/image/setImageBorderTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/image/setImageBoxShadowTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/link/adjustLinkSelectionTest.ts (97%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/link/insertLinkTest.ts (97%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/link/removeLinkTest.ts (98%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/list/setListStartNumberTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/list/setListStyleTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/list/toggleBulletTest.ts (91%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/list/toggleNumberingTest.ts (90%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/segment/applySegmentFormatTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/segment/changeCapitalizationTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/segment/changeFontSizeTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/segment/segmentTestCommon.ts (86%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/segment/setBackgroundColorTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/segment/setFontNameTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/segment/setFontSizeTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/segment/setTextColorTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/segment/toggleBoldTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/segment/toggleCodeTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/segment/toggleItalicTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/segment/toggleStrikethroughTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/segment/toggleSubscriptTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/segment/toggleSuperscriptTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/segment/toggleUnderlineTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/selection/hasSelectionInBlockTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/selection/hasSelectionInSegmentTest.ts (100%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/table/applyTableBorderFormatTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/table/setTableCellShadeTest.ts (99%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/utils/formatImageWithContentModelTest.ts (97%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/utils/formatParagraphWithContentModelTest.ts (95%) rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-api}/test/publicApi/utils/formatSegmentWithContentModelTest.ts (96%) diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelDocumentView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelDocumentView.tsx index 6fbe45c1889..cb2729be51d 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelDocumentView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelDocumentView.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { BlockGroupContentView } from './BlockGroupContentView'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { ContentModelView } from '../ContentModelView'; -import { hasSelectionInBlockGroup } from 'roosterjs-content-model-editor'; +import { hasSelectionInBlockGroup } from 'roosterjs-content-model-api'; const styles = require('./ContentModelDocumentView.scss'); diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelFormatContainerView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelFormatContainerView.tsx index 646f71b63da..e7d93eba233 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelFormatContainerView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelFormatContainerView.tsx @@ -5,7 +5,7 @@ import { ContentModelView } from '../ContentModelView'; import { DisplayFormatRenderer } from '../format/formatPart/DisplayFormatRenderer'; import { FormatRenderer } from '../format/utils/FormatRenderer'; import { FormatView } from '../format/FormatView'; -import { hasSelectionInBlock } from 'roosterjs-content-model-editor'; +import { hasSelectionInBlock } from 'roosterjs-content-model-api'; import { SegmentFormatView } from '../format/SegmentFormatView'; import { SizeFormatRenderers } from '../format/formatPart/SizeFormatRenderers'; import { diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelGeneralView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelGeneralView.tsx index bf957e34a1e..d42b2ef1eb0 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelGeneralView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelGeneralView.tsx @@ -3,7 +3,7 @@ import { BlockGroupContentView } from './BlockGroupContentView'; import { ContentModelCodeView } from './ContentModelCodeView'; import { ContentModelLinkView } from './ContentModelLinkView'; import { ContentModelView } from '../ContentModelView'; -import { hasSelectionInBlock } from 'roosterjs-content-model-editor'; +import { hasSelectionInBlock } from 'roosterjs-content-model-api'; import { SegmentFormatView } from '../format/SegmentFormatView'; import { ContentModelGeneralBlock, diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelListItemView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelListItemView.tsx index b453f77ae99..4d75adf63a1 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelListItemView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelListItemView.tsx @@ -7,7 +7,7 @@ import { FontFamilyFormatRenderer } from '../format/formatPart/FontFamilyFormatR import { FontSizeFormatRenderer } from '../format/formatPart/FontSizeFormatRenderer'; import { FormatRenderer } from '../format/utils/FormatRenderer'; import { FormatView } from '../format/FormatView'; -import { hasSelectionInBlockGroup } from 'roosterjs-content-model-editor'; +import { hasSelectionInBlockGroup } from 'roosterjs-content-model-api'; import { LineHeightFormatRenderer } from '../format/formatPart/LineHeightFormatRenderer'; import { MarginFormatRenderer } from '../format/formatPart/MarginFormatRenderer'; import { TextAlignFormatRenderer } from '../format/formatPart/TextAlignFormatRenderer'; diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelParagraphView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelParagraphView.tsx index e5c7907f3f6..10fd06f9ef5 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelParagraphView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelParagraphView.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { BlockFormatView } from '../format/BlockFormatView'; import { ContentModelSegmentView } from './ContentModelSegmentView'; import { ContentModelView } from '../ContentModelView'; -import { hasSelectionInBlock } from 'roosterjs-content-model-editor'; +import { hasSelectionInBlock } from 'roosterjs-content-model-api'; import { SegmentFormatView } from '../format/SegmentFormatView'; import { useProperty } from '../../hooks/useProperty'; import { diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelTableCellView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelTableCellView.tsx index f0a3ca81e4b..065c8ead528 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelTableCellView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelTableCellView.tsx @@ -8,7 +8,7 @@ import { ContentModelView } from '../ContentModelView'; import { DirectionFormatRenderer } from '../format/formatPart/DirectionFormatRenderer'; import { FormatRenderer } from '../format/utils/FormatRenderer'; import { FormatView } from '../format/FormatView'; -import { hasSelectionInBlockGroup } from 'roosterjs-content-model-editor'; +import { hasSelectionInBlockGroup } from 'roosterjs-content-model-api'; import { HtmlAlignFormatRenderer } from '../format/formatPart/HtmlAlignFormatRenderer'; import { MetadataView } from '../format/MetadataView'; import { PaddingFormatRenderer } from '../format/formatPart/PaddingFormatRenderer'; diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelTableRowView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelTableRowView.tsx index fbd8a002d6e..7a70f087476 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelTableRowView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelTableRowView.tsx @@ -5,7 +5,7 @@ import { ContentModelBlockGroupView } from './ContentModelBlockGroupView'; import { ContentModelView } from '../ContentModelView'; import { FormatRenderer } from '../format/utils/FormatRenderer'; import { FormatView } from '../format/FormatView'; -import { hasSelectionInBlockGroup } from 'roosterjs-content-model-editor'; +import { hasSelectionInBlockGroup } from 'roosterjs-content-model-api'; import { useProperty } from '../../hooks/useProperty'; const styles = require('./ContentModelTableRowView.scss'); diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelTableView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelTableView.tsx index acd847ac78d..744d9ae4db2 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelTableView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelTableView.tsx @@ -8,7 +8,7 @@ import { ContentModelView } from '../ContentModelView'; import { DisplayFormatRenderer } from '../format/formatPart/DisplayFormatRenderer'; import { FormatRenderer } from '../format/utils/FormatRenderer'; import { FormatView } from '../format/FormatView'; -import { hasSelectionInBlock } from 'roosterjs-content-model-editor'; +import { hasSelectionInBlock } from 'roosterjs-content-model-api'; import { IdFormatRenderer } from '../format/formatPart/IdFormatRenderer'; import { MarginFormatRenderer } from '../format/formatPart/MarginFormatRenderer'; import { MetadataView } from '../format/MetadataView'; diff --git a/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts b/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts index e9f2fee28a9..616096e1d59 100644 --- a/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts +++ b/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts @@ -1,10 +1,7 @@ +import { applySegmentFormat, getFormatState } from 'roosterjs-content-model-api'; import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { EditorPlugin, IEditor, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; -import { - applySegmentFormat, - getFormatState, - IContentModelEditor, -} from 'roosterjs-content-model-editor'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; const FORMATPAINTERCURSOR_SVG = require('./formatpaintercursor.svg'); const FORMATPAINTERCURSOR_STYLE = `;cursor: url("${FORMATPAINTERCURSOR_SVG}") 8.5 16, auto`; diff --git a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts index d24ef3a8bf4..7dd24709acd 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts @@ -1,7 +1,8 @@ import { ContentModelFormatState } from 'roosterjs-content-model-types'; import { FormatState, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; -import { getFormatState, IContentModelEditor } from 'roosterjs-content-model-editor'; +import { getFormatState } from 'roosterjs-content-model-api'; import { getObjectKeys } from 'roosterjs-editor-dom'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { LocalizedStrings, RibbonButton, RibbonPlugin, UIUtilities } from 'roosterjs-react'; export class ContentModelRibbonPlugin implements RibbonPlugin { diff --git a/demo/scripts/controls/ribbonButtons/contentModel/alignCenterButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/alignCenterButton.ts index 7fb8b8bdd65..968d17e3d01 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/alignCenterButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/alignCenterButton.ts @@ -1,5 +1,6 @@ import { AlignCenterButtonStringKey, RibbonButton } from 'roosterjs-react'; -import { isContentModelEditor, setAlignment } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { setAlignment } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/alignLeftButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/alignLeftButton.ts index b2f53aef58d..55088314ec5 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/alignLeftButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/alignLeftButton.ts @@ -1,5 +1,6 @@ import { AlignLeftButtonStringKey, RibbonButton } from 'roosterjs-react'; -import { isContentModelEditor, setAlignment } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { setAlignment } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/alignRightButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/alignRightButton.ts index 8412d73bfda..bc73fd6dce1 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/alignRightButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/alignRightButton.ts @@ -1,5 +1,6 @@ import { AlignRightButtonStringKey, RibbonButton } from 'roosterjs-react'; -import { isContentModelEditor, setAlignment } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { setAlignment } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/backgroundColorButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/backgroundColorButton.ts index 3fa9846098b..b3b9e2641ad 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/backgroundColorButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/backgroundColorButton.ts @@ -1,4 +1,5 @@ -import { isContentModelEditor, setBackgroundColor } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { setBackgroundColor } from 'roosterjs-content-model-api'; import { BackgroundColorButtonStringKey, getBackgroundColorValue, diff --git a/demo/scripts/controls/ribbonButtons/contentModel/blockQuoteButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/blockQuoteButton.ts index 656a4f25848..583287296be 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/blockQuoteButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/blockQuoteButton.ts @@ -1,5 +1,6 @@ -import { isContentModelEditor, toggleBlockQuote } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { QuoteButtonStringKey, RibbonButton } from 'roosterjs-react'; +import { toggleBlockQuote } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/boldButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/boldButton.ts index 52cda87d295..9bcd3597502 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/boldButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/boldButton.ts @@ -1,5 +1,6 @@ import { BoldButtonStringKey, RibbonButton } from 'roosterjs-react'; -import { isContentModelEditor, toggleBold } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { toggleBold } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/bulletedListButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/bulletedListButton.ts index 9d9c2c35607..12c2d492889 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/bulletedListButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/bulletedListButton.ts @@ -1,5 +1,6 @@ import { BulletedListButtonStringKey, RibbonButton } from 'roosterjs-react'; -import { isContentModelEditor, toggleBullet } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { toggleBullet } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/changeImageButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/changeImageButton.ts index 620cc60b64e..d6ab58b8c71 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/changeImageButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/changeImageButton.ts @@ -1,6 +1,7 @@ -import { changeImage, isContentModelEditor } from 'roosterjs-content-model-editor'; +import { changeImage } from 'roosterjs-content-model-api'; import { createElement } from 'roosterjs-editor-dom'; import { CreateElementData } from 'roosterjs-editor-types'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; const FileInput: CreateElementData = { diff --git a/demo/scripts/controls/ribbonButtons/contentModel/clearFormatButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/clearFormatButton.ts index 5e8709dab20..cdfc02e6f51 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/clearFormatButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/clearFormatButton.ts @@ -1,5 +1,6 @@ -import { clearFormat, isContentModelEditor } from 'roosterjs-content-model-editor'; +import { clearFormat } from 'roosterjs-content-model-api'; import { ClearFormatButtonStringKey, RibbonButton } from 'roosterjs-react'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; /** * "Clear format" button on the format ribbon diff --git a/demo/scripts/controls/ribbonButtons/contentModel/codeButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/codeButton.ts index e3bafe5629f..5c26c62e66c 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/codeButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/codeButton.ts @@ -1,5 +1,6 @@ import { CodeButtonStringKey, RibbonButton } from 'roosterjs-react'; -import { isContentModelEditor, toggleCode } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { toggleCode } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/decreaseFontSizeButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/decreaseFontSizeButton.ts index 0e1c9409275..f0d0ac14a1d 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/decreaseFontSizeButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/decreaseFontSizeButton.ts @@ -1,5 +1,6 @@ -import { changeFontSize, isContentModelEditor } from 'roosterjs-content-model-editor'; +import { changeFontSize } from 'roosterjs-content-model-api'; import { DecreaseFontSizeButtonStringKey, RibbonButton } from 'roosterjs-react'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/decreaseIndentButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/decreaseIndentButton.ts index ab5cd8c029b..c1d99a80c7a 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/decreaseIndentButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/decreaseIndentButton.ts @@ -1,5 +1,6 @@ import { DecreaseIndentButtonStringKey, RibbonButton } from 'roosterjs-react'; -import { isContentModelEditor, setIndentation } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { setIndentation } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/fontButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/fontButton.ts index 885a805b06d..139fec0771d 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/fontButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/fontButton.ts @@ -1,5 +1,6 @@ import { FontButtonStringKey, RibbonButton } from 'roosterjs-react'; -import { isContentModelEditor, setFontName } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { setFontName } from 'roosterjs-content-model-api'; interface FontName { name: string; diff --git a/demo/scripts/controls/ribbonButtons/contentModel/fontSizeButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/fontSizeButton.ts index e8c37f02703..5fc487586ee 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/fontSizeButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/fontSizeButton.ts @@ -1,5 +1,6 @@ import { FontSizeButtonStringKey, RibbonButton } from 'roosterjs-react'; -import { isContentModelEditor, setFontSize } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { setFontSize } from 'roosterjs-content-model-api'; const FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72]; diff --git a/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts index 789b9929698..e0b0a2236fb 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts @@ -1,4 +1,5 @@ -import { formatTable, isContentModelEditor } from 'roosterjs-content-model-editor'; +import { formatTable } from 'roosterjs-content-model-api'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; import { TableBorderFormat, TableMetadataFormat } from 'roosterjs-content-model-types'; diff --git a/demo/scripts/controls/ribbonButtons/contentModel/imageBorderColorButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/imageBorderColorButton.ts index 3236dd65263..a45ea0174dc 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/imageBorderColorButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/imageBorderColorButton.ts @@ -1,6 +1,7 @@ import { getButtons, getTextColorValue, KnownRibbonButtonKey } from 'roosterjs-react'; -import { isContentModelEditor, setImageBorder } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; +import { setImageBorder } from 'roosterjs-content-model-api'; const originalButton = getButtons([KnownRibbonButtonKey.TextColor])[0] as RibbonButton< 'buttonNameImageBorderColor' diff --git a/demo/scripts/controls/ribbonButtons/contentModel/imageBorderRemoveButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/imageBorderRemoveButton.ts index 29c8b7cd4af..3fe95a2dfcc 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/imageBorderRemoveButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/imageBorderRemoveButton.ts @@ -1,5 +1,6 @@ -import { isContentModelEditor, setImageBorder } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; +import { setImageBorder } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/imageBorderStyleButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/imageBorderStyleButton.ts index 4d0e559f067..c39e9ee88d3 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/imageBorderStyleButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/imageBorderStyleButton.ts @@ -1,5 +1,6 @@ -import { isContentModelEditor, setImageBorder } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; +import { setImageBorder } from 'roosterjs-content-model-api'; const STYLES: Record = { dashed: 'dashed', diff --git a/demo/scripts/controls/ribbonButtons/contentModel/imageBorderWidthButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/imageBorderWidthButton.ts index fda580b2411..be6086f3fc8 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/imageBorderWidthButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/imageBorderWidthButton.ts @@ -1,5 +1,6 @@ -import { isContentModelEditor, setImageBorder } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; +import { setImageBorder } from 'roosterjs-content-model-api'; const WIDTH = [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72]; diff --git a/demo/scripts/controls/ribbonButtons/contentModel/imageBoxShadowButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/imageBoxShadowButton.ts index b5a093174a5..a1eed8d8f92 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/imageBoxShadowButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/imageBoxShadowButton.ts @@ -1,5 +1,6 @@ -import { isContentModelEditor, setImageBoxShadow } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; +import { setImageBoxShadow } from 'roosterjs-content-model-api'; const STYLES_NAMES: Record = { noShadow: 'noShadow', diff --git a/demo/scripts/controls/ribbonButtons/contentModel/increaseFontSizeButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/increaseFontSizeButton.ts index 80eb5fb5428..f9308f87024 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/increaseFontSizeButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/increaseFontSizeButton.ts @@ -1,5 +1,6 @@ -import { changeFontSize, isContentModelEditor } from 'roosterjs-content-model-editor'; +import { changeFontSize } from 'roosterjs-content-model-api'; import { IncreaseFontSizeButtonStringKey, RibbonButton } from 'roosterjs-react'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/increaseIndentButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/increaseIndentButton.ts index 972f35be14d..bdfefb9cbc6 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/increaseIndentButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/increaseIndentButton.ts @@ -1,5 +1,6 @@ import { IncreaseIndentButtonStringKey, RibbonButton } from 'roosterjs-react'; -import { isContentModelEditor, setIndentation } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { setIndentation } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/insertImageButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/insertImageButton.ts index e37d31f7db3..62a14215b5c 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/insertImageButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/insertImageButton.ts @@ -1,7 +1,8 @@ import { createElement } from 'roosterjs-editor-dom'; import { CreateElementData } from 'roosterjs-editor-types'; -import { insertImage, isContentModelEditor } from 'roosterjs-content-model-editor'; +import { insertImage } from 'roosterjs-content-model-api'; import { InsertImageButtonStringKey, RibbonButton } from 'roosterjs-react'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; const FileInput: CreateElementData = { tag: 'input', diff --git a/demo/scripts/controls/ribbonButtons/contentModel/insertLinkButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/insertLinkButton.ts index e8f971511dc..b0e6c717754 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/insertLinkButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/insertLinkButton.ts @@ -1,10 +1,7 @@ +import { adjustLinkSelection, insertLink } from 'roosterjs-content-model-api'; import { InsertLinkButtonStringKey, RibbonButton } from 'roosterjs-react'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { showInputDialog } from 'roosterjs-react/lib/inputDialog'; -import { - adjustLinkSelection, - insertLink, - isContentModelEditor, -} from 'roosterjs-content-model-editor'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/insertTableButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/insertTableButton.ts index c10fd5028d8..91fe7a4cfb9 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/insertTableButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/insertTableButton.ts @@ -1,6 +1,7 @@ import { getButtons, KnownRibbonButtonKey } from 'roosterjs-react'; -import { insertTable, isContentModelEditor } from 'roosterjs-content-model-editor'; +import { insertTable } from 'roosterjs-content-model-api'; import { InsertTableButtonStringKey, RibbonButton } from 'roosterjs-react'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; const originalPasteButton: RibbonButton = getButtons([ KnownRibbonButtonKey.InsertTable, diff --git a/demo/scripts/controls/ribbonButtons/contentModel/italicButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/italicButton.ts index 0ed0d8f3993..18b3f5a70cd 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/italicButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/italicButton.ts @@ -1,5 +1,6 @@ -import { isContentModelEditor, toggleItalic } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { ItalicButtonStringKey, RibbonButton } from 'roosterjs-react'; +import { toggleItalic } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/listStartNumberButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/listStartNumberButton.ts index 7e186693445..cacf17df6c6 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/listStartNumberButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/listStartNumberButton.ts @@ -1,6 +1,7 @@ import showInputDialog from 'roosterjs-react/lib/inputDialog/utils/showInputDialog'; import { CancelButtonStringKey, OkButtonStringKey, RibbonButton } from 'roosterjs-react'; -import { isContentModelEditor, setListStartNumber } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { setListStartNumber } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/ltrButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/ltrButton.ts index 602bb3e7028..ac32f4fdc18 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/ltrButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/ltrButton.ts @@ -1,5 +1,6 @@ +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { LtrButtonStringKey, RibbonButton } from 'roosterjs-react'; -import { isContentModelEditor, setDirection } from 'roosterjs-content-model-editor'; +import { setDirection } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/numberedListButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/numberedListButton.ts index 555b8d54a08..45919be26f9 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/numberedListButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/numberedListButton.ts @@ -1,5 +1,6 @@ -import { isContentModelEditor, toggleNumbering } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { NumberedListButtonStringKey, RibbonButton } from 'roosterjs-react'; +import { toggleNumbering } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/removeLinkButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/removeLinkButton.ts index 08a176b9ef0..3be5b6a0e7f 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/removeLinkButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/removeLinkButton.ts @@ -1,4 +1,5 @@ -import { isContentModelEditor, removeLink } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { removeLink } from 'roosterjs-content-model-api'; import { RemoveLinkButtonStringKey, RibbonButton } from 'roosterjs-react'; /** diff --git a/demo/scripts/controls/ribbonButtons/contentModel/rtlButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/rtlButton.ts index a115c24ae7f..ad786f118fb 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/rtlButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/rtlButton.ts @@ -1,5 +1,6 @@ -import { isContentModelEditor, setDirection } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton, RtlButtonStringKey } from 'roosterjs-react'; +import { setDirection } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/setBulletedListStyleButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/setBulletedListStyleButton.ts index b00646a4e9f..abbb0052cfe 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/setBulletedListStyleButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/setBulletedListStyleButton.ts @@ -1,6 +1,7 @@ import { BulletListType } from 'roosterjs-content-model-types'; -import { isContentModelEditor, setListStyle } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; +import { setListStyle } from 'roosterjs-content-model-api'; const dropDownMenuItems = { [BulletListType.Disc]: 'Disc', [BulletListType.Dash]: 'Dash', diff --git a/demo/scripts/controls/ribbonButtons/contentModel/setHeadingLevelButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/setHeadingLevelButton.ts index 03c44f0dbb7..2b9dd18e30d 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/setHeadingLevelButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/setHeadingLevelButton.ts @@ -1,4 +1,5 @@ -import { isContentModelEditor, setHeadingLevel } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { setHeadingLevel } from 'roosterjs-content-model-api'; import { getButtons, HeadingButtonStringKey, diff --git a/demo/scripts/controls/ribbonButtons/contentModel/setNumberedListStyleButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/setNumberedListStyleButton.ts index b732f4f8761..87fe7369ef7 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/setNumberedListStyleButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/setNumberedListStyleButton.ts @@ -1,6 +1,7 @@ -import { isContentModelEditor, setListStyle } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { NumberingListType } from 'roosterjs-content-model-types'; import { RibbonButton } from 'roosterjs-react'; +import { setListStyle } from 'roosterjs-content-model-api'; const dropDownMenuItems = { [NumberingListType.Decimal]: 'Decimal', diff --git a/demo/scripts/controls/ribbonButtons/contentModel/setTableCellShadeButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/setTableCellShadeButton.ts index 0541618a119..f463e37a3f2 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/setTableCellShadeButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/setTableCellShadeButton.ts @@ -1,4 +1,5 @@ -import { isContentModelEditor, setTableCellShade } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { setTableCellShade } from 'roosterjs-content-model-api'; import { BackgroundColorKeys, getBackgroundColorValue, diff --git a/demo/scripts/controls/ribbonButtons/contentModel/setTableHeaderButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/setTableHeaderButton.ts index 557a5776baf..83795a3336b 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/setTableHeaderButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/setTableHeaderButton.ts @@ -1,5 +1,6 @@ -import { formatTable, isContentModelEditor } from 'roosterjs-content-model-editor'; +import { formatTable } from 'roosterjs-content-model-api'; import { getFormatState } from 'roosterjs-editor-api'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; export const setTableHeaderButton: RibbonButton<'ribbonButtonSetTableHeader'> = { diff --git a/demo/scripts/controls/ribbonButtons/contentModel/spaceBeforeAfterButtons.ts b/demo/scripts/controls/ribbonButtons/contentModel/spaceBeforeAfterButtons.ts index 15cbd8e42a9..9dd219ddfa5 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/spaceBeforeAfterButtons.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/spaceBeforeAfterButtons.ts @@ -1,9 +1,6 @@ +import { getFormatState, setParagraphMargin } from 'roosterjs-content-model-api'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; -import { - getFormatState, - isContentModelEditor, - setParagraphMargin, -} from 'roosterjs-content-model-editor'; const spaceAfterButtonKey = 'buttonNameSpaceAfter'; const spaceBeforeButtonKey = 'buttonNameSpaceBefore'; diff --git a/demo/scripts/controls/ribbonButtons/contentModel/spacingButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/spacingButton.ts index b1666224b0e..32f50c7eb6c 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/spacingButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/spacingButton.ts @@ -1,4 +1,5 @@ -import { isContentModelEditor, setSpacing } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { setSpacing } from 'roosterjs-content-model-api'; import type { RibbonButton } from 'roosterjs-react'; const SPACING_OPTIONS = ['1.0', '1.15', '1.5', '2.0']; diff --git a/demo/scripts/controls/ribbonButtons/contentModel/strikethroughButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/strikethroughButton.ts index 49457b725d1..040752c5b5d 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/strikethroughButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/strikethroughButton.ts @@ -1,5 +1,6 @@ -import { isContentModelEditor, toggleStrikethrough } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton, StrikethroughButtonStringKey } from 'roosterjs-react'; +import { toggleStrikethrough } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/subscriptButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/subscriptButton.ts index 50529a5dac3..4490322311a 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/subscriptButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/subscriptButton.ts @@ -1,5 +1,6 @@ -import { isContentModelEditor, toggleSubscript } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton, SubscriptButtonStringKey } from 'roosterjs-react'; +import { toggleSubscript } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/superscriptButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/superscriptButton.ts index f14c3d30a37..ad2c161e357 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/superscriptButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/superscriptButton.ts @@ -1,5 +1,6 @@ -import { isContentModelEditor, toggleSuperscript } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton, SuperscriptButtonStringKey } from 'roosterjs-react'; +import { toggleSuperscript } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/ribbonButtons/contentModel/tableBorderApplyButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/tableBorderApplyButton.ts index 86dc32f59f4..12552ed4c3d 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/tableBorderApplyButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/tableBorderApplyButton.ts @@ -1,6 +1,7 @@ import MainPaneBase from '../../MainPaneBase'; -import { applyTableBorderFormat, isContentModelEditor } from 'roosterjs-content-model-editor'; +import { applyTableBorderFormat } from 'roosterjs-content-model-api'; import { BorderOperations } from 'roosterjs-content-model-types'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; const TABLE_OPERATIONS: Record = { diff --git a/demo/scripts/controls/ribbonButtons/contentModel/tableEditButtons.ts b/demo/scripts/controls/ribbonButtons/contentModel/tableEditButtons.ts index 2b6d4e86da5..fdf7e39e4f1 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/tableEditButtons.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/tableEditButtons.ts @@ -1,4 +1,5 @@ -import { editTable, isContentModelEditor } from 'roosterjs-content-model-editor'; +import { editTable } from 'roosterjs-content-model-api'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { TableOperation } from 'roosterjs-content-model-types'; import { RibbonButton, diff --git a/demo/scripts/controls/ribbonButtons/contentModel/textColorButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/textColorButton.ts index 0f9a8b8fbf1..168ad36c22c 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/textColorButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/textColorButton.ts @@ -1,4 +1,5 @@ -import { isContentModelEditor, setTextColor } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { setTextColor } from 'roosterjs-content-model-api'; import { getButtons, getTextColorValue, diff --git a/demo/scripts/controls/ribbonButtons/contentModel/underlineButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/underlineButton.ts index 33a77e8e815..0abfc7ada87 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/underlineButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/underlineButton.ts @@ -1,5 +1,6 @@ -import { isContentModelEditor, toggleUnderline } from 'roosterjs-content-model-editor'; +import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton, UnderlineButtonStringKey } from 'roosterjs-react'; +import { toggleUnderline } from 'roosterjs-content-model-api'; /** * @internal diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx index 98ce08e071c..47bba88757b 100644 --- a/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx @@ -2,7 +2,8 @@ import * as React from 'react'; import ApiPaneProps from '../ApiPaneProps'; import { Entity } from 'roosterjs-editor-types'; import { getEntityFromElement, getEntitySelector } from 'roosterjs-editor-dom'; -import { IContentModelEditor, insertEntity } from 'roosterjs-content-model-editor'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; +import { insertEntity } from 'roosterjs-content-model-api'; import { InsertEntityOptions } from 'roosterjs-content-model-types'; import { trustedHTMLHandler } from '../../../../utils/trustedHTMLHandler'; diff --git a/demo/scripts/controls/sidePane/formatState/ContentModelFormatStatePlugin.ts b/demo/scripts/controls/sidePane/formatState/ContentModelFormatStatePlugin.ts index 8028e309225..1cec23429bf 100644 --- a/demo/scripts/controls/sidePane/formatState/ContentModelFormatStatePlugin.ts +++ b/demo/scripts/controls/sidePane/formatState/ContentModelFormatStatePlugin.ts @@ -1,7 +1,8 @@ import FormatStatePlugin from './FormatStatePlugin'; import { FormatState } from 'roosterjs-editor-types'; -import { getFormatState, IContentModelEditor } from 'roosterjs-content-model-editor'; +import { getFormatState } from 'roosterjs-content-model-api'; import { getPositionRect } from 'roosterjs-editor-dom'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; export default class ContentModelFormatStatePlugin extends FormatStatePlugin { protected getFormatState() { diff --git a/demo/scripts/tsconfig.json b/demo/scripts/tsconfig.json index 349e0655b2a..be116dab8c8 100644 --- a/demo/scripts/tsconfig.json +++ b/demo/scripts/tsconfig.json @@ -43,6 +43,12 @@ "roosterjs-content-model-core/lib/*": [ "packages-content-model/roosterjs-content-model-core/lib/*" ], + "roosterjs-content-model-api": [ + "packages-content-model/roosterjs-content-model-api/lib/index" + ], + "roosterjs-content-model-api/lib/*": [ + "packages-content-model/roosterjs-content-model-api/lib/*" + ], "roosterjs-content-model-editor": [ "packages-content-model/roosterjs-content-model-editor/lib/index" ], diff --git a/packages-content-model/roosterjs-content-model-api/lib/index.ts b/packages-content-model/roosterjs-content-model-api/lib/index.ts new file mode 100644 index 00000000000..1db5ab16114 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/lib/index.ts @@ -0,0 +1,45 @@ +export { default as insertTable } from './publicApi/table/insertTable'; +export { default as formatTable } from './publicApi/table/formatTable'; +export { default as setTableCellShade } from './publicApi/table/setTableCellShade'; +export { default as editTable } from './publicApi/table/editTable'; +export { default as applyTableBorderFormat } from './publicApi/table/applyTableBorderFormat'; +export { default as toggleBullet } from './publicApi/list/toggleBullet'; +export { default as toggleNumbering } from './publicApi/list/toggleNumbering'; +export { default as toggleBold } from './publicApi/segment/toggleBold'; +export { default as toggleItalic } from './publicApi/segment/toggleItalic'; +export { default as toggleUnderline } from './publicApi/segment/toggleUnderline'; +export { default as toggleStrikethrough } from './publicApi/segment/toggleStrikethrough'; +export { default as toggleSubscript } from './publicApi/segment/toggleSubscript'; +export { default as toggleSuperscript } from './publicApi/segment/toggleSuperscript'; +export { default as setBackgroundColor } from './publicApi/segment/setBackgroundColor'; +export { default as setFontName } from './publicApi/segment/setFontName'; +export { default as setFontSize } from './publicApi/segment/setFontSize'; +export { default as setTextColor } from './publicApi/segment/setTextColor'; +export { default as changeFontSize } from './publicApi/segment/changeFontSize'; +export { default as applySegmentFormat } from './publicApi/segment/applySegmentFormat'; +export { default as changeCapitalization } from './publicApi/segment/changeCapitalization'; +export { default as insertImage } from './publicApi/image/insertImage'; +export { default as setListStyle } from './publicApi/list/setListStyle'; +export { default as setListStartNumber } from './publicApi/list/setListStartNumber'; +export { default as hasSelectionInBlock } from './publicApi/selection/hasSelectionInBlock'; +export { default as hasSelectionInSegment } from './publicApi/selection/hasSelectionInSegment'; +export { default as hasSelectionInBlockGroup } from './publicApi/selection/hasSelectionInBlockGroup'; +export { default as setIndentation } from './publicApi/block/setIndentation'; +export { default as setAlignment } from './publicApi/block/setAlignment'; +export { default as setDirection } from './publicApi/block/setDirection'; +export { default as setHeadingLevel } from './publicApi/block/setHeadingLevel'; +export { default as toggleBlockQuote } from './publicApi/block/toggleBlockQuote'; +export { default as setSpacing } from './publicApi/block/setSpacing'; +export { default as setImageBorder } from './publicApi/image/setImageBorder'; +export { default as setImageBoxShadow } from './publicApi/image/setImageBoxShadow'; +export { default as changeImage } from './publicApi/image/changeImage'; +export { default as getFormatState } from './publicApi/format/getFormatState'; +export { default as clearFormat } from './publicApi/format/clearFormat'; +export { default as insertLink } from './publicApi/link/insertLink'; +export { default as removeLink } from './publicApi/link/removeLink'; +export { default as adjustLinkSelection } from './publicApi/link/adjustLinkSelection'; +export { default as setImageAltText } from './publicApi/image/setImageAltText'; +export { default as adjustImageSelection } from './publicApi/image/adjustImageSelection'; +export { default as setParagraphMargin } from './publicApi/block/setParagraphMargin'; +export { default as toggleCode } from './publicApi/segment/toggleCode'; +export { default as insertEntity } from './publicApi/entity/insertEntity'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelAlignment.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelAlignment.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelAlignment.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelDirection.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelDirection.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelDirection.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelDirection.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelIndentation.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/setModelIndentation.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/block/setModelIndentation.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/toggleModelBlockQuote.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/block/toggleModelBlockQuote.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/clearModelFormat.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/clearModelFormat.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/common/clearModelFormat.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/common/retrieveModelFormatState.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/wrapBlock.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/common/wrapBlock.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/wrapBlock.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/common/wrapBlock.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/domUtils/readFile.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/domUtils/readFile.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/domUtils/readFile.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/domUtils/readFile.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/entity/insertEntityModel.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/image/applyImageBorderFormat.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/image/applyImageBorderFormat.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/image/applyImageBorderFormat.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/image/applyImageBorderFormat.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/findListItemsInSameThread.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/findListItemsInSameThread.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/list/findListItemsInSameThread.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/setListType.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/list/setListType.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustSegmentSelection.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/selection/adjustSegmentSelection.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustSegmentSelection.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/selection/adjustSegmentSelection.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/selection/adjustWordSelection.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/adjustWordSelection.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/selection/adjustWordSelection.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collapseTableSelection.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/selection/collapseTableSelection.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collapseTableSelection.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/selection/collapseTableSelection.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTable.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/alignTable.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTable.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/alignTable.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTableCell.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/alignTableCell.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/alignTableCell.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/alignTableCell.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/canMergeCells.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/canMergeCells.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/canMergeCells.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/canMergeCells.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/createTableStructure.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/createTableStructure.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/createTableStructure.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/createTableStructure.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/deleteTable.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/deleteTable.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/deleteTable.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/deleteTable.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/deleteTableColumn.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/deleteTableColumn.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/deleteTableColumn.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/deleteTableColumn.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/deleteTableRow.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/deleteTableRow.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/deleteTableRow.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/deleteTableRow.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/ensureFocusableParagraphForTable.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/ensureFocusableParagraphForTable.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/ensureFocusableParagraphForTable.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/ensureFocusableParagraphForTable.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/getSelectedCells.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/getSelectedCells.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/getSelectedCells.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/getSelectedCells.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/insertTableColumn.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableColumn.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/insertTableColumn.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableColumn.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/insertTableRow.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableRow.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/insertTableRow.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/insertTableRow.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/mergeTableCells.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/mergeTableCells.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/mergeTableCells.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/mergeTableCells.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/mergeTableColumn.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/mergeTableColumn.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/mergeTableColumn.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/mergeTableColumn.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/mergeTableRow.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/mergeTableRow.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/mergeTableRow.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/mergeTableRow.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/splitTableCellHorizontally.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/splitTableCellHorizontally.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/splitTableCellHorizontally.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/splitTableCellHorizontally.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/splitTableCellVertically.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/splitTableCellVertically.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/splitTableCellVertically.ts rename to packages-content-model/roosterjs-content-model-api/lib/modelApi/table/splitTableCellVertically.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setAlignment.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setAlignment.ts similarity index 80% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setAlignment.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setAlignment.ts index 0cba0f96d16..64e975c9493 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setAlignment.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setAlignment.ts @@ -1,5 +1,5 @@ import { setModelAlignment } from '../../modelApi/block/setModelAlignment'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Set text alignment of selected paragraphs @@ -7,7 +7,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' * @param alignment Alignment value: left, center or right */ export default function setAlignment( - editor: IContentModelEditor, + editor: IStandaloneEditor, alignment: 'left' | 'center' | 'right' ) { editor.focus(); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setDirection.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setDirection.ts similarity index 70% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setDirection.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setDirection.ts index ed273683572..2ec3396557a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setDirection.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setDirection.ts @@ -1,12 +1,12 @@ import { setModelDirection } from '../../modelApi/block/setModelDirection'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Set text direction of selected paragraphs (Left to right or Right to left) * @param editor The editor to set alignment * @param direction Direction value: ltr (Left to right) or rtl (Right to left) */ -export default function setDirection(editor: IContentModelEditor, direction: 'ltr' | 'rtl') { +export default function setDirection(editor: IStandaloneEditor, direction: 'ltr' | 'rtl') { editor.focus(); editor.formatContentModel(model => setModelDirection(model, direction), { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setHeadingLevel.ts similarity index 90% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setHeadingLevel.ts index 0a172bb41d1..d77e53f82d6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setHeadingLevel.ts @@ -1,6 +1,8 @@ import { formatParagraphWithContentModel } from '../utils/formatParagraphWithContentModel'; -import type { ContentModelParagraphDecorator } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { + ContentModelParagraphDecorator, + IStandaloneEditor, +} from 'roosterjs-content-model-types'; type HeadingLevelTags = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; @@ -19,7 +21,7 @@ const HeaderFontSizes: Record = { * @param headingLevel Level of heading, from 1 to 6. Set to 0 means set it back to a regular paragraph */ export default function setHeadingLevel( - editor: IContentModelEditor, + editor: IStandaloneEditor, headingLevel: 0 | 1 | 2 | 3 | 4 | 5 | 6 ) { editor.focus(); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setIndentation.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setIndentation.ts similarity index 88% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setIndentation.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setIndentation.ts index 609e3c8315e..24266a90f78 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setIndentation.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setIndentation.ts @@ -1,6 +1,6 @@ import { normalizeContentModel } from 'roosterjs-content-model-dom'; import { setModelIndentation } from '../../modelApi/block/setModelIndentation'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Indent or outdent to selected paragraphs @@ -9,7 +9,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' * @param length The length of pixel to indent/outdent @default 40 */ export default function setIndentation( - editor: IContentModelEditor, + editor: IStandaloneEditor, indentation: 'indent' | 'outdent', length?: number ) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setParagraphMargin.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setParagraphMargin.ts similarity index 90% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setParagraphMargin.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setParagraphMargin.ts index 903f8cae4e9..a2f85af4d5f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setParagraphMargin.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setParagraphMargin.ts @@ -1,6 +1,6 @@ import { createParagraphDecorator } from 'roosterjs-content-model-dom'; import { formatParagraphWithContentModel } from '../utils/formatParagraphWithContentModel'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Toggles the current block(s) margin properties. @@ -10,7 +10,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' * @param marginBottom value for bottom margin */ export default function setParagraphMargin( - editor: IContentModelEditor, + editor: IStandaloneEditor, marginTop?: string | null, marginBottom?: string | null ) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setSpacing.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setSpacing.ts similarity index 78% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setSpacing.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setSpacing.ts index f5c36d46448..dd204e6d253 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setSpacing.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/setSpacing.ts @@ -1,12 +1,12 @@ import { formatParagraphWithContentModel } from '../utils/formatParagraphWithContentModel'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Sets current selected block(s) line-height property and wipes such property from child segments * @param editor The editor to operate on * @param spacing Unitless/px value to set line height */ -export default function setSpacing(editor: IContentModelEditor, spacing: number | string) { +export default function setSpacing(editor: IStandaloneEditor, spacing: number | string) { editor.focus(); formatParagraphWithContentModel(editor, 'setSpacing', paragraph => { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/toggleBlockQuote.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/toggleBlockQuote.ts similarity index 86% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/toggleBlockQuote.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/block/toggleBlockQuote.ts index 471da98421c..ec3ac86d7a5 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/toggleBlockQuote.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/block/toggleBlockQuote.ts @@ -1,6 +1,8 @@ import { toggleModelBlockQuote } from '../../modelApi/block/toggleModelBlockQuote'; -import type { ContentModelFormatContainerFormat } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { + ContentModelFormatContainerFormat, + IStandaloneEditor, +} from 'roosterjs-content-model-types'; const DefaultQuoteFormat: ContentModelFormatContainerFormat = { borderLeft: '3px solid rgb(200, 200, 200)', // TODO: Support RTL @@ -22,7 +24,7 @@ const BuildInQuoteFormat: ContentModelFormatContainerFormat = { * @param quoteFormat @optional Block format for the new quote object */ export default function toggleBlockQuote( - editor: IContentModelEditor, + editor: IStandaloneEditor, quoteFormat: ContentModelFormatContainerFormat = DefaultQuoteFormat ) { const fullQuoteFormat = { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts index 964c4e59837..7b7b4701d5d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/entity/insertEntity.ts @@ -6,9 +6,9 @@ import type { DOMSelection, InsertEntityPosition, InsertEntityOptions, + IStandaloneEditor, } from 'roosterjs-content-model-types'; import type { Entity } from 'roosterjs-editor-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; const BlockEntityTag = 'div'; const InlineEntityTag = 'span'; @@ -25,7 +25,7 @@ const InlineEntityTag = 'span'; * @param options Move options to insert. See InsertEntityOptions */ export default function insertEntity( - editor: IContentModelEditor, + editor: IStandaloneEditor, type: string, isBlock: boolean, position: 'focus' | 'begin' | 'end' | DOMSelection, @@ -44,7 +44,7 @@ export default function insertEntity( * @param options Move options to insert. See InsertEntityOptions */ export default function insertEntity( - editor: IContentModelEditor, + editor: IStandaloneEditor, type: string, isBlock: true, position: InsertEntityPosition | DOMSelection, @@ -52,7 +52,7 @@ export default function insertEntity( ): ContentModelEntity | null; export default function insertEntity( - editor: IContentModelEditor, + editor: IStandaloneEditor, type: string, isBlock: boolean, position?: InsertEntityPosition | DOMSelection, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/clearFormat.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/clearFormat.ts similarity index 87% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/clearFormat.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/format/clearFormat.ts index 05d0b56138e..a890b26dc60 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/clearFormat.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/clearFormat.ts @@ -1,7 +1,7 @@ import { clearModelFormat } from '../../modelApi/common/clearModelFormat'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { + IStandaloneEditor, ContentModelBlock, ContentModelBlockGroup, ContentModelSegment, @@ -12,7 +12,7 @@ import type { * Clear format of selection * @param editor The editor to clear format from */ -export default function clearFormat(editor: IContentModelEditor) { +export default function clearFormat(editor: IStandaloneEditor) { editor.focus(); editor.formatContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts index deebd5dac76..188c656e011 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/format/getFormatState.ts @@ -1,11 +1,12 @@ import { getSelectionRootNode } from 'roosterjs-content-model-core'; import { retrieveModelFormatState } from '../../modelApi/common/retrieveModelFormatState'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { + IStandaloneEditor, ContentModelBlockGroup, ContentModelFormatState, DomToModelContext, } from 'roosterjs-content-model-types'; + import { getRegularSelectionOffsets, handleRegularSelection, @@ -17,7 +18,7 @@ import { * Get current format state * @param editor The editor to get format from */ -export default function getFormatState(editor: IContentModelEditor): ContentModelFormatState { +export default function getFormatState(editor: IStandaloneEditor): ContentModelFormatState { const pendingFormat = editor.getPendingFormat(); const model = editor.createContentModel({ processorOverride: { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/adjustImageSelection.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/adjustImageSelection.ts similarity index 75% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/adjustImageSelection.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/image/adjustImageSelection.ts index e2f7705bed1..83e7e76c6a1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/adjustImageSelection.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/adjustImageSelection.ts @@ -1,14 +1,11 @@ import { adjustSegmentSelection } from '../../modelApi/selection/adjustSegmentSelection'; -import type { ContentModelImage } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { ContentModelImage, IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Adjust selection to make sure select an image if any * @return Content Model Image object if an image is select, or null */ -export default function adjustImageSelection( - editor: IContentModelEditor -): ContentModelImage | null { +export default function adjustImageSelection(editor: IStandaloneEditor): ContentModelImage | null { let image: ContentModelImage | null = null; editor.formatContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/changeImage.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/changeImage.ts similarity index 80% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/changeImage.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/image/changeImage.ts index fcad8e95237..ff9e109db3e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/changeImage.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/changeImage.ts @@ -1,16 +1,15 @@ import formatImageWithContentModel from '../utils/formatImageWithContentModel'; import { PluginEventType } from 'roosterjs-editor-types'; -import { readFile } from '../../domUtils/readFile'; +import { readFile } from '../../modelApi/domUtils/readFile'; import { updateImageMetadata } from 'roosterjs-content-model-core'; -import type { ContentModelImage } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { ContentModelImage, IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Change the selected image src * @param editor The editor instance * @param file The image file */ -export default function changeImage(editor: IContentModelEditor, file: File) { +export default function changeImage(editor: IStandaloneEditor, file: File) { editor.focus(); const selection = editor.getDOMSelection(); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/insertImage.ts similarity index 77% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/image/insertImage.ts index 1b0b814e942..d1ad6da48c6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/insertImage.ts @@ -1,14 +1,14 @@ import { addSegment, createContentModelDocument, createImage } from 'roosterjs-content-model-dom'; import { mergeModel } from 'roosterjs-content-model-core'; -import { readFile } from '../../domUtils/readFile'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import { readFile } from '../../modelApi/domUtils/readFile'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Insert an image into current selected position * @param editor The editor to operate on * @param file Image Blob file or source string */ -export default function insertImage(editor: IContentModelEditor, imageFileOrSrc: File | string) { +export default function insertImage(editor: IStandaloneEditor, imageFileOrSrc: File | string) { editor.focus(); if (typeof imageFileOrSrc == 'string') { @@ -22,7 +22,7 @@ export default function insertImage(editor: IContentModelEditor, imageFileOrSrc: } } -function insertImageWithSrc(editor: IContentModelEditor, src: string) { +function insertImageWithSrc(editor: IStandaloneEditor, src: string) { editor.formatContentModel( (model, context) => { const image = createImage(src, { backgroundColor: '' }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/setImageAltText.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageAltText.ts similarity index 63% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/setImageAltText.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageAltText.ts index fc40ca1c4ad..c21995e2d3f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/setImageAltText.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageAltText.ts @@ -1,6 +1,5 @@ import formatImageWithContentModel from '../utils/formatImageWithContentModel'; -import type { ContentModelImage } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { ContentModelImage, IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Set image alt text for all selected images at selection. If no images is contained @@ -8,7 +7,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' * @param editor The editor instance * @param altText The image alt text */ -export default function setImageAltText(editor: IContentModelEditor, altText: string) { +export default function setImageAltText(editor: IStandaloneEditor, altText: string) { editor.focus(); formatImageWithContentModel(editor, 'setImageAltText', (image: ContentModelImage) => { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/setImageBorder.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageBorder.ts similarity index 81% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/setImageBorder.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageBorder.ts index 891c83dd166..71b2d5a0306 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/setImageBorder.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageBorder.ts @@ -1,7 +1,6 @@ import applyImageBorderFormat from '../../modelApi/image/applyImageBorderFormat'; import formatImageWithContentModel from '../utils/formatImageWithContentModel'; -import type { Border, ContentModelImage } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { Border, ContentModelImage, IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Set image border style for all selected images at selection. @@ -11,7 +10,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' * @param borderRadius the border radius value, if undefined, the border radius will keep the actual value */ export default function setImageBorder( - editor: IContentModelEditor, + editor: IStandaloneEditor, border: Border | null, borderRadius?: string ) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/setImageBoxShadow.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageBoxShadow.ts similarity index 84% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/setImageBoxShadow.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageBoxShadow.ts index 014221dce4c..2f50789079b 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/setImageBoxShadow.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/image/setImageBoxShadow.ts @@ -1,6 +1,5 @@ import formatImageWithContentModel from '../utils/formatImageWithContentModel'; -import type { ContentModelImage } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { ContentModelImage, IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Set image box shadow for all selected images at selection. @@ -9,7 +8,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' * @param margin The image margin for all sides (eg. "4px"), null to remove margin */ export default function setImageBoxShadow( - editor: IContentModelEditor, + editor: IStandaloneEditor, boxShadow: string, margin?: string | null ) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/adjustLinkSelection.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/adjustLinkSelection.ts similarity index 89% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/adjustLinkSelection.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/link/adjustLinkSelection.ts index 17ff9066661..c0e1dc63610 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/adjustLinkSelection.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/adjustLinkSelection.ts @@ -1,13 +1,13 @@ import { adjustSegmentSelection } from '../../modelApi/selection/adjustSegmentSelection'; import { adjustWordSelection } from '../../modelApi/selection/adjustWordSelection'; import { getSelectedSegments, setSelection } from 'roosterjs-content-model-core'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Adjust selection to make sure select a hyperlink if any, or a word if original selection is collapsed * @return A combination of existing link display text and url if any. If there is no existing link, return selected text and null */ -export default function adjustLinkSelection(editor: IContentModelEditor): [string, string | null] { +export default function adjustLinkSelection(editor: IStandaloneEditor): [string, string | null] { let text = ''; let url: string | null = null; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts index f61e29a619d..3d29e419aae 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/insertLink.ts @@ -1,7 +1,6 @@ import { ChangeSource, getSelectedSegments, mergeModel } from 'roosterjs-content-model-core'; import { HtmlSanitizer, matchLink } from 'roosterjs-editor-dom'; -import type { ContentModelLink } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { ContentModelLink, IStandaloneEditor } from 'roosterjs-content-model-types'; import { addLink, addSegment, @@ -30,7 +29,7 @@ const FTP_REGEX = /^ftp\./i; * If not specified and there wasn't a link, the link url will be used as display text. */ export default function insertLink( - editor: IContentModelEditor, + editor: IStandaloneEditor, link: string, anchorTitle?: string, displayText?: string, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/removeLink.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/removeLink.ts similarity index 89% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/removeLink.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/link/removeLink.ts index b26e40da2c8..b6c7f38abb6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/removeLink.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/link/removeLink.ts @@ -1,6 +1,6 @@ import { adjustSegmentSelection } from '../../modelApi/selection/adjustSegmentSelection'; import { getSelectedSegments } from 'roosterjs-content-model-core'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Remove link at selection. If no links at selection, do nothing. @@ -8,7 +8,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' * If only part of a link is selected, the whole link style will be removed. * @param editor The editor instance */ -export default function removeLink(editor: IContentModelEditor) { +export default function removeLink(editor: IStandaloneEditor) { editor.focus(); editor.formatContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStartNumber.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/setListStartNumber.ts similarity index 80% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStartNumber.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/list/setListStartNumber.ts index 7de0ef9ff52..1ce023a263a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStartNumber.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/setListStartNumber.ts @@ -1,12 +1,12 @@ import { getFirstSelectedListItem } from 'roosterjs-content-model-core'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Set start number of a list item * @param editor The editor to operate on * @param value The number to set to, must be equal or greater than 1 */ -export default function setListStartNumber(editor: IContentModelEditor, value: number) { +export default function setListStartNumber(editor: IStandaloneEditor, value: number) { editor.focus(); editor.formatContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStyle.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/setListStyle.ts similarity index 81% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStyle.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/list/setListStyle.ts index 83bee9865ff..1a4c075e19f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/setListStyle.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/setListStyle.ts @@ -1,14 +1,13 @@ import { findListItemsInSameThread } from '../../modelApi/list/findListItemsInSameThread'; import { getFirstSelectedListItem, updateListMetadata } from 'roosterjs-content-model-core'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import type { ListMetadataFormat } from 'roosterjs-content-model-types'; +import type { IStandaloneEditor, ListMetadataFormat } from 'roosterjs-content-model-types'; /** * Set style of list items with in same thread of current item * @param editor The editor to operate on * @param style The target list item style to set */ -export default function setListStyle(editor: IContentModelEditor, style: ListMetadataFormat) { +export default function setListStyle(editor: IStandaloneEditor, style: ListMetadataFormat) { editor.focus(); editor.formatContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleBullet.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/toggleBullet.ts similarity index 79% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleBullet.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/list/toggleBullet.ts index abe38abd469..7729eade0c6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleBullet.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/toggleBullet.ts @@ -1,5 +1,5 @@ import { setListType } from '../../modelApi/list/setListType'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Toggle bullet list type @@ -7,7 +7,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' * - When all blocks are already in bullet list, turn off / outdent there list type * @param editor The editor to operate on */ -export default function toggleBullet(editor: IContentModelEditor) { +export default function toggleBullet(editor: IStandaloneEditor) { editor.focus(); editor.formatContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleNumbering.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/toggleNumbering.ts similarity index 79% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleNumbering.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/list/toggleNumbering.ts index a7e360b530a..b98c6df7b2f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/list/toggleNumbering.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/list/toggleNumbering.ts @@ -1,5 +1,5 @@ import { setListType } from '../../modelApi/list/setListType'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Toggle numbering list type @@ -7,7 +7,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' * - When all blocks are already in numbering list, turn off / outdent there list type * @param editor The editor to operate on */ -export default function toggleNumbering(editor: IContentModelEditor) { +export default function toggleNumbering(editor: IStandaloneEditor) { editor.focus(); editor.formatContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/applySegmentFormat.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/applySegmentFormat.ts similarity index 84% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/applySegmentFormat.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/applySegmentFormat.ts index d4efff86879..a3cf335538a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/applySegmentFormat.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/applySegmentFormat.ts @@ -1,6 +1,5 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { ContentModelSegmentFormat, IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Bulk apply segment format to all selected content. This is usually used for format painter. @@ -8,7 +7,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' * @param newFormat The segment format to apply */ export default function applySegmentFormat( - editor: IContentModelEditor, + editor: IStandaloneEditor, newFormat: ContentModelSegmentFormat ) { formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/changeCapitalization.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeCapitalization.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/changeCapitalization.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeCapitalization.ts index e0f11220813..207efe20aae 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/changeCapitalization.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeCapitalization.ts @@ -1,5 +1,5 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Change the capitalization of text in the selection @@ -10,7 +10,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' * Default is the host environment’s current locale. */ export default function changeCapitalization( - editor: IContentModelEditor, + editor: IStandaloneEditor, capitalization: 'sentence' | 'lowerCase' | 'upperCase' | 'capitalize', language?: string ) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/changeFontSize.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeFontSize.ts similarity index 93% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/changeFontSize.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeFontSize.ts index 1d9481c4f27..fcfba224fd0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/changeFontSize.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/changeFontSize.ts @@ -4,8 +4,8 @@ import { setFontSizeInternal } from './setFontSize'; import type { ContentModelParagraph, ContentModelSegmentFormat, + IStandaloneEditor, } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; /** * Default font size sequence, in pt. Suggest editor UI use this sequence as your font size list, @@ -21,10 +21,7 @@ const MAX_FONT_SIZE = 1000; * @param change Whether increase or decrease font size * @param fontSizes A sorted font size array, in pt. Default value is FONT_SIZES */ -export default function changeFontSize( - editor: IContentModelEditor, - change: 'increase' | 'decrease' -) { +export default function changeFontSize(editor: IStandaloneEditor, change: 'increase' | 'decrease') { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setBackgroundColor.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setBackgroundColor.ts similarity index 89% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setBackgroundColor.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setBackgroundColor.ts index 7bd4d0609f1..099c2d82ac6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setBackgroundColor.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setBackgroundColor.ts @@ -1,8 +1,7 @@ import { createSelectionMarker } from 'roosterjs-content-model-dom'; import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; import { setSelection } from 'roosterjs-content-model-core'; -import type { ContentModelParagraph } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { ContentModelParagraph, IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Set background color @@ -10,7 +9,7 @@ import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor' * @param backgroundColor The color to set. Pass null to remove existing color. */ export default function setBackgroundColor( - editor: IContentModelEditor, + editor: IStandaloneEditor, backgroundColor: string | null ) { editor.focus(); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setFontName.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setFontName.ts similarity index 77% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setFontName.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setFontName.ts index 64b8014f39f..bef80eaed7e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setFontName.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setFontName.ts @@ -1,12 +1,12 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Set font name * @param editor The editor to operate on * @param fontName The font name to set */ -export default function setFontName(editor: IContentModelEditor, fontName: string) { +export default function setFontName(editor: IStandaloneEditor, fontName: string) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setFontSize.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setFontSize.ts similarity index 88% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setFontSize.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setFontSize.ts index e958825158f..f39104ecd1a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setFontSize.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setFontSize.ts @@ -2,15 +2,15 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContent import type { ContentModelParagraph, ContentModelSegmentFormat, + IStandaloneEditor, } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; /** * Set font size * @param editor The editor to operate on * @param fontSize The font size to set */ -export default function setFontSize(editor: IContentModelEditor, fontSize: string) { +export default function setFontSize(editor: IStandaloneEditor, fontSize: string) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setTextColor.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setTextColor.ts similarity index 83% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setTextColor.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setTextColor.ts index 60d51fcaae3..edf70181d89 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/setTextColor.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/setTextColor.ts @@ -1,12 +1,12 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Set text color * @param editor The editor to operate on * @param textColor The text color to set. Pass null to remove existing color. */ -export default function setTextColor(editor: IContentModelEditor, textColor: string | null) { +export default function setTextColor(editor: IStandaloneEditor, textColor: string | null) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleBold.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleBold.ts similarity index 84% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleBold.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleBold.ts index f31e6a73981..f20eae3930a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleBold.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleBold.ts @@ -1,11 +1,11 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Toggle bold style * @param editor The editor to operate on */ -export default function toggleBold(editor: IContentModelEditor) { +export default function toggleBold(editor: IStandaloneEditor) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleCode.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleCode.ts similarity index 76% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleCode.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleCode.ts index 05a9d49f62f..4269bcd762a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleCode.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleCode.ts @@ -1,7 +1,6 @@ import { addCode } from 'roosterjs-content-model-dom'; import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { ContentModelCode } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { ContentModelCode, IStandaloneEditor } from 'roosterjs-content-model-types'; const DefaultCode: ContentModelCode = { format: { @@ -13,7 +12,7 @@ const DefaultCode: ContentModelCode = { * Toggle italic style * @param editor The editor to operate on */ -export default function toggleCode(editor: IContentModelEditor) { +export default function toggleCode(editor: IStandaloneEditor) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleItalic.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleItalic.ts similarity index 72% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleItalic.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleItalic.ts index 70a8d718153..b3c25de6524 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleItalic.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleItalic.ts @@ -1,11 +1,11 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Toggle italic style * @param editor The editor to operate on */ -export default function toggleItalic(editor: IContentModelEditor) { +export default function toggleItalic(editor: IStandaloneEditor) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleStrikethrough.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleStrikethrough.ts similarity index 72% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleStrikethrough.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleStrikethrough.ts index 075eb2ebefa..93e2df84d2c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleStrikethrough.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleStrikethrough.ts @@ -1,11 +1,11 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Toggle strikethrough style * @param editor The editor to operate on */ -export default function toggleStrikethrough(editor: IContentModelEditor) { +export default function toggleStrikethrough(editor: IStandaloneEditor) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleSubscript.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleSubscript.ts similarity index 75% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleSubscript.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleSubscript.ts index 8e1d177a21d..28c9b6da37b 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleSubscript.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleSubscript.ts @@ -1,11 +1,11 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Toggle subscript style * @param editor The editor to operate on */ -export default function toggleSubscript(editor: IContentModelEditor) { +export default function toggleSubscript(editor: IStandaloneEditor) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleSuperscript.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleSuperscript.ts similarity index 75% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleSuperscript.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleSuperscript.ts index 716838ae7da..2e84d4d5059 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleSuperscript.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleSuperscript.ts @@ -1,11 +1,11 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Toggle superscript style * @param editor The editor to operate on */ -export default function toggleSuperscript(editor: IContentModelEditor) { +export default function toggleSuperscript(editor: IStandaloneEditor) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleUnderline.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleUnderline.ts similarity index 78% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleUnderline.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleUnderline.ts index 6fdd7cd2aec..8614fdd7b1d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/segment/toggleUnderline.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/segment/toggleUnderline.ts @@ -1,11 +1,11 @@ import { formatSegmentWithContentModel } from '../utils/formatSegmentWithContentModel'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Toggle underline style * @param editor The editor to operate on */ -export default function toggleUnderline(editor: IContentModelEditor) { +export default function toggleUnderline(editor: IStandaloneEditor) { editor.focus(); formatSegmentWithContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/hasSelectionInBlock.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInBlock.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/hasSelectionInBlock.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInBlock.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/hasSelectionInBlockGroup.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInBlockGroup.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/hasSelectionInBlockGroup.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInBlockGroup.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/hasSelectionInSegment.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInSegment.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/selection/hasSelectionInSegment.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInSegment.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/applyTableBorderFormat.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/table/applyTableBorderFormat.ts index 52e9dbe5508..7d73af2f1c2 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/applyTableBorderFormat.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/applyTableBorderFormat.ts @@ -5,8 +5,8 @@ import { getFirstSelectedTable, updateTableCellMetadata, } from 'roosterjs-content-model-core'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { + IStandaloneEditor, Border, ContentModelTable, ContentModelTableCell, @@ -39,7 +39,7 @@ type Perimeter = { * @param operation The operation to apply */ export default function applyTableBorderFormat( - editor: IContentModelEditor, + editor: IStandaloneEditor, border: Border, operation: BorderOperations ) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts index 5a439ee67ae..1ffadcc164f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts @@ -17,12 +17,11 @@ import { normalizeTable, setSelection, } from 'roosterjs-content-model-core'; -import type { TableOperation } from 'roosterjs-content-model-types'; +import type { TableOperation, IStandaloneEditor } from 'roosterjs-content-model-types'; import { alignTableCellHorizontally, alignTableCellVertically, } from '../../modelApi/table/alignTableCell'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { createSelectionMarker, hasMetadata, @@ -34,7 +33,7 @@ import { * @param editor The editor instance * @param operation The table operation to apply */ -export default function editTable(editor: IContentModelEditor, operation: TableOperation) { +export default function editTable(editor: IStandaloneEditor, operation: TableOperation) { editor.focus(); editor.formatContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/formatTable.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/formatTable.ts similarity index 87% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/formatTable.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/table/formatTable.ts index 5da4bd5be59..411f229bedb 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/formatTable.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/formatTable.ts @@ -3,8 +3,7 @@ import { getFirstSelectedTable, updateTableCellMetadata, } from 'roosterjs-content-model-core'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import type { TableMetadataFormat } from 'roosterjs-content-model-types'; +import type { IStandaloneEditor, TableMetadataFormat } from 'roosterjs-content-model-types'; /** * Format current focused table with the given format @@ -13,7 +12,7 @@ import type { TableMetadataFormat } from 'roosterjs-content-model-types'; * @param keepCellShade Whether keep existing shade color when apply format if there is a manually set shade color */ export default function formatTable( - editor: IContentModelEditor, + editor: IStandaloneEditor, format: TableMetadataFormat, keepCellShade?: boolean ) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/insertTable.ts similarity index 91% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/table/insertTable.ts index 12640af2a55..0128b7913a2 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/insertTable.ts @@ -7,8 +7,7 @@ import { normalizeTable, setSelection, } from 'roosterjs-content-model-core'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import type { TableMetadataFormat } from 'roosterjs-content-model-types'; +import type { IStandaloneEditor, TableMetadataFormat } from 'roosterjs-content-model-types'; /** * Insert table into editor at current selection @@ -20,7 +19,7 @@ import type { TableMetadataFormat } from 'roosterjs-content-model-types'; * background color: #FFF; border color: #ABABAB */ export default function insertTable( - editor: IContentModelEditor, + editor: IStandaloneEditor, columns: number, rows: number, format?: Partial diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/setTableCellShade.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/setTableCellShade.ts similarity index 85% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/setTableCellShade.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/table/setTableCellShade.ts index 43b9f7efbcb..46a284f4d3a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/setTableCellShade.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/setTableCellShade.ts @@ -4,14 +4,14 @@ import { normalizeTable, setTableCellBackgroundColor, } from 'roosterjs-content-model-core'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { IStandaloneEditor } from 'roosterjs-content-model-types'; /** * Set table cell shade color * @param editor The editor instance * @param color The color to set. Pass null to remove existing shade color */ -export default function setTableCellShade(editor: IContentModelEditor, color: string | null) { +export default function setTableCellShade(editor: IStandaloneEditor, color: string | null) { editor.focus(); editor.formatContentModel( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatImageWithContentModel.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatImageWithContentModel.ts similarity index 74% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatImageWithContentModel.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatImageWithContentModel.ts index a0f5e4ceb05..df35d9b8e74 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatImageWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatImageWithContentModel.ts @@ -1,12 +1,11 @@ import { formatSegmentWithContentModel } from './formatSegmentWithContentModel'; -import type { ContentModelImage } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { ContentModelImage, IStandaloneEditor } from 'roosterjs-content-model-types'; /** * @internal */ export default function formatImageWithContentModel( - editor: IContentModelEditor, + editor: IStandaloneEditor, apiName: string, callback: (segment: ContentModelImage) => void ) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatParagraphWithContentModel.ts similarity index 74% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatParagraphWithContentModel.ts index 39d0b385dc1..a54e2e8deca 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatParagraphWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatParagraphWithContentModel.ts @@ -1,12 +1,11 @@ import { getSelectedParagraphs } from 'roosterjs-content-model-core'; -import type { ContentModelParagraph } from 'roosterjs-content-model-types'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import type { ContentModelParagraph, IStandaloneEditor } from 'roosterjs-content-model-types'; /** * @internal */ export function formatParagraphWithContentModel( - editor: IContentModelEditor, + editor: IStandaloneEditor, apiName: string, setStyleCallback: (paragraph: ContentModelParagraph) => void ) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts rename to packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts index 64bb3292af5..30dd6a92c00 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatSegmentWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/utils/formatSegmentWithContentModel.ts @@ -1,17 +1,17 @@ import { adjustWordSelection } from '../../modelApi/selection/adjustWordSelection'; import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core'; -import type { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import type { ContentModelDocument, ContentModelParagraph, ContentModelSegment, ContentModelSegmentFormat, + IStandaloneEditor, } from 'roosterjs-content-model-types'; /** * @internal */ export function formatSegmentWithContentModel( - editor: IContentModelEditor, + editor: IStandaloneEditor, apiName: string, toggleStyleCallback: ( format: ContentModelSegmentFormat, diff --git a/packages-content-model/roosterjs-content-model-api/package.json b/packages-content-model/roosterjs-content-model-api/package.json new file mode 100644 index 00000000000..d4f7558e6ae --- /dev/null +++ b/packages-content-model/roosterjs-content-model-api/package.json @@ -0,0 +1,14 @@ +{ + "name": "roosterjs-content-model-api", + "description": "Content Model for roosterjs (Under development)", + "dependencies": { + "tslib": "^2.3.1", + "roosterjs-editor-types": "", + "roosterjs-editor-dom": "", + "roosterjs-content-model-core": "", + "roosterjs-content-model-dom": "", + "roosterjs-content-model-types": "" + }, + "version": "0.0.0", + "main": "./lib/index.ts" +} diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/block/setModelAlignmentTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelAlignmentTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/block/setModelAlignmentTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelAlignmentTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/block/setModelDirectionTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelDirectionTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/block/setModelDirectionTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelDirectionTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/block/setModelIndentationTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/block/setModelIndentationTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/block/setModelIndentationTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/block/toggleModelBlockQuoteTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/block/toggleModelBlockQuoteTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/block/toggleModelBlockQuoteTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/block/toggleModelBlockQuoteTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/clearModelFormatTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/common/clearModelFormatTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/common/clearModelFormatTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/common/clearModelFormatTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/common/retrieveModelFormatStateTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/common/retrieveModelFormatStateTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/wrapBlockTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/common/wrapBlockTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/common/wrapBlockTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/common/wrapBlockTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/entity/insertEntityModelTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/entity/insertEntityModelTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/entity/insertEntityModelTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/image/applyImageBorderFormatTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/image/applyImageBorderFormatTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/image/applyImageBorderFormatTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/image/applyImageBorderFormatTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/list/findListItemsInSameThreadTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/list/findListItemsInSameThreadTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/list/findListItemsInSameThreadTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/list/findListItemsInSameThreadTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/list/setListTypeTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/list/setListTypeTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/adjustSegmentSelectionTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/selection/adjustSegmentSelectionTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/adjustSegmentSelectionTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/selection/adjustSegmentSelectionTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/adjustWordSelectionTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/selection/adjustWordSelectionTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/adjustWordSelectionTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/selection/adjustWordSelectionTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collapseTableSelectionTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/selection/collapseTableSelectionTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collapseTableSelectionTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/selection/collapseTableSelectionTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/alignTableCellTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/alignTableCellTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/alignTableCellTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/alignTableCellTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/alignTableTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/alignTableTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/alignTableTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/alignTableTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/canMergeCellsTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/canMergeCellsTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/canMergeCellsTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/canMergeCellsTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/createTableStructureTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/createTableStructureTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/createTableStructureTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/createTableStructureTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/deleteTableColumnTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/deleteTableColumnTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/deleteTableColumnTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/deleteTableColumnTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/deleteTableRowTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/deleteTableRowTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/deleteTableRowTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/deleteTableRowTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/deleteTableTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/deleteTableTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/deleteTableTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/deleteTableTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/ensureFocusableParagraphForTableTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/ensureFocusableParagraphForTableTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/ensureFocusableParagraphForTableTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/ensureFocusableParagraphForTableTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/getSelectedCellsTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/getSelectedCellsTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/getSelectedCellsTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/getSelectedCellsTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/insertTableColumnTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableColumnTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/insertTableColumnTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableColumnTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/insertTableRowTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableRowTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/insertTableRowTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/insertTableRowTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/mergeTableCellsTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/mergeTableCellsTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/mergeTableCellsTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/mergeTableCellsTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/mergeTableColumnTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/mergeTableColumnTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/mergeTableColumnTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/mergeTableColumnTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/mergeTableRowTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/mergeTableRowTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/mergeTableRowTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/mergeTableRowTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/splitTableCellHorizontallyTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/splitTableCellHorizontallyTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/splitTableCellHorizontallyTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/splitTableCellHorizontallyTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/splitTableCellVerticallyTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/splitTableCellVerticallyTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/modelApi/table/splitTableCellVerticallyTest.ts rename to packages-content-model/roosterjs-content-model-api/test/modelApi/table/splitTableCellVerticallyTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/paragraphTestCommon.ts similarity index 85% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/block/paragraphTestCommon.ts index c595d5a0320..80604aed393 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/paragraphTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/paragraphTestCommon.ts @@ -1,4 +1,4 @@ -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, @@ -7,7 +7,7 @@ import { export function paragraphTestCommon( apiName: string, - executionCallback: (editor: IContentModelEditor) => void, + executionCallback: (editor: IStandaloneEditor) => void, model: ContentModelDocument, result: ContentModelDocument, calledTimes: number @@ -27,7 +27,7 @@ export function paragraphTestCommon( getCustomData: () => ({}), getFocusedPosition: () => ({}), formatContentModel, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setAlignmentTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/block/setAlignmentTest.ts index fa2b9c84006..c6fc09cedb2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setAlignmentTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setAlignmentTest.ts @@ -1,7 +1,7 @@ import * as normalizeTable from 'roosterjs-content-model-core/lib/publicApi/table/normalizeTable'; import setAlignment from '../../../lib/publicApi/block/setAlignment'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { paragraphTestCommon } from './paragraphTestCommon'; import { ContentModelDocument, @@ -415,8 +415,8 @@ describe('setAlignment', () => { }); describe('setAlignment in table', () => { - let editor: IContentModelEditor; - let createContentModel: jasmine.Spy; + let editor: IStandaloneEditor; + let createContentModel: jasmine.Spy; let triggerPluginEvent: jasmine.Spy; let getVisibleViewport: jasmine.Spy; @@ -434,7 +434,7 @@ describe('setAlignment in table', () => { isDarkMode: () => false, triggerPluginEvent, getVisibleViewport, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; }); function runTest( @@ -820,9 +820,9 @@ describe('setAlignment in table', () => { }); describe('setAlignment in list', () => { - let editor: IContentModelEditor; - let setContentModel: jasmine.Spy; - let createContentModel: jasmine.Spy; + let editor: IStandaloneEditor; + let setContentModel: jasmine.Spy; + let createContentModel: jasmine.Spy; let triggerPluginEvent: jasmine.Spy; let getVisibleViewport: jasmine.Spy; @@ -840,7 +840,7 @@ describe('setAlignment in list', () => { isDarkMode: () => false, triggerPluginEvent, getVisibleViewport, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; }); function runTest( diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setDirectionTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setDirectionTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setDirectionTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/block/setDirectionTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setHeadingLevelTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setHeadingLevelTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setHeadingLevelTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/block/setHeadingLevelTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setIndentationTest.ts similarity index 93% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/block/setIndentationTest.ts index d4162033dc2..adddd59431e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setIndentationTest.ts @@ -1,6 +1,6 @@ import * as setModelIndentation from '../../../lib/modelApi/block/setModelIndentation'; import setIndentation from '../../../lib/publicApi/block/setIndentation'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelFormatter, FormatWithContentModelContext, @@ -8,7 +8,7 @@ import { describe('setIndentation', () => { const fakeModel: any = { a: 'b' }; - let editor: IContentModelEditor; + let editor: IStandaloneEditor; let formatContentModelSpy: jasmine.Spy; let context: FormatWithContentModelContext; @@ -29,7 +29,7 @@ describe('setIndentation', () => { formatContentModel: formatContentModelSpy, focus: jasmine.createSpy('focus'), getPendingFormat: () => null as any, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; }); it('indent', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setParagraphMarginTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setParagraphMarginTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setParagraphMarginTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/block/setParagraphMarginTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setSpacingTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/setSpacingTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setSpacingTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/block/setSpacingTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts similarity index 94% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts index 690c4dd8f2e..6d897dd1b17 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/block/toggleBlockQuoteTest.ts @@ -1,6 +1,6 @@ import * as toggleModelBlockQuote from '../../../lib/modelApi/block/toggleModelBlockQuote'; import toggleBlockQuote from '../../../lib/publicApi/block/toggleBlockQuote'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelFormatter, FormatWithContentModelContext, @@ -8,7 +8,7 @@ import { describe('toggleBlockQuote', () => { const fakeModel: any = { a: 'b' }; - let editor: IContentModelEditor; + let editor: IStandaloneEditor; let formatContentModelSpy: jasmine.Spy; let context: FormatWithContentModelContext; @@ -29,7 +29,7 @@ describe('toggleBlockQuote', () => { editor = ({ focus: jasmine.createSpy('focus'), formatContentModel: formatContentModelSpy, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; }); it('toggleBlockQuote', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts index 95bfbf5d206..7706ab548ef 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/entity/insertEntityTest.ts @@ -2,14 +2,14 @@ import * as insertEntityModel from '../../../lib/modelApi/entity/insertEntityMod import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import insertEntity from '../../../lib/publicApi/entity/insertEntity'; import { ChangeSource } from 'roosterjs-content-model-core'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { FormatWithContentModelContext, FormatWithContentModelOptions, } from 'roosterjs-content-model-types'; describe('insertEntity', () => { - let editor: IContentModelEditor; + let editor: IStandaloneEditor; let context: FormatWithContentModelContext; let wrapper: HTMLElement; const model = 'MockedModel' as any; diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts similarity index 92% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts index 67e1c179af2..7a5893e74dd 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/clearFormatTest.ts @@ -1,7 +1,7 @@ import * as clearModelFormat from '../../../lib/modelApi/common/clearModelFormat'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import clearFormat from '../../../lib/publicApi/format/clearFormat'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, @@ -23,7 +23,7 @@ describe('clearFormat', () => { const editor = ({ focus: () => {}, formatContentModel: formatContentModelSpy, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; spyOn(clearModelFormat, 'clearModelFormat'); spyOn(normalizeContentModel, 'normalizeContentModel'); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts index b1800c8e780..55393ebc998 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/format/getFormatStateTest.ts @@ -1,7 +1,7 @@ import * as getSelectionRootNode from 'roosterjs-content-model-core/lib/publicApi/selection/getSelectionRootNode'; import * as retrieveModelFormatState from '../../../lib/modelApi/common/retrieveModelFormatState'; import { ContentModelFormatState, DomToModelContext } from 'roosterjs-content-model-types'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import getFormatState, { reducedModelChildProcessor, } from '../../../lib/publicApi/format/getFormatState'; @@ -63,7 +63,7 @@ describe('getFormatState', () => { return model; }, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; const result = getFormatState(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/adjustImageSelectionTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/adjustImageSelectionTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/image/adjustImageSelectionTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/image/adjustImageSelectionTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/changeImageTest.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/image/changeImageTest.ts index dab3091f466..d018958bee3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/changeImageTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/changeImageTest.ts @@ -1,6 +1,6 @@ -import * as readFile from '../../../lib/domUtils/readFile'; +import * as readFile from '../../../lib/modelApi/domUtils/readFile'; import changeImage from '../../../lib/publicApi/image/changeImage'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { PluginEventType } from 'roosterjs-editor-types'; import { ContentModelDocument, @@ -22,7 +22,7 @@ describe('changeImage', () => { function runTest( model: ContentModelDocument, - executionCallback: (editor: IContentModelEditor) => void, + executionCallback: (editor: IStandaloneEditor) => void, result: ContentModelDocument, calledTimes: number ) { @@ -52,7 +52,7 @@ describe('changeImage', () => { getDOMSelection, triggerPluginEvent, formatContentModel, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/insertImageTest.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/image/insertImageTest.ts index f99a8cc9fb0..60a3e63a7f3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/insertImageTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/insertImageTest.ts @@ -1,6 +1,6 @@ -import * as readFile from '../../../lib/domUtils/readFile'; +import * as readFile from '../../../lib/modelApi/domUtils/readFile'; import insertImage from '../../../lib/publicApi/image/insertImage'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, @@ -17,7 +17,7 @@ describe('insertImage', () => { function runTest( apiName: string, - executionCallback: (editor: IContentModelEditor) => void, + executionCallback: (editor: IStandaloneEditor) => void, model: ContentModelDocument, result: ContentModelDocument, calledTimes: number @@ -39,7 +39,7 @@ describe('insertImage', () => { focus: jasmine.createSpy(), isDisposed: () => false, formatContentModel, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/setImageAltTextTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/setImageAltTextTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/image/setImageAltTextTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/image/setImageAltTextTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/setImageBorderTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/setImageBorderTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/image/setImageBorderTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/image/setImageBorderTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/image/setImageBoxShadowTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/image/setImageBoxShadowTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/image/setImageBoxShadowTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/image/setImageBoxShadowTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts similarity index 97% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts index 83e590e2946..bd4fe07ee36 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/adjustLinkSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/adjustLinkSelectionTest.ts @@ -1,5 +1,5 @@ import adjustLinkSelection from '../../../lib/publicApi/link/adjustLinkSelection'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelLink, @@ -17,8 +17,8 @@ import { } from 'roosterjs-content-model-dom'; describe('adjustLinkSelection', () => { - let editor: IContentModelEditor; - let createContentModel: jasmine.Spy; + let editor: IStandaloneEditor; + let createContentModel: jasmine.Spy; let formatContentModel: jasmine.Spy; let formatResult: boolean | undefined; let model: ContentModelDocument | undefined; @@ -45,7 +45,7 @@ describe('adjustLinkSelection', () => { editor = ({ formatContentModel, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; }); function runTest( diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts similarity index 97% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts index dafb43b835b..b63fcf8da10 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts @@ -1,7 +1,7 @@ -import ContentModelEditor from '../../../lib/editor/ContentModelEditor'; import insertLink from '../../../lib/publicApi/link/insertLink'; import { ChangeSource } from 'roosterjs-content-model-core'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { ContentModelEditor } from 'roosterjs-content-model-editor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { PluginEventType } from 'roosterjs-editor-types'; import { ContentModelDocument, @@ -18,13 +18,13 @@ import { } from 'roosterjs-content-model-dom'; describe('insertLink', () => { - let editor: IContentModelEditor; + let editor: IStandaloneEditor; beforeEach(() => { editor = ({ focus: () => {}, getPendingFormat: () => null as any, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; }); function runTest( diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts similarity index 98% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts index 08db1193703..d50ab264da6 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/link/removeLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/removeLinkTest.ts @@ -1,5 +1,5 @@ import removeLink from '../../../lib/publicApi/link/removeLink'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelLink, @@ -15,12 +15,12 @@ import { } from 'roosterjs-content-model-dom'; describe('removeLink', () => { - let editor: IContentModelEditor; + let editor: IStandaloneEditor; beforeEach(() => { editor = ({ focus: () => {}, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; }); function runTest(model: ContentModelDocument, expectedModel: ContentModelDocument | null) { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/setListStartNumberTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/list/setListStartNumberTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/setListStyleTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/list/setListStyleTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleBulletTest.ts similarity index 91% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleBulletTest.ts index 1d6d6039852..e603930384b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleBulletTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleBulletTest.ts @@ -1,6 +1,6 @@ import * as setListType from '../../../lib/modelApi/list/setListType'; import toggleBullet from '../../../lib/publicApi/list/toggleBullet'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, @@ -9,7 +9,7 @@ import { } from 'roosterjs-content-model-types'; describe('toggleBullet', () => { - let editor = ({} as any) as IContentModelEditor; + let editor = ({} as any) as IStandaloneEditor; let formatContentModel: jasmine.Spy; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; @@ -39,7 +39,7 @@ describe('toggleBullet', () => { formatContentModel, getCustomData: () => ({}), getFocusedPosition: () => ({}), - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; spyOn(setListType, 'setListType').and.returnValue(true); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleNumberingTest.ts similarity index 90% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleNumberingTest.ts index 310f9e6cee9..20f6f6f92da 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/toggleNumberingTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/list/toggleNumberingTest.ts @@ -1,6 +1,6 @@ import * as setListType from '../../../lib/modelApi/list/setListType'; import toggleNumbering from '../../../lib/publicApi/list/toggleNumbering'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelFormatter, @@ -9,7 +9,7 @@ import { } from 'roosterjs-content-model-types'; describe('toggleNumbering', () => { - let editor = ({} as any) as IContentModelEditor; + let editor = ({} as any) as IStandaloneEditor; let focus: jasmine.Spy; let mockedModel: ContentModelDocument; let context: FormatWithContentModelContext; @@ -37,7 +37,7 @@ describe('toggleNumbering', () => { editor = ({ focus, formatContentModel, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; spyOn(setListType, 'setListType').and.returnValue(true); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/applySegmentFormatTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/applySegmentFormatTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/applySegmentFormatTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/segment/applySegmentFormatTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeCapitalizationTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeCapitalizationTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeCapitalizationTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeCapitalizationTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts index b6a19e46e5a..69de2270a9b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/changeFontSizeTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/changeFontSizeTest.ts @@ -1,7 +1,7 @@ import changeFontSize from '../../../lib/publicApi/segment/changeFontSize'; import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; import { createRange } from 'roosterjs-editor-dom'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { segmentTestCommon } from './segmentTestCommon'; import { ContentModelDocument, @@ -362,7 +362,7 @@ describe('changeFontSize', () => { formatContentModel, focus: jasmine.createSpy(), getPendingFormat: () => null as ContentModelSegmentFormat, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; changeFontSize(editor, 'increase'); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts similarity index 86% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts index 0cb32c53568..4ed22a5ea0c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/segmentTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/segmentTestCommon.ts @@ -1,4 +1,4 @@ -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { NodePosition } from 'roosterjs-editor-types'; import { ContentModelDocument, @@ -8,7 +8,7 @@ import { export function segmentTestCommon( apiName: string, - executionCallback: (editor: IContentModelEditor) => void, + executionCallback: (editor: IStandaloneEditor) => void, model: ContentModelDocument, result: ContentModelDocument, calledTimes: number @@ -29,7 +29,7 @@ export function segmentTestCommon( getFocusedPosition: () => null as NodePosition, getPendingFormat: () => null as any, formatContentModel, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/setBackgroundColorTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/setBackgroundColorTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/setBackgroundColorTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/segment/setBackgroundColorTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/setFontNameTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/setFontNameTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/setFontNameTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/segment/setFontNameTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/setFontSizeTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/setFontSizeTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/setFontSizeTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/segment/setFontSizeTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/setTextColorTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/setTextColorTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/setTextColorTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/segment/setTextColorTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleBoldTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleBoldTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleBoldTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleBoldTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleCodeTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleCodeTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleCodeTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleCodeTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleItalicTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleItalicTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleItalicTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleItalicTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleStrikethroughTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleStrikethroughTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleStrikethroughTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleStrikethroughTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleSubscriptTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleSubscriptTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleSubscriptTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleSubscriptTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleSuperscriptTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleSuperscriptTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleSuperscriptTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleSuperscriptTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleUnderlineTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleUnderlineTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleUnderlineTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/segment/toggleUnderlineTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/selection/hasSelectionInBlockTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/selection/hasSelectionInBlockTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/selection/hasSelectionInBlockTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/selection/hasSelectionInBlockTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/selection/hasSelectionInSegmentTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/selection/hasSelectionInSegmentTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/selection/hasSelectionInSegmentTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/selection/hasSelectionInSegmentTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts index c8a1865d0be..5644056ff92 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/applyTableBorderFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/applyTableBorderFormatTest.ts @@ -2,7 +2,7 @@ import * as normalizeTable from 'roosterjs-content-model-core/lib/publicApi/tabl import applyTableBorderFormat from '../../../lib/publicApi/table/applyTableBorderFormat'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { createTable, createTableCell } from 'roosterjs-content-model-dom'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { Border, BorderOperations, @@ -13,7 +13,7 @@ import { } from 'roosterjs-content-model-types'; describe('applyTableBorderFormat', () => { - let editor: IContentModelEditor; + let editor: IStandaloneEditor; const width = '3px'; const style = 'double'; const color = '#AABBCC'; @@ -43,7 +43,7 @@ describe('applyTableBorderFormat', () => { beforeEach(() => { spyOn(normalizeTable, 'normalizeTable'); - editor = ({} as any) as IContentModelEditor; + editor = ({} as any) as IStandaloneEditor; }); function runTest( diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/setTableCellShadeTest.ts similarity index 99% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/table/setTableCellShadeTest.ts index 7f6ffaec4d9..e1b7c52b50c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/table/setTableCellShadeTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/table/setTableCellShadeTest.ts @@ -1,7 +1,7 @@ import * as normalizeTable from 'roosterjs-content-model-core/lib/publicApi/table/normalizeTable'; import setTableCellShade from '../../../lib/publicApi/table/setTableCellShade'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelTable, ContentModelFormatter, @@ -9,14 +9,14 @@ import { } from 'roosterjs-content-model-types'; describe('setTableCellShade', () => { - let editor: IContentModelEditor; + let editor: IStandaloneEditor; beforeEach(() => { spyOn(normalizeTable, 'normalizeTable'); editor = ({ focus: () => {}, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; }); function runTest( diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatImageWithContentModelTest.ts similarity index 97% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatImageWithContentModelTest.ts index ee4d6c7c373..37f40684079 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatImageWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatImageWithContentModelTest.ts @@ -1,5 +1,5 @@ import formatImageWithContentModel from '../../../lib/publicApi/utils/formatImageWithContentModel'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelImage, @@ -196,7 +196,7 @@ describe('formatImageWithContentModel', () => { function segmentTestForPluginEvent( apiName: string, - executionCallback: (editor: IContentModelEditor) => void, + executionCallback: (editor: IStandaloneEditor) => void, model: ContentModelDocument, result: ContentModelDocument, calledTimes: number @@ -215,7 +215,7 @@ function segmentTestForPluginEvent( const editor = ({ formatContentModel, getPendingFormat: () => null as any, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; executionCallback(editor); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatParagraphWithContentModelTest.ts similarity index 95% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatParagraphWithContentModelTest.ts index 7b3610db256..cb7d301229c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatParagraphWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatParagraphWithContentModelTest.ts @@ -1,5 +1,5 @@ import { formatParagraphWithContentModel } from '../../../lib/publicApi/utils/formatParagraphWithContentModel'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelParagraph, @@ -14,7 +14,7 @@ import { } from 'roosterjs-content-model-dom'; describe('formatParagraphWithContentModel', () => { - let editor: IContentModelEditor; + let editor: IStandaloneEditor; let model: ContentModelDocument; let context: FormatWithContentModelContext; @@ -45,7 +45,7 @@ describe('formatParagraphWithContentModel', () => { getCustomData: () => ({}), getFocusedPosition: () => ({ node: mockedContainer, offset: mockedOffset }), formatContentModel, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; }); it('empty doc', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts rename to packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts index 57d18d37583..0512432c32a 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatSegmentWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/utils/formatSegmentWithContentModelTest.ts @@ -1,5 +1,5 @@ import { formatSegmentWithContentModel } from '../../../lib/publicApi/utils/formatSegmentWithContentModel'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { ContentModelDocument, ContentModelSegmentFormat, @@ -15,7 +15,7 @@ import { } from 'roosterjs-content-model-dom'; describe('formatSegmentWithContentModel', () => { - let editor: IContentModelEditor; + let editor: IStandaloneEditor; let focus: jasmine.Spy; let model: ContentModelDocument; let getPendingFormat: jasmine.Spy; @@ -49,7 +49,7 @@ describe('formatSegmentWithContentModel', () => { focus, formatContentModel, getPendingFormat, - } as any) as IContentModelEditor; + } as any) as IStandaloneEditor; }); it('empty doc', () => { @@ -249,11 +249,6 @@ describe('formatSegmentWithContentModel', () => { para.segments.push(marker); model.blocks.push(para); - const mockedContainer = 'C' as any; - const mockedOffset = 'O' as any; - - editor.getFocusedPosition = () => ({ node: mockedContainer, offset: mockedOffset } as any); - formatSegmentWithContentModel(editor, apiName, format => (format.fontFamily = 'test')); expect(model).toEqual({ blockGroupType: 'Document', diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 0694938d05e..d4b10a5c5de 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -4,51 +4,5 @@ export { } from './publicTypes/ContentModelEditorCore'; export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; -export { default as insertTable } from './publicApi/table/insertTable'; -export { default as formatTable } from './publicApi/table/formatTable'; -export { default as setTableCellShade } from './publicApi/table/setTableCellShade'; -export { default as editTable } from './publicApi/table/editTable'; -export { default as applyTableBorderFormat } from './publicApi/table/applyTableBorderFormat'; -export { default as toggleBullet } from './publicApi/list/toggleBullet'; -export { default as toggleNumbering } from './publicApi/list/toggleNumbering'; -export { default as toggleBold } from './publicApi/segment/toggleBold'; -export { default as toggleItalic } from './publicApi/segment/toggleItalic'; -export { default as toggleUnderline } from './publicApi/segment/toggleUnderline'; -export { default as toggleStrikethrough } from './publicApi/segment/toggleStrikethrough'; -export { default as toggleSubscript } from './publicApi/segment/toggleSubscript'; -export { default as toggleSuperscript } from './publicApi/segment/toggleSuperscript'; -export { default as setBackgroundColor } from './publicApi/segment/setBackgroundColor'; -export { default as setFontName } from './publicApi/segment/setFontName'; -export { default as setFontSize } from './publicApi/segment/setFontSize'; -export { default as setTextColor } from './publicApi/segment/setTextColor'; -export { default as changeFontSize } from './publicApi/segment/changeFontSize'; -export { default as applySegmentFormat } from './publicApi/segment/applySegmentFormat'; -export { default as changeCapitalization } from './publicApi/segment/changeCapitalization'; -export { default as insertImage } from './publicApi/image/insertImage'; -export { default as setListStyle } from './publicApi/list/setListStyle'; -export { default as setListStartNumber } from './publicApi/list/setListStartNumber'; -export { default as hasSelectionInBlock } from './publicApi/selection/hasSelectionInBlock'; -export { default as hasSelectionInSegment } from './publicApi/selection/hasSelectionInSegment'; -export { default as hasSelectionInBlockGroup } from './publicApi/selection/hasSelectionInBlockGroup'; -export { default as setIndentation } from './publicApi/block/setIndentation'; -export { default as setAlignment } from './publicApi/block/setAlignment'; -export { default as setDirection } from './publicApi/block/setDirection'; -export { default as setHeadingLevel } from './publicApi/block/setHeadingLevel'; -export { default as toggleBlockQuote } from './publicApi/block/toggleBlockQuote'; -export { default as setSpacing } from './publicApi/block/setSpacing'; -export { default as setImageBorder } from './publicApi/image/setImageBorder'; -export { default as setImageBoxShadow } from './publicApi/image/setImageBoxShadow'; -export { default as changeImage } from './publicApi/image/changeImage'; -export { default as getFormatState } from './publicApi/format/getFormatState'; -export { default as clearFormat } from './publicApi/format/clearFormat'; -export { default as insertLink } from './publicApi/link/insertLink'; -export { default as removeLink } from './publicApi/link/removeLink'; -export { default as adjustLinkSelection } from './publicApi/link/adjustLinkSelection'; -export { default as setImageAltText } from './publicApi/image/setImageAltText'; -export { default as adjustImageSelection } from './publicApi/image/adjustImageSelection'; -export { default as setParagraphMargin } from './publicApi/block/setParagraphMargin'; -export { default as toggleCode } from './publicApi/segment/toggleCode'; -export { default as insertEntity } from './publicApi/entity/insertEntity'; - export { default as ContentModelEditor } from './editor/ContentModelEditor'; export { default as isContentModelEditor } from './editor/isContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-plugins/package.json b/packages-content-model/roosterjs-content-model-plugins/package.json index e17cccbee0c..e33c18cf24d 100644 --- a/packages-content-model/roosterjs-content-model-plugins/package.json +++ b/packages-content-model/roosterjs-content-model-plugins/package.json @@ -5,7 +5,6 @@ "tslib": "^2.3.1", "roosterjs-editor-types": "", "roosterjs-editor-dom": "", - "roosterjs-editor-core": "", "roosterjs-content-model-core": "", "roosterjs-content-model-editor": "", "roosterjs-content-model-dom": "", diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts index 12d1ffdf951..c5911a87ac3 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts @@ -1,3 +1,4 @@ +import type { CompatiblePluginEventType } from 'roosterjs-editor-types/lib/compatibleTypes'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; import type { DOMSelection } from '../selection/DOMSelection'; @@ -9,6 +10,12 @@ import type { ContentModelFormatter, FormatWithContentModelOptions, } from '../parameter/FormatWithContentModelOptions'; +import type { + EditorUndoState, + PluginEventData, + PluginEventFromType, + PluginEventType, +} from 'roosterjs-editor-types'; /** * An interface of standalone Content Model editor. @@ -74,4 +81,59 @@ export interface IStandaloneEditor { * Get pending format of editor if any, or return null */ getPendingFormat(): ContentModelSegmentFormat | null; + + //#region Editor API copied from legacy editor, will be ported to use Content Model instead + + /** + * Get whether this editor is disposed + * @returns True if editor is disposed, otherwise false + */ + isDisposed(): boolean; + + /** + * Get document which contains this editor + * @returns The HTML document which contains this editor + */ + getDocument(): Document; + + /** + * Focus to this editor, the selection was restored to where it was before, no unexpected scroll. + */ + focus(): void; + + /** + * Trigger an event to be dispatched to all plugins + * @param eventType Type of the event + * @param data data of the event with given type, this is the rest part of PluginEvent with the given type + * @param broadcast indicates if the event needs to be dispatched to all plugins + * True means to all, false means to allow exclusive handling from one plugin unless no one wants that + * @returns the event object which is really passed into plugins. Some plugin may modify the event object so + * the result of this function provides a chance to read the modified result + */ + triggerPluginEvent( + eventType: T, + data: PluginEventData, + broadcast?: boolean + ): PluginEventFromType; + + /** + * Whether there is an available undo/redo snapshot + */ + getUndoState(): EditorUndoState; + + /** + * Check if the editor is in dark mode + * @returns True if the editor is in dark mode, otherwise false + */ + isDarkMode(): boolean; + + /** + * Get current zoom scale, default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale + * to let editor behave correctly especially for those mouse drag/drop behaviors + * @returns current zoom scale number + */ + getZoomScale(): number; + + //#endregion } diff --git a/packages-content-model/roosterjs-content-model/lib/index.ts b/packages-content-model/roosterjs-content-model/lib/index.ts index 85fdf04405e..ef7a866c977 100644 --- a/packages-content-model/roosterjs-content-model/lib/index.ts +++ b/packages-content-model/roosterjs-content-model/lib/index.ts @@ -1,5 +1,7 @@ export { createContentModelEditor } from './createContentModelEditor'; export * from 'roosterjs-content-model-types'; export * from 'roosterjs-content-model-dom'; +export * from 'roosterjs-content-model-core'; +export * from 'roosterjs-content-model-api'; export * from 'roosterjs-content-model-editor'; export * from 'roosterjs-content-model-plugins'; diff --git a/packages-content-model/roosterjs-content-model/package.json b/packages-content-model/roosterjs-content-model/package.json index afb0cbafea8..698c3df6db1 100644 --- a/packages-content-model/roosterjs-content-model/package.json +++ b/packages-content-model/roosterjs-content-model/package.json @@ -4,10 +4,10 @@ "dependencies": { "tslib": "^2.3.1", "roosterjs-editor-types": "", - "roosterjs-editor-dom": "", - "roosterjs-editor-core": "", "roosterjs-content-model-types": "", "roosterjs-content-model-dom": "", + "roosterjs-content-model-core": "", + "roosterjs-content-model-api": "", "roosterjs-content-model-editor": "", "roosterjs-content-model-plugins": "", "roosterjs-color-utils": "" From 448266bc90a827f2e8fe85a5fecfa0c28a51af54 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 10 Nov 2023 12:20:59 -0800 Subject: [PATCH 046/111] Decouple ContentModelEditor from roosterjs-editor-core (#2201) * Move ContentModelEdit plugin to plugins package * Move types to roosterjs-content-model-types package * improve * Improve * improve * Improve * improve * Move core API to core package * fix build * improve * Move corePlugins to roosterjs-content-model-core package * fix build * improve * fix build * Move format API to roosterjs-content-model-api package * fix build * Improve * Decouple ContentModelEditor from roosterjs-editor-core * improve --- .../controls/ContentModelEditorMainPane.tsx | 3 +- .../test/publicApi/link/insertLinkTest.ts | 5 +- .../editor/createContentModelEditorCore.ts | 17 +- .../roosterjs-content-model-core/package.json | 1 - .../createContentModelEditorCoreTest.ts | 10 +- .../test/publicApi/model/pasteTest.ts | 5 +- .../backgroundColorFormatHandlerTest.ts | 3 +- .../segment/textColorFormatHandlerTest.ts | 3 +- .../test/modelToDom/handlers/handleBrTest.ts | 2 - .../handlers/handleSegmentDecoratorTest.ts | 4 - .../modelToDom/handlers/handleTableTest.ts | 2 - .../modelToDom/handlers/handleTextTest.ts | 2 - .../utils/handleSegmentCommonTest.ts | 5 - .../lib/coreApi/addUndoSnapshot.ts | 137 +++ .../lib/coreApi/attachDomEvent.ts | 64 + .../lib/coreApi/coreApiMap.ts | 49 + .../lib/coreApi/createPasteFragment.ts | 152 +++ .../lib/coreApi/ensureTypeInContainer.ts | 88 ++ .../lib/coreApi/focus.ts | 44 + .../lib/coreApi/getContent.ts | 91 ++ .../lib/coreApi/getPendableFormatState.ts | 101 ++ .../lib/coreApi/getSelectionRange.ts | 44 + .../lib/coreApi/getSelectionRangeEx.ts | 101 ++ .../lib/coreApi/getStyleBasedFormatState.ts | 96 ++ .../lib/coreApi/hasFocus.ts | 15 + .../lib/coreApi/insertNode.ts | 235 ++++ .../lib/coreApi/restoreUndoSnapshot.ts | 69 ++ .../lib/coreApi/select.ts | 179 +++ .../lib/coreApi/selectImage.ts | 65 + .../lib/coreApi/selectRange.ts | 74 ++ .../lib/coreApi/selectTable.ts | 268 +++++ .../lib/coreApi/setContent.ts | 120 ++ .../lib/coreApi/switchShadowEdit.ts | 111 ++ .../lib/coreApi/transformColor.ts | 69 ++ .../lib/coreApi/triggerEvent.ts | 44 + .../lib/coreApi/utils/addUniqueId.ts | 31 + .../lib/corePlugins/CopyPastePlugin.ts | 296 +++++ .../lib/corePlugins/DOMEventPlugin.ts | 259 ++++ .../lib/corePlugins/EditPlugin.ts | 96 ++ .../lib/corePlugins/EntityPlugin.ts | 390 ++++++ .../lib/corePlugins/ImageSelection.ts | 100 ++ .../lib/corePlugins/LifecyclePlugin.ts | 188 +++ .../lib/corePlugins/MouseUpPlugin.ts | 72 ++ .../lib/corePlugins/NormalizeTablePlugin.ts | 180 +++ .../corePlugins/PendingFormatStatePlugin.ts | 184 +++ .../lib/corePlugins/TypeInContainerPlugin.ts | 99 ++ .../lib/corePlugins/UndoPlugin.ts | 279 +++++ .../lib/corePlugins/createCorePlugins.ts | 66 ++ .../corePlugins/utils/forEachSelectedCell.ts | 22 + .../utils/inlineEntityOnPluginEvent.ts | 291 +++++ .../utils/removeCellsOutsideSelection.ts | 37 + .../lib/editor/ContentModelEditor.ts | 1047 ++++++++++++++++- .../lib/editor/DarkColorHandlerImpl.ts | 173 +++ .../lib/editor/createEditorCore.ts | 59 + .../lib/editor/isContentModelEditor.ts | 4 +- .../lib/index.ts | 5 +- .../lib/publicTypes/IContentModelEditor.ts | 7 +- .../package.json | 1 - .../test/editor/ContentModelEditorTest.ts | 23 +- .../test/editor/isContentModelEditorTest.ts | 42 +- .../test/paste/e2e/testUtils.ts | 8 +- .../lib/createContentModelEditor.ts | 3 +- 62 files changed, 6162 insertions(+), 78 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/attachDomEvent.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/createPasteFragment.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/getPendableFormatState.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/hasFocus.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/restoreUndoSnapshot.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectTable.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/switchShadowEdit.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/transformColor.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/triggerEvent.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/utils/addUniqueId.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/CopyPastePlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/DOMEventPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EditPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ImageSelection.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/MouseUpPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/PendingFormatStatePlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/TypeInContainerPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/forEachSelectedCell.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/removeCellsOutsideSelection.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 704414e9081..8ca58743441 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -17,6 +17,7 @@ import { arrayPush } from 'roosterjs-editor-dom'; import { ContentModelEditor } from 'roosterjs-content-model-editor'; import { ContentModelEditPlugin } from 'roosterjs-content-model-plugins'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; +import { createContentModelEditorCore } from 'roosterjs-content-model-core'; import { createEmojiPlugin, createPasteOptionPlugin, RibbonPlugin } from 'roosterjs-react'; import { EditorOptions, EditorPlugin } from 'roosterjs-editor-types'; import { PartialTheme } from '@fluentui/react/lib/Theme'; @@ -183,7 +184,7 @@ class ContentModelEditorMainPane extends MainPaneBase { this.toggleablePlugins = null; this.setState({ editorCreator: (div: HTMLDivElement, options: EditorOptions) => - new ContentModelEditor(div, { + new ContentModelEditor(div, createContentModelEditorCore, { ...options, cacheModel: this.state.initState.cacheModel, }), diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts index b63fcf8da10..0a76059a86a 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts @@ -1,6 +1,7 @@ import insertLink from '../../../lib/publicApi/link/insertLink'; import { ChangeSource } from 'roosterjs-content-model-core'; import { ContentModelEditor } from 'roosterjs-content-model-editor'; +import { createContentModelEditorCore } from 'roosterjs-content-model-core'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { PluginEventType } from 'roosterjs-editor-types'; import { @@ -330,7 +331,9 @@ describe('insertLink', () => { getName: () => 'mock', onPluginEvent: onPluginEvent, }; - const editor = new ContentModelEditor(div, { plugins: [mockedPlugin] }); + const editor = new ContentModelEditor(div, createContentModelEditorCore, { + plugins: [mockedPlugin], + }); editor.focus(); diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createContentModelEditorCore.ts index e847a849fd9..d82667d2a90 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createContentModelEditorCore.ts @@ -3,7 +3,6 @@ import { ContentModelTypeInContainerPlugin } from '../corePlugin/ContentModelTyp import { createContentModelCachePlugin } from '../corePlugin/ContentModelCachePlugin'; import { createContentModelCopyPastePlugin } from '../corePlugin/ContentModelCopyPastePlugin'; import { createContentModelFormatPlugin } from '../corePlugin/ContentModelFormatPlugin'; -import { createEditorCore } from 'roosterjs-editor-core'; import { promoteToContentModelEditorCore } from './promoteToContentModelEditorCore'; import type { CoreCreator, EditorCore, EditorOptions } from 'roosterjs-editor-types'; import type { @@ -14,11 +13,15 @@ import type { /** * Editor Core creator for Content Model editor + * @param contentDiv Container DIV of editor + * @param options Options for creating editor + * @param baseCreator Base core creator used for creating base EditorCore */ -export const createContentModelEditorCore: CoreCreator< - EditorCore & StandaloneEditorCore, - EditorOptions & StandaloneEditorOptions -> = (contentDiv, options) => { +export function createContentModelEditorCore( + contentDiv: HTMLDivElement, + options: EditorOptions & StandaloneEditorOptions, + baseCreator: CoreCreator +): EditorCore & StandaloneEditorCore { const pluginState = getPluginState(options); const modifiedOptions: EditorOptions & StandaloneEditorOptions = { ...options, @@ -34,12 +37,12 @@ export const createContentModelEditorCore: CoreCreator< }, }; - const core = createEditorCore(contentDiv, modifiedOptions) as EditorCore & StandaloneEditorCore; + const core = baseCreator(contentDiv, modifiedOptions) as EditorCore & StandaloneEditorCore; promoteToContentModelEditorCore(core, modifiedOptions, pluginState); return core; -}; +} function getPluginState(options: EditorOptions & StandaloneEditorOptions): ContentModelPluginState { const format = options.defaultFormat || {}; diff --git a/packages-content-model/roosterjs-content-model-core/package.json b/packages-content-model/roosterjs-content-model-core/package.json index c932d7dfbde..c037037dea2 100644 --- a/packages-content-model/roosterjs-content-model-core/package.json +++ b/packages-content-model/roosterjs-content-model-core/package.json @@ -5,7 +5,6 @@ "tslib": "^2.3.1", "roosterjs-editor-types": "", "roosterjs-editor-dom": "", - "roosterjs-editor-core": "", "roosterjs-content-model-dom": "", "roosterjs-content-model-types": "" }, diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/createContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/createContentModelEditorCoreTest.ts index fb1cb935557..138e9e8e4e5 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/createContentModelEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/createContentModelEditorCoreTest.ts @@ -1,7 +1,7 @@ import * as ContentModelCachePlugin from '../../lib/corePlugin/ContentModelCachePlugin'; import * as ContentModelCopyPastePlugin from '../../lib/corePlugin/ContentModelCopyPastePlugin'; import * as ContentModelFormatPlugin from '../../lib/corePlugin/ContentModelFormatPlugin'; -import * as createEditorCore from 'roosterjs-editor-core/lib/editor/createEditorCore'; +import * as createEditorCore from 'roosterjs-content-model-editor/lib/editor/createEditorCore'; import * as promoteToContentModelEditorCore from '../../lib/editor/promoteToContentModelEditorCore'; import { contentModelDomIndexer } from '../../lib/corePlugin/utils/contentModelDomIndexer'; import { ContentModelTypeInContainerPlugin } from '../../lib/corePlugin/ContentModelTypeInContainerPlugin'; @@ -58,7 +58,7 @@ describe('createContentModelEditorCore', () => { }); it('No additional option', () => { - const core = createContentModelEditorCore(contentDiv, {}); + const core = createContentModelEditorCore(contentDiv, {}, createEditorCoreSpy); const expectedOptions = { plugins: [mockedCachePlugin, mockedFormatPlugin], @@ -103,7 +103,7 @@ describe('createContentModelEditorCore', () => { copyPaste: mockedCopyPastePlugin2, }, }; - const core = createContentModelEditorCore(contentDiv, options); + const core = createContentModelEditorCore(contentDiv, options, createEditorCoreSpy); const expectedOptions = { defaultDomToModelOptions, @@ -152,7 +152,7 @@ describe('createContentModelEditorCore', () => { }, }; - const core = createContentModelEditorCore(contentDiv, options); + const core = createContentModelEditorCore(contentDiv, options, createEditorCoreSpy); const expectedOptions = { plugins: [mockedCachePlugin, mockedFormatPlugin], @@ -200,7 +200,7 @@ describe('createContentModelEditorCore', () => { cacheModel: true, }; - const core = createContentModelEditorCore(contentDiv, options); + const core = createContentModelEditorCore(contentDiv, options, createEditorCoreSpy); const expectedOptions = { plugins: [mockedCachePlugin, mockedFormatPlugin], diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/model/pasteTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/pasteTest.ts index b0e61a6a330..12782283455 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/model/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/pasteTest.ts @@ -12,6 +12,7 @@ import * as WordDesktopFile from '../../../../roosterjs-content-model-plugins/li import { ContentModelEditor } from 'roosterjs-content-model-editor'; import { ContentModelPastePlugin } from '../../../../roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin'; import { createContentModelDocument, tableProcessor } from 'roosterjs-content-model-dom'; +import { createContentModelEditorCore } from 'roosterjs-content-model-core'; import { ContentModelDocument, DomToModelOption, @@ -230,7 +231,7 @@ describe('paste with content model & paste plugin', () => { beforeEach(() => { div = document.createElement('div'); document.body.appendChild(div); - editor = new ContentModelEditor(div, { + editor = new ContentModelEditor(div, createContentModelEditorCore, { plugins: [new ContentModelPastePlugin()], }); spyOn(addParserF, 'default').and.callThrough(); @@ -377,7 +378,7 @@ describe('paste with content model & paste plugin', () => { }; let eventChecker: BeforePasteEvent = {}; - editor = new ContentModelEditor(div!, { + editor = new ContentModelEditor(div!, createContentModelEditorCore, { plugins: [ { initialize: () => {}, diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts index 4e8b02318c8..be3f5925e60 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts @@ -1,7 +1,7 @@ -import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { backgroundColorFormatHandler } from '../../../lib/formatHandlers/common/backgroundColorFormatHandler'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; +import { DarkColorHandlerImpl } from 'roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl'; import { DeprecatedColors } from '../../../lib/formatHandlers/utils/color'; import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; import { @@ -95,7 +95,6 @@ describe('backgroundColorFormatHandler.apply', () => { it('Simple color', () => { format.backgroundColor = 'red'; - context.darkColorHandler = new DarkColorHandlerImpl(div, s => 'darkMock:' + s); backgroundColorFormatHandler.apply(format, div, context); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts index 00feb9fd174..74ded2e190c 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts @@ -1,6 +1,6 @@ -import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; +import { DarkColorHandlerImpl } from 'roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl'; import { defaultHTMLStyleMap } from '../../../lib/config/defaultHTMLStyleMap'; import { DeprecatedColors } from '../../../lib'; import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; @@ -19,7 +19,6 @@ describe('textColorFormatHandler.parse', () => { beforeEach(() => { div = document.createElement('div'); context = createDomToModelContext(); - context.darkColorHandler = new DarkColorHandlerImpl(div, s => 'darkMock: ' + s); format = {}; }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts index 9f3dfbc414f..abddc4827ca 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBrTest.ts @@ -1,4 +1,3 @@ -import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { ContentModelBr, ModelToDomContext } from 'roosterjs-content-model-types'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { handleBr } from '../../../lib/modelToDom/handlers/handleBr'; @@ -10,7 +9,6 @@ describe('handleSegment', () => { beforeEach(() => { parent = document.createElement('div'); context = createModelToDomContext(); - context.darkColorHandler = new DarkColorHandlerImpl({} as any, s => 'darkMock: ' + s); }); it('Br segment', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts index 242074a48f1..3cd180aae63 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleSegmentDecoratorTest.ts @@ -1,4 +1,3 @@ -import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; import { handleSegmentDecorator } from '../../../lib/modelToDom/handlers/handleSegmentDecorator'; @@ -68,8 +67,6 @@ describe('handleSegmentDecorator', () => { dataset: {}, }; - context.darkColorHandler = new DarkColorHandlerImpl({} as any, s => 'darkMock: ' + s); - runTest(link, undefined, 'test', [ 'test', ]); @@ -227,7 +224,6 @@ describe('handleSegmentDecorator', () => { }, dataset: {}, }; - context.darkColorHandler = new DarkColorHandlerImpl({} as any, s => 'darkMock: ' + s); runTest( link, diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts index a6fd119a446..eef92223996 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts @@ -1,5 +1,4 @@ import * as handleBlock from '../../../lib/modelToDom/handlers/handleBlock'; -import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { createTable } from '../../../lib/modelApi/creators/createTable'; import { createTableCell } from '../../../lib/modelApi/creators/createTableCell'; @@ -17,7 +16,6 @@ describe('handleTable', () => { beforeEach(() => { spyOn(handleBlock, 'handleBlock'); context = createModelToDomContext({ allowCacheElement: true }); - context.darkColorHandler = new DarkColorHandlerImpl(null!, s => 'darkMock: ' + s); }); function runTest(model: ContentModelTable, expectedInnerHTML: string) { diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTextTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTextTest.ts index 4737c071f3d..8bfa17f2a14 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTextTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTextTest.ts @@ -1,5 +1,4 @@ import * as stackFormat from '../../../lib/modelToDom/utils/stackFormat'; -import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { ContentModelText, ModelToDomContext } from 'roosterjs-content-model-types'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { handleText } from '../../../lib/modelToDom/handlers/handleText'; @@ -31,7 +30,6 @@ describe('handleText', () => { text: 'test', format: { textColor: 'red' }, }; - context.darkColorHandler = new DarkColorHandlerImpl({} as any, s => 'darkMock: ' + s); handleText(document, parent, text, context, []); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts index 5ed00d3f49f..8a3b3c923a4 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/utils/handleSegmentCommonTest.ts @@ -1,4 +1,3 @@ -import DarkColorHandlerImpl from 'roosterjs-editor-core/lib/editor/DarkColorHandlerImpl'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { createText } from '../../../lib/modelApi/creators/createText'; import { handleSegmentCommon } from '../../../lib/modelToDom/utils/handleSegmentCommon'; @@ -17,10 +16,6 @@ describe('handleSegmentCommon', () => { const context = createModelToDomContext(); context.onNodeCreated = onNodeCreated; - context.darkColorHandler = new DarkColorHandlerImpl( - document.createElement('div'), - s => 'darkMock: ' + s - ); segment.link = { dataset: {}, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts new file mode 100644 index 00000000000..aa7919c803f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts @@ -0,0 +1,137 @@ +import { getSelectionPath, Position } from 'roosterjs-editor-dom'; +import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import type { + EntityState, + AddUndoSnapshot, + ChangeSource, + ContentChangedData, + ContentChangedEvent, + ContentMetadata, + EditorCore, + NodePosition, + SelectionRangeEx, +} from 'roosterjs-editor-types'; +import type { CompatibleChangeSource } from 'roosterjs-editor-types/lib/compatibleTypes'; + +/** + * @internal + * Call an editing callback with adding undo snapshots around, and trigger a ContentChanged event if change source is specified. + * Undo snapshot will not be added if this call is nested inside another addUndoSnapshot() call. + * @param core The EditorCore object + * @param callback The editing callback, accepting current selection start and end position, returns an optional object used as the data field of ContentChangedEvent. + * @param changeSource The ChangeSource string of ContentChangedEvent. @default ChangeSource.Format. Set to null to avoid triggering ContentChangedEvent + * @param canUndoByBackspace True if this action can be undone when user press Backspace key (aka Auto Complete). + * @param additionalData @optional parameter to provide additional data related to the ContentChanged Event. + */ +export const addUndoSnapshot: AddUndoSnapshot = ( + core: EditorCore, + callback: ((start: NodePosition | null, end: NodePosition | null) => any) | null, + changeSource: ChangeSource | CompatibleChangeSource | string | null, + canUndoByBackspace: boolean, + additionalData?: ContentChangedData +) => { + const undoState = core.undo; + const isNested = undoState.isNested; + let data: any; + + if (!isNested) { + undoState.isNested = true; + + // When there is getEntityState, it means this is triggered by an entity change. + // So if HTML content is not changed (hasNewContent is false), no need to add another snapshot before change + if (core.undo.hasNewContent || !additionalData?.getEntityState || !callback) { + addUndoSnapshotInternal(core, canUndoByBackspace, additionalData?.getEntityState?.()); + } + } + + try { + if (callback) { + const range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); + data = callback( + range && Position.getStart(range).normalize(), + range && Position.getEnd(range).normalize() + ); + + if (!isNested) { + const entityStates = additionalData?.getEntityState?.(); + addUndoSnapshotInternal(core, false /*isAutoCompleteSnapshot*/, entityStates); + } + } + } finally { + if (!isNested) { + undoState.isNested = false; + } + } + + if (callback && changeSource) { + const event: ContentChangedEvent = { + eventType: PluginEventType.ContentChanged, + source: changeSource, + data: data, + additionalData, + }; + core.api.triggerEvent(core, event, true /*broadcast*/); + } + + if (canUndoByBackspace) { + const range = core.api.getSelectionRange(core, false /*tryGetFromCache*/); + + if (range) { + core.undo.hasNewContent = false; + core.undo.autoCompletePosition = Position.getStart(range); + } + } +}; + +function addUndoSnapshotInternal( + core: EditorCore, + canUndoByBackspace: boolean, + entityStates?: EntityState[] +) { + if (!core.lifecycle.shadowEditFragment) { + const rangeEx = core.api.getSelectionRangeEx(core); + const isDarkMode = core.lifecycle.isDarkMode; + const metadata = createContentMetadata(core.contentDiv, rangeEx, isDarkMode) || null; + + core.undo.snapshotsService.addSnapshot( + { + html: core.contentDiv.innerHTML, + metadata, + knownColors: core.darkColorHandler?.getKnownColorsCopy() || [], + entityStates, + }, + canUndoByBackspace + ); + core.undo.hasNewContent = false; + } +} + +function createContentMetadata( + root: HTMLElement, + rangeEx: SelectionRangeEx, + isDarkMode: boolean +): ContentMetadata | undefined { + switch (rangeEx?.type) { + case SelectionRangeTypes.TableSelection: + return { + type: SelectionRangeTypes.TableSelection, + tableId: rangeEx.table.id, + isDarkMode: !!isDarkMode, + ...rangeEx.coordinates!, + }; + case SelectionRangeTypes.ImageSelection: + return { + type: SelectionRangeTypes.ImageSelection, + imageId: rangeEx.image.id, + isDarkMode: !!isDarkMode, + }; + case SelectionRangeTypes.Normal: + return { + type: SelectionRangeTypes.Normal, + isDarkMode: !!isDarkMode, + start: [], + end: [], + ...(getSelectionPath(root, rangeEx.ranges[0]) || {}), + }; + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/attachDomEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/attachDomEvent.ts new file mode 100644 index 00000000000..0ca6916e4e6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/attachDomEvent.ts @@ -0,0 +1,64 @@ +import { getObjectKeys } from 'roosterjs-editor-dom'; +import type { + AttachDomEvent, + DOMEventHandler, + DOMEventHandlerObject, + EditorCore, + PluginDomEvent, +} from 'roosterjs-editor-types'; + +/** + * @internal + * Attach a DOM event to the editor content DIV + * @param core The EditorCore object + * @param eventName The DOM event name + * @param pluginEventType Optional event type. When specified, editor will trigger a plugin event with this name when the DOM event is triggered + * @param beforeDispatch Optional callback function to be invoked when the DOM event is triggered before trigger plugin event + */ +export const attachDomEvent: AttachDomEvent = ( + core: EditorCore, + eventMap: Record +) => { + const disposers = getObjectKeys(eventMap || {}).map(key => { + const { pluginEventType, beforeDispatch } = extractHandler(eventMap[key]); + const eventName = key as keyof HTMLElementEventMap; + const onEvent = (event: HTMLElementEventMap[typeof eventName]) => { + if (beforeDispatch) { + beforeDispatch(event); + } + if (pluginEventType != null) { + core.api.triggerEvent( + core, + { + eventType: pluginEventType, + rawEvent: event, + }, + false /*broadcast*/ + ); + } + }; + + core.contentDiv.addEventListener(eventName, onEvent); + + return () => { + core.contentDiv.removeEventListener(eventName, onEvent); + }; + }); + return () => disposers.forEach(disposers => disposers()); +}; + +function extractHandler(handlerObj: DOMEventHandler): DOMEventHandlerObject { + let result: DOMEventHandlerObject = { + pluginEventType: null, + beforeDispatch: null, + }; + + if (typeof handlerObj === 'number') { + result.pluginEventType = handlerObj; + } else if (typeof handlerObj === 'function') { + result.beforeDispatch = handlerObj; + } else if (typeof handlerObj === 'object') { + result = handlerObj; + } + return result; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts new file mode 100644 index 00000000000..dd12197703f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts @@ -0,0 +1,49 @@ +import { addUndoSnapshot } from './addUndoSnapshot'; +import { attachDomEvent } from './attachDomEvent'; +import { createPasteFragment } from './createPasteFragment'; +import { ensureTypeInContainer } from './ensureTypeInContainer'; +import { focus } from './focus'; +import { getContent } from './getContent'; +import { getPendableFormatState } from './getPendableFormatState'; +import { getSelectionRange } from './getSelectionRange'; +import { getSelectionRangeEx } from './getSelectionRangeEx'; +import { getStyleBasedFormatState } from './getStyleBasedFormatState'; +import { hasFocus } from './hasFocus'; +import { insertNode } from './insertNode'; +import { restoreUndoSnapshot } from './restoreUndoSnapshot'; +import { select } from './select'; +import { selectImage } from './selectImage'; +import { selectRange } from './selectRange'; +import { selectTable } from './selectTable'; +import { setContent } from './setContent'; +import { switchShadowEdit } from './switchShadowEdit'; +import { transformColor } from './transformColor'; +import { triggerEvent } from './triggerEvent'; +import type { CoreApiMap } from 'roosterjs-editor-types'; + +/** + * @internal + */ +export const coreApiMap: CoreApiMap = { + attachDomEvent, + addUndoSnapshot, + createPasteFragment, + ensureTypeInContainer, + focus, + getContent, + getSelectionRange, + getSelectionRangeEx, + getStyleBasedFormatState, + getPendableFormatState, + hasFocus, + insertNode, + restoreUndoSnapshot, + select, + selectRange, + setContent, + switchShadowEdit, + transformColor, + triggerEvent, + selectTable, + selectImage, +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/createPasteFragment.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/createPasteFragment.ts new file mode 100644 index 00000000000..5dcc65d82a7 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/createPasteFragment.ts @@ -0,0 +1,152 @@ +import { PasteType, PluginEventType } from 'roosterjs-editor-types'; +import { + applyFormat, + applyTextStyle, + createDefaultHtmlSanitizerOptions, + getPasteType, + handleImagePaste, + handleTextPaste, + moveChildNodes, + retrieveMetadataFromClipboard, + sanitizePasteContent, +} from 'roosterjs-editor-dom'; +import type { + BeforePasteEvent, + ClipboardData, + CreatePasteFragment, + EditorCore, + NodePosition, + DefaultFormat, +} from 'roosterjs-editor-types'; + +/** + * @internal + * Create a DocumentFragment for paste from a ClipboardData + * @param core The EditorCore object. + * @param clipboardData Clipboard data retrieved from clipboard + * @param position The position to paste to + * @param pasteAsText True to force use plain text as the content to paste, false to choose HTML or Image if any + * @param applyCurrentStyle True if apply format of current selection to the pasted content, + * false to keep original format + * @param pasteAsImage True if the image should be pasted as image + */ +export const createPasteFragment: CreatePasteFragment = ( + core: EditorCore, + clipboardData: ClipboardData, + position: NodePosition | null, + pasteAsText: boolean, + applyCurrentStyle: boolean, + pasteAsImage: boolean = false +) => { + if (!clipboardData) { + return null; + } + + const pasteType = getPasteType(pasteAsText, applyCurrentStyle, pasteAsImage); + + // Step 1: Prepare BeforePasteEvent object + const event = createBeforePasteEvent(core, clipboardData, pasteType); + return createFragmentFromClipboardData( + core, + clipboardData, + position, + pasteAsText, + applyCurrentStyle, + pasteAsImage, + event + ); +}; + +function createBeforePasteEvent( + core: EditorCore, + clipboardData: ClipboardData, + pasteType: PasteType +): BeforePasteEvent { + const options = createDefaultHtmlSanitizerOptions(); + + // Remove "caret-color" style generated by Safari to make sure caret shows in right color after paste + options.cssStyleCallbacks['caret-color'] = () => false; + + return { + eventType: PluginEventType.BeforePaste, + clipboardData, + fragment: core.contentDiv.ownerDocument.createDocumentFragment(), + sanitizingOption: options, + htmlBefore: '', + htmlAfter: '', + htmlAttributes: {}, + pasteType: pasteType, + }; +} + +/** + * Create a DocumentFragment for paste from a ClipboardData + * @param core The EditorCore object. + * @param clipboardData Clipboard data retrieved from clipboard + * @param position The position to paste to + * @param pasteAsText True to force use plain text as the content to paste, false to choose HTML or Image if any + * @param applyCurrentStyle True if apply format of current selection to the pasted content, + * @param pasteAsImage Whether to force paste as image + * @param event Event to trigger. + * false to keep original format + */ +function createFragmentFromClipboardData( + core: EditorCore, + clipboardData: ClipboardData, + position: NodePosition | null, + pasteAsText: boolean, + applyCurrentStyle: boolean, + pasteAsImage: boolean, + event: BeforePasteEvent +) { + const { fragment } = event; + const { rawHtml, text, imageDataUri } = clipboardData; + const doc: Document | undefined = rawHtml + ? new DOMParser().parseFromString(core.trustedHTMLHandler(rawHtml), 'text/html') + : undefined; + + // Step 2: Retrieve Metadata from Html and the Html that was copied. + retrieveMetadataFromClipboard(doc, event, core.trustedHTMLHandler); + + // Step 3: Fill the BeforePasteEvent object, especially the fragment for paste + if ((pasteAsImage && imageDataUri) || (!pasteAsText && !text && imageDataUri)) { + // Paste image + handleImagePaste(imageDataUri, fragment); + } else if (!pasteAsText && rawHtml && doc ? doc.body : false) { + moveChildNodes(fragment, doc?.body); + + if (applyCurrentStyle && position) { + const format = getCurrentFormat(core, position.node); + applyTextStyle(fragment, node => applyFormat(node, format)); + } + } else if (text) { + // Paste text + handleTextPaste(text, position, fragment); + } + + // Step 4: Trigger BeforePasteEvent so that plugins can do proper change before paste, when the type of paste is different than Plain Text + if (event.pasteType !== PasteType.AsPlainText) { + core.api.triggerEvent(core, event, true /*broadcast*/); + } + + // Step 5. Sanitize the fragment before paste to make sure the content is safe + sanitizePasteContent(event, position); + + return fragment; +} + +function getCurrentFormat(core: EditorCore, node: Node): DefaultFormat { + const pendableFormat = core.api.getPendableFormatState(core, true /** forceGetStateFromDOM*/); + const styleBasedFormat = core.api.getStyleBasedFormatState(core, node); + return { + fontFamily: styleBasedFormat.fontName, + fontSize: styleBasedFormat.fontSize, + textColor: styleBasedFormat.textColor, + backgroundColor: styleBasedFormat.backgroundColor, + textColors: styleBasedFormat.textColors, + backgroundColors: styleBasedFormat.backgroundColors, + bold: pendableFormat.isBold, + italic: pendableFormat.isItalic, + underline: pendableFormat.isUnderline, + }; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts new file mode 100644 index 00000000000..3860ac2a4d7 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts @@ -0,0 +1,88 @@ +import { ContentPosition, KnownCreateElementDataIndex, PositionType } from 'roosterjs-editor-types'; +import type { EditorCore, EnsureTypeInContainer, NodePosition } from 'roosterjs-editor-types'; +import { + applyFormat, + createElement, + createRange, + findClosestElementAncestor, + getBlockElementAtNode, + isNodeEmpty, + Position, + safeInstanceOf, +} from 'roosterjs-editor-dom'; + +/** + * @internal + * When typing goes directly under content div, many things can go wrong + * We fix it by wrapping it with a div and reposition cursor within the div + */ +export const ensureTypeInContainer: EnsureTypeInContainer = ( + core: EditorCore, + position: NodePosition, + keyboardEvent?: KeyboardEvent +) => { + const table = findClosestElementAncestor(position.node, core.contentDiv, 'table'); + let td: HTMLElement | null; + + if (table && (td = table.querySelector('td,th'))) { + position = new Position(td, PositionType.Begin); + } + position = position.normalize(); + + const block = getBlockElementAtNode(core.contentDiv, position.node); + let formatNode: HTMLElement | null; + + if (block) { + formatNode = block.collapseToSingleElement(); + if (isNodeEmpty(formatNode, false /* trimContent */, true /* shouldCountBrAsVisible */)) { + const brEl = formatNode.ownerDocument.createElement('br'); + formatNode.append(brEl); + } + // if the block is empty, apply default format + // Otherwise, leave it as it is as we don't want to change the style for existing data + // unless the block was just created by the keyboard event (e.g. ctrl+a & start typing) + const shouldSetNodeStyles = + isNodeEmpty(formatNode) || + (keyboardEvent && wasNodeJustCreatedByKeyboardEvent(keyboardEvent, formatNode)); + formatNode = formatNode && shouldSetNodeStyles ? formatNode : null; + } else { + // Only reason we don't get the selection block is that we have an empty content div + // which can happen when users removes everything (i.e. select all and DEL, or backspace from very end to begin) + // The fix is to add a DIV wrapping, apply default format and move cursor over + formatNode = createElement( + KnownCreateElementDataIndex.EmptyLine, + core.contentDiv.ownerDocument + ) as HTMLElement; + core.api.insertNode(core, formatNode, { + position: ContentPosition.End, + updateCursor: false, + replaceSelection: false, + insertOnNewLine: false, + }); + + // element points to a wrapping node we added "

                                                          ". We should move the selection left to
                                                          + position = new Position(formatNode, PositionType.Begin); + } + + if (formatNode && core.lifecycle.defaultFormat) { + applyFormat( + formatNode, + core.lifecycle.defaultFormat, + core.lifecycle.isDarkMode, + core.darkColorHandler + ); + } + + // If this is triggered by a keyboard event, let's select the new position + if (keyboardEvent) { + core.api.selectRange(core, createRange(new Position(position))); + } +}; + +function wasNodeJustCreatedByKeyboardEvent(event: KeyboardEvent, formatNode: HTMLElement) { + return ( + safeInstanceOf(event.target, 'Node') && + event.target.contains(formatNode) && + event.key === formatNode.innerText + ); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts new file mode 100644 index 00000000000..4490f5a24a0 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts @@ -0,0 +1,44 @@ +import { createRange, getFirstLeafNode } from 'roosterjs-editor-dom'; +import { PositionType } from 'roosterjs-editor-types'; +import type { EditorCore, Focus } from 'roosterjs-editor-types'; + +/** + * @internal + * Focus to editor. If there is a cached selection range, use it as current selection + * @param core The EditorCore object + */ +export const focus: Focus = (core: EditorCore) => { + if (!core.lifecycle.shadowEditFragment) { + if ( + !core.api.hasFocus(core) || + !core.api.getSelectionRange(core, false /*tryGetFromCache*/) + ) { + // Focus (document.activeElement indicates) and selection are mostly in sync, but could be out of sync in some extreme cases. + // i.e. if you programmatically change window selection to point to a non-focusable DOM element (i.e. tabindex=-1 etc.). + // On Chrome/Firefox, it does not change document.activeElement. On Edge/IE, it change document.activeElement to be body + // Although on Chrome/Firefox, document.activeElement points to editor, you cannot really type which we don't want (no cursor). + // So here we always do a live selection pull on DOM and make it point in Editor. The pitfall is, the cursor could be reset + // to very begin to of editor since we don't really have last saved selection (created on blur which does not fire in this case). + // It should be better than the case you cannot type + if ( + !core.domEvent.selectionRange || + !core.api.selectRange(core, core.domEvent.selectionRange, true /*skipSameRange*/) + ) { + const node = getFirstLeafNode(core.contentDiv) || core.contentDiv; + core.api.selectRange( + core, + createRange(node, PositionType.Begin), + true /*skipSameRange*/ + ); + } + } + + // remember to clear cached selection range + core.domEvent.selectionRange = null; + + // This is more a fallback to ensure editor gets focus if it didn't manage to move focus to editor + if (!core.api.hasFocus(core)) { + core.contentDiv.focus(); + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts new file mode 100644 index 00000000000..8135e2866e3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts @@ -0,0 +1,91 @@ +import { ColorTransformDirection, GetContentMode, PluginEventType } from 'roosterjs-editor-types'; +import type { EditorCore, GetContent } from 'roosterjs-editor-types'; +import { + createRange, + getHtmlWithSelectionPath, + getSelectionPath, + getTextContent, + safeInstanceOf, +} from 'roosterjs-editor-dom'; +import type { CompatibleGetContentMode } from 'roosterjs-editor-types/lib/compatibleTypes'; + +/** + * @internal + * Get current editor content as HTML string + * @param core The EditorCore object + * @param mode specify what kind of HTML content to retrieve + * @returns HTML string representing current editor content + */ +export const getContent: GetContent = ( + core: EditorCore, + mode: GetContentMode | CompatibleGetContentMode +): string => { + let content: string | null = ''; + const triggerExtractContentEvent = mode == GetContentMode.CleanHTML; + const includeSelectionMarker = mode == GetContentMode.RawHTMLWithSelection; + + // When there is fragment for shadow edit, always use the cached fragment as document since HTML node in editor + // has been changed by uncommitted shadow edit which should be ignored. + const root = core.lifecycle.shadowEditFragment || core.contentDiv; + + if (mode == GetContentMode.PlainTextFast) { + content = root.textContent; + } else if (mode == GetContentMode.PlainText) { + content = getTextContent(root); + } else { + const clonedRoot = cloneNode(root); + clonedRoot.normalize(); + + const originalRange = core.api.getSelectionRange(core, true /*tryGetFromCache*/); + const path = !includeSelectionMarker + ? null + : core.lifecycle.shadowEditFragment + ? core.lifecycle.shadowEditSelectionPath + : originalRange + ? getSelectionPath(core.contentDiv, originalRange) + : null; + const range = path && createRange(clonedRoot, path.start, path.end); + + core.api.transformColor( + core, + clonedRoot, + false /*includeSelf*/, + null /*callback*/, + ColorTransformDirection.DarkToLight, + true /*forceTransform*/, + core.lifecycle.isDarkMode + ); + + if (triggerExtractContentEvent) { + core.api.triggerEvent( + core, + { + eventType: PluginEventType.ExtractContentWithDom, + clonedRoot, + }, + true /*broadcast*/ + ); + + content = clonedRoot.innerHTML; + } else if (range) { + // range is not null, which means we want to include a selection path in the content + content = getHtmlWithSelectionPath(clonedRoot, range); + } else { + content = clonedRoot.innerHTML; + } + } + + return content ?? ''; +}; + +function cloneNode(node: HTMLElement | DocumentFragment): HTMLElement { + let clonedNode: HTMLElement; + if (safeInstanceOf(node, 'DocumentFragment')) { + clonedNode = node.ownerDocument.createElement('div'); + clonedNode.appendChild(node.cloneNode(true /*deep*/)); + } else { + clonedNode = node.cloneNode(true /*deep*/) as HTMLElement; + } + + return clonedNode; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getPendableFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getPendableFormatState.ts new file mode 100644 index 00000000000..f68e1a1ad5b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getPendableFormatState.ts @@ -0,0 +1,101 @@ +import { contains, getObjectKeys, getTagOfNode, Position } from 'roosterjs-editor-dom'; +import { NodeType } from 'roosterjs-editor-types'; +import type { PendableFormatNames } from 'roosterjs-editor-dom'; +import type { + EditorCore, + GetPendableFormatState, + NodePosition, + PendableFormatState, +} from 'roosterjs-editor-types'; + +/** + * @internal + * @param core The EditorCore object + * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. + * @returns The cached format state if it exists. If the cached position do not exist, search for pendable elements in the DOM tree and return the pendable format state. + */ +export const getPendableFormatState: GetPendableFormatState = ( + core: EditorCore, + forceGetStateFromDOM: boolean +): PendableFormatState => { + const range = core.api.getSelectionRange(core, true /* tryGetFromCache*/); + const cachedPendableFormatState = core.pendingFormatState.pendableFormatState; + const cachedPosition = core.pendingFormatState.pendableFormatPosition?.normalize(); + const currentPosition = range && Position.getStart(range).normalize(); + const isSamePosition = + currentPosition && + cachedPosition && + range.collapsed && + currentPosition.equalTo(cachedPosition); + + if (range && cachedPendableFormatState && isSamePosition && !forceGetStateFromDOM) { + return cachedPendableFormatState; + } else { + return currentPosition ? queryCommandStateFromDOM(core, currentPosition) : {}; + } +}; + +const PendableStyleCheckers: Record< + PendableFormatNames, + (tagName: string, style: CSSStyleDeclaration) => boolean +> = { + isBold: (tag, style) => + tag == 'B' || + tag == 'STRONG' || + tag == 'H1' || + tag == 'H2' || + tag == 'H3' || + tag == 'H4' || + tag == 'H5' || + tag == 'H6' || + parseInt(style.fontWeight) >= 700 || + ['bold', 'bolder'].indexOf(style.fontWeight) >= 0, + isUnderline: (tag, style) => tag == 'U' || style.textDecoration.indexOf('underline') >= 0, + isItalic: (tag, style) => tag == 'I' || tag == 'EM' || style.fontStyle === 'italic', + isSubscript: (tag, style) => tag == 'SUB' || style.verticalAlign === 'sub', + isSuperscript: (tag, style) => tag == 'SUP' || style.verticalAlign === 'super', + isStrikeThrough: (tag, style) => + tag == 'S' || tag == 'STRIKE' || style.textDecoration.indexOf('line-through') >= 0, +}; + +/** + * CssFalsyCheckers checks for non pendable format that might overlay a pendable format, then it can prevent getPendableFormatState return falsy pendable format states. + */ + +const CssFalsyCheckers: Record boolean> = { + isBold: style => + (style.fontWeight !== '' && parseInt(style.fontWeight) < 700) || + style.fontWeight === 'normal', + isUnderline: style => + style.textDecoration !== '' && style.textDecoration.indexOf('underline') < 0, + isItalic: style => style.fontStyle !== '' && style.fontStyle !== 'italic', + isSubscript: style => style.verticalAlign !== '' && style.verticalAlign !== 'sub', + isSuperscript: style => style.verticalAlign !== '' && style.verticalAlign !== 'super', + isStrikeThrough: style => + style.textDecoration !== '' && style.textDecoration.indexOf('line-through') < 0, +}; + +function queryCommandStateFromDOM( + core: EditorCore, + currentPosition: NodePosition +): PendableFormatState { + let node: Node | null = currentPosition.node; + const formatState: PendableFormatState = {}; + const pendableKeys: PendableFormatNames[] = []; + while (node && contains(core.contentDiv, node)) { + const tag = getTagOfNode(node); + const style = node.nodeType == NodeType.Element && (node as HTMLElement).style; + if (tag && style) { + getObjectKeys(PendableStyleCheckers).forEach(key => { + if (!(pendableKeys.indexOf(key) >= 0)) { + formatState[key] = formatState[key] || PendableStyleCheckers[key](tag, style); + if (CssFalsyCheckers[key](style)) { + pendableKeys.push(key); + } + } + }); + } + node = node.parentNode; + } + return formatState; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts new file mode 100644 index 00000000000..9e85478152c --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts @@ -0,0 +1,44 @@ +import { contains, createRange } from 'roosterjs-editor-dom'; +import type { EditorCore, GetSelectionRange } from 'roosterjs-editor-types'; + +/** + * @internal + * Get current or cached selection range + * @param core The EditorCore object + * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now + * @returns A Range object of the selection range + */ +export const getSelectionRange: GetSelectionRange = ( + core: EditorCore, + tryGetFromCache: boolean +) => { + let result: Range | null = null; + + if (core.lifecycle.shadowEditFragment) { + result = + core.lifecycle.shadowEditSelectionPath && + createRange( + core.contentDiv, + core.lifecycle.shadowEditSelectionPath.start, + core.lifecycle.shadowEditSelectionPath.end + ); + + return result; + } else { + if (!tryGetFromCache || core.api.hasFocus(core)) { + const selection = core.contentDiv.ownerDocument.defaultView?.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + if (contains(core.contentDiv, range)) { + result = range; + } + } + } + + if (!result && tryGetFromCache) { + result = core.domEvent.selectionRange; + } + + return result; + } +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts new file mode 100644 index 00000000000..049546a1813 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts @@ -0,0 +1,101 @@ +import { contains, createRange, findClosestElementAncestor } from 'roosterjs-editor-dom'; +import { SelectionRangeTypes } from 'roosterjs-editor-types'; +import type { EditorCore, GetSelectionRangeEx, SelectionRangeEx } from 'roosterjs-editor-types'; + +/** + * @internal + * Get current or cached selection range + * @param core The EditorCore object + * @returns A Range object of the selection range + */ +export const getSelectionRangeEx: GetSelectionRangeEx = (core: EditorCore) => { + const result: SelectionRangeEx | null = null; + if (core.lifecycle.shadowEditFragment) { + const { + shadowEditTableSelectionPath, + shadowEditSelectionPath, + shadowEditImageSelectionPath, + } = core.lifecycle; + + if ((shadowEditTableSelectionPath?.length || 0) > 0) { + const ranges = core.lifecycle.shadowEditTableSelectionPath!.map(path => + createRange(core.contentDiv, path.start, path.end) + ); + + return { + type: SelectionRangeTypes.TableSelection, + ranges, + areAllCollapsed: checkAllCollapsed(ranges), + table: findClosestElementAncestor( + ranges[0].startContainer, + core.contentDiv, + 'table' + ) as HTMLTableElement, + coordinates: undefined, + }; + } else if ((shadowEditImageSelectionPath?.length || 0) > 0) { + const ranges = core.lifecycle.shadowEditImageSelectionPath!.map(path => + createRange(core.contentDiv, path.start, path.end) + ); + return { + type: SelectionRangeTypes.ImageSelection, + ranges, + areAllCollapsed: checkAllCollapsed(ranges), + image: findClosestElementAncestor( + ranges[0].startContainer, + core.contentDiv, + 'img' + ) as HTMLImageElement, + imageId: undefined, + }; + } else { + const shadowRange = + shadowEditSelectionPath && + createRange( + core.contentDiv, + shadowEditSelectionPath.start, + shadowEditSelectionPath.end + ); + + return createNormalSelectionEx(shadowRange ? [shadowRange] : []); + } + } else { + if (core.api.hasFocus(core)) { + if (core.domEvent.tableSelectionRange) { + return core.domEvent.tableSelectionRange; + } + + if (core.domEvent.imageSelectionRange) { + return core.domEvent.imageSelectionRange; + } + + const selection = core.contentDiv.ownerDocument.defaultView?.getSelection(); + if (!result && selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + if (contains(core.contentDiv, range)) { + return createNormalSelectionEx([range]); + } + } + } + + return ( + core.domEvent.tableSelectionRange ?? + core.domEvent.imageSelectionRange ?? + createNormalSelectionEx( + core.domEvent.selectionRange ? [core.domEvent.selectionRange] : [] + ) + ); + } +}; + +function createNormalSelectionEx(ranges: Range[]): SelectionRangeEx { + return { + type: SelectionRangeTypes.Normal, + ranges: ranges, + areAllCollapsed: checkAllCollapsed(ranges), + }; +} + +function checkAllCollapsed(ranges: Range[]): boolean { + return ranges.filter(range => range?.collapsed).length == ranges.length; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts new file mode 100644 index 00000000000..0be0504b38e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts @@ -0,0 +1,96 @@ +import { contains, getComputedStyles } from 'roosterjs-editor-dom'; +import { NodeType } from 'roosterjs-editor-types'; +import type { EditorCore, GetStyleBasedFormatState } from 'roosterjs-editor-types'; + +/** + * @internal + * Get style based format state from current selection, including font name/size and colors + * @param core The EditorCore objects + * @param node The node to get style from + */ +export const getStyleBasedFormatState: GetStyleBasedFormatState = ( + core: EditorCore, + node: Node | null +) => { + if (!node) { + return {}; + } + + let override: string[] = []; + const pendableFormatSpan = core.pendingFormatState.pendableFormatSpan; + + if (pendableFormatSpan) { + override = [ + pendableFormatSpan.style.fontFamily, + pendableFormatSpan.style.fontSize, + pendableFormatSpan.style.color, + pendableFormatSpan.style.backgroundColor, + ]; + } + + const styles = node + ? getComputedStyles(node, [ + 'font-family', + 'font-size', + 'color', + 'background-color', + 'line-height', + 'margin-top', + 'margin-bottom', + 'text-align', + 'direction', + 'font-weight', + ]) + : []; + const { contentDiv, darkColorHandler } = core; + + let styleTextColor: string | undefined; + let styleBackColor: string | undefined; + + while ( + node && + contains(contentDiv, node, true /*treatSameNodeAsContain*/) && + !(styleTextColor && styleBackColor) + ) { + if (node.nodeType == NodeType.Element) { + const element = node as HTMLElement; + + styleTextColor = styleTextColor || element.style.getPropertyValue('color'); + styleBackColor = styleBackColor || element.style.getPropertyValue('background-color'); + } + node = node.parentNode; + } + + if (!core.lifecycle.isDarkMode && node == core.contentDiv) { + styleTextColor = styleTextColor || styles[2]; + styleBackColor = styleBackColor || styles[3]; + } + + const textColor = darkColorHandler.parseColorValue(override[2] || styleTextColor); + const backColor = darkColorHandler.parseColorValue(override[3] || styleBackColor); + + return { + fontName: override[0] || styles[0], + fontSize: override[1] || styles[1], + textColor: textColor.lightModeColor, + backgroundColor: backColor.lightModeColor, + textColors: textColor.darkModeColor + ? { + lightModeColor: textColor.lightModeColor, + darkModeColor: textColor.darkModeColor, + } + : undefined, + backgroundColors: backColor.darkModeColor + ? { + lightModeColor: backColor.lightModeColor, + darkModeColor: backColor.darkModeColor, + } + : undefined, + lineHeight: styles[4], + marginTop: styles[5], + marginBottom: styles[6], + textAlign: styles[7], + direction: styles[8], + fontWeight: styles[9], + }; +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/hasFocus.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/hasFocus.ts new file mode 100644 index 00000000000..35ad6eb49a8 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/hasFocus.ts @@ -0,0 +1,15 @@ +import { contains } from 'roosterjs-editor-dom'; +import type { EditorCore, HasFocus } from 'roosterjs-editor-types'; + +/** + * @internal + * Check if the editor has focus now + * @param core The EditorCore object + * @returns True if the editor has focus, otherwise false + */ +export const hasFocus: HasFocus = (core: EditorCore) => { + const activeElement = core.contentDiv.ownerDocument.activeElement; + return !!( + activeElement && contains(core.contentDiv, activeElement, true /*treatSameNodeAsContain*/) + ); +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts new file mode 100644 index 00000000000..8176d491d6a --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts @@ -0,0 +1,235 @@ +import type { + BlockElement, + EditorCore, + InsertNode, + InsertOption, + NodePosition, +} from 'roosterjs-editor-types'; +import { + ContentPosition, + ColorTransformDirection, + NodeType, + PositionType, + RegionType, +} from 'roosterjs-editor-types'; +import { + createRange, + getBlockElementAtNode, + getFirstLastBlockElement, + isBlockElement, + isVoidHtmlElement, + Position, + safeInstanceOf, + toArray, + wrap, + adjustInsertPosition, + getRegionsFromRange, + splitTextNode, + splitParentNode, +} from 'roosterjs-editor-dom'; + +function getInitialRange( + core: EditorCore, + option: InsertOption +): { range: Range | null; rangeToRestore: Range | null } { + // Selection start replaces based on the current selection. + // Range inserts based on a provided range. + // Both have the potential to use the current selection to restore cursor position + // So in both cases we need to store the selection state. + let range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); + let rangeToRestore = null; + if (option.position == ContentPosition.Range) { + rangeToRestore = range; + range = option.range; + } else if (range) { + rangeToRestore = range.cloneRange(); + } + + return { range, rangeToRestore }; +} + +/** + * @internal + * Insert a DOM node into editor content + * @param core The EditorCore object. No op if null. + * @param option An insert option object to specify how to insert the node + */ +export const insertNode: InsertNode = ( + core: EditorCore, + node: Node, + option: InsertOption | null +) => { + option = option || { + position: ContentPosition.SelectionStart, + insertOnNewLine: false, + updateCursor: true, + replaceSelection: true, + insertToRegionRoot: false, + }; + const contentDiv = core.contentDiv; + + if (option.updateCursor) { + core.api.focus(core); + } + + if (option.position == ContentPosition.Outside) { + contentDiv.parentNode?.insertBefore(node, contentDiv.nextSibling); + return true; + } + + core.api.transformColor( + core, + node, + true /*includeSelf*/, + () => { + if (!option) { + return; + } + switch (option.position) { + case ContentPosition.Begin: + case ContentPosition.End: { + const isBegin = option.position == ContentPosition.Begin; + const block = getFirstLastBlockElement(contentDiv, isBegin); + let insertedNode: Node | Node[] | undefined; + if (block) { + const refNode = isBegin ? block.getStartNode() : block.getEndNode(); + if ( + option.insertOnNewLine || + refNode.nodeType == NodeType.Text || + isVoidHtmlElement(refNode) + ) { + // For insert on new line, or refNode is text or void html element (HR, BR etc.) + // which cannot have children, i.e.
                                                          hello
                                                          world
                                                          . 'hello', 'world' are the + // first and last node. Insert before 'hello' or after 'world', but still inside DIV + if (safeInstanceOf(node, 'DocumentFragment')) { + // if the node to be inserted is DocumentFragment, use its childNodes as insertedNode + // because insertBefore() returns an empty DocumentFragment + insertedNode = toArray(node.childNodes); + refNode.parentNode?.insertBefore( + node, + isBegin ? refNode : refNode.nextSibling + ); + } else { + insertedNode = refNode.parentNode?.insertBefore( + node, + isBegin ? refNode : refNode.nextSibling + ); + } + } else { + // if the refNode can have child, use appendChild (which is like to insert as first/last child) + // i.e.
                                                          hello
                                                          , the content will be inserted before/after hello + insertedNode = refNode.insertBefore( + node, + isBegin ? refNode.firstChild : null + ); + } + } else { + // No first block, this can happen when editor is empty. Use appendChild to insert the content in contentDiv + insertedNode = contentDiv.appendChild(node); + } + + // Final check to see if the inserted node is a block. If not block and the ask is to insert on new line, + // add a DIV wrapping + if (insertedNode && option.insertOnNewLine) { + const nodes = Array.isArray(insertedNode) ? insertedNode : [insertedNode]; + if (!isBlockElement(nodes[0]) || !isBlockElement(nodes[nodes.length - 1])) { + wrap(nodes); + } + } + + break; + } + case ContentPosition.DomEnd: + // Use appendChild to insert the node at the end of the content div. + const insertedNode = contentDiv.appendChild(node); + // Final check to see if the inserted node is a block. If not block and the ask is to insert on new line, + // add a DIV wrapping + if (insertedNode && option.insertOnNewLine && !isBlockElement(insertedNode)) { + wrap(insertedNode); + } + break; + case ContentPosition.Range: + case ContentPosition.SelectionStart: + let { range, rangeToRestore } = getInitialRange(core, option); + if (!range) { + return; + } + + // if to replace the selection and the selection is not collapsed, remove the the content at selection first + if (option.replaceSelection && !range.collapsed) { + range.deleteContents(); + } + + let pos: NodePosition = Position.getStart(range); + let blockElement: BlockElement | null; + + if (option.insertOnNewLine && option.insertToRegionRoot) { + pos = adjustInsertPositionRegionRoot(core, range, pos); + } else if ( + option.insertOnNewLine && + (blockElement = getBlockElementAtNode(contentDiv, pos.normalize().node)) + ) { + pos = adjustInsertPositionNewLine(blockElement, core, pos); + } else { + pos = adjustInsertPosition(contentDiv, node, pos, range); + } + + const nodeForCursor = + node.nodeType == NodeType.DocumentFragment ? node.lastChild : node; + + range = createRange(pos); + range.insertNode(node); + + if (option.updateCursor && nodeForCursor) { + rangeToRestore = createRange( + new Position(nodeForCursor, PositionType.After).normalize() + ); + } + + if (rangeToRestore) { + core.api.selectRange(core, rangeToRestore); + } + + break; + } + }, + ColorTransformDirection.LightToDark + ); + + return true; +}; + +function adjustInsertPositionRegionRoot(core: EditorCore, range: Range, position: NodePosition) { + const region = getRegionsFromRange(core.contentDiv, range, RegionType.Table)[0]; + let node: Node | null = position.node; + + if (region) { + if (node.nodeType == NodeType.Text && !position.isAtEnd) { + node = splitTextNode(node as Text, position.offset, true /*returnFirstPart*/); + } + + if (node != region.rootNode) { + while (node && node.parentNode != region.rootNode) { + splitParentNode(node, false /*splitBefore*/); + node = node.parentNode; + } + } + + if (node) { + position = new Position(node, PositionType.After); + } + } + + return position; +} + +function adjustInsertPositionNewLine(blockElement: BlockElement, core: EditorCore, pos: Position) { + let tempPos = new Position(blockElement.getEndNode(), PositionType.After); + if (safeInstanceOf(tempPos.node, 'HTMLTableRowElement')) { + const div = core.contentDiv.ownerDocument.createElement('div'); + const range = createRange(pos); + range.insertNode(div); + tempPos = new Position(div, PositionType.Begin); + } + return tempPos; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/restoreUndoSnapshot.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/restoreUndoSnapshot.ts new file mode 100644 index 00000000000..4433ebc3b7c --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/restoreUndoSnapshot.ts @@ -0,0 +1,69 @@ +import { EntityOperation, PluginEventType } from 'roosterjs-editor-types'; +import { getEntityFromElement, getEntitySelector, queryElements } from 'roosterjs-editor-dom'; +import type { EditorCore, RestoreUndoSnapshot } from 'roosterjs-editor-types'; + +/** + * @internal + * Restore an undo snapshot into editor + * @param core The editor core object + * @param step Steps to move, can be 0, positive or negative + */ +export const restoreUndoSnapshot: RestoreUndoSnapshot = (core: EditorCore, step: number) => { + if (core.undo.hasNewContent && step < 0) { + core.api.addUndoSnapshot( + core, + null /*callback*/, + null /*changeSource*/, + false /*canUndoByBackspace*/ + ); + } + + const snapshot = core.undo.snapshotsService.move(step); + + if (snapshot && snapshot.html != null) { + try { + core.undo.isRestoring = true; + core.api.setContent( + core, + snapshot.html, + true /*triggerContentChangedEvent*/, + snapshot.metadata ?? undefined + ); + + const darkColorHandler = core.darkColorHandler; + const isDarkModel = core.lifecycle.isDarkMode; + + snapshot.knownColors.forEach(color => { + darkColorHandler.registerColor( + color.lightModeColor, + isDarkModel, + color.darkModeColor + ); + }); + + snapshot.entityStates?.forEach(entityState => { + const { type, id, state } = entityState; + const wrapper = queryElements( + core.contentDiv, + getEntitySelector(type, id) + )[0] as HTMLElement; + const entity = wrapper && getEntityFromElement(wrapper); + + if (entity) { + core.api.triggerEvent( + core, + { + eventType: PluginEventType.EntityOperation, + operation: EntityOperation.UpdateEntityState, + entity: entity, + state, + }, + false + ); + } + }); + } finally { + core.undo.isRestoring = false; + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts new file mode 100644 index 00000000000..a3179bfea8e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts @@ -0,0 +1,179 @@ +import { contains, createRange, safeInstanceOf } from 'roosterjs-editor-dom'; +import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import type { + EditorCore, + NodePosition, + PositionType, + Select, + SelectionPath, + SelectionRangeEx, + TableSelection, +} from 'roosterjs-editor-types'; + +/** + * @internal + * Select content according to the given information. + * There are a bunch of allowed combination of parameters. See IEditor.select for more details + * @param core The editor core object + * @param arg1 A DOM Range, or SelectionRangeEx, or NodePosition, or Node, or Selection Path + * @param arg2 (optional) A NodePosition, or an offset number, or a PositionType, or a TableSelection + * @param arg3 (optional) A Node + * @param arg4 (optional) An offset number, or a PositionType + */ +export const select: Select = (core, arg1, arg2, arg3, arg4) => { + const rangeEx = buildRangeEx(core, arg1, arg2, arg3, arg4); + + if (rangeEx) { + const skipReselectOnFocus = core.domEvent.skipReselectOnFocus; + + // We are applying a new selection, so we don't need to apply cached selection in DOMEventPlugin. + // Set skipReselectOnFocus to skip this behavior + core.domEvent.skipReselectOnFocus = true; + + try { + applyRangeEx(core, rangeEx); + } finally { + core.domEvent.skipReselectOnFocus = skipReselectOnFocus; + } + } else { + core.domEvent.tableSelectionRange = core.api.selectTable(core, null); + core.domEvent.imageSelectionRange = core.api.selectImage(core, null); + } + + return !!rangeEx; +}; + +function buildRangeEx( + core: EditorCore, + arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, + arg2?: NodePosition | number | PositionType | TableSelection | null, + arg3?: Node, + arg4?: number | PositionType +) { + let rangeEx: SelectionRangeEx | null = null; + + if (isSelectionRangeEx(arg1)) { + rangeEx = arg1; + } else if (safeInstanceOf(arg1, 'HTMLTableElement') && isTableSelectionOrNull(arg2)) { + rangeEx = { + type: SelectionRangeTypes.TableSelection, + ranges: [], + areAllCollapsed: false, + table: arg1, + coordinates: arg2 ?? undefined, + }; + } else if (safeInstanceOf(arg1, 'HTMLImageElement') && typeof arg2 == 'undefined') { + rangeEx = { + type: SelectionRangeTypes.ImageSelection, + ranges: [], + areAllCollapsed: false, + image: arg1, + }; + } else { + const range = !arg1 + ? null + : safeInstanceOf(arg1, 'Range') + ? arg1 + : isSelectionPath(arg1) + ? createRange(core.contentDiv, arg1.start, arg1.end) + : isNodePosition(arg1) || safeInstanceOf(arg1, 'Node') + ? createRange( + arg1, + arg2, + arg3, + arg4 + ) + : null; + + rangeEx = range + ? { + type: SelectionRangeTypes.Normal, + ranges: [range], + areAllCollapsed: range.collapsed, + } + : null; + } + + return rangeEx; +} + +function applyRangeEx(core: EditorCore, rangeEx: SelectionRangeEx | null) { + switch (rangeEx?.type) { + case SelectionRangeTypes.TableSelection: + if (contains(core.contentDiv, rangeEx.table)) { + core.domEvent.imageSelectionRange = core.api.selectImage(core, null); + core.domEvent.tableSelectionRange = core.api.selectTable( + core, + rangeEx.table, + rangeEx.coordinates + ); + rangeEx = core.domEvent.tableSelectionRange; + } + break; + case SelectionRangeTypes.ImageSelection: + if (contains(core.contentDiv, rangeEx.image)) { + core.domEvent.tableSelectionRange = core.api.selectTable(core, null); + core.domEvent.imageSelectionRange = core.api.selectImage(core, rangeEx.image); + rangeEx = core.domEvent.imageSelectionRange; + } + break; + case SelectionRangeTypes.Normal: + core.domEvent.tableSelectionRange = core.api.selectTable(core, null); + core.domEvent.imageSelectionRange = core.api.selectImage(core, null); + + if (contains(core.contentDiv, rangeEx.ranges[0])) { + core.api.selectRange(core, rangeEx.ranges[0]); + } else { + rangeEx = null; + } + break; + } + + core.api.triggerEvent( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: rangeEx, + }, + true /** broadcast **/ + ); +} + +function isSelectionRangeEx(obj: any): obj is SelectionRangeEx { + const rangeEx = obj as SelectionRangeEx; + return ( + rangeEx && + typeof rangeEx == 'object' && + typeof rangeEx.type == 'number' && + Array.isArray(rangeEx.ranges) + ); +} + +function isTableSelectionOrNull(obj: any): obj is TableSelection | null { + const selection = obj as TableSelection | null; + + return ( + selection === null || + (selection && + typeof selection == 'object' && + typeof selection.firstCell == 'object' && + typeof selection.lastCell == 'object') + ); +} + +function isSelectionPath(obj: any): obj is SelectionPath { + const path = obj as SelectionPath; + + return path && typeof path == 'object' && Array.isArray(path.start) && Array.isArray(path.end); +} + +function isNodePosition(obj: any): obj is NodePosition { + const pos = obj as NodePosition; + + return ( + pos && + typeof pos == 'object' && + typeof pos.node == 'object' && + typeof pos.offset == 'number' + ); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts new file mode 100644 index 00000000000..44bcfb974e6 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts @@ -0,0 +1,65 @@ +import addUniqueId from './utils/addUniqueId'; +import { PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { + createRange, + Position, + removeGlobalCssStyle, + removeImportantStyleRule, + setGlobalCssStyles, +} from 'roosterjs-editor-dom'; +import type { EditorCore, ImageSelectionRange, SelectImage } from 'roosterjs-editor-types'; + +const IMAGE_ID = 'imageSelected'; +const CONTENT_DIV_ID = 'contentDiv_'; +const STYLE_ID = 'imageStyle'; +const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; + +/** + * @internal + * Select a image and save data of the selected range + * @param image Image to select + * @returns Selected image information + */ +export const selectImage: SelectImage = (core: EditorCore, image: HTMLImageElement | null) => { + unselect(core); + + let selection: ImageSelectionRange | null = null; + + if (image) { + const range = createRange(image); + + addUniqueId(image, IMAGE_ID); + addUniqueId(core.contentDiv, CONTENT_DIV_ID); + + core.api.selectRange(core, createRange(new Position(image, PositionType.After))); + + select(core, image); + + selection = { + type: SelectionRangeTypes.ImageSelection, + ranges: [range], + image: image, + areAllCollapsed: range.collapsed, + }; + } + + return selection; +}; + +const select = (core: EditorCore, image: HTMLImageElement) => { + removeImportantStyleRule(image, ['border', 'margin']); + const borderCSS = buildBorderCSS(core, image.id); + setGlobalCssStyles(core.contentDiv.ownerDocument, borderCSS, STYLE_ID + core.contentDiv.id); +}; + +const buildBorderCSS = (core: EditorCore, imageId: string): string => { + const divId = core.contentDiv.id; + const color = core.imageSelectionBorderColor || DEFAULT_SELECTION_BORDER_COLOR; + + return `#${divId} #${imageId} {outline-style: auto!important;outline-color: ${color}!important;caret-color: transparent!important;}`; +}; + +const unselect = (core: EditorCore) => { + const doc = core.contentDiv.ownerDocument; + removeGlobalCssStyle(doc, STYLE_ID + core.contentDiv.id); +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts new file mode 100644 index 00000000000..d4816eb43fc --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts @@ -0,0 +1,74 @@ +import { hasFocus } from './hasFocus'; +import type { EditorCore, SelectRange } from 'roosterjs-editor-types'; +import { + contains, + getPendableFormatState, + Position, + PendableFormatCommandMap, + addRangeToSelection, + getObjectKeys, +} from 'roosterjs-editor-dom'; + +/** + * @internal + * Change the editor selection to the given range + * @param core The EditorCore object + * @param range The range to select + * @param skipSameRange When set to true, do nothing if the given range is the same with current selection + * in editor, otherwise it will always remove current selection range and set to the given one. + * This parameter is always treat as true in Edge to avoid some weird runtime exception. + */ +export const selectRange: SelectRange = ( + core: EditorCore, + range: Range, + skipSameRange?: boolean +) => { + if (!core.lifecycle.shadowEditSelectionPath && contains(core.contentDiv, range)) { + addRangeToSelection(range, skipSameRange); + + if (!hasFocus(core)) { + core.domEvent.selectionRange = range; + } + + if (range.collapsed) { + // If selected, and current selection is collapsed, + // need to restore pending format state if exists. + restorePendingFormatState(core); + } + + return true; + } else { + return false; + } +}; + +/** + * Restore cached pending format state (if exist) to current selection + */ +function restorePendingFormatState(core: EditorCore) { + const { + contentDiv, + pendingFormatState, + api: { getSelectionRange }, + } = core; + + if (pendingFormatState.pendableFormatState) { + const document = contentDiv.ownerDocument; + const formatState = getPendableFormatState(document); + getObjectKeys(PendableFormatCommandMap).forEach(key => { + if (!!pendingFormatState.pendableFormatState?.[key] != formatState[key]) { + document.execCommand( + PendableFormatCommandMap[key], + false /* showUI */, + undefined /* value */ + ); + } + }); + + const range = getSelectionRange(core, true /*tryGetFromCache*/); + const position: Position | null = range && Position.getStart(range); + if (position) { + pendingFormatState.pendableFormatPosition = position; + } + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectTable.ts new file mode 100644 index 00000000000..3d74d61b6bd --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectTable.ts @@ -0,0 +1,268 @@ +import addUniqueId from './utils/addUniqueId'; +import { PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { + createRange, + getTagOfNode, + isWholeTableSelected, + Position, + removeGlobalCssStyle, + removeImportantStyleRule, + setGlobalCssStyles, + toArray, + VTable, +} from 'roosterjs-editor-dom'; +import type { EditorCore, TableSelection, SelectTable, Coordinates } from 'roosterjs-editor-types'; + +const TABLE_ID = 'tableSelected'; +const CONTENT_DIV_ID = 'contentDiv_'; +const STYLE_ID = 'tableStyle'; +const SELECTED_CSS_RULE = + '{background-color: rgb(198,198,198) !important; caret-color: transparent}'; +const MAX_RULE_SELECTOR_LENGTH = 9000; + +/** + * @internal + * Select a table and save data of the selected range + * @param core The EditorCore object + * @param table table to select + * @param coordinates first and last cell of the selection, if this parameter is null, instead of + * selecting, will unselect the table. + * @returns true if successful + */ +export const selectTable: SelectTable = ( + core: EditorCore, + table: HTMLTableElement | null, + coordinates?: TableSelection +) => { + unselect(core); + + if (areValidCoordinates(coordinates) && table) { + addUniqueId(table, TABLE_ID); + addUniqueId(core.contentDiv, CONTENT_DIV_ID); + + const { ranges, isWholeTableSelected } = select(core, table, coordinates); + if (!isMergedCell(table, coordinates)) { + const cellToSelect = table.rows + .item(coordinates.firstCell.y) + ?.cells.item(coordinates.firstCell.x); + + if (cellToSelect) { + core.api.selectRange( + core, + createRange(new Position(cellToSelect, PositionType.Begin)) + ); + } + } + + return { + type: SelectionRangeTypes.TableSelection, + ranges, + table, + areAllCollapsed: ranges.filter(range => range?.collapsed).length == ranges.length, + coordinates, + isWholeTableSelected, + }; + } + + return null; +}; + +function buildCss( + table: HTMLTableElement, + coordinates: TableSelection, + contentDivSelector: string +): { cssRules: string[]; ranges: Range[]; isWholeTableSelected: boolean } { + const ranges: Range[] = []; + const selectors: string[] = []; + + const vTable = new VTable(table); + const isAllTableSelected = isWholeTableSelected(vTable, coordinates); + if (isAllTableSelected) { + handleAllTableSelected(contentDivSelector, vTable, selectors, ranges); + } else { + handleTableSelected(coordinates, vTable, contentDivSelector, selectors, ranges); + } + + const cssRules: string[] = []; + let currentRules: string = ''; + while (selectors.length > 0) { + currentRules += (currentRules.length > 0 ? ',' : '') + selectors.shift() || ''; + if ( + currentRules.length + (selectors[0]?.length || 0) > MAX_RULE_SELECTOR_LENGTH || + selectors.length == 0 + ) { + cssRules.push(currentRules + ' ' + SELECTED_CSS_RULE); + currentRules = ''; + } + } + + return { cssRules, ranges, isWholeTableSelected: isAllTableSelected }; +} + +function handleAllTableSelected( + contentDivSelector: string, + vTable: VTable, + selectors: string[], + ranges: Range[] +) { + const table = vTable.table; + const tableSelector = contentDivSelector + ' #' + table.id; + selectors.push(tableSelector, `${tableSelector} *`); + + const tableRange = new Range(); + tableRange.selectNode(table); + ranges.push(tableRange); +} + +function handleTableSelected( + coordinates: TableSelection, + vTable: VTable, + contentDivSelector: string, + selectors: string[], + ranges: Range[] +) { + const tr1 = coordinates.firstCell.y; + const td1 = coordinates.firstCell.x; + const tr2 = coordinates.lastCell.y; + const td2 = coordinates.lastCell.x; + const table = vTable.table; + + let firstSelected: HTMLTableCellElement | null = null; + let lastSelected: HTMLTableCellElement | null = null; + // Get whether table has thead, tbody or tfoot. + const tableChildren = toArray(table.childNodes).filter( + node => ['THEAD', 'TBODY', 'TFOOT'].indexOf(getTagOfNode(node)) > -1 + ); + // Set the start and end of each of the table children, so we can build the selector according the element between the table and the row. + let cont = 0; + const indexes = tableChildren.map(node => { + const result = { + el: getTagOfNode(node), + start: cont, + end: node.childNodes.length + cont, + }; + + cont = result.end; + return result; + }); + + vTable.cells?.forEach((row, rowIndex) => { + let tdCount = 0; + firstSelected = null; + lastSelected = null; + + //Get current TBODY/THEAD/TFOOT + const midElement = indexes.filter(ind => ind.start <= rowIndex && ind.end > rowIndex)[0]; + + const middleElSelector = midElement ? '>' + midElement.el + '>' : '>'; + const currentRow = + midElement && rowIndex + 1 >= midElement.start + ? rowIndex + 1 - midElement.start + : rowIndex + 1; + + for (let cellIndex = 0; cellIndex < row.length; cellIndex++) { + const cell = row[cellIndex].td; + if (cell) { + tdCount++; + if (rowIndex >= tr1 && rowIndex <= tr2 && cellIndex >= td1 && cellIndex <= td2) { + removeImportant(cell); + + const selector = generateCssFromCell( + contentDivSelector, + table.id, + middleElSelector, + currentRow, + getTagOfNode(cell), + tdCount + ); + const elementsSelector = selector + ' *'; + + selectors.push(selector, elementsSelector); + firstSelected = firstSelected || table.querySelector(selector); + lastSelected = table.querySelector(selector); + } + } + } + + if (firstSelected && lastSelected) { + const rowRange = new Range(); + rowRange.setStartBefore(firstSelected); + rowRange.setEndAfter(lastSelected); + ranges.push(rowRange); + } + }); +} + +function select( + core: EditorCore, + table: HTMLTableElement, + coordinates: TableSelection +): { ranges: Range[]; isWholeTableSelected: boolean } { + const contentDivSelector = '#' + core.contentDiv.id; + const { cssRules, ranges, isWholeTableSelected } = buildCss( + table, + coordinates, + contentDivSelector + ); + cssRules.forEach(css => + setGlobalCssStyles(core.contentDiv.ownerDocument, css, STYLE_ID + core.contentDiv.id) + ); + + return { ranges, isWholeTableSelected }; +} + +const unselect = (core: EditorCore) => { + const doc = core.contentDiv.ownerDocument; + removeGlobalCssStyle(doc, STYLE_ID + core.contentDiv.id); +}; + +function generateCssFromCell( + contentDivSelector: string, + tableId: string, + middleElSelector: string, + rowIndex: number, + cellTag: string, + index: number +): string { + return ( + contentDivSelector + + ' #' + + tableId + + middleElSelector + + ' tr:nth-child(' + + rowIndex + + ')>' + + cellTag + + ':nth-child(' + + index + + ')' + ); +} + +function removeImportant(cell: HTMLTableCellElement) { + if (cell) { + removeImportantStyleRule(cell, ['background-color', 'background']); + } +} + +function areValidCoordinates(input?: TableSelection): input is TableSelection { + if (input) { + const { firstCell, lastCell } = input || {}; + if (firstCell && lastCell) { + const handler = (coordinate: Coordinates) => + isValidCoordinate(coordinate.x) && isValidCoordinate(coordinate.y); + return handler(firstCell) && handler(lastCell); + } + } + + return false; +} + +function isValidCoordinate(input: number): boolean { + return (!!input || input == 0) && input > -1; +} + +function isMergedCell(table: HTMLTableElement, coordinates: TableSelection): boolean { + const { firstCell } = coordinates; + return !(table.rows.item(firstCell.y) && table.rows.item(firstCell.y)?.cells.item(firstCell.x)); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts new file mode 100644 index 00000000000..58e8eb86c72 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts @@ -0,0 +1,120 @@ +import { + ChangeSource, + ColorTransformDirection, + PluginEventType, + SelectionRangeTypes, +} from 'roosterjs-editor-types'; +import { + createRange, + extractContentMetadata, + queryElements, + restoreContentWithEntityPlaceholder, +} from 'roosterjs-editor-dom'; +import type { ContentMetadata, EditorCore, SetContent } from 'roosterjs-editor-types'; + +/** + * @internal + * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered + * if triggerContentChangedEvent is set to true + * @param core The EditorCore object + * @param content HTML content to set in + * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true + * @param metadata @optional Metadata of the content that helps editor know the selection and color mode. + * If not passed, we will treat content as in light mode without selection + */ +export const setContent: SetContent = ( + core: EditorCore, + content: string, + triggerContentChangedEvent: boolean, + metadata?: ContentMetadata +) => { + let contentChanged = false; + if (core.contentDiv.innerHTML != content) { + core.api.triggerEvent( + core, + { + eventType: PluginEventType.BeforeSetContent, + newContent: content, + }, + true /*broadcast*/ + ); + + const entities = core.entity.entityMap; + const html = content || ''; + const body = new DOMParser().parseFromString( + core.trustedHTMLHandler?.(html) ?? html, + 'text/html' + ).body; + + restoreContentWithEntityPlaceholder(body, core.contentDiv, entities); + + const metadataFromContent = extractContentMetadata(core.contentDiv); + metadata = metadata || metadataFromContent; + selectContentMetadata(core, metadata); + contentChanged = true; + } + + const isDarkMode = core.lifecycle.isDarkMode; + + if ((!metadata && isDarkMode) || (metadata && !!metadata.isDarkMode != !!isDarkMode)) { + core.api.transformColor( + core, + core.contentDiv, + false /*includeSelf*/, + null /*callback*/, + isDarkMode ? ColorTransformDirection.LightToDark : ColorTransformDirection.DarkToLight, + true /*forceTransform*/, + metadata?.isDarkMode + ); + contentChanged = true; + } + + if (triggerContentChangedEvent && contentChanged) { + core.api.triggerEvent( + core, + { + eventType: PluginEventType.ContentChanged, + source: ChangeSource.SetContent, + }, + false /*broadcast*/ + ); + } +}; + +function selectContentMetadata(core: EditorCore, metadata: ContentMetadata | undefined) { + if (!core.lifecycle.shadowEditSelectionPath && metadata) { + core.domEvent.tableSelectionRange = null; + core.domEvent.imageSelectionRange = null; + core.domEvent.selectionRange = null; + + switch (metadata.type) { + case SelectionRangeTypes.Normal: + core.api.selectTable(core, null); + core.api.selectImage(core, null); + + const range = createRange(core.contentDiv, metadata.start, metadata.end); + core.api.selectRange(core, range); + break; + case SelectionRangeTypes.TableSelection: + const table = queryElements( + core.contentDiv, + '#' + metadata.tableId + )[0] as HTMLTableElement; + + if (table) { + core.domEvent.tableSelectionRange = core.api.selectTable(core, table, metadata); + } + break; + case SelectionRangeTypes.ImageSelection: + const image = queryElements( + core.contentDiv, + '#' + metadata.imageId + )[0] as HTMLImageElement; + + if (image) { + core.domEvent.imageSelectionRange = core.api.selectImage(core, image); + } + break; + } + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/switchShadowEdit.ts new file mode 100644 index 00000000000..5d3dd632aaf --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/switchShadowEdit.ts @@ -0,0 +1,111 @@ +import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { + createRange, + getSelectionPath, + moveContentWithEntityPlaceholders, + restoreContentWithEntityPlaceholder, +} from 'roosterjs-editor-dom'; +import type { EditorCore, SelectionRangeEx, SwitchShadowEdit } from 'roosterjs-editor-types'; + +/** + * @internal + */ +export const switchShadowEdit: SwitchShadowEdit = (core: EditorCore, isOn: boolean): void => { + const { lifecycle, contentDiv } = core; + let { + shadowEditEntities, + shadowEditFragment, + shadowEditSelectionPath, + shadowEditTableSelectionPath, + shadowEditImageSelectionPath, + } = lifecycle; + const wasInShadowEdit = !!shadowEditFragment; + + const getShadowEditSelectionPath = ( + selectionType: SelectionRangeTypes, + shadowEditSelection?: SelectionRangeEx + ) => { + return ( + (shadowEditSelection?.type == selectionType && + shadowEditSelection.ranges + .map(range => getSelectionPath(contentDiv, range)) + .map(w => w!!)) || + null + ); + }; + + if (isOn) { + if (!wasInShadowEdit) { + const selection = core.api.getSelectionRangeEx(core); + const range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); + + shadowEditSelectionPath = range && getSelectionPath(contentDiv, range); + shadowEditTableSelectionPath = getShadowEditSelectionPath( + SelectionRangeTypes.TableSelection, + selection + ); + shadowEditImageSelectionPath = getShadowEditSelectionPath( + SelectionRangeTypes.ImageSelection, + selection + ); + + shadowEditEntities = {}; + shadowEditFragment = moveContentWithEntityPlaceholders(contentDiv, shadowEditEntities); + + core.api.triggerEvent( + core, + { + eventType: PluginEventType.EnteredShadowEdit, + fragment: shadowEditFragment, + selectionPath: shadowEditSelectionPath, + }, + false /*broadcast*/ + ); + + lifecycle.shadowEditFragment = shadowEditFragment; + lifecycle.shadowEditSelectionPath = shadowEditSelectionPath; + lifecycle.shadowEditTableSelectionPath = shadowEditTableSelectionPath; + lifecycle.shadowEditImageSelectionPath = shadowEditImageSelectionPath; + lifecycle.shadowEditEntities = shadowEditEntities; + } + + if (lifecycle.shadowEditFragment) { + restoreContentWithEntityPlaceholder( + lifecycle.shadowEditFragment, + contentDiv, + lifecycle.shadowEditEntities, + true /*insertClonedNode*/ + ); + } + } else { + lifecycle.shadowEditFragment = null; + lifecycle.shadowEditSelectionPath = null; + lifecycle.shadowEditEntities = null; + + if (wasInShadowEdit) { + core.api.triggerEvent( + core, + { + eventType: PluginEventType.LeavingShadowEdit, + }, + false /*broadcast*/ + ); + + if (shadowEditFragment) { + restoreContentWithEntityPlaceholder( + shadowEditFragment, + contentDiv, + shadowEditEntities + ); + } + + if (shadowEditSelectionPath) { + core.domEvent.selectionRange = createRange( + contentDiv, + shadowEditSelectionPath.start, + shadowEditSelectionPath.end + ); + } + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/transformColor.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/transformColor.ts new file mode 100644 index 00000000000..c0b89cc38c1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/transformColor.ts @@ -0,0 +1,69 @@ +import { ColorTransformDirection } from 'roosterjs-editor-types'; +import type { EditorCore, TransformColor } from 'roosterjs-editor-types'; +import type { CompatibleColorTransformDirection } from 'roosterjs-editor-types/lib/compatibleTypes'; + +/** + * @internal + * Edit and transform color of elements between light mode and dark mode + * @param core The EditorCore object + * @param rootNode The root HTML elements to transform + * @param includeSelf True to transform the root node as well, otherwise false + * @param callback The callback function to invoke before do color transformation + * @param direction To specify the transform direction, light to dark, or dark to light + * @param forceTransform By default this function will only work when editor core is in dark mode. + * Pass true to this value to force do color transformation even editor core is in light mode + */ +export const transformColor: TransformColor = ( + core: EditorCore, + rootNode: Node | null, + includeSelf: boolean, + callback: (() => void) | null, + direction: ColorTransformDirection | CompatibleColorTransformDirection, + forceTransform?: boolean, + fromDarkMode: boolean = false +) => { + const { + darkColorHandler, + lifecycle: { onExternalContentTransform }, + } = core; + const toDarkMode = direction == ColorTransformDirection.LightToDark; + if (rootNode && (forceTransform || core.lifecycle.isDarkMode)) { + const transformer = onExternalContentTransform + ? (element: HTMLElement) => { + onExternalContentTransform(element, fromDarkMode, toDarkMode, darkColorHandler); + } + : (element: HTMLElement) => { + darkColorHandler.transformElementColor(element, fromDarkMode, toDarkMode); + }; + + iterateElements(rootNode, transformer, includeSelf); + } + + callback?.(); +}; + +function iterateElements( + root: Node, + transformer: (element: HTMLElement) => void, + includeSelf?: boolean +) { + if (includeSelf && isHTMLElement(root)) { + transformer(root); + } + + for (let child = root.firstChild; child; child = child.nextSibling) { + if (isHTMLElement(child)) { + transformer(child); + } + + iterateElements(child, transformer); + } +} + +// This is not a strict check, we just need to make sure this element has style so that we can set style to it +// We don't use safeInstanceOf() here since this function will be called very frequently when extract html content +// in dark mode, so we need to make sure this check is fast enough +function isHTMLElement(node: Node): node is HTMLElement { + const htmlElement = node; + return node.nodeType == Node.ELEMENT_NODE && !!htmlElement.style; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/triggerEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/triggerEvent.ts new file mode 100644 index 00000000000..7fc7272bf7d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/triggerEvent.ts @@ -0,0 +1,44 @@ +import { PluginEventType } from 'roosterjs-editor-types'; +import type { EditorCore, EditorPlugin, PluginEvent, TriggerEvent } from 'roosterjs-editor-types'; +import type { CompatiblePluginEventType } from 'roosterjs-editor-types/lib/compatibleTypes'; + +const allowedEventsInShadowEdit: (PluginEventType | CompatiblePluginEventType)[] = [ + PluginEventType.EditorReady, + PluginEventType.BeforeDispose, + PluginEventType.ExtractContentWithDom, + PluginEventType.ZoomChanged, +]; + +/** + * @internal + * Trigger a plugin event + * @param core The EditorCore object + * @param pluginEvent The event object to trigger + * @param broadcast Set to true to skip the shouldHandleEventExclusively check + */ +export const triggerEvent: TriggerEvent = ( + core: EditorCore, + pluginEvent: PluginEvent, + broadcast: boolean +) => { + if ( + (!core.lifecycle.shadowEditFragment || + allowedEventsInShadowEdit.indexOf(pluginEvent.eventType) >= 0) && + (broadcast || !core.plugins.some(plugin => handledExclusively(pluginEvent, plugin))) + ) { + core.plugins.forEach(plugin => { + if (plugin.onPluginEvent) { + plugin.onPluginEvent(pluginEvent); + } + }); + } +}; + +function handledExclusively(event: PluginEvent, plugin: EditorPlugin): boolean { + if (plugin.onPluginEvent && plugin.willHandleEventExclusively?.(event)) { + plugin.onPluginEvent(event); + return true; + } + + return false; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/utils/addUniqueId.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/utils/addUniqueId.ts new file mode 100644 index 00000000000..9d3897bc5a3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/utils/addUniqueId.ts @@ -0,0 +1,31 @@ +/** + * @internal + * Add an unique id to element and ensure that is unique + * @param el The HTMLElement that will receive the id + * @param idPrefix The prefix that will antecede the id (Ex: tableSelected01) + */ +export default function addUniqueId(el: HTMLElement, idPrefix: string) { + const doc = el.ownerDocument; + if (!el.id) { + applyId(el, idPrefix, doc); + } else { + const elements = doc.querySelectorAll(`#${el.id}`); + if (elements.length > 1) { + el.removeAttribute('id'); + applyId(el, idPrefix, doc); + } + } +} + +function applyId(el: HTMLElement, idPrefix: string, doc: Document) { + let cont = 0; + const getElement = () => doc.getElementById(idPrefix + cont); + //Ensure that there are no elements with the same ID + let element = getElement(); + while (element) { + cont++; + element = getElement(); + } + + el.id = idPrefix + cont; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/CopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/CopyPastePlugin.ts new file mode 100644 index 00000000000..b968d9a12cb --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/CopyPastePlugin.ts @@ -0,0 +1,296 @@ +import { forEachSelectedCell } from './utils/forEachSelectedCell'; +import { removeCellsOutsideSelection } from './utils/removeCellsOutsideSelection'; +import { + addRangeToSelection, + createElement, + extractClipboardEvent, + moveChildNodes, + Browser, + setHtmlWithMetadata, + createRange, + VTable, + isWholeTableSelected, +} from 'roosterjs-editor-dom'; +import type { + CopyPastePluginState, + EditorOptions, + IEditor, + PluginWithState, + SelectionRangeEx, + TableSelection, +} from 'roosterjs-editor-types'; +import { + ChangeSource, + GetContentMode, + PluginEventType, + KnownCreateElementDataIndex, + SelectionRangeTypes, + TableOperation, +} from 'roosterjs-editor-types'; + +/** + * @internal + * Copy and paste plugin for handling onCopy and onPaste event + */ +export default class CopyPastePlugin implements PluginWithState { + private editor: IEditor | null = null; + private disposer: (() => void) | null = null; + private state: CopyPastePluginState; + + /** + * Construct a new instance of CopyPastePlugin + * @param options The editor options + */ + constructor(options: EditorOptions) { + this.state = { + allowedCustomPasteType: options.allowedCustomPasteType || [], + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'CopyPaste'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + this.disposer = this.editor.addDomEventHandler({ + paste: e => this.onPaste(e), + copy: e => this.onCutCopy(e, false /*isCut*/), + cut: e => this.onCutCopy(e, true /*isCut*/), + }); + } + + /** + * Dispose this plugin + */ + dispose() { + if (this.disposer) { + this.disposer(); + } + this.disposer = null; + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + private onCutCopy(event: Event, isCut: boolean) { + if (this.editor) { + const selection = this.editor.getSelectionRangeEx(); + if (selection && !selection.areAllCollapsed) { + const html = this.editor.getContent(GetContentMode.RawHTMLWithSelection); + const tempDiv = this.getTempDiv(this.editor, true /*forceInLightMode*/); + const metadata = setHtmlWithMetadata( + tempDiv, + html, + this.editor.getTrustedHTMLHandler() + ); + let newRange: Range | null = null; + + if ( + selection.type === SelectionRangeTypes.TableSelection && + selection.coordinates + ) { + const table = tempDiv.querySelector( + `#${selection.table.id}` + ) as HTMLTableElement; + newRange = this.createTableRange(table, selection.coordinates); + if (isCut) { + this.deleteTableContent( + this.editor, + selection.table, + selection.coordinates + ); + } + } else if (selection.type === SelectionRangeTypes.ImageSelection) { + const image = tempDiv.querySelector('#' + selection.image.id); + + if (image) { + newRange = createRange(image); + if (isCut) { + this.deleteImage(this.editor, selection.image.id); + } + } + } else { + newRange = + metadata?.type === SelectionRangeTypes.Normal + ? createRange(tempDiv, metadata.start, metadata.end) + : null; + } + if (newRange) { + const cutCopyEvent = this.editor.triggerPluginEvent( + PluginEventType.BeforeCutCopy, + { + clonedRoot: tempDiv, + range: newRange, + rawEvent: event as ClipboardEvent, + isCut, + } + ); + + if (cutCopyEvent.range) { + addRangeToSelection(newRange); + } + + this.editor.runAsync(editor => { + this.cleanUpAndRestoreSelection(tempDiv, selection, !isCut /* isCopy */); + + if (isCut) { + editor.addUndoSnapshot(() => { + const position = editor.deleteSelectedContent(); + editor.focus(); + editor.select(position); + }, ChangeSource.Cut); + } + }); + } + } + } + } + + private onPaste = (event: Event) => { + let range: Range | null = null; + if (this.editor) { + const editor = this.editor; + extractClipboardEvent( + event as ClipboardEvent, + clipboardData => { + if (editor && !editor.isDisposed()) { + editor.paste(clipboardData); + } + }, + { + allowedCustomPasteType: this.state.allowedCustomPasteType, + getTempDiv: () => { + range = editor.getSelectionRange() ?? null; + return this.getTempDiv(editor); + }, + removeTempDiv: div => { + if (range) { + this.cleanUpAndRestoreSelection(div, range, false /* isCopy */); + } + }, + }, + this.editor.getSelectionRange() ?? undefined + ); + } + }; + + private getTempDiv(editor: IEditor, forceInLightMode?: boolean) { + const div = editor.getCustomData( + 'CopyPasteTempDiv', + () => { + const tempDiv = createElement( + KnownCreateElementDataIndex.CopyPasteTempDiv, + editor.getDocument() + ) as HTMLDivElement; + + editor.getDocument().body.appendChild(tempDiv); + + return tempDiv; + }, + tempDiv => tempDiv.parentNode?.removeChild(tempDiv) + ); + + if (forceInLightMode) { + div.style.backgroundColor = 'white'; + div.style.color = 'black'; + } + + div.style.display = ''; + div.focus(); + + return div; + } + + private cleanUpAndRestoreSelection( + tempDiv: HTMLDivElement, + range: Range | SelectionRangeEx, + isCopy: boolean + ) { + if (!!(range)?.type || (range).type == 0) { + const selection = range; + switch (selection.type) { + case SelectionRangeTypes.TableSelection: + case SelectionRangeTypes.ImageSelection: + this.editor?.select(selection); + break; + case SelectionRangeTypes.Normal: + const range = selection.ranges?.[0]; + this.restoreRange(range, isCopy); + break; + } + } else { + this.restoreRange(range, isCopy); + } + + tempDiv.style.backgroundColor = ''; + tempDiv.style.color = ''; + tempDiv.style.display = 'none'; + moveChildNodes(tempDiv); + } + + private restoreRange(range: Range, isCopy: boolean) { + if (range && this.editor) { + if (isCopy && Browser.isAndroid) { + range.collapse(); + } + this.editor.select(range); + } + } + + private createTableRange(table: HTMLTableElement, selection: TableSelection) { + const clonedVTable = new VTable(table as HTMLTableElement); + clonedVTable.selection = selection; + removeCellsOutsideSelection(clonedVTable); + clonedVTable.writeBack(); + return createRange(clonedVTable.table); + } + + private deleteTableContent( + editor: IEditor, + table: HTMLTableElement, + selection: TableSelection + ) { + const selectedVTable = new VTable(table); + selectedVTable.selection = selection; + + forEachSelectedCell(selectedVTable, cell => { + if (cell?.td) { + cell.td.innerHTML = editor.getTrustedHTMLHandler()('
                                                          '); + } + }); + + const wholeTableSelected = isWholeTableSelected(selectedVTable, selection); + const isWholeColumnSelected = + table.rows.length - 1 === selection.lastCell.y && selection.firstCell.y === 0; + if (wholeTableSelected) { + selectedVTable.edit(TableOperation.DeleteTable); + selectedVTable.writeBack(); + } else if (isWholeColumnSelected) { + selectedVTable.edit(TableOperation.DeleteColumn); + selectedVTable.writeBack(); + } + if (wholeTableSelected || isWholeColumnSelected) { + table.style.removeProperty('width'); + table.style.removeProperty('height'); + } + } + + private deleteImage(editor: IEditor, imageId: string) { + editor.queryElements('#' + imageId, node => { + editor.deleteNode(node); + }); + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/DOMEventPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/DOMEventPlugin.ts new file mode 100644 index 00000000000..206ab44193e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/DOMEventPlugin.ts @@ -0,0 +1,259 @@ +import { arrayPush, Browser, isCharacterValue } from 'roosterjs-editor-dom'; +import { ChangeSource, Keys, PluginEventType } from 'roosterjs-editor-types'; +import type { + ContextMenuProvider, + DOMEventHandler, + DOMEventPluginState, + EditorOptions, + EditorPlugin, + IEditor, + PluginWithState, +} from 'roosterjs-editor-types'; + +/** + * @internal + * DOMEventPlugin handles customized DOM events, including: + * 1. Keyboard event + * 2. Mouse event + * 3. IME state + * 4. Drop event + * 5. Focus and blur event + * 6. Input event + * 7. Scroll event + * It contains special handling for Safari since Safari cannot get correct selection when onBlur event is triggered in editor. + */ +export default class DOMEventPlugin implements PluginWithState { + private editor: IEditor | null = null; + private disposer: (() => void) | null = null; + private state: DOMEventPluginState; + + /** + * Construct a new instance of DOMEventPlugin + * @param options The editor options + * @param contentDiv The editor content DIV + */ + constructor(options: EditorOptions, contentDiv: HTMLDivElement) { + this.state = { + isInIME: false, + scrollContainer: options.scrollContainer || contentDiv, + selectionRange: null, + stopPrintableKeyboardEventPropagation: !options.allowKeyboardEventPropagation, + contextMenuProviders: + options.plugins?.filter>(isContextMenuProvider) || [], + tableSelectionRange: null, + imageSelectionRange: null, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'DOMEvent'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + + const document = this.editor.getDocument(); + //Record + const eventHandlers: Partial< + { [P in keyof HTMLElementEventMap]: DOMEventHandler } + > = { + // 1. Keyboard event + keypress: this.getEventHandler(PluginEventType.KeyPress), + keydown: this.getEventHandler(PluginEventType.KeyDown), + keyup: this.getEventHandler(PluginEventType.KeyUp), + + // 2. Mouse event + mousedown: PluginEventType.MouseDown, + contextmenu: this.onContextMenuEvent, + + // 3. IME state management + compositionstart: () => (this.state.isInIME = true), + compositionend: (rawEvent: CompositionEvent) => { + this.state.isInIME = false; + editor.triggerPluginEvent(PluginEventType.CompositionEnd, { + rawEvent, + }); + }, + + // 4. Drag and Drop event + dragstart: this.onDragStart, + drop: this.onDrop, + + // 5. Focus management + focus: this.onFocus, + + // 6. Input event + [Browser.isIE ? 'textinput' : 'input']: this.getEventHandler(PluginEventType.Input), + }; + + // 7. onBlur handlers + if (Browser.isSafari) { + document.addEventListener('mousedown', this.onMouseDownDocument, true /*useCapture*/); + document.addEventListener('keydown', this.onKeyDownDocument); + document.defaultView?.addEventListener('blur', this.cacheSelection); + } else if (Browser.isIEOrEdge) { + type EventHandlersIE = { + beforedeactivate: DOMEventHandler; + }; + (eventHandlers as EventHandlersIE).beforedeactivate = this.cacheSelection; + } else { + eventHandlers.blur = this.cacheSelection; + } + + this.disposer = editor.addDomEventHandler(>eventHandlers); + + // 8. Scroll event + this.state.scrollContainer.addEventListener('scroll', this.onScroll); + document.defaultView?.addEventListener('scroll', this.onScroll); + document.defaultView?.addEventListener('resize', this.onScroll); + } + + /** + * Dispose this plugin + */ + dispose() { + const document = this.editor?.getDocument(); + if (document && Browser.isSafari) { + document.removeEventListener( + 'mousedown', + this.onMouseDownDocument, + true /*useCapture*/ + ); + document.removeEventListener('keydown', this.onKeyDownDocument); + document.defaultView?.removeEventListener('blur', this.cacheSelection); + } + + document?.defaultView?.removeEventListener('resize', this.onScroll); + document?.defaultView?.removeEventListener('scroll', this.onScroll); + this.state.scrollContainer.removeEventListener('scroll', this.onScroll); + this.disposer?.(); + this.disposer = null; + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + private onDragStart = (e: Event) => { + const dragEvent = e as DragEvent; + const element = this.editor?.getElementAtCursor('*', dragEvent.target as Node); + + if (element && !element.isContentEditable) { + dragEvent.preventDefault(); + } + }; + private onDrop = () => { + this.editor?.runAsync(editor => { + editor.addUndoSnapshot(() => {}, ChangeSource.Drop); + }); + }; + + private onFocus = () => { + if (!this.state.skipReselectOnFocus) { + const { table, coordinates } = this.state.tableSelectionRange || {}; + const { image } = this.state.imageSelectionRange || {}; + + if (table && coordinates) { + this.editor?.select(table, coordinates); + } else if (image) { + this.editor?.select(image); + } else if (this.state.selectionRange) { + this.editor?.select(this.state.selectionRange); + } + } + + this.state.selectionRange = null; + }; + private onKeyDownDocument = (event: KeyboardEvent) => { + if (event.which == Keys.TAB && !event.defaultPrevented) { + this.cacheSelection(); + } + }; + + private onMouseDownDocument = (event: MouseEvent) => { + if ( + this.editor && + !this.state.selectionRange && + !this.editor.contains(event.target as Node) + ) { + this.cacheSelection(); + } + }; + + private cacheSelection = () => { + if (!this.state.selectionRange && this.editor) { + this.state.selectionRange = this.editor.getSelectionRange(false /*tryGetFromCache*/); + } + }; + private onScroll = (e: Event) => { + this.editor?.triggerPluginEvent(PluginEventType.Scroll, { + rawEvent: e, + scrollContainer: this.state.scrollContainer, + }); + }; + + private getEventHandler(eventType: PluginEventType): DOMEventHandler { + const beforeDispatch = (event: Event) => + eventType == PluginEventType.Input + ? this.onInputEvent(event) + : this.onKeyboardEvent(event); + + return this.state.stopPrintableKeyboardEventPropagation + ? { + pluginEventType: eventType, + beforeDispatch, + } + : eventType; + } + + private onKeyboardEvent = (event: KeyboardEvent) => { + if (isCharacterValue(event) || (event.which >= Keys.PAGEUP && event.which <= Keys.DOWN)) { + // Stop propagation for Character keys and Up/Down/Left/Right/Home/End/PageUp/PageDown + // since editor already handles these keys and no need to propagate to parents + event.stopPropagation(); + } + }; + + private onInputEvent = (event: InputEvent) => { + event.stopPropagation(); + }; + + private onContextMenuEvent = (event: MouseEvent) => { + const allItems: any[] = []; + const searcher = this.editor?.getContentSearcherOfCursor(); + const elementBeforeCursor = searcher?.getInlineElementBefore(); + + let eventTargetNode = event.target as Node; + if (event.button != 2 && elementBeforeCursor) { + eventTargetNode = elementBeforeCursor.getContainerNode(); + } + this.state.contextMenuProviders.forEach(provider => { + const items = provider.getContextMenuItems(eventTargetNode) ?? []; + if (items?.length > 0) { + if (allItems.length > 0) { + allItems.push(null); + } + arrayPush(allItems, items); + } + }); + this.editor?.triggerPluginEvent(PluginEventType.ContextMenu, { + rawEvent: event, + items: allItems, + }); + }; +} + +function isContextMenuProvider(source: EditorPlugin): source is ContextMenuProvider { + return !!(>source)?.getContextMenuItems; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EditPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EditPlugin.ts new file mode 100644 index 00000000000..ea627186823 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EditPlugin.ts @@ -0,0 +1,96 @@ +import { isCtrlOrMetaPressed } from 'roosterjs-editor-dom'; +import { Keys, PluginEventType } from 'roosterjs-editor-types'; +import type { + EditPluginState, + GenericContentEditFeature, + IEditor, + PluginEvent, + PluginWithState, +} from 'roosterjs-editor-types'; + +/** + * @internal + * Edit Component helps handle Content edit features + */ +export default class EditPlugin implements PluginWithState { + private editor: IEditor | null = null; + private state: EditPluginState; + + /** + * Construct a new instance of EditPlugin + * @param options The editor options + */ + constructor() { + this.state = { + features: {}, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Edit'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + let hasFunctionKey = false; + let features: GenericContentEditFeature[] | null = null; + let ctrlOrMeta = false; + const isKeyDownEvent = event.eventType == PluginEventType.KeyDown; + + if (isKeyDownEvent) { + const rawEvent = event.rawEvent; + const range = this.editor?.getSelectionRange(); + + ctrlOrMeta = isCtrlOrMetaPressed(rawEvent); + hasFunctionKey = ctrlOrMeta || rawEvent.altKey; + features = + this.state.features[rawEvent.which] || + (range && !range.collapsed && this.state.features[Keys.RANGE]); + } else if (event.eventType == PluginEventType.ContentChanged) { + features = this.state.features[Keys.CONTENTCHANGED]; + } + + for (let i = 0; features && i < features?.length; i++) { + const feature = features[i]; + if ( + (feature.allowFunctionKeys || !hasFunctionKey) && + this.editor && + feature.shouldHandleEvent(event, this.editor, ctrlOrMeta) + ) { + feature.handleEvent(event, this.editor); + if (isKeyDownEvent) { + event.handledByEditFeature = true; + } + break; + } + } + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityPlugin.ts new file mode 100644 index 00000000000..df8d8198e33 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityPlugin.ts @@ -0,0 +1,390 @@ +import { + inlineEntityOnPluginEvent, + normalizeDelimitersInEditor, +} from './utils/inlineEntityOnPluginEvent'; +import { + Browser, + commitEntity, + getEntityFromElement, + getEntitySelector, + isCharacterValue, + toArray, + arrayPush, + createElement, + addRangeToSelection, + createRange, + isBlockElement, + getObjectKeys, +} from 'roosterjs-editor-dom'; +import type { + ContentChangedEvent, + Entity, + EntityOperationEvent, + EntityPluginState, + KnownEntityItem, + HtmlSanitizerOptions, + IEditor, + PluginEvent, + PluginMouseUpEvent, + PluginWithState, +} from 'roosterjs-editor-types'; +import { + ChangeSource, + ContentPosition, + EntityClasses, + EntityOperation, + Keys, + PluginEventType, + QueryScope, +} from 'roosterjs-editor-types'; +import type { CompatibleEntityOperation } from 'roosterjs-editor-types/lib/compatibleTypes'; + +const ENTITY_ID_REGEX = /_(\d{1,8})$/; + +const ENTITY_CSS_REGEX = '^' + EntityClasses.ENTITY_INFO_NAME + '$'; +const ENTITY_ID_CSS_REGEX = '^' + EntityClasses.ENTITY_ID_PREFIX; +const ENTITY_TYPE_CSS_REGEX = '^' + EntityClasses.ENTITY_TYPE_PREFIX; +const ENTITY_READONLY_CSS_REGEX = '^' + EntityClasses.ENTITY_READONLY_PREFIX; +const ALLOWED_CSS_CLASSES = [ + ENTITY_CSS_REGEX, + ENTITY_ID_CSS_REGEX, + ENTITY_TYPE_CSS_REGEX, + ENTITY_READONLY_CSS_REGEX, +]; +const REMOVE_ENTITY_OPERATIONS: (EntityOperation | CompatibleEntityOperation)[] = [ + EntityOperation.Overwrite, + EntityOperation.PartialOverwrite, + EntityOperation.RemoveFromStart, + EntityOperation.RemoveFromEnd, +]; + +/** + * @internal + * Entity Plugin helps handle all operations related to an entity and generate entity specified events + */ +export default class EntityPlugin implements PluginWithState { + private editor: IEditor | null = null; + private state: EntityPluginState; + + /** + * Construct a new instance of EntityPlugin + */ + constructor() { + this.state = { + entityMap: {}, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Entity'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + this.state.entityMap = {}; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + switch (event.eventType) { + case PluginEventType.MouseUp: + this.handleMouseUpEvent(event); + break; + case PluginEventType.KeyDown: + this.handleKeyDownEvent(event.rawEvent); + break; + case PluginEventType.BeforeCutCopy: + if (event.isCut) { + this.handleCutEvent(event.rawEvent); + } + break; + case PluginEventType.BeforePaste: + this.handleBeforePasteEvent(event.sanitizingOption); + break; + case PluginEventType.ContentChanged: + this.handleContentChangedEvent(event); + break; + case PluginEventType.EditorReady: + this.handleContentChangedEvent(); + break; + case PluginEventType.ExtractContentWithDom: + this.handleExtractContentWithDomEvent(event.clonedRoot); + break; + case PluginEventType.ContextMenu: + this.handleContextMenuEvent(event.rawEvent); + break; + case PluginEventType.EntityOperation: + this.handleEntityOperationEvent(event); + break; + } + + if (this.editor) { + inlineEntityOnPluginEvent(event, this.editor); + } + } + + private handleContextMenuEvent(event: UIEvent) { + const node = event.target as Node; + const entityElement = node && this.editor?.getElementAtCursor(getEntitySelector(), node); + + if (entityElement) { + event.preventDefault(); + this.triggerEvent(entityElement, EntityOperation.ContextMenu, event); + } + } + + private handleCutEvent = (event: ClipboardEvent) => { + const range = this.editor?.getSelectionRange(); + if (range && !range.collapsed) { + this.checkRemoveEntityForRange(event); + } + }; + + private handleMouseUpEvent(event: PluginMouseUpEvent) { + const { rawEvent, isClicking } = event; + const node = rawEvent.target as Node; + let entityElement: HTMLElement | null; + + if ( + this.editor && + isClicking && + node && + !!(entityElement = this.editor.getElementAtCursor(getEntitySelector(), node)) + ) { + this.triggerEvent(entityElement, EntityOperation.Click, rawEvent); + + workaroundSelectionIssueForIE(this.editor); + } + } + + private handleKeyDownEvent(event: KeyboardEvent) { + if ( + isCharacterValue(event) || + event.which == Keys.BACKSPACE || + event.which == Keys.DELETE || + event.which == Keys.ENTER + ) { + const range = this.editor?.getSelectionRange(); + if (range && !range.collapsed) { + this.checkRemoveEntityForRange(event); + } + } + } + + private handleBeforePasteEvent(sanitizingOption: HtmlSanitizerOptions) { + const range = this.editor?.getSelectionRange(); + + if (range && !range.collapsed) { + this.checkRemoveEntityForRange(null! /*rawEvent*/); + } + + if (sanitizingOption.additionalAllowedCssClasses) { + arrayPush(sanitizingOption.additionalAllowedCssClasses, ALLOWED_CSS_CLASSES); + } + } + + private handleContentChangedEvent(event?: ContentChangedEvent) { + let shouldNormalizeDelimiters: boolean = false; + // 1. find removed entities + getObjectKeys(this.state.entityMap).forEach(id => { + const item = this.state.entityMap[id]; + const element = item.element; + + if (this.editor && !item.isDeleted && !this.editor.contains(element)) { + item.isDeleted = true; + + this.triggerEvent(element, EntityOperation.Overwrite); + + if ( + !shouldNormalizeDelimiters && + !element.isContentEditable && + !isBlockElement(element) + ) { + shouldNormalizeDelimiters = true; + } + } + }); + + // 2. collect all new entities + const newEntities = + event?.source == ChangeSource.InsertEntity && event.data + ? [event.data as Entity] + : this.getExistingEntities().filter(entity => { + const item = this.state.entityMap[entity.id]; + + return !item || item.element != entity.wrapper || item.isDeleted; + }); + + // 3. Add new entities to known entity list, and hydrate + newEntities.forEach(entity => { + const { wrapper, type, id, isReadonly } = entity; + + entity.id = this.ensureUniqueId(type, id, wrapper); + commitEntity(wrapper, type, isReadonly, entity.id); // Use entity.id here because it is newly updated + this.handleNewEntity(entity); + }); + + if (shouldNormalizeDelimiters && this.editor) { + normalizeDelimitersInEditor(this.editor); + } + } + + private handleEntityOperationEvent(event: EntityOperationEvent) { + if (this.editor && REMOVE_ENTITY_OPERATIONS.indexOf(event.operation) >= 0) { + const item = this.state.entityMap[event.entity.id]; + + if (item) { + item.isDeleted = true; + } + } + } + + private handleExtractContentWithDomEvent(root: HTMLElement) { + toArray(root.querySelectorAll(getEntitySelector())).forEach(element => { + element.removeAttribute('contentEditable'); + + this.triggerEvent(element as HTMLElement, EntityOperation.ReplaceTemporaryContent); + }); + } + + private checkRemoveEntityForRange(event: Event) { + const editableEntityElements: HTMLElement[] = []; + const selector = getEntitySelector(); + this.editor?.queryElements(selector, QueryScope.OnSelection, element => { + if (element.isContentEditable) { + editableEntityElements.push(element); + } else { + this.triggerEvent(element, EntityOperation.Overwrite, event); + } + }); + + // For editable entities, we need to check if it is fully or partially covered by current selection, + // and trigger different events; + if (this.editor && editableEntityElements.length > 0) { + const inSelectionEntityElements = this.editor.queryElements( + selector, + QueryScope.InSelection + ); + editableEntityElements.forEach(element => { + const isFullyCovered = inSelectionEntityElements.indexOf(element) >= 0; + this.triggerEvent( + element, + isFullyCovered ? EntityOperation.Overwrite : EntityOperation.PartialOverwrite, + event + ); + }); + } + } + + private triggerEvent(element: HTMLElement, operation: EntityOperation, rawEvent?: Event) { + const entity = element && getEntityFromElement(element); + + return entity + ? this.editor?.triggerPluginEvent(PluginEventType.EntityOperation, { + operation, + rawEvent, + entity, + }) + : null; + } + + private handleNewEntity(entity: Entity) { + const { wrapper } = entity; + const event = this.triggerEvent(wrapper, EntityOperation.NewEntity); + + const newItem: KnownEntityItem = { + element: entity.wrapper, + }; + + if (event?.shouldPersist) { + newItem.canPersist = true; + } + + this.state.entityMap[entity.id] = newItem; + } + + private getExistingEntities(): Entity[] { + return ( + this.editor + ?.queryElements(getEntitySelector()) + .map(getEntityFromElement) + .filter((x): x is Entity => !!x) ?? [] + ); + } + + private ensureUniqueId(type: string, id: string, wrapper: HTMLElement) { + const match = ENTITY_ID_REGEX.exec(id); + const baseId = (match ? id.substr(0, id.length - match[0].length) : id) || type; + + // Make sure entity id is unique + let newId = ''; + + for (let num = (match && parseInt(match[1])) || 0; ; num++) { + newId = num > 0 ? `${baseId}_${num}` : baseId; + + const item = this.state.entityMap[newId]; + + if (!item || item.element == wrapper) { + break; + } + } + + return newId; + } +} + +/** + * IE will show a resize border around the readonly content within content editable DIV + * This is a workaround to remove it by temporarily move focus out of editor + */ +const workaroundSelectionIssueForIE = Browser.isIE + ? (editor: IEditor) => { + editor.runAsync(editor => { + const workaroundButton = editor.getCustomData('ENTITY_IE_FOCUS_BUTTON', () => { + const button = createElement( + { + tag: 'button', + style: 'overflow:hidden;position:fixed;width:0;height:0;top:-1000px', + }, + editor.getDocument() + ) as HTMLElement; + button.onblur = () => { + button.style.display = 'none'; + }; + + editor.insertNode(button, { + position: ContentPosition.Outside, + }); + + return button; + }); + + workaroundButton.style.display = ''; + addRangeToSelection(createRange(workaroundButton, 0)); + }); + } + : () => {}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ImageSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ImageSelection.ts new file mode 100644 index 00000000000..d5e98228bcb --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ImageSelection.ts @@ -0,0 +1,100 @@ +import { PluginEventType, PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { safeInstanceOf } from 'roosterjs-editor-dom'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; + +const Escape = 'Escape'; +const Delete = 'Delete'; +const mouseMiddleButton = 1; + +/** + * @internal + * Detect image selection and help highlight the image + */ +export default class ImageSelection implements EditorPlugin { + private editor: IEditor | null = null; + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'ImageSelection'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor?.select(null); + this.editor = null; + } + + onPluginEvent(event: PluginEvent) { + if (this.editor) { + switch (event.eventType) { + case PluginEventType.MouseUp: + const target = event.rawEvent.target; + if ( + safeInstanceOf(target, 'HTMLImageElement') && + target.isContentEditable && + event.rawEvent.button != mouseMiddleButton + ) { + this.editor.select(target); + } + break; + case PluginEventType.MouseDown: + const mouseTarget = event.rawEvent.target; + const mouseSelection = this.editor.getSelectionRangeEx(); + if ( + mouseSelection && + mouseSelection.type === SelectionRangeTypes.ImageSelection && + mouseSelection.image !== mouseTarget + ) { + this.editor.select(null); + } + break; + case PluginEventType.KeyDown: + const rawEvent = event.rawEvent; + const key = rawEvent.key; + const keyDownSelection = this.editor.getSelectionRangeEx(); + if ( + !rawEvent.ctrlKey && + !rawEvent.altKey && + !rawEvent.shiftKey && + !rawEvent.metaKey && + keyDownSelection.type === SelectionRangeTypes.ImageSelection + ) { + const imageParent = keyDownSelection.image?.parentNode; + if (key === Escape && imageParent) { + this.editor.select(keyDownSelection.image, PositionType.Before); + this.editor.getSelectionRange()?.collapse(); + event.rawEvent.stopPropagation(); + } else if (key === Delete) { + this.editor.deleteNode(keyDownSelection.image); + event.rawEvent.preventDefault(); + } else if (imageParent) { + this.editor.select(keyDownSelection.image, PositionType.Before); + } + } + break; + case PluginEventType.ContextMenu: + const contextMenuTarget = event.rawEvent.target; + const actualSelection = this.editor.getSelectionRangeEx(); + if ( + safeInstanceOf(contextMenuTarget, 'HTMLImageElement') && + (actualSelection.type !== SelectionRangeTypes.ImageSelection || + actualSelection.image !== contextMenuTarget) + ) { + this.editor.select(contextMenuTarget); + } + } + } + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts new file mode 100644 index 00000000000..cd17471380c --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts @@ -0,0 +1,188 @@ +import { ChangeSource, PluginEventType } from 'roosterjs-editor-types'; +import { getObjectKeys, setColor } from 'roosterjs-editor-dom'; +import type { + EditorOptions, + IEditor, + LifecyclePluginState, + PluginWithState, + PluginEvent, +} from 'roosterjs-editor-types'; + +const CONTENT_EDITABLE_ATTRIBUTE_NAME = 'contenteditable'; + +const DARK_MODE_DEFAULT_FORMAT = { + backgroundColors: { + darkModeColor: 'rgb(51,51,51)', + lightModeColor: 'rgb(255,255,255)', + }, + textColors: { + darkModeColor: 'rgb(255,255,255)', + lightModeColor: 'rgb(0,0,0)', + }, +}; + +/** + * @internal + * Lifecycle plugin handles editor initialization and disposing + */ +export default class LifecyclePlugin implements PluginWithState { + private editor: IEditor | null = null; + private state: LifecyclePluginState; + private initialContent: string; + private initializer: (() => void) | null = null; + private disposer: (() => void) | null = null; + private adjustColor: () => void; + + /** + * Construct a new instance of LifecyclePlugin + * @param options The editor options + * @param contentDiv The editor content DIV + */ + constructor(options: EditorOptions, contentDiv: HTMLDivElement) { + this.initialContent = options.initialContent || contentDiv.innerHTML || ''; + + // Make the container editable and set its selection styles + if (contentDiv.getAttribute(CONTENT_EDITABLE_ATTRIBUTE_NAME) === null) { + this.initializer = () => { + contentDiv.contentEditable = 'true'; + contentDiv.style.userSelect = 'text'; + }; + this.disposer = () => { + contentDiv.style.userSelect = ''; + contentDiv.removeAttribute(CONTENT_EDITABLE_ATTRIBUTE_NAME); + }; + } + this.adjustColor = options.doNotAdjustEditorColor + ? () => {} + : () => { + const { textColors, backgroundColors } = DARK_MODE_DEFAULT_FORMAT; + const { isDarkMode } = this.state; + const darkColorHandler = this.editor?.getDarkColorHandler(); + setColor( + contentDiv, + textColors, + false /*isBackground*/, + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler + ); + setColor( + contentDiv, + backgroundColors, + true /*isBackground*/, + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler + ); + }; + + const getDarkColor = options.getDarkColor ?? ((color: string) => color); + const defaultFormat = options.defaultFormat ? { ...options.defaultFormat } : null; + + if (defaultFormat) { + if (defaultFormat.textColor && !defaultFormat.textColors) { + defaultFormat.textColors = { + lightModeColor: defaultFormat.textColor, + darkModeColor: getDarkColor(defaultFormat.textColor), + }; + delete defaultFormat.textColor; + } + + if (defaultFormat.backgroundColor && !defaultFormat.backgroundColors) { + defaultFormat.backgroundColors = { + lightModeColor: defaultFormat.backgroundColor, + darkModeColor: getDarkColor(defaultFormat.backgroundColor), + }; + delete defaultFormat.backgroundColor; + } + } + + this.state = { + customData: {}, + defaultFormat, + isDarkMode: !!options.inDarkMode, + getDarkColor, + onExternalContentTransform: options.onExternalContentTransform ?? null, + experimentalFeatures: options.experimentalFeatures || [], + shadowEditFragment: null, + shadowEditEntities: null, + shadowEditSelectionPath: null, + shadowEditTableSelectionPath: null, + shadowEditImageSelectionPath: null, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Lifecycle'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + + // Ensure initial content and its format + this.editor.setContent(this.initialContent, false /*triggerContentChangedEvent*/); + + // Set content DIV to be editable + this.initializer?.(); + + // Set editor background color for dark mode + this.adjustColor(); + + // Let other plugins know that we are ready + this.editor.triggerPluginEvent(PluginEventType.EditorReady, {}, true /*broadcast*/); + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor?.triggerPluginEvent(PluginEventType.BeforeDispose, {}, true /*broadcast*/); + + getObjectKeys(this.state.customData).forEach(key => { + const data = this.state.customData[key]; + + if (data && data.disposer) { + data.disposer(data.value); + } + + delete this.state.customData[key]; + }); + + if (this.disposer) { + this.disposer(); + this.disposer = null; + this.initializer = null; + } + + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + if ( + event.eventType == PluginEventType.ContentChanged && + (event.source == ChangeSource.SwitchToDarkMode || + event.source == ChangeSource.SwitchToLightMode) + ) { + this.state.isDarkMode = event.source == ChangeSource.SwitchToDarkMode; + this.adjustColor(); + } + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/MouseUpPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/MouseUpPlugin.ts new file mode 100644 index 00000000000..2f072e3b10d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/MouseUpPlugin.ts @@ -0,0 +1,72 @@ +import { PluginEventType } from 'roosterjs-editor-types'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; + +/** + * @internal + * MouseUpPlugin help trigger MouseUp event even when mouse up happens outside editor + * as long as the mouse was pressed within Editor before + */ +export default class MouseUpPlugin implements EditorPlugin { + private editor: IEditor | null = null; + private mouseUpEventListerAdded: boolean = false; + private mouseDownX: number | null = null; + private mouseDownY: number | null = null; + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'MouseUp'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.removeMouseUpEventListener(); + this.editor = null; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + if ( + this.editor && + event.eventType == PluginEventType.MouseDown && + !this.mouseUpEventListerAdded + ) { + this.editor + .getDocument() + .addEventListener('mouseup', this.onMouseUp, true /*setCapture*/); + this.mouseUpEventListerAdded = true; + this.mouseDownX = event.rawEvent.pageX; + this.mouseDownY = event.rawEvent.pageY; + } + } + private removeMouseUpEventListener() { + if (this.editor && this.mouseUpEventListerAdded) { + this.mouseUpEventListerAdded = false; + this.editor.getDocument().removeEventListener('mouseup', this.onMouseUp, true); + } + } + + private onMouseUp = (rawEvent: MouseEvent) => { + if (this.editor) { + this.removeMouseUpEventListener(); + this.editor.triggerPluginEvent(PluginEventType.MouseUp, { + rawEvent, + isClicking: this.mouseDownX == rawEvent.pageX && this.mouseDownY == rawEvent.pageY, + }); + } + }; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts new file mode 100644 index 00000000000..79ce990da14 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts @@ -0,0 +1,180 @@ +import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { + changeElementTag, + getTagOfNode, + moveChildNodes, + safeInstanceOf, + toArray, +} from 'roosterjs-editor-dom'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; + +/** + * @internal + * TODO: Rename this plugin since it is not only for table now + * + * NormalizeTable plugin makes sure each table in editor has TBODY/THEAD/TFOOT tag around TR tags + * + * When we retrieve HTML content using innerHTML, browser will always add TBODY around TR nodes if there is not. + * This causes some issue when we restore the HTML content with selection path since the selection path is + * deeply coupled with DOM structure. So we need to always make sure there is already TBODY tag whenever + * new table is inserted, to make sure the selection path we created is correct. + */ +export default class NormalizeTablePlugin implements EditorPlugin { + private editor: IEditor | null = null; + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'NormalizeTable'; + } + + /** + * The first method that editor will call to a plugin when editor is initializing. + * It will pass in the editor instance, plugin should take this chance to save the + * editor reference so that it can call to any editor method or format API later. + * @param editor The editor object + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * The last method that editor will call to a plugin before it is disposed. + * Plugin can take this chance to clear the reference to editor. After this method is + * called, plugin should not call to any editor method since it will result in error. + */ + dispose() { + this.editor = null; + } + + /** + * Core method for a plugin. Once an event happens in editor, editor will call this + * method of each plugin to handle the event as long as the event is not handled + * exclusively by another plugin. + * @param event The event to handle: + */ + onPluginEvent(event: PluginEvent) { + switch (event.eventType) { + case PluginEventType.EditorReady: + case PluginEventType.ContentChanged: + if (this.editor) { + this.normalizeTables(this.editor.queryElements('table')); + } + break; + + case PluginEventType.BeforePaste: + this.normalizeTables(toArray(event.fragment.querySelectorAll('table'))); + break; + + case PluginEventType.MouseDown: + this.normalizeTableFromEvent(event.rawEvent); + break; + + case PluginEventType.KeyDown: + if (event.rawEvent.shiftKey) { + this.normalizeTableFromEvent(event.rawEvent); + } + break; + + case PluginEventType.ExtractContentWithDom: + normalizeListsForExport(event.clonedRoot); + break; + } + } + + private normalizeTableFromEvent(event: KeyboardEvent | MouseEvent) { + const table = this.editor?.getElementAtCursor('table', event.target as Node); + + if (table) { + this.normalizeTables([table]); + } + } + + private normalizeTables(tables: HTMLTableElement[]) { + if (this.editor && tables.length > 0) { + const rangeEx = this.editor.getSelectionRangeEx(); + const { startContainer, endContainer, startOffset, endOffset } = + (rangeEx?.type == SelectionRangeTypes.Normal && rangeEx.ranges[0]) || {}; + + const isChanged = normalizeTables(tables); + + if (isChanged) { + if ( + startContainer && + endContainer && + typeof startOffset === 'number' && + typeof endOffset === 'number' + ) { + this.editor.select(startContainer, startOffset, endContainer, endOffset); + } else if ( + rangeEx?.type == SelectionRangeTypes.TableSelection && + rangeEx.coordinates + ) { + this.editor.select(rangeEx.table, rangeEx.coordinates); + } + } + } + } +} + +function normalizeTables(tables: HTMLTableElement[]) { + let isDOMChanged = false; + tables.forEach(table => { + let tbody: HTMLTableSectionElement | null = null; + + for (let child = table.firstChild; child; child = child.nextSibling) { + const tag = getTagOfNode(child); + switch (tag) { + case 'TR': + if (!tbody) { + tbody = table.ownerDocument.createElement('tbody'); + table.insertBefore(tbody, child); + } + + tbody.appendChild(child); + child = tbody; + isDOMChanged = true; + + break; + case 'TBODY': + if (tbody) { + moveChildNodes(tbody, child, true /*keepExistingChildren*/); + child.parentNode?.removeChild(child); + child = tbody; + isDOMChanged = true; + } else { + tbody = child as HTMLTableSectionElement; + } + break; + default: + tbody = null; + break; + } + } + + const colgroups = table.querySelectorAll('colgroup'); + const thead = table.querySelector('thead'); + if (thead) { + colgroups.forEach(colgroup => { + if (!thead.contains(colgroup)) { + thead.appendChild(colgroup); + } + }); + } + }); + + return isDOMChanged; +} + +function normalizeListsForExport(root: ParentNode) { + toArray(root.querySelectorAll('li')).forEach(li => { + const prevElement = li.previousSibling; + + if (li.style.display == 'block' && safeInstanceOf(prevElement, 'HTMLLIElement')) { + li.style.removeProperty('display'); + + prevElement.appendChild(changeElementTag(li, 'div')); + } + }); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/PendingFormatStatePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/PendingFormatStatePlugin.ts new file mode 100644 index 00000000000..4a726f9f504 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/PendingFormatStatePlugin.ts @@ -0,0 +1,184 @@ +import { ChangeSource, Keys, PluginEventType, PositionType } from 'roosterjs-editor-types'; +import { isCharacterValue, Position, setColor } from 'roosterjs-editor-dom'; +import type { + IEditor, + NodePosition, + PendingFormatStatePluginState, + PluginEvent, + PluginWithState, +} from 'roosterjs-editor-types'; + +const ZERO_WIDTH_SPACE = '\u200B'; + +/** + * @internal + * PendingFormatStatePlugin handles pending format state management + */ +export default class PendingFormatStatePlugin + implements PluginWithState { + private editor: IEditor | null = null; + private state: PendingFormatStatePluginState; + + /** + * Construct a new instance of PendingFormatStatePlugin + * @param options The editor options + * @param contentDiv The editor content DIV + */ + constructor() { + this.state = { + pendableFormatPosition: null, + pendableFormatState: null, + pendableFormatSpan: null, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'PendingFormatState'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + this.clear(); + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + switch (event.eventType) { + case PluginEventType.PendingFormatStateChanged: + // Got PendingFormatStateChanged event, cache current position and pending format if a format is passed in + // otherwise clear existing pending format. + if (event.formatState) { + this.state.pendableFormatPosition = this.getCurrentPosition(); + this.state.pendableFormatState = event.formatState; + this.state.pendableFormatSpan = event.formatCallback + ? this.createPendingFormatSpan(event.formatCallback) + : null; + } else { + this.clear(); + } + + break; + case PluginEventType.KeyDown: + case PluginEventType.MouseDown: + case PluginEventType.ContentChanged: + let currentPosition: NodePosition | null = null; + if ( + this.editor && + event.eventType == PluginEventType.KeyDown && + isCharacterValue(event.rawEvent) && + this.state.pendableFormatSpan + ) { + this.state.pendableFormatSpan.removeAttribute('contentEditable'); + this.editor.insertNode(this.state.pendableFormatSpan); + this.editor.select( + this.state.pendableFormatSpan, + PositionType.Begin, + this.state.pendableFormatSpan, + PositionType.End + ); + this.clear(); + } else if ( + (event.eventType == PluginEventType.KeyDown && + event.rawEvent.which >= Keys.PAGEUP && + event.rawEvent.which <= Keys.DOWN) || + (this.state.pendableFormatPosition && + (currentPosition = this.getCurrentPosition()) && + !this.state.pendableFormatPosition.equalTo(currentPosition)) || + (event.eventType == PluginEventType.ContentChanged && + (event.source == ChangeSource.SwitchToDarkMode || + event.source == ChangeSource.SwitchToLightMode)) + ) { + // If content or position is changed (by keyboard, mouse, or code), + // check if current position is still the same with the cached one (if exist), + // and clear cached format if position is changed since it is out-of-date now + this.clear(); + } + + break; + } + } + + private clear() { + this.state.pendableFormatPosition = null; + this.state.pendableFormatState = null; + this.state.pendableFormatSpan = null; + } + + private getCurrentPosition() { + const range = this.editor?.getSelectionRange(); + return (range && Position.getStart(range).normalize()) ?? null; + } + + private createPendingFormatSpan( + callback: (element: HTMLElement, isInnerNode?: boolean) => any + ) { + let span = this.state.pendableFormatSpan; + + if (!span && this.editor) { + const currentStyle = this.editor.getStyleBasedFormatState(); + const doc = this.editor.getDocument(); + const isDarkMode = this.editor.isDarkMode(); + + span = doc.createElement('span'); + span.contentEditable = 'true'; + span.appendChild(doc.createTextNode(ZERO_WIDTH_SPACE)); + + span.style.setProperty('font-family', currentStyle.fontName ?? null); + span.style.setProperty('font-size', currentStyle.fontSize ?? null); + + const darkColorHandler = this.editor.getDarkColorHandler(); + + if (currentStyle.textColors || currentStyle.textColor) { + setColor( + span, + (currentStyle.textColors || currentStyle.textColor)!, + false /*isBackground*/, + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler + ); + } + + if (currentStyle.backgroundColors || currentStyle.backgroundColor) { + setColor( + span, + (currentStyle.backgroundColors || currentStyle.backgroundColor)!, + true /*isBackground*/, + isDarkMode, + false /*shouldAdaptFontColor*/, + darkColorHandler + ); + } + } + + if (span) { + callback(span); + } + + return span; + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/TypeInContainerPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/TypeInContainerPlugin.ts new file mode 100644 index 00000000000..227c1f2f3f4 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/TypeInContainerPlugin.ts @@ -0,0 +1,99 @@ +import { PluginEventType } from 'roosterjs-editor-types'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; +import { + Browser, + findClosestElementAncestor, + getTagOfNode, + isCtrlOrMetaPressed, + Position, +} from 'roosterjs-editor-dom'; + +/** + * @internal + * Typing Component helps to ensure typing is always happening under a DOM container + */ +export default class TypeInContainerPlugin implements EditorPlugin { + private editor: IEditor | null = null; + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'TypeInContainer'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + } + + private isRangeEmpty(range: Range) { + if ( + range.collapsed && + range.startContainer.nodeType === Node.ELEMENT_NODE && + getTagOfNode(range.startContainer) == 'DIV' && + !range.startContainer.firstChild + ) { + return true; + } + return false; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + // We need to check if the ctrl key or the meta key is pressed, + // browsers like Safari fire the "keypress" event when the meta key is pressed. + if ( + event.eventType == PluginEventType.KeyPress && + this.editor && + !(event.rawEvent && isCtrlOrMetaPressed(event.rawEvent)) + ) { + // If normalization was not possible before the keypress, + // check again after the keyboard event has been processed by browser native behavior. + // + // This handles the case where the keyboard event that first inserts content happens when + // there is already content under the selection (e.g. Ctrl+a -> type new content). + // + // Only schedule when the range is not collapsed to catch this edge case. + const range = this.editor.getSelectionRange(); + + const styledAncestor = + range && + findClosestElementAncestor(range.startContainer, undefined /* root */, '[style]'); + + if (!range || (!this.isRangeEmpty(range) && this.editor.contains(styledAncestor))) { + return; + } + + if (range.collapsed) { + this.editor.ensureTypeInContainer(Position.getStart(range), event.rawEvent); + } else { + const callback = () => { + const focusedPosition = this.editor?.getFocusedPosition(); + if (focusedPosition) { + this.editor?.ensureTypeInContainer(focusedPosition, event.rawEvent); + } + }; + + if (Browser.isMobileOrTablet) { + this.editor.getDocument().defaultView?.setTimeout(callback, 100); + } else { + this.editor.runAsync(callback); + } + } + } + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts new file mode 100644 index 00000000000..4cd9c12de9b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts @@ -0,0 +1,279 @@ +import { ChangeSource, Keys, PluginEventType } from 'roosterjs-editor-types'; +import type { + ContentChangedEvent, + EditorOptions, + IEditor, + PluginEvent, + PluginWithState, + Snapshot, + UndoPluginState, + UndoSnapshotsService, +} from 'roosterjs-editor-types'; +import { + addSnapshotV2, + canMoveCurrentSnapshot, + clearProceedingSnapshotsV2, + createSnapshots, + isCtrlOrMetaPressed, + moveCurrentSnapshot, + canUndoAutoComplete, +} from 'roosterjs-editor-dom'; + +// Max stack size that cannot be exceeded. When exceeded, old undo history will be dropped +// to keep size under limit. This is kept at 10MB +const MAX_SIZE_LIMIT = 1e7; + +/** + * @internal + * Provides snapshot based undo service for Editor + */ +export default class UndoPlugin implements PluginWithState { + private editor: IEditor | null = null; + private lastKeyPress: number | null = null; + private state: UndoPluginState; + + /** + * Construct a new instance of UndoPlugin + * @param options The wrapper of the state object + */ + constructor(options: EditorOptions) { + this.state = { + snapshotsService: + options.undoMetadataSnapshotService || + createUndoSnapshotServiceBridge(options.undoSnapshotService) || + createUndoSnapshots(), + isRestoring: false, + hasNewContent: false, + isNested: false, + autoCompletePosition: null, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Undo'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor): void { + this.editor = editor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Check if the plugin should handle the given event exclusively. + * @param event The event to check + */ + willHandleEventExclusively(event: PluginEvent) { + return ( + event.eventType == PluginEventType.KeyDown && + event.rawEvent.which == Keys.BACKSPACE && + !event.rawEvent.ctrlKey && + this.canUndoAutoComplete() + ); + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent): void { + // if editor is in IME, don't do anything + if (!this.editor || this.editor.isInIME()) { + return; + } + + switch (event.eventType) { + case PluginEventType.EditorReady: + const undoState = this.editor.getUndoState(); + if (!undoState.canUndo && !undoState.canRedo) { + // Only add initial snapshot when there is no existing snapshot + // Otherwise preserved undo/redo state may be ruined + this.addUndoSnapshot(); + } + break; + case PluginEventType.KeyDown: + this.onKeyDown(event.rawEvent); + break; + case PluginEventType.KeyPress: + this.onKeyPress(event.rawEvent); + break; + case PluginEventType.CompositionEnd: + this.clearRedoForInput(); + this.addUndoSnapshot(); + break; + case PluginEventType.ContentChanged: + this.onContentChanged(event); + break; + case PluginEventType.BeforeKeyboardEditing: + this.onBeforeKeyboardEditing(event.rawEvent); + break; + } + } + + private onKeyDown(evt: KeyboardEvent): void { + // Handle backspace/delete when there is a selection to take a snapshot + // since we want the state prior to deletion restorable + // Ignore if keycombo is ALT+BACKSPACE + if ((evt.which == Keys.BACKSPACE && !evt.altKey) || evt.which == Keys.DELETE) { + if (evt.which == Keys.BACKSPACE && !evt.ctrlKey && this.canUndoAutoComplete()) { + evt.preventDefault(); + this.editor?.undo(); + this.state.autoCompletePosition = null; + this.lastKeyPress = evt.which; + } else if (!evt.defaultPrevented) { + const selectionRange = this.editor?.getSelectionRange(); + + // Add snapshot when + // 1. Something has been selected (not collapsed), or + // 2. It has a different key code from the last keyDown event (to prevent adding too many snapshot when keeping press the same key), or + // 3. Ctrl/Meta key is pressed so that a whole word will be deleted + if ( + selectionRange && + (!selectionRange.collapsed || + this.lastKeyPress != evt.which || + isCtrlOrMetaPressed(evt)) + ) { + this.addUndoSnapshot(); + } + + // Since some content is deleted, always set hasNewContent to true so that we will take undo snapshot next time + this.state.hasNewContent = true; + this.lastKeyPress = evt.which; + } + } else if (evt.which >= Keys.PAGEUP && evt.which <= Keys.DOWN) { + // PageUp, PageDown, Home, End, Left, Right, Up, Down + if (this.state.hasNewContent) { + this.addUndoSnapshot(); + } + this.lastKeyPress = 0; + } else if (this.lastKeyPress == Keys.BACKSPACE || this.lastKeyPress == Keys.DELETE) { + if (this.state.hasNewContent) { + this.addUndoSnapshot(); + } + } + } + + private onKeyPress(evt: KeyboardEvent): void { + if (evt.metaKey) { + // if metaKey is pressed, simply return since no actual effect will be taken on the editor. + // this is to prevent changing hasNewContent to true when meta + v to paste on Safari. + return; + } + + const range = this.editor?.getSelectionRange(); + if ( + (range && !range.collapsed) || + (evt.which == Keys.SPACE && this.lastKeyPress != Keys.SPACE) || + evt.which == Keys.ENTER + ) { + this.addUndoSnapshot(); + if (evt.which == Keys.ENTER) { + // Treat ENTER as new content so if there is no input after ENTER and undo, + // we restore the snapshot before ENTER + this.state.hasNewContent = true; + } + } else { + this.clearRedoForInput(); + } + + this.lastKeyPress = evt.which; + } + + private onBeforeKeyboardEditing(event: KeyboardEvent) { + // For keyboard event (triggered from Content Model), we can get its keycode from event.data + // And when user is keep pressing the same key, mark editor with "hasNewContent" so that next time user + // do some other action or press a different key, we will add undo snapshot + if (event.which != this.lastKeyPress) { + this.addUndoSnapshot(); + } + + this.lastKeyPress = event.which; + this.state.hasNewContent = true; + } + + private onContentChanged(event: ContentChangedEvent) { + if ( + !( + this.state.isRestoring || + event.source == ChangeSource.SwitchToDarkMode || + event.source == ChangeSource.SwitchToLightMode || + event.source == ChangeSource.Keyboard + ) + ) { + this.clearRedoForInput(); + } + } + + private clearRedoForInput() { + this.state.snapshotsService.clearRedo(); + this.lastKeyPress = 0; + this.state.hasNewContent = true; + } + + private canUndoAutoComplete() { + const focusedPosition = this.editor?.getFocusedPosition(); + return ( + this.state.snapshotsService.canUndoAutoComplete() && + !!focusedPosition && + !!this.state.autoCompletePosition?.equalTo(focusedPosition) + ); + } + + private addUndoSnapshot() { + this.editor?.addUndoSnapshot(); + this.state.autoCompletePosition = null; + } +} + +function createUndoSnapshots(): UndoSnapshotsService { + const snapshots = createSnapshots(MAX_SIZE_LIMIT); + + return { + canMove: (delta: number): boolean => canMoveCurrentSnapshot(snapshots, delta), + move: (delta: number): Snapshot | null => moveCurrentSnapshot(snapshots, delta), + addSnapshot: (snapshot: Snapshot, isAutoCompleteSnapshot: boolean) => + addSnapshotV2(snapshots, snapshot, isAutoCompleteSnapshot), + clearRedo: () => clearProceedingSnapshotsV2(snapshots), + canUndoAutoComplete: () => canUndoAutoComplete(snapshots), + }; +} + +function createUndoSnapshotServiceBridge( + service: UndoSnapshotsService | undefined +): UndoSnapshotsService | undefined { + let html: string | null; + return service + ? { + canMove: (delta: number) => service.canMove(delta), + move: (delta: number): Snapshot | null => + (html = service.move(delta)) ? { html, metadata: null, knownColors: [] } : null, + addSnapshot: (snapshot: Snapshot, isAutoCompleteSnapshot: boolean) => + service.addSnapshot( + snapshot.html + + (snapshot.metadata ? `` : ''), + isAutoCompleteSnapshot + ), + clearRedo: () => service.clearRedo(), + canUndoAutoComplete: () => service.canUndoAutoComplete(), + } + : undefined; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts new file mode 100644 index 00000000000..b1d6d5b6d4d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts @@ -0,0 +1,66 @@ +import CopyPastePlugin from './CopyPastePlugin'; +import DOMEventPlugin from './DOMEventPlugin'; +import EditPlugin from './EditPlugin'; +import EntityPlugin from './EntityPlugin'; +import ImageSelection from './ImageSelection'; +import LifecyclePlugin from './LifecyclePlugin'; +import MouseUpPlugin from './MouseUpPlugin'; +import NormalizeTablePlugin from './NormalizeTablePlugin'; +import PendingFormatStatePlugin from './PendingFormatStatePlugin'; +import TypeInContainerPlugin from './TypeInContainerPlugin'; +import UndoPlugin from './UndoPlugin'; +import type { CorePlugins, EditorOptions, PluginState } from 'roosterjs-editor-types'; + +/** + * @internal + */ +export interface CreateCorePluginResponse extends CorePlugins { + _placeholder: null; +} + +/** + * @internal + * Create Core Plugins + * @param contentDiv Content DIV of editor + * @param options Editor options + */ +export function createCorePlugins( + contentDiv: HTMLDivElement, + options: EditorOptions +): CreateCorePluginResponse { + const map = options.corePluginOverride || {}; + // The order matters, some plugin needs to be put before/after others to make sure event + // can be handled in right order + return { + typeInContainer: map.typeInContainer || new TypeInContainerPlugin(), + edit: map.edit || new EditPlugin(), + pendingFormatState: map.pendingFormatState || new PendingFormatStatePlugin(), + _placeholder: null, + typeAfterLink: null!, //deprecated after firefox update + undo: map.undo || new UndoPlugin(options), + domEvent: map.domEvent || new DOMEventPlugin(options, contentDiv), + mouseUp: map.mouseUp || new MouseUpPlugin(), + copyPaste: map.copyPaste || new CopyPastePlugin(options), + entity: map.entity || new EntityPlugin(), + imageSelection: map.imageSelection || new ImageSelection(), + normalizeTable: map.normalizeTable || new NormalizeTablePlugin(), + lifecycle: map.lifecycle || new LifecyclePlugin(options, contentDiv), + }; +} + +/** + * @internal + * Get plugin state of core plugins + * @param corePlugins CorePlugins object + */ +export function getPluginState(corePlugins: CorePlugins): PluginState { + return { + domEvent: corePlugins.domEvent.getState(), + pendingFormatState: corePlugins.pendingFormatState.getState(), + edit: corePlugins.edit.getState(), + lifecycle: corePlugins.lifecycle.getState(), + undo: corePlugins.undo.getState(), + entity: corePlugins.entity.getState(), + copyPaste: corePlugins.copyPaste.getState(), + }; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/forEachSelectedCell.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/forEachSelectedCell.ts new file mode 100644 index 00000000000..edd97016f3c --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/forEachSelectedCell.ts @@ -0,0 +1,22 @@ +import type { VCell } from 'roosterjs-editor-types'; +import type { VTable } from 'roosterjs-editor-dom'; + +/** + * @internal + * Executes an action to all the cells within the selection range. + * @param callback action to apply on each selected cell + * @returns the amount of cells modified + */ +export const forEachSelectedCell = (vTable: VTable, callback: (cell: VCell) => void): void => { + if (vTable.selection) { + const { lastCell, firstCell } = vTable.selection; + + for (let y = firstCell.y; y <= lastCell.y; y++) { + for (let x = firstCell.x; x <= lastCell.x; x++) { + if (vTable.cells && vTable.cells[y][x]?.td) { + callback(vTable.cells[y][x]); + } + } + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts new file mode 100644 index 00000000000..e077b6cc5ce --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts @@ -0,0 +1,291 @@ +import { + addDelimiters, + arrayPush, + createRange, + getDelimiterFromElement, + getEntityFromElement, + getEntitySelector, + isBlockElement, + isCharacterValue, + matchesSelector, + Position, + safeInstanceOf, + splitTextNode, +} from 'roosterjs-editor-dom'; +import type { Entity, IEditor, PluginEvent, PluginKeyDownEvent } from 'roosterjs-editor-types'; +import { + ChangeSource, + DelimiterClasses, + Keys, + NodeType, + PluginEventType, + PositionType, + SelectionRangeTypes, +} from 'roosterjs-editor-types'; + +const DELIMITER_SELECTOR = + '.' + DelimiterClasses.DELIMITER_AFTER + ',.' + DelimiterClasses.DELIMITER_BEFORE; +const ZERO_WIDTH_SPACE = '\u200B'; +const INLINE_ENTITY_SELECTOR = 'span' + getEntitySelector(); + +/** + * @internal + */ +export function inlineEntityOnPluginEvent(event: PluginEvent, editor: IEditor) { + switch (event.eventType) { + case PluginEventType.ContentChanged: + if (event.source === ChangeSource.SetContent) { + normalizeDelimitersInEditor(editor); + } + break; + case PluginEventType.EditorReady: + normalizeDelimitersInEditor(editor); + break; + + case PluginEventType.BeforePaste: + const { fragment, sanitizingOption } = event; + addDelimitersIfNeeded(fragment.querySelectorAll(INLINE_ENTITY_SELECTOR)); + + if (sanitizingOption.additionalAllowedCssClasses) { + arrayPush(sanitizingOption.additionalAllowedCssClasses, [ + DelimiterClasses.DELIMITER_AFTER, + DelimiterClasses.DELIMITER_BEFORE, + ]); + } + break; + + case PluginEventType.ExtractContentWithDom: + case PluginEventType.BeforeCutCopy: + event.clonedRoot.querySelectorAll(DELIMITER_SELECTOR).forEach(node => { + if (getDelimiterFromElement(node)) { + removeNode(node); + } else { + removeDelimiterAttr(node); + } + }); + break; + + case PluginEventType.KeyDown: + handleKeyDownEvent(editor, event); + break; + } +} + +function preventTypeInDelimiter(delimiter: HTMLElement) { + delimiter.normalize(); + const textNode = delimiter.firstChild as Node; + const index = textNode.nodeValue?.indexOf(ZERO_WIDTH_SPACE) ?? -1; + if (index >= 0) { + splitTextNode(textNode, index == 0 ? 1 : index, false /* returnFirstPart */); + let nodeToMove: Node | undefined; + delimiter.childNodes.forEach(node => { + if (node.nodeValue !== ZERO_WIDTH_SPACE) { + nodeToMove = node; + } + }); + if (nodeToMove) { + delimiter.parentElement?.insertBefore( + nodeToMove, + delimiter.className == DelimiterClasses.DELIMITER_BEFORE + ? delimiter + : delimiter.nextSibling + ); + const selection = nodeToMove.ownerDocument?.getSelection(); + + if (selection) { + selection.setPosition( + nodeToMove, + new Position(nodeToMove, PositionType.End).offset + ); + } + } + } +} + +/** + * @internal + */ +export function normalizeDelimitersInEditor(editor: IEditor) { + removeInvalidDelimiters(editor.queryElements(DELIMITER_SELECTOR)); + addDelimitersIfNeeded(editor.queryElements(INLINE_ENTITY_SELECTOR)); +} + +function addDelimitersIfNeeded(nodes: Element[] | NodeListOf) { + nodes.forEach(node => { + if (isEntityElement(node)) { + addDelimiters(node); + } + }); +} + +function isEntityElement(node: Node | null): node is HTMLElement { + return !!( + node && + safeInstanceOf(node, 'HTMLElement') && + isReadOnly(getEntityFromElement(node)) + ); +} + +function removeNode(el: Node | undefined | null) { + el?.parentElement?.removeChild(el); +} + +function isReadOnly(entity: Entity | null) { + return ( + entity?.isReadonly && + !isBlockElement(entity.wrapper) && + safeInstanceOf(entity.wrapper, 'HTMLElement') + ); +} + +function removeInvalidDelimiters(nodes: Element[] | NodeListOf) { + nodes.forEach(node => { + if (getDelimiterFromElement(node)) { + const sibling = node.classList.contains(DelimiterClasses.DELIMITER_BEFORE) + ? node.nextElementSibling + : node.previousElementSibling; + if (!(safeInstanceOf(sibling, 'HTMLElement') && getEntityFromElement(sibling))) { + removeNode(node); + } + } else { + removeDelimiterAttr(node); + } + }); +} + +function removeDelimiterAttr(node: Element | undefined | null, checkEntity: boolean = true) { + if (!node) { + return; + } + + const isAfter = node.classList.contains(DelimiterClasses.DELIMITER_AFTER); + const entitySibling = isAfter ? node.previousElementSibling : node.nextElementSibling; + if (checkEntity && entitySibling && isEntityElement(entitySibling)) { + return; + } + + node.classList.remove(DelimiterClasses.DELIMITER_AFTER, DelimiterClasses.DELIMITER_BEFORE); + + node.normalize(); + node.childNodes.forEach(cn => { + const index = cn.textContent?.indexOf(ZERO_WIDTH_SPACE) ?? -1; + if (index >= 0) { + createRange(cn, index, cn, index + 1)?.deleteContents(); + } + }); +} + +function handleCollapsedEnter(editor: IEditor, delimiter: HTMLElement) { + const isAfter = delimiter.classList.contains(DelimiterClasses.DELIMITER_AFTER); + const entity = !isAfter ? delimiter.nextSibling : delimiter.previousSibling; + const block = getBlock(editor, delimiter); + + editor.runAsync(() => { + if (!block) { + return; + } + const blockToCheck = isAfter ? block.nextSibling : block.previousSibling; + if (blockToCheck && safeInstanceOf(blockToCheck, 'HTMLElement')) { + const delimiters = blockToCheck.querySelectorAll(DELIMITER_SELECTOR); + // Check if the last or first delimiter still contain the delimiter class and remove it. + const delimiterToCheck = delimiters.item(isAfter ? 0 : delimiters.length - 1); + removeDelimiterAttr(delimiterToCheck); + } + + if (isEntityElement(entity)) { + const { nextElementSibling, previousElementSibling } = entity; + [nextElementSibling, previousElementSibling].forEach(el => { + // Check if after Enter the ZWS got removed but we still have a element with the class + // Remove the attributes of the element if it is invalid now. + if (el && matchesSelector(el, DELIMITER_SELECTOR) && !getDelimiterFromElement(el)) { + removeDelimiterAttr(el, false /* checkEntity */); + } + }); + // Add delimiters to the entity if needed because on Enter we can sometimes lose the ZWS of the element. + addDelimiters(entity); + } + }); +} + +const getPosition = (container: HTMLElement | null) => { + if (container && getDelimiterFromElement(container)) { + const isAfter = container.classList.contains(DelimiterClasses.DELIMITER_AFTER); + return new Position(container, isAfter ? PositionType.After : PositionType.Before); + } + return undefined; +}; + +function getBlock(editor: IEditor, element: Node | undefined) { + if (!element) { + return undefined; + } + + let block = editor.getBlockElementAtNode(element)?.getStartNode(); + + while (block && !isBlockElement(block)) { + block = editor.contains(block.parentElement) ? block.parentElement! : undefined; + } + + return block; +} + +function handleSelectionNotCollapsed(editor: IEditor, range: Range, event: KeyboardEvent) { + const { startContainer, endContainer, startOffset, endOffset } = range; + + const startElement = editor.getElementAtCursor(DELIMITER_SELECTOR, startContainer); + const endElement = editor.getElementAtCursor(DELIMITER_SELECTOR, endContainer); + + const startUpdate = getPosition(startElement); + const endUpdate = getPosition(endElement); + + if (startUpdate || endUpdate) { + editor.select( + startUpdate ?? new Position(startContainer, startOffset), + endUpdate ?? new Position(endContainer, endOffset) + ); + } + editor.runAsync(aEditor => { + const delimiter = aEditor.getElementAtCursor(DELIMITER_SELECTOR); + if (delimiter) { + preventTypeInDelimiter(delimiter); + if (event.which === Keys.ENTER) { + removeDelimiterAttr(delimiter); + } + } + }); +} + +function handleKeyDownEvent(editor: IEditor, event: PluginKeyDownEvent) { + const range = editor.getSelectionRangeEx(); + const { rawEvent } = event; + if (range.type != SelectionRangeTypes.Normal) { + return; + } + + if (range.areAllCollapsed && (isCharacterValue(rawEvent) || rawEvent.which === Keys.ENTER)) { + const position = editor.getFocusedPosition()?.normalize(); + if (!position) { + return; + } + + const { element, node } = position; + const refNode = element == node ? element.childNodes.item(position.offset) : element; + + const delimiter = editor.getElementAtCursor(DELIMITER_SELECTOR, refNode); + if (!delimiter) { + return; + } + + if (rawEvent.which === Keys.ENTER) { + handleCollapsedEnter(editor, delimiter); + } else if (delimiter.firstChild?.nodeType == NodeType.Text) { + editor.runAsync(() => preventTypeInDelimiter(delimiter)); + } + } else if (!range.areAllCollapsed && !rawEvent.shiftKey && rawEvent.which != Keys.SHIFT) { + const currentRange = range.ranges[0]; + if (!currentRange) { + return; + } + handleSelectionNotCollapsed(editor, currentRange, rawEvent); + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/removeCellsOutsideSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/removeCellsOutsideSelection.ts new file mode 100644 index 00000000000..e2c75d2f55b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/removeCellsOutsideSelection.ts @@ -0,0 +1,37 @@ +import { isWholeTableSelected } from 'roosterjs-editor-dom'; +import type { VTable } from 'roosterjs-editor-dom'; +import type { VCell } from 'roosterjs-editor-types'; + +/** + * @internal + * Remove the cells outside of the selection. + * @param vTable VTable to remove selection + */ +export const removeCellsOutsideSelection = (vTable: VTable) => { + if (vTable.selection) { + if (isWholeTableSelected(vTable, vTable.selection)) { + return; + } + + vTable.table.style.removeProperty('width'); + vTable.table.style.removeProperty('height'); + + const { firstCell, lastCell } = vTable.selection; + const resultCells: VCell[][] = []; + + const firstX = firstCell.x; + const firstY = firstCell.y; + const lastX = lastCell.x; + const lastY = lastCell.y; + + if (vTable.cells) { + vTable.cells.forEach((row, y) => { + row = row.filter((_, x) => y >= firstY && y <= lastY && x >= firstX && x <= lastX); + if (row.length > 0) { + resultCells.push(row); + } + }); + vTable.cells = resultCells; + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 7ea629c336e..d7e3a7dd25f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -1,5 +1,72 @@ -import { createContentModelEditorCore } from 'roosterjs-content-model-core'; -import { EditorBase } from 'roosterjs-editor-core'; +import { createEditorCore } from './createEditorCore'; +import { + ChangeSource, + ColorTransformDirection, + ContentPosition, + GetContentMode, + PluginEventType, + PositionType, + QueryScope, + RegionType, +} from 'roosterjs-editor-types'; +import type { + BlockElement, + ClipboardData, + ContentChangedData, + CoreCreator, + DOMEventHandler, + DarkColorHandler, + DefaultFormat, + EditorCore, + EditorOptions, + EditorUndoState, + ExperimentalFeatures, + GenericContentEditFeature, + IContentTraverser, + IPositionContentSearcher, + InsertOption, + NodePosition, + PendableFormatState, + PluginEvent, + PluginEventData, + PluginEventFromType, + Rect, + Region, + SelectionPath, + SelectionRangeEx, + SizeTransformer, + StyleBasedFormatState, + TableSelection, + TrustedHTMLHandler, +} from 'roosterjs-editor-types'; +import type { + CompatibleChangeSource, + CompatibleColorTransformDirection, + CompatibleContentPosition, + CompatibleExperimentalFeatures, + CompatibleGetContentMode, + CompatiblePluginEventType, + CompatibleQueryScope, + CompatibleRegionType, +} from 'roosterjs-editor-types/lib/compatibleTypes'; +import { + ContentTraverser, + Position, + PositionContentSearcher, + cacheGetEventData, + collapseNodes, + contains, + deleteSelectedContent, + findClosestElementAncestor, + getBlockElementAtNode, + getRegionsFromRange, + getSelectionPath, + isNodeEmpty, + isPositionAtBeginningOf, + queryElements, + toArray, + wrap, +} from 'roosterjs-editor-dom'; import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; import type { ContentModelEditorOptions, @@ -21,18 +88,30 @@ import type { * Editor for Content Model. * (This class is still under development, and may still be changed in the future with some breaking changes) */ -export default class ContentModelEditor - extends EditorBase - implements IContentModelEditor { +export class ContentModelEditor implements IContentModelEditor { + private core: ContentModelEditorCore | null = null; + /** * Creates an instance of Editor * @param contentDiv The DIV HTML element which will be the container element of editor * @param options An optional options object to customize the editor */ - constructor(contentDiv: HTMLDivElement, options: ContentModelEditorOptions = {}) { - super(contentDiv, options, createContentModelEditorCore); + constructor( + contentDiv: HTMLDivElement, + createContentModelEditorCore: ( + contentDiv: HTMLDivElement, + options: ContentModelEditorOptions, + baseCreator: CoreCreator + ) => ContentModelEditorCore, + options: ContentModelEditorOptions = {} + ) { + this.core = createContentModelEditorCore(contentDiv, options, createEditorCore); + this.core.plugins.forEach(plugin => plugin.initialize(this)); + this.ensureTypeInContainer( + new Position(this.core.contentDiv, PositionType.Begin).normalize() + ); - if (options.cacheModel) { + if (options.cacheModel && this.isContentModelEditor()) { // Create an initial content model to cache // TODO: Once we have standalone editor and get rid of `ensureTypeInContainer` function, we can set init content // using content model and cache the model directly @@ -40,6 +119,13 @@ export default class ContentModelEditor } } + /** + * Check if current editor can be used as ContentModelEditor + */ + isContentModelEditor(): boolean { + return !!this.core && this.isContentModelEditorCore(this.core); + } + /** * Create Content Model from DOM tree in this editor * @param option The option to customize the behavior of DOM to Content Model conversion @@ -48,7 +134,7 @@ export default class ContentModelEditor option?: DomToModelOption, selectionOverride?: DOMSelection ): ContentModelDocument { - const core = this.getCore(); + const core = this.getContentModelEditorCore(); return core.api.createContentModel(core, option, selectionOverride); } @@ -64,7 +150,7 @@ export default class ContentModelEditor option?: ModelToDomOption, onNodeCreated?: OnNodeCreated ): DOMSelection | null { - const core = this.getCore(); + const core = this.getContentModelEditorCore(); return core.api.setContentModel(core, model, option, onNodeCreated); } @@ -73,14 +159,14 @@ export default class ContentModelEditor * Get current running environment, such as if editor is running on Mac */ getEnvironment(): EditorEnvironment { - return this.getCore().environment; + return this.getContentModelEditorCore().environment; } /** * Get current DOM selection */ getDOMSelection(): DOMSelection | null { - const core = this.getCore(); + const core = this.getContentModelEditorCore(); return core.api.getDOMSelection(core); } @@ -91,7 +177,7 @@ export default class ContentModelEditor * @param selection The selection to set */ setDOMSelection(selection: DOMSelection) { - const core = this.getCore(); + const core = this.getContentModelEditorCore(); core.api.setDOMSelection(core, selection); } @@ -108,7 +194,7 @@ export default class ContentModelEditor formatter: ContentModelFormatter, options?: FormatWithContentModelOptions ): void { - const core = this.getCore(); + const core = this.getContentModelEditorCore(); core.api.formatContentModel(core, formatter, options); } @@ -117,6 +203,937 @@ export default class ContentModelEditor * Get pending format of editor if any, or return null */ getPendingFormat(): ContentModelSegmentFormat | null { - return this.getCore().format.pendingFormat?.format ?? null; + return this.getContentModelEditorCore().format.pendingFormat?.format ?? null; + } + + /** + * Dispose this editor, dispose all plugins and custom data + */ + dispose(): void { + const core = this.getCore(); + + for (let i = core.plugins.length - 1; i >= 0; i--) { + const plugin = core.plugins[i]; + + try { + plugin.dispose(); + } catch (e) { + // Cache the error and pass it out, then keep going since dispose should always succeed + core.disposeErrorHandler?.(plugin, e as Error); + } + } + + core.darkColorHandler.reset(); + + this.core = null; + } + + /** + * Get whether this editor is disposed + * @returns True if editor is disposed, otherwise false + */ + isDisposed(): boolean { + return !this.core; + } + + /** + * Insert node into editor + * @param node The node to insert + * @param option Insert options. Default value is: + * position: ContentPosition.SelectionStart + * updateCursor: true + * replaceSelection: true + * insertOnNewLine: false + * @returns true if node is inserted. Otherwise false + */ + insertNode(node: Node, option?: InsertOption): boolean { + const core = this.getCore(); + return node ? core.api.insertNode(core, node, option ?? null) : false; + } + + /** + * Delete a node from editor content + * @param node The node to delete + * @returns true if node is deleted. Otherwise false + */ + deleteNode(node: Node): boolean { + // Only remove the node when it falls within editor + if (node && this.contains(node) && node.parentNode) { + node.parentNode.removeChild(node); + return true; + } + + return false; + } + + /** + * Replace a node in editor content with another node + * @param existingNode The existing node to be replaced + * @param toNode node to replace to + * @param transformColorForDarkMode (optional) Whether to transform new node to dark mode. Default is false + * @returns true if node is replaced. Otherwise false + */ + replaceNode(existingNode: Node, toNode: Node, transformColorForDarkMode?: boolean): boolean { + const core = this.getCore(); + // Only replace the node when it falls within editor + if (this.contains(existingNode) && toNode) { + core.api.transformColor( + core, + transformColorForDarkMode ? toNode : null, + true /*includeSelf*/, + () => existingNode.parentNode?.replaceChild(toNode, existingNode), + ColorTransformDirection.LightToDark + ); + + return true; + } + + return false; + } + + /** + * Get BlockElement at given node + * @param node The node to create InlineElement + * @returns The BlockElement result + */ + getBlockElementAtNode(node: Node): BlockElement | null { + return getBlockElementAtNode(this.getCore().contentDiv, node); + } + + contains(arg: Node | Range | null): boolean { + if (!arg) { + return false; + } + return contains(this.getCore().contentDiv, arg); + } + + queryElements( + selector: string, + scopeOrCallback: + | QueryScope + | CompatibleQueryScope + | ((node: Node) => any) = QueryScope.Body, + callback?: (node: Node) => any + ) { + const core = this.getCore(); + const result: HTMLElement[] = []; + const scope = scopeOrCallback instanceof Function ? QueryScope.Body : scopeOrCallback; + callback = scopeOrCallback instanceof Function ? scopeOrCallback : callback; + + const selectionEx = scope == QueryScope.Body ? null : this.getSelectionRangeEx(); + if (selectionEx) { + selectionEx.ranges.forEach(range => { + result.push(...queryElements(core.contentDiv, selector, callback, scope, range)); + }); + } else { + return queryElements(core.contentDiv, selector, callback, scope, undefined /* range */); + } + + return result; + } + + /** + * Collapse nodes within the given start and end nodes to their common ancestor node, + * split parent nodes if necessary + * @param start The start node + * @param end The end node + * @param canSplitParent True to allow split parent node there are nodes before start or after end under the same parent + * and the returned nodes will be all nodes from start through end after splitting + * False to disallow split parent + * @returns When canSplitParent is true, returns all node from start through end after splitting, + * otherwise just return start and end + */ + collapseNodes(start: Node, end: Node, canSplitParent: boolean): Node[] { + return collapseNodes(this.getCore().contentDiv, start, end, canSplitParent); + } + + //#endregion + + //#region Content API + + /** + * Check whether the editor contains any visible content + * @param trim Whether trim the content string before check. Default is false + * @returns True if there's no visible content, otherwise false + */ + isEmpty(trim?: boolean): boolean { + return isNodeEmpty(this.getCore().contentDiv, trim); + } + + /** + * Get current editor content as HTML string + * @param mode specify what kind of HTML content to retrieve + * @returns HTML string representing current editor content + */ + getContent(mode: GetContentMode | CompatibleGetContentMode = GetContentMode.CleanHTML): string { + const core = this.getCore(); + return core.api.getContent(core, mode); + } + + /** + * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered + * @param content HTML content to set in + * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true + */ + setContent(content: string, triggerContentChangedEvent: boolean = true) { + const core = this.getCore(); + core.api.setContent(core, content, triggerContentChangedEvent); + } + + /** + * Insert HTML content into editor + * @param HTML content to insert + * @param option Insert options. Default value is: + * position: ContentPosition.SelectionStart + * updateCursor: true + * replaceSelection: true + * insertOnNewLine: false + */ + insertContent(content: string, option?: InsertOption) { + if (content) { + const doc = this.getDocument(); + const body = new DOMParser().parseFromString( + this.getCore().trustedHTMLHandler(content), + 'text/html' + )?.body; + let allNodes = body?.childNodes ? toArray(body.childNodes) : []; + + // If it is to insert on new line, and there are more than one node in the collection, wrap all nodes with + // a parent DIV before calling insertNode on each top level sub node. Otherwise, every sub node may get wrapped + // separately to show up on its own line + if (option && option.insertOnNewLine && allNodes.length > 1) { + allNodes = [wrap(allNodes)]; + } + + const fragment = doc.createDocumentFragment(); + allNodes.forEach(node => fragment.appendChild(node)); + + this.insertNode(fragment, option); + } + } + + /** + * Delete selected content + */ + deleteSelectedContent(): NodePosition | null { + const range = this.getSelectionRange(); + if (range && !range.collapsed) { + return deleteSelectedContent(this.getCore().contentDiv, range); + } + return null; + } + + /** + * Paste into editor using a clipboardData object + * @param clipboardData Clipboard data retrieved from clipboard + * @param pasteAsText Force pasting as plain text. Default value is false + * @param applyCurrentStyle True if apply format of current selection to the pasted content, + * false to keep original format. Default value is false. When pasteAsText is true, this parameter is ignored + * @param pasteAsImage: When set to true, if the clipboardData contains a imageDataUri will paste the image to the editor + */ + paste( + clipboardData: ClipboardData, + pasteAsText: boolean = false, + applyCurrentFormat: boolean = false, + pasteAsImage: boolean = false + ) { + const core = this.getCore(); + if (!clipboardData) { + return; + } + + if (clipboardData.snapshotBeforePaste) { + // Restore original content before paste a new one + this.setContent(clipboardData.snapshotBeforePaste); + } else { + clipboardData.snapshotBeforePaste = this.getContent( + GetContentMode.RawHTMLWithSelection + ); + } + + const range = this.getSelectionRange(); + const pos = range && Position.getStart(range); + const fragment = core.api.createPasteFragment( + core, + clipboardData, + pos, + pasteAsText, + applyCurrentFormat, + pasteAsImage + ); + if (fragment) { + this.addUndoSnapshot(() => { + this.insertNode(fragment); + return clipboardData; + }, ChangeSource.Paste); + } + } + + //#endregion + + //#region Focus and Selection + + /** + * Get current selection range from Editor. + * It does a live pull on the selection, if nothing retrieved, return whatever we have in cache. + * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now. + * Default value is true + * @returns current selection range, or null if editor never got focus before + */ + getSelectionRange(tryGetFromCache: boolean = true): Range | null { + const core = this.getCore(); + return core.api.getSelectionRange(core, tryGetFromCache); + } + + /** + * Get current selection range from Editor. + * It does a live pull on the selection, if nothing retrieved, return whatever we have in cache. + * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now. + * Default value is true + * @returns current selection range, or null if editor never got focus before + */ + getSelectionRangeEx(): SelectionRangeEx { + const core = this.getCore(); + return core.api.getSelectionRangeEx(core); + } + + /** + * Get current selection in a serializable format + * It does a live pull on the selection, if nothing retrieved, return whatever we have in cache. + * @returns current selection path, or null if editor never got focus before + */ + getSelectionPath(): SelectionPath | null { + const range = this.getSelectionRange(); + return range && getSelectionPath(this.getCore().contentDiv, range); + } + + /** + * Check if focus is in editor now + * @returns true if focus is in editor, otherwise false + */ + hasFocus(): boolean { + const core = this.getCore(); + return core.api.hasFocus(core); + } + + /** + * Focus to this editor, the selection was restored to where it was before, no unexpected scroll. + */ + focus() { + const core = this.getCore(); + core.api.focus(core); + } + + select( + arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, + arg2?: NodePosition | number | PositionType | TableSelection | null, + arg3?: Node, + arg4?: number | PositionType + ): boolean { + const core = this.getCore(); + + return core.api.select(core, arg1, arg2, arg3, arg4); + } + + /** + * Get current focused position. Return null if editor doesn't have focus at this time. + */ + getFocusedPosition(): NodePosition | null { + const sel = this.getDocument().defaultView?.getSelection(); + if (sel?.focusNode && this.contains(sel.focusNode)) { + return new Position(sel.focusNode, sel.focusOffset); + } + + const range = this.getSelectionRange(); + if (range) { + return Position.getStart(range); + } + + return null; + } + + /** + * Get an HTML element from current cursor position. + * When expectedTags is not specified, return value is the current node (if it is HTML element) + * or its parent node (if current node is a Text node). + * When expectedTags is specified, return value is the first ancestor of current node which has + * one of the expected tags. + * If no element found within editor by the given tag, return null. + * @param selector Optional, an HTML selector to find HTML element with. + * @param startFrom Start search from this node. If not specified, start from current focused position + * @param event Optional, if specified, editor will try to get cached result from the event object first. + * If it is not cached before, query from DOM and cache the result into the event object + */ + getElementAtCursor( + selector?: string, + startFrom?: Node, + event?: PluginEvent + ): HTMLElement | null { + event = startFrom ? undefined : event; // Only use cache when startFrom is not specified, for different start position can have different result + + return ( + cacheGetEventData(event ?? null, 'GET_ELEMENT_AT_CURSOR_' + selector, () => { + if (!startFrom) { + const position = this.getFocusedPosition(); + startFrom = position?.node; + } + return ( + startFrom && + findClosestElementAncestor(startFrom, this.getCore().contentDiv, selector) + ); + }) ?? null + ); + } + + /** + * Check if this position is at beginning of the editor. + * This will return true if all nodes between the beginning of target node and the position are empty. + * @param position The position to check + * @returns True if position is at beginning of the editor, otherwise false + */ + isPositionAtBeginning(position: NodePosition): boolean { + return isPositionAtBeginningOf(position, this.getCore().contentDiv); + } + + /** + * Get impacted regions from selection + */ + getSelectedRegions(type: RegionType | CompatibleRegionType = RegionType.Table): Region[] { + const selection = this.getSelectionRangeEx(); + const result: Region[] = []; + const contentDiv = this.getCore().contentDiv; + selection.ranges.forEach(range => { + result.push(...(range ? getRegionsFromRange(contentDiv, range, type) : [])); + }); + return result.filter((value, index, self) => { + return self.indexOf(value) === index; + }); + } + + //#endregion + + //#region EVENT API + + addDomEventHandler( + nameOrMap: string | Record, + handler?: DOMEventHandler + ): () => void { + const eventsToMap = typeof nameOrMap == 'string' ? { [nameOrMap]: handler! } : nameOrMap; + const core = this.getCore(); + return core.api.attachDomEvent(core, eventsToMap); + } + + /** + * Trigger an event to be dispatched to all plugins + * @param eventType Type of the event + * @param data data of the event with given type, this is the rest part of PluginEvent with the given type + * @param broadcast indicates if the event needs to be dispatched to all plugins + * True means to all, false means to allow exclusive handling from one plugin unless no one wants that + * @returns the event object which is really passed into plugins. Some plugin may modify the event object so + * the result of this function provides a chance to read the modified result + */ + triggerPluginEvent( + eventType: T, + data: PluginEventData, + broadcast: boolean = false + ): PluginEventFromType { + const core = this.getCore(); + const event = ({ + eventType, + ...data, + } as any) as PluginEventFromType; + core.api.triggerEvent(core, event, broadcast); + + return event; + } + + /** + * Trigger a ContentChangedEvent + * @param source Source of this event, by default is 'SetContent' + * @param data additional data for this event + */ + triggerContentChangedEvent( + source: ChangeSource | CompatibleChangeSource | string = ChangeSource.SetContent, + data?: any + ) { + this.triggerPluginEvent(PluginEventType.ContentChanged, { + source, + data, + }); + } + + //#endregion + + //#region Undo API + + /** + * Undo last edit operation + */ + undo() { + this.focus(); + const core = this.getCore(); + core.api.restoreUndoSnapshot(core, -1 /*step*/); + } + + /** + * Redo next edit operation + */ + redo() { + this.focus(); + const core = this.getCore(); + core.api.restoreUndoSnapshot(core, 1 /*step*/); + } + + /** + * Add undo snapshot, and execute a format callback function, then add another undo snapshot, then trigger + * ContentChangedEvent with given change source. + * If this function is called nested, undo snapshot will only be added in the outside one + * @param callback The callback function to perform formatting, returns a data object which will be used as + * the data field in ContentChangedEvent if changeSource is not null. + * @param changeSource The change source to use when fire ContentChangedEvent. When the value is not null, + * a ContentChangedEvent will be fired with change source equal to this value + * @param canUndoByBackspace True if this action can be undone when user press Backspace key (aka Auto Complete). + */ + addUndoSnapshot( + callback?: (start: NodePosition | null, end: NodePosition | null) => any, + changeSource?: ChangeSource | CompatibleChangeSource | string, + canUndoByBackspace?: boolean, + additionalData?: ContentChangedData + ) { + const core = this.getCore(); + core.api.addUndoSnapshot( + core, + callback ?? null, + changeSource ?? null, + canUndoByBackspace ?? false, + additionalData + ); + } + + /** + * Whether there is an available undo/redo snapshot + */ + getUndoState(): EditorUndoState { + const { hasNewContent, snapshotsService } = this.getCore().undo; + return { + canUndo: hasNewContent || snapshotsService.canMove(-1 /*previousSnapshot*/), + canRedo: snapshotsService.canMove(1 /*nextSnapshot*/), + }; + } + + //#endregion + + //#region Misc + + /** + * Get document which contains this editor + * @returns The HTML document which contains this editor + */ + getDocument(): Document { + return this.getCore().contentDiv.ownerDocument; + } + + /** + * Get the scroll container of the editor + */ + getScrollContainer(): HTMLElement { + return this.getCore().domEvent.scrollContainer; + } + + /** + * Get custom data related to this editor + * @param key Key of the custom data + * @param getter Getter function. If custom data for the given key doesn't exist, + * call this function to get one and store it if it is specified. Otherwise return undefined + * @param disposer An optional disposer function to dispose this custom data when + * dispose editor. + */ + getCustomData(key: string, getter?: () => T, disposer?: (value: T) => void): T { + const core = this.getCore(); + return (core.lifecycle.customData[key] = core.lifecycle.customData[key] || { + value: getter ? getter() : undefined, + disposer, + }).value as T; + } + + /** + * Check if editor is in IME input sequence + * @returns True if editor is in IME input sequence, otherwise false + */ + isInIME(): boolean { + return this.getCore().domEvent.isInIME; + } + + /** + * Get default format of this editor + * @returns Default format object of this editor + */ + getDefaultFormat(): DefaultFormat { + return this.getCore().lifecycle.defaultFormat ?? {}; + } + + /** + * Get a content traverser for the whole editor + * @param startNode The node to start from. If not passed, it will start from the beginning of the body + */ + getBodyTraverser(startNode?: Node): IContentTraverser { + return ContentTraverser.createBodyTraverser(this.getCore().contentDiv, startNode); + } + + /** + * Get a content traverser for current selection + * @returns A content traverser, or null if editor never got focus before + */ + getSelectionTraverser(range?: Range): IContentTraverser | null { + range = range ?? this.getSelectionRange() ?? undefined; + return range + ? ContentTraverser.createSelectionTraverser(this.getCore().contentDiv, range) + : null; + } + + /** + * Get a content traverser for current block element start from specified position + * @param startFrom Start position of the traverser. Default value is ContentPosition.SelectionStart + * @returns A content traverser, or null if editor never got focus before + */ + getBlockTraverser( + startFrom: ContentPosition | CompatibleContentPosition = ContentPosition.SelectionStart + ): IContentTraverser | null { + const range = this.getSelectionRange(); + return range + ? ContentTraverser.createBlockTraverser(this.getCore().contentDiv, range, startFrom) + : null; + } + + /** + * Get a text traverser of current selection + * @param event Optional, if specified, editor will try to get cached result from the event object first. + * If it is not cached before, query from DOM and cache the result into the event object + * @returns A content traverser, or null if editor never got focus before + */ + getContentSearcherOfCursor(event?: PluginEvent): IPositionContentSearcher | null { + return cacheGetEventData(event ?? null, 'ContentSearcher', () => { + const range = this.getSelectionRange(); + return ( + range && + new PositionContentSearcher(this.getCore().contentDiv, Position.getStart(range)) + ); + }); + } + + /** + * Run a callback function asynchronously + * @param callback The callback function to run + * @returns a function to cancel this async run + */ + runAsync(callback: (editor: IContentModelEditor) => void) { + const win = this.getCore().contentDiv.ownerDocument.defaultView || window; + const handle = win.requestAnimationFrame(() => { + if (!this.isDisposed() && callback) { + callback(this); + } + }); + + return () => { + win.cancelAnimationFrame(handle); + }; + } + + /** + * Set DOM attribute of editor content DIV + * @param name Name of the attribute + * @param value Value of the attribute + */ + setEditorDomAttribute(name: string, value: string | null) { + if (value === null) { + this.getCore().contentDiv.removeAttribute(name); + } else { + this.getCore().contentDiv.setAttribute(name, value); + } + } + + /** + * Get DOM attribute of editor content DIV, null if there is no such attribute. + * @param name Name of the attribute + */ + getEditorDomAttribute(name: string): string | null { + return this.getCore().contentDiv.getAttribute(name); + } + + /** + * @deprecated Use getVisibleViewport() instead. + * + * Get current relative distance from top-left corner of the given element to top-left corner of editor content DIV. + * @param element The element to calculate from. If the given element is not in editor, return value will be null + * @param addScroll When pass true, The return value will also add scrollLeft and scrollTop if any. So the value + * may be different than what user is seeing from the view. When pass false, scroll position will be ignored. + * @returns An [x, y] array which contains the left and top distances, or null if the given element is not in editor. + */ + getRelativeDistanceToEditor(element: HTMLElement, addScroll?: boolean): number[] | null { + if (this.contains(element)) { + const contentDiv = this.getCore().contentDiv; + const editorRect = contentDiv.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + if (editorRect && elementRect) { + let x = elementRect.left - editorRect?.left; + let y = elementRect.top - editorRect?.top; + + if (addScroll) { + x += contentDiv.scrollLeft; + y += contentDiv.scrollTop; + } + + return [x, y]; + } + } + + return null; + } + + /** + * Add a Content Edit feature. + * @param feature The feature to add + */ + addContentEditFeature(feature: GenericContentEditFeature) { + const core = this.getCore(); + feature?.keys.forEach(key => { + const array = core.edit.features[key] || []; + array.push(feature); + core.edit.features[key] = array; + }); + } + + /** + * Remove a Content Edit feature. + * @param feature The feature to remove + */ + removeContentEditFeature(feature: GenericContentEditFeature) { + const core = this.getCore(); + feature?.keys.forEach(key => { + const featureSet = core.edit.features[key]; + const index = featureSet?.indexOf(feature) ?? -1; + if (index >= 0) { + core.edit.features[key].splice(index, 1); + if (core.edit.features[key].length < 1) { + delete core.edit.features[key]; + } + } + }); + } + + /** + * Get style based format state from current selection, including font name/size and colors + */ + getStyleBasedFormatState(node?: Node): StyleBasedFormatState { + if (!node) { + const range = this.getSelectionRange(); + node = (range && Position.getStart(range).normalize().node) ?? undefined; + } + const core = this.getCore(); + return core.api.getStyleBasedFormatState(core, node ?? null); + } + + /** + * Get the pendable format such as underline and bold + * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. + * @returns The pending format state + */ + getPendableFormatState(forceGetStateFromDOM: boolean = false): PendableFormatState { + const core = this.getCore(); + return core.api.getPendableFormatState(core, forceGetStateFromDOM); + } + + /** + * Ensure user will type into a container element rather than into the editor content DIV directly + * @param position The position that user is about to type to + * @param keyboardEvent Optional keyboard event object + */ + ensureTypeInContainer(position: NodePosition, keyboardEvent?: KeyboardEvent) { + const core = this.getCore(); + core.api.ensureTypeInContainer(core, position, keyboardEvent); + } + + //#endregion + + //#region Dark mode APIs + + /** + * Set the dark mode state and transforms the content to match the new state. + * @param nextDarkMode The next status of dark mode. True if the editor should be in dark mode, false if not. + */ + setDarkModeState(nextDarkMode?: boolean) { + const isDarkMode = this.isDarkMode(); + + if (isDarkMode == !!nextDarkMode) { + return; + } + const core = this.getCore(); + + core.api.transformColor( + core, + core.contentDiv, + false /*includeSelf*/, + null /*callback*/, + nextDarkMode + ? ColorTransformDirection.LightToDark + : ColorTransformDirection.DarkToLight, + true /*forceTransform*/, + isDarkMode + ); + + this.triggerContentChangedEvent( + nextDarkMode ? ChangeSource.SwitchToDarkMode : ChangeSource.SwitchToLightMode + ); + } + + /** + * Check if the editor is in dark mode + * @returns True if the editor is in dark mode, otherwise false + */ + isDarkMode(): boolean { + return this.getCore().lifecycle.isDarkMode; + } + + /** + * Transform the given node and all its child nodes to dark mode color if editor is in dark mode + * @param node The node to transform + * @param direction The transform direction. @default ColorTransformDirection.LightToDark + */ + transformToDarkColor( + node: Node, + direction: + | ColorTransformDirection + | CompatibleColorTransformDirection = ColorTransformDirection.LightToDark + ) { + const core = this.getCore(); + core.api.transformColor(core, node, true /*includeSelf*/, null /*callback*/, direction); + } + + /** + * Get a darkColorHandler object for this editor. + */ + getDarkColorHandler(): DarkColorHandler { + return this.getCore().darkColorHandler; + } + + /** + * Make the editor in "Shadow Edit" mode. + * In Shadow Edit mode, all format change will finally be ignored. + * This can be used for building a live preview feature for format button, to allow user + * see format result without really apply it. + * This function can be called repeated. If editor is already in shadow edit mode, we can still + * use this function to do more shadow edit operation. + */ + startShadowEdit() { + const core = this.getCore(); + core.api.switchShadowEdit(core, true /*isOn*/); + } + + /** + * Leave "Shadow Edit" mode, all changes made during shadow edit will be discarded + */ + stopShadowEdit() { + const core = this.getCore(); + core.api.switchShadowEdit(core, false /*isOn*/); + } + + /** + * Check if editor is in Shadow Edit mode + */ + isInShadowEdit() { + return !!this.getCore().lifecycle.shadowEditFragment; + } + + /** + * Check if the given experimental feature is enabled + * @param feature The feature to check + */ + isFeatureEnabled(feature: ExperimentalFeatures | CompatibleExperimentalFeatures): boolean { + return this.getCore().lifecycle.experimentalFeatures.indexOf(feature) >= 0; + } + + /** + * Get a function to convert HTML string to trusted HTML string. + * By default it will just return the input HTML directly. To override this behavior, + * pass your own trusted HTML handler to EditorOptions.trustedHTMLHandler + * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types + */ + getTrustedHTMLHandler(): TrustedHTMLHandler { + return this.getCore().trustedHTMLHandler; + } + + /** + * @deprecated Use getZoomScale() instead + */ + getSizeTransformer(): SizeTransformer { + return this.getCore().sizeTransformer; + } + + /** + * Get current zoom scale, default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale + * to let editor behave correctly especially for those mouse drag/drop behaviors + * @returns current zoom scale number + */ + getZoomScale(): number { + return this.getCore().zoomScale; + } + + /** + * Set current zoom scale, default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using EditorOptions.zoomScale + * to let editor behave correctly especially for those mouse drag/drop behaviors + * @param scale The new scale number to set. It should be positive number and no greater than 10, otherwise it will be ignored. + */ + setZoomScale(scale: number): void { + const core = this.getCore(); + if (scale > 0 && scale <= 10) { + const oldValue = core.zoomScale; + core.zoomScale = scale; + + if (oldValue != scale) { + this.triggerPluginEvent( + PluginEventType.ZoomChanged, + { + oldZoomScale: oldValue, + newZoomScale: scale, + }, + true /*broadcast*/ + ); + } + } + } + + /** + * Retrieves the rect of the visible viewport of the editor. + */ + getVisibleViewport(): Rect | null { + return this.getCore().getVisibleViewport(); + } + + /** + * @returns the current EditorCore object + * @throws a standard Error if there's no core object + */ + private getCore(): EditorCore { + if (!this.core) { + throw new Error('Editor is already disposed'); + } + return this.core; + } + + private getContentModelEditorCore(): ContentModelEditorCore { + const core = this.getCore(); + + if (!this.isContentModelEditorCore(core)) { + throw new Error('Current editor is not promoted to Content Model editor'); + } + + return core; + } + + private isContentModelEditorCore(core: EditorCore): core is ContentModelEditorCore { + return !!(core as ContentModelEditorCore).api.formatContentModel; } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts new file mode 100644 index 00000000000..0f9687fcc6e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts @@ -0,0 +1,173 @@ +import { getObjectKeys, parseColor, setColor } from 'roosterjs-editor-dom'; +import type { + ColorKeyAndValue, + DarkColorHandler, + ModeIndependentColor, +} from 'roosterjs-editor-types'; + +const VARIABLE_REGEX = /^\s*var\(\s*(\-\-[a-zA-Z0-9\-_]+)\s*(?:,\s*(.*))?\)\s*$/; +const VARIABLE_PREFIX = 'var('; +const COLOR_VAR_PREFIX = 'darkColor'; +const enum ColorAttributeEnum { + CssColor = 0, + HtmlColor = 1, +} +const ColorAttributeName: { [key in ColorAttributeEnum]: string }[] = [ + { + [ColorAttributeEnum.CssColor]: 'color', + [ColorAttributeEnum.HtmlColor]: 'color', + }, + { + [ColorAttributeEnum.CssColor]: 'background-color', + [ColorAttributeEnum.HtmlColor]: 'bgcolor', + }, +]; + +/** + * @internal + */ +export class DarkColorHandlerImpl implements DarkColorHandler { + private knownColors: Record> = {}; + + constructor(private contentDiv: HTMLElement, private getDarkColor: (color: string) => string) {} + + /** + * Get a copy of known colors + * @returns + */ + getKnownColorsCopy() { + return Object.values(this.knownColors); + } + + /** + * Given a light mode color value and an optional dark mode color value, register this color + * so that editor can handle it, then return the CSS color value for current color mode. + * @param lightModeColor Light mode color value + * @param isDarkMode Whether current color mode is dark mode + * @param darkModeColor Optional dark mode color value. If not passed, we will calculate one. + */ + registerColor(lightModeColor: string, isDarkMode: boolean, darkModeColor?: string): string { + const parsedColor = this.parseColorValue(lightModeColor); + let colorKey: string | undefined; + + if (parsedColor) { + lightModeColor = parsedColor.lightModeColor; + darkModeColor = parsedColor.darkModeColor || darkModeColor; + colorKey = parsedColor.key; + } + + if (isDarkMode && lightModeColor) { + colorKey = + colorKey || `--${COLOR_VAR_PREFIX}_${lightModeColor.replace(/[^\d\w]/g, '_')}`; + + if (!this.knownColors[colorKey]) { + darkModeColor = darkModeColor || this.getDarkColor(lightModeColor); + + this.knownColors[colorKey] = { lightModeColor, darkModeColor }; + this.contentDiv.style.setProperty(colorKey, darkModeColor); + } + + return `var(${colorKey}, ${lightModeColor})`; + } else { + return lightModeColor; + } + } + + /** + * Reset known color record, clean up registered color variables. + */ + reset(): void { + getObjectKeys(this.knownColors).forEach(key => this.contentDiv.style.removeProperty(key)); + this.knownColors = {}; + } + + /** + * Parse an existing color value, if it is in variable-based color format, extract color key, + * light color and query related dark color if any + * @param color The color string to parse + * @param isInDarkMode Whether current content is in dark mode. When set to true, if the color value is not in dark var format, + * we will treat is as a dark mode color and try to find a matched dark mode color. + */ + parseColorValue(color: string | undefined | null, isInDarkMode?: boolean): ColorKeyAndValue { + let key: string | undefined; + let lightModeColor = ''; + let darkModeColor: string | undefined; + + if (color) { + const match = color.startsWith(VARIABLE_PREFIX) ? VARIABLE_REGEX.exec(color) : null; + + if (match) { + if (match[2]) { + key = match[1]; + lightModeColor = match[2]; + darkModeColor = this.knownColors[key]?.darkModeColor; + } else { + lightModeColor = ''; + } + } else if (isInDarkMode) { + // If editor is in dark mode but the color is not in dark color format, it is possible the color was inserted from external code + // without any light color info. So we first try to see if there is a known dark color can match this color, and use its related + // light color as light mode color. Otherwise we need to drop this color to avoid show "white on white" content. + lightModeColor = this.findLightColorFromDarkColor(color) || ''; + + if (lightModeColor) { + darkModeColor = color; + } + } else { + lightModeColor = color; + } + } + + return { key, lightModeColor, darkModeColor }; + } + + /** + * Find related light mode color from dark mode color. + * @param darkColor The existing dark color + */ + findLightColorFromDarkColor(darkColor: string): string | null { + const rgbSearch = parseColor(darkColor); + + if (rgbSearch) { + const key = getObjectKeys(this.knownColors).find(key => { + const rgbCurrent = parseColor(this.knownColors[key].darkModeColor); + + return ( + rgbCurrent && + rgbCurrent[0] == rgbSearch[0] && + rgbCurrent[1] == rgbSearch[1] && + rgbCurrent[2] == rgbSearch[2] + ); + }); + + if (key) { + return this.knownColors[key].lightModeColor; + } + } + + return null; + } + + /** + * Transform element color, from dark to light or from light to dark + * @param element The element to transform color + * @param fromDarkMode Whether this is transforming color from dark mode + * @param toDarkMode Whether this is transforming color to dark mode + */ + transformElementColor(element: HTMLElement, fromDarkMode: boolean, toDarkMode: boolean): void { + ColorAttributeName.forEach((names, i) => { + const color = this.parseColorValue( + element.style.getPropertyValue(names[ColorAttributeEnum.CssColor]) || + element.getAttribute(names[ColorAttributeEnum.HtmlColor]), + !!fromDarkMode + ).lightModeColor; + + element.style.setProperty(names[ColorAttributeEnum.CssColor], null); + element.removeAttribute(names[ColorAttributeEnum.HtmlColor]); + + if (color && color != 'inherit') { + setColor(element, color, i != 0, toDarkMode, false /*shouldAdaptFontColor*/, this); + } + }); + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts new file mode 100644 index 00000000000..ed50f72e549 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts @@ -0,0 +1,59 @@ +import { arrayPush, getIntersectedRect, getObjectKeys } from 'roosterjs-editor-dom'; +import { coreApiMap } from '../coreApi/coreApiMap'; +import { createCorePlugins, getPluginState } from '../corePlugins/createCorePlugins'; +import { DarkColorHandlerImpl } from './DarkColorHandlerImpl'; +import type { CoreCreator, EditorCore, EditorOptions, EditorPlugin } from 'roosterjs-editor-types'; + +/** + * Create a new instance of Editor Core + * @param contentDiv The DIV HTML element which will be the container element of editor + * @param options An optional options object to customize the editor + */ +export const createEditorCore: CoreCreator = (contentDiv, options) => { + const corePlugins = createCorePlugins(contentDiv, options); + const plugins: EditorPlugin[] = []; + + getObjectKeys(corePlugins).forEach(name => { + if (name == '_placeholder') { + if (options.plugins) { + arrayPush(plugins, options.plugins); + } + } else { + plugins.push(corePlugins[name]); + } + }); + + const pluginState = getPluginState(corePlugins); + const zoomScale: number = (options.zoomScale ?? -1) > 0 ? options.zoomScale! : 1; + const getVisibleViewport = + options.getVisibleViewport || + (() => { + const scrollContainer = pluginState.domEvent.scrollContainer; + + return getIntersectedRect( + scrollContainer == core.contentDiv + ? [scrollContainer] + : [scrollContainer, core.contentDiv] + ); + }); + + const core: EditorCore = { + contentDiv, + api: { + ...coreApiMap, + ...(options.coreApiOverride || {}), + }, + originalApi: { ...coreApiMap }, + plugins: plugins.filter(x => !!x), + ...pluginState, + trustedHTMLHandler: options.trustedHTMLHandler || ((html: string) => html), + zoomScale: zoomScale, + sizeTransformer: options.sizeTransformer || ((size: number) => size / zoomScale), + getVisibleViewport, + imageSelectionBorderColor: options.imageSelectionBorderColor, + darkColorHandler: new DarkColorHandlerImpl(contentDiv, pluginState.lifecycle.getDarkColor), + disposeErrorHandler: options.disposeErrorHandler, + }; + + return core; +}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts index 87c731c6674..d1ad620f218 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts @@ -6,8 +6,8 @@ import type { IEditor } from 'roosterjs-editor-types'; * @param editor The editor to check * @returns True if the given editor is Content Model editor, otherwise false */ -export default function isContentModelEditor(editor: IEditor): editor is IContentModelEditor { +export function isContentModelEditor(editor: IEditor): editor is IContentModelEditor { const contentModelEditor = editor as IContentModelEditor; - return !!contentModelEditor.createContentModel; + return !!contentModelEditor.isContentModelEditor?.(); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index d4b10a5c5de..5580319c5a0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -4,5 +4,6 @@ export { } from './publicTypes/ContentModelEditorCore'; export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; -export { default as ContentModelEditor } from './editor/ContentModelEditor'; -export { default as isContentModelEditor } from './editor/isContentModelEditor'; +export { ContentModelEditor } from './editor/ContentModelEditor'; +export { isContentModelEditor } from './editor/isContentModelEditor'; +export { createEditorCore } from './editor/createEditorCore'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index 0eabedea19b..e27b0aa9627 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -5,7 +5,12 @@ import type { StandaloneEditorOptions, IStandaloneEditor } from 'roosterjs-conte * An interface of editor with Content Model support. * (This interface is still under development, and may still be changed in the future with some breaking changes) */ -export interface IContentModelEditor extends IEditor, IStandaloneEditor {} +export interface IContentModelEditor extends IEditor, IStandaloneEditor { + /** + * Check if current editor can be used as ContentModelEditor + */ + isContentModelEditor(): boolean; +} /** * Options for Content Model editor diff --git a/packages-content-model/roosterjs-content-model-editor/package.json b/packages-content-model/roosterjs-content-model-editor/package.json index 6debbfeb1cb..91e09718abb 100644 --- a/packages-content-model/roosterjs-content-model-editor/package.json +++ b/packages-content-model/roosterjs-content-model-editor/package.json @@ -5,7 +5,6 @@ "tslib": "^2.3.1", "roosterjs-editor-types": "", "roosterjs-editor-dom": "", - "roosterjs-editor-core": "", "roosterjs-content-model-core": "", "roosterjs-content-model-dom": "", "roosterjs-content-model-types": "" diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts index 47a14d34e74..1c542f0d197 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -2,9 +2,10 @@ import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/c import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; -import ContentModelEditor from '../../lib/editor/ContentModelEditor'; import { ContentModelDocument, EditorContext } from 'roosterjs-content-model-types'; +import { ContentModelEditor } from '../../lib/editor/ContentModelEditor'; import { ContentModelEditorCore } from '../../lib/publicTypes/ContentModelEditorCore'; +import { createContentModelEditorCore } from 'roosterjs-content-model-core'; import { EditorPlugin, PluginEventType } from 'roosterjs-editor-types'; const editorContext: EditorContext = { @@ -25,7 +26,7 @@ describe('ContentModelEditor', () => { spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue(mockedConfig); const div = document.createElement('div'); - const editor = new ContentModelEditor(div); + const editor = new ContentModelEditor(div, createContentModelEditorCore); spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); @@ -56,7 +57,7 @@ describe('ContentModelEditor', () => { spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue(mockedConfig); const div = document.createElement('div'); - const editor = new ContentModelEditor(div); + const editor = new ContentModelEditor(div, createContentModelEditorCore); spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); @@ -91,7 +92,7 @@ describe('ContentModelEditor', () => { spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue(mockedConfig); const div = document.createElement('div'); - const editor = new ContentModelEditor(div); + const editor = new ContentModelEditor(div, createContentModelEditorCore); spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); @@ -128,7 +129,7 @@ describe('ContentModelEditor', () => { spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue(mockedConfig); const div = document.createElement('div'); - const editor = new ContentModelEditor(div); + const editor = new ContentModelEditor(div, createContentModelEditorCore); spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); @@ -168,7 +169,7 @@ describe('ContentModelEditor', () => { } }, }; - const editor = new ContentModelEditor(div, { + const editor = new ContentModelEditor(div, createContentModelEditorCore, { plugins: [plugin], }); editor.dispose(); @@ -190,7 +191,7 @@ describe('ContentModelEditor', () => { it('get model with cache', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div); + const editor = new ContentModelEditor(div, createContentModelEditorCore); const cachedModel = 'MODEL' as any; (editor as any).core.cache.cachedModel = cachedModel; @@ -205,7 +206,7 @@ describe('ContentModelEditor', () => { it('formatContentModel', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div); + const editor = new ContentModelEditor(div, createContentModelEditorCore); const core = (editor as any).core; const formatContentModelSpy = spyOn(core.api, 'formatContentModel'); const callback = jasmine.createSpy('callback'); @@ -218,7 +219,7 @@ describe('ContentModelEditor', () => { it('default format', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div, { + const editor = new ContentModelEditor(div, createContentModelEditorCore, { defaultFormat: { bold: true, italic: true, @@ -251,7 +252,7 @@ describe('ContentModelEditor', () => { it('getPendingFormat', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div); + const editor = new ContentModelEditor(div, createContentModelEditorCore); const core: ContentModelEditorCore = (editor as any).core; const mockedFormat = 'FORMAT' as any; @@ -268,7 +269,7 @@ describe('ContentModelEditor', () => { const div = document.createElement('div'); div.style.fontFamily = 'Arial'; - const editor = new ContentModelEditor(div); + const editor = new ContentModelEditor(div, createContentModelEditorCore); expect(div.style.fontFamily).toBe('Arial'); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts index 505f3ca385a..644565eeb54 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts @@ -1,21 +1,49 @@ -import ContentModelEditor from '../../lib/editor/ContentModelEditor'; -import isContentModelEditor from '../../lib/editor/isContentModelEditor'; -import { Editor } from 'roosterjs-editor-core'; -import { IEditor } from 'roosterjs-editor-types'; +import * as createContentModelEditorCore from 'roosterjs-content-model-core/lib/editor/createContentModelEditorCore'; +import { ContentModelEditor } from '../../lib/editor/ContentModelEditor'; +import { ContentModelEditorCore } from '../../lib/publicTypes/ContentModelEditorCore'; +import { ContentModelEditorOptions } from '../../lib/publicTypes/IContentModelEditor'; +import { createEditorCore } from '../../lib/editor/createEditorCore'; +import { isContentModelEditor } from '../../lib/editor/isContentModelEditor'; describe('isContentModelEditor', () => { it('Legacy editor', () => { const div = document.createElement('div'); - const editor: IEditor = new Editor(div); - + const option: ContentModelEditorOptions = { + initialContent: 'test', + }; + const mockedCreateContentModelEditorCore = jasmine + .createSpy('createContentModelEditorCore') + .and.callFake( + (contentDiv, options, baseCreator) => + baseCreator(contentDiv, options) as ContentModelEditorCore + ); + const editor = new ContentModelEditor(div, mockedCreateContentModelEditorCore, option); const result = isContentModelEditor(editor); + expect(mockedCreateContentModelEditorCore).toHaveBeenCalledWith( + div, + option, + createEditorCore + ); expect(result).toBeFalse(); }); it('Content Model editor', () => { const div = document.createElement('div'); - const editor: IEditor = new ContentModelEditor(div); + const option: ContentModelEditorOptions = { + initialContent: 'test', + }; + const createContentModelEditorCoreSpy = spyOn( + createContentModelEditorCore, + 'createContentModelEditorCore' + ).and.callThrough(); + const editor = new ContentModelEditor( + div, + createContentModelEditorCore.createContentModelEditorCore, + option + ); + + expect(createContentModelEditorCoreSpy).toHaveBeenCalledWith(div, option, createEditorCore); const result = isContentModelEditor(editor); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts index 025c0d7882e..da9683644df 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts @@ -1,4 +1,4 @@ -import { cloneModel } from 'roosterjs-content-model-core'; +import { cloneModel, createContentModelEditorCore } from 'roosterjs-content-model-core'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { ContentModelPastePlugin } from '../../../lib/paste/ContentModelPastePlugin'; import { @@ -24,7 +24,11 @@ export function initEditor(id: string): IContentModelEditor { }, }; - let editor = new ContentModelEditor(node as HTMLDivElement, options); + let editor = new ContentModelEditor( + node as HTMLDivElement, + createContentModelEditorCore, + options + ); return editor; } diff --git a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts index 821d628f9e6..bcddc67c081 100644 --- a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts @@ -1,5 +1,6 @@ import { ContentModelEditor } from 'roosterjs-content-model-editor'; import { ContentModelEditPlugin, ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; +import { createContentModelEditorCore } from 'roosterjs-content-model-core'; import { getDarkColor } from 'roosterjs-color-utils'; import type { EditorPlugin } from 'roosterjs-editor-types'; import type { @@ -33,5 +34,5 @@ export function createContentModelEditor( textColor: '#000000', }, }; - return new ContentModelEditor(contentDiv, options); + return new ContentModelEditor(contentDiv, createContentModelEditorCore, options); } From 6cec1ccedfe129d021bf276b9462d7859ffff06f Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 13 Nov 2023 15:11:18 -0800 Subject: [PATCH 047/111] Remove public enum from Content Model (#2204) --- .../formatPart/TableMetadataFormatRenders.ts | 27 ++-- .../contentModel/formatTableButton.ts | 27 ++-- .../setBulletedListStyleButton.ts | 4 +- .../setNumberedListStyleButton.ts | 4 +- .../lib/constants/BulletListType.ts | 49 ++++++ .../lib/constants/NumberingListType.ts | 93 +++++++++++ .../lib/constants/TableBorderFormat.ts | 91 +++++++++++ .../roosterjs-content-model-core/lib/index.ts | 4 + .../lib/metadata/updateListMetadata.ts | 7 +- .../lib/metadata/updateTableMetadata.ts | 6 +- .../lib/publicApi/table/applyTableFormat.ts | 35 ++-- .../test/metadata/updateListMetadataTest.ts | 12 +- .../test/metadata/updateTableMetadataTest.ts | 17 +- .../publicApi/table/applyTableFormatTest.ts | 16 +- .../processors/listProcessorTest.ts | 9 +- .../modelToDom/handlers/handleListTest.ts | 9 +- .../lib/format/metadata/ListMetadataFormat.ts | 152 +----------------- .../format/metadata/TableMetadataFormat.ts | 86 +--------- .../lib/index.ts | 8 +- 19 files changed, 331 insertions(+), 325 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/constants/BulletListType.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/constants/NumberingListType.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/constants/TableBorderFormat.ts diff --git a/demo/scripts/controls/contentModel/components/format/formatPart/TableMetadataFormatRenders.ts b/demo/scripts/controls/contentModel/components/format/formatPart/TableMetadataFormatRenders.ts index b07c50f53d4..01ebf662005 100644 --- a/demo/scripts/controls/contentModel/components/format/formatPart/TableMetadataFormatRenders.ts +++ b/demo/scripts/controls/contentModel/components/format/formatPart/TableMetadataFormatRenders.ts @@ -2,7 +2,9 @@ import { createCheckboxFormatRenderer } from '../utils/createCheckboxFormatRende import { createColorFormatRenderer } from '../utils/createColorFormatRender'; import { createDropDownFormatRenderer } from '../utils/createDropDownFormatRenderer'; import { FormatRenderer } from '../utils/FormatRenderer'; -import { TableBorderFormat, TableMetadataFormat } from 'roosterjs-content-model-types'; +import { getObjectKeys } from 'roosterjs-content-model-dom'; +import { TableBorderFormat } from 'roosterjs-content-model-core'; +import { TableMetadataFormat } from 'roosterjs-content-model-types'; export const TableMetadataFormatRenders: FormatRenderer[] = [ createColorFormatRenderer( @@ -60,17 +62,20 @@ export const TableMetadataFormatRenders: FormatRenderer[] = createDropDownFormatRenderer( 'TableBorderFormat', [ - 'DEFAULT', - 'LIST_WITH_SIDE_BORDERS', - 'NO_HEADER_BORDERS', - 'NO_SIDE_BORDERS', - 'FIRST_COLUMN_HEADER_EXTERNAL', - 'ESPECIAL_TYPE_1', - 'ESPECIAL_TYPE_2', - 'ESPECIAL_TYPE_3', - 'CLEAR', + 'Default', + 'ListWithSideBorders', + 'NoHeaderBorders', + 'NoSideBorders', + 'FirstColumnHeaderExternal', + 'EspecialType1', + 'EspecialType2', + 'EspecialType3', + 'Clear', ], - format => TableBorderFormat[format.tableBorderFormat] as keyof typeof TableBorderFormat, + format => + getObjectKeys(TableBorderFormat)[ + Object.values(TableBorderFormat).indexOf(format.tableBorderFormat) + ], (format, newValue) => (format.tableBorderFormat = TableBorderFormat[newValue]) ), ]; diff --git a/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts index e0b0a2236fb..9c81a9b9662 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/formatTableButton.ts @@ -1,7 +1,8 @@ import { formatTable } from 'roosterjs-content-model-api'; import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; -import { TableBorderFormat, TableMetadataFormat } from 'roosterjs-content-model-types'; +import { TableBorderFormat } from 'roosterjs-content-model-core'; +import { TableMetadataFormat } from 'roosterjs-content-model-types'; const PREDEFINED_STYLES: Record< string, @@ -16,7 +17,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.DEFAULT /** tableBorderFormat */, + TableBorderFormat.Default /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -30,7 +31,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.DEFAULT /** tableBorderFormat */, + TableBorderFormat.Default /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -44,7 +45,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.NO_SIDE_BORDERS /** tableBorderFormat */, + TableBorderFormat.NoSideBorders /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -58,7 +59,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.DEFAULT /** tableBorderFormat */, + TableBorderFormat.Default /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -72,7 +73,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.FIRST_COLUMN_HEADER_EXTERNAL /** tableBorderFormat */, + TableBorderFormat.FirstColumnHeaderExternal /** tableBorderFormat */, '#B0B0B0' /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -86,7 +87,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.LIST_WITH_SIDE_BORDERS /** tableBorderFormat */, + TableBorderFormat.ListWithSideBorders /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -100,7 +101,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.NO_HEADER_BORDERS /** tableBorderFormat */, + TableBorderFormat.NoHeaderBorders /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -114,7 +115,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.ESPECIAL_TYPE_1 /** tableBorderFormat */, + TableBorderFormat.EspecialType1 /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -128,7 +129,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.ESPECIAL_TYPE_2 /** tableBorderFormat */, + TableBorderFormat.EspecialType2 /** tableBorderFormat */, null /** bgColorEven */, lightColor /** bgColorOdd */, color /** headerRowColor */ @@ -142,7 +143,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.ESPECIAL_TYPE_3 /** tableBorderFormat */, + TableBorderFormat.EspecialType3 /** tableBorderFormat */, lightColor /** bgColorEven */, null /** bgColorOdd */, color /** headerRowColor */ @@ -156,7 +157,7 @@ const PREDEFINED_STYLES: Record< false /** bandedColumns */, false /** headerRow */, false /** firstColumn */, - TableBorderFormat.CLEAR /** tableBorderFormat */, + TableBorderFormat.Clear /** tableBorderFormat */, lightColor /** bgColorEven */, null /** bgColorOdd */, color /** headerRowColor */ @@ -171,7 +172,7 @@ export function createTableFormat( bandedColumns?: boolean, headerRow?: boolean, firstColumn?: boolean, - borderFormat?: TableBorderFormat, + borderFormat?: number, bgColorEven?: string, bgColorOdd?: string, headerRowColor?: string diff --git a/demo/scripts/controls/ribbonButtons/contentModel/setBulletedListStyleButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/setBulletedListStyleButton.ts index abbb0052cfe..d95efa096c7 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/setBulletedListStyleButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/setBulletedListStyleButton.ts @@ -1,4 +1,4 @@ -import { BulletListType } from 'roosterjs-content-model-types'; +import { BulletListType } from 'roosterjs-content-model-core'; import { isContentModelEditor } from 'roosterjs-content-model-editor'; import { RibbonButton } from 'roosterjs-react'; import { setListStyle } from 'roosterjs-content-model-api'; @@ -21,7 +21,7 @@ export const setBulletedListStyleButton: RibbonButton<'ribbonButtonBulletedListS iconName: 'BulletedList', isDisabled: formatState => !formatState.isBullet, onClick: (editor, key) => { - const value = parseInt(key) as BulletListType; + const value = parseInt(key); if (isContentModelEditor(editor)) { setListStyle(editor, { diff --git a/demo/scripts/controls/ribbonButtons/contentModel/setNumberedListStyleButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/setNumberedListStyleButton.ts index 87fe7369ef7..ae195e3fb3a 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/setNumberedListStyleButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/setNumberedListStyleButton.ts @@ -1,5 +1,5 @@ import { isContentModelEditor } from 'roosterjs-content-model-editor'; -import { NumberingListType } from 'roosterjs-content-model-types'; +import { NumberingListType } from 'roosterjs-content-model-core'; import { RibbonButton } from 'roosterjs-react'; import { setListStyle } from 'roosterjs-content-model-api'; @@ -33,7 +33,7 @@ export const setNumberedListStyleButton: RibbonButton<'ribbonButtonNumberedListS iconName: 'NumberedList', isDisabled: formatState => !formatState.isNumbering, onClick: (editor, key) => { - const value = parseInt(key) as NumberingListType; + const value = parseInt(key); if (isContentModelEditor(editor)) { setListStyle(editor, { diff --git a/packages-content-model/roosterjs-content-model-core/lib/constants/BulletListType.ts b/packages-content-model/roosterjs-content-model-core/lib/constants/BulletListType.ts new file mode 100644 index 00000000000..70fcf326c41 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/constants/BulletListType.ts @@ -0,0 +1,49 @@ +/** + * Enum used to control the different types of bullet list + */ +export const BulletListType = { + /** + * Minimum value of the enum + */ + Min: 1, + /** + * Bullet triggered by * + */ + Disc: 1, + /** + * Bullet triggered by - + */ + Dash: 2, + /** + * Bullet triggered by -- + */ + Square: 3, + /** + * Bullet triggered by > + */ + ShortArrow: 4, + /** + * Bullet triggered by -> + */ + LongArrow: 5, + /** + * Bullet triggered by => + */ + UnfilledArrow: 6, + /** + * Bullet triggered by — + */ + Hyphen: 7, + /** + * Bullet triggered by --> + */ + DoubleLongArrow: 8, + /** + * Bullet type circle + */ + Circle: 9, + /** + * Maximum value of the enum + */ + Max: 9, +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/constants/NumberingListType.ts b/packages-content-model/roosterjs-content-model-core/lib/constants/NumberingListType.ts new file mode 100644 index 00000000000..2a2e8cb72a8 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/constants/NumberingListType.ts @@ -0,0 +1,93 @@ +/** + * Enum used to control the different types of numbering list + */ +export const NumberingListType = { + /** + * Minimum value of the enum + */ + Min: 1, + /** + * Numbering triggered by 1. + */ + Decimal: 1, + /** + * Numbering triggered by 1- + */ + DecimalDash: 2, + /** + * Numbering triggered by 1) + */ + DecimalParenthesis: 3, + /** + * Numbering triggered by (1) + */ + DecimalDoubleParenthesis: 4, + /** + * Numbering triggered by a. + */ + LowerAlpha: 5, + /** + * Numbering triggered by a) + */ + LowerAlphaParenthesis: 6, + /** + * Numbering triggered by (a) + */ + LowerAlphaDoubleParenthesis: 7, + /** + * Numbering triggered by a- + */ + LowerAlphaDash: 8, + /** + * Numbering triggered by A. + */ + UpperAlpha: 9, + /** + * Numbering triggered by A) + */ + UpperAlphaParenthesis: 10, + /** + * Numbering triggered by (A) + */ + UpperAlphaDoubleParenthesis: 11, + /** + * Numbering triggered by A- + */ + UpperAlphaDash: 12, + /** + * Numbering triggered by i. + */ + LowerRoman: 13, + /** + * Numbering triggered by i) + */ + LowerRomanParenthesis: 14, + /** + * Numbering triggered by (i) + */ + LowerRomanDoubleParenthesis: 15, + /** + * Numbering triggered by i- + */ + LowerRomanDash: 16, + /** + * Numbering triggered by I. + */ + UpperRoman: 17, + /** + * Numbering triggered by I) + */ + UpperRomanParenthesis: 18, + /** + * Numbering triggered by (I) + */ + UpperRomanDoubleParenthesis: 19, + /** + * Numbering triggered by I- + */ + UpperRomanDash: 20, + /** + * Maximum value of the enum + */ + Max: 20, +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/constants/TableBorderFormat.ts b/packages-content-model/roosterjs-content-model-core/lib/constants/TableBorderFormat.ts new file mode 100644 index 00000000000..21a81fa3246 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/constants/TableBorderFormat.ts @@ -0,0 +1,91 @@ +/** + * Table format border + */ +export const TableBorderFormat = { + /** + * Minimum value + */ + Min: 0, + + /** + * All border of the table are displayed + * __ __ __ + * |__|__|__| + * |__|__|__| + * |__|__|__| + */ + Default: 0, + + /** + * Middle vertical border are not displayed + * __ __ __ + * |__ __ __| + * |__ __ __| + * |__ __ __| + */ + ListWithSideBorders: 1, + + /** + * All borders except header rows borders are displayed + * __ __ __ + * __|__|__ + * __|__|__ + */ + NoHeaderBorders: 2, + + /** + * The left and right border of the table are not displayed + * __ __ __ + * __|__|__ + * __|__|__ + * __|__|__ + */ + NoSideBorders: 3, + + /** + * Only the borders that divides the header row, first column and externals are displayed + * __ __ __ + * |__ __ __| + * | | | + * |__|__ __| + */ + FirstColumnHeaderExternal: 4, + + /** + * The header row has no vertical border, except for the first one + * The first column has no horizontal border, except for the first one + * __ __ __ + * |__ __ __ + * | |__|__| + * | |__|__| + */ + EspecialType1: 5, + + /** + * The header row has no vertical border, except for the first one + * The only horizontal border of the table is the top and bottom of header row + * __ __ __ + * |__ __ __ + * | | | + * | | | + */ + EspecialType2: 6, + + /** + * The only borders are the bottom of header row and the right border of first column + * __ __ __ + * | + * | + */ + EspecialType3: 7, + + /** + * No border + */ + Clear: 8, + + /** + * Maximum value + */ + Max: 8, +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/index.ts b/packages-content-model/roosterjs-content-model-core/lib/index.ts index a22614c4e03..0659c0aee86 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/index.ts @@ -42,7 +42,11 @@ export { updateListMetadata } from './metadata/updateListMetadata'; export { promoteToContentModelEditorCore } from './editor/promoteToContentModelEditorCore'; export { createContentModelEditorCore } from './editor/createContentModelEditorCore'; + export { ChangeSource } from './constants/ChangeSource'; +export { BulletListType } from './constants/BulletListType'; +export { NumberingListType } from './constants/NumberingListType'; +export { TableBorderFormat } from './constants/TableBorderFormat'; export { ContentModelCachePlugin } from './corePlugin/ContentModelCachePlugin'; export { ContentModelCopyPastePlugin } from './corePlugin/ContentModelCopyPastePlugin'; diff --git a/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts index 9b53e901fd0..dcc31ec1b62 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateListMetadata.ts @@ -1,6 +1,7 @@ -import { BulletListType, NumberingListType } from 'roosterjs-content-model-types'; +import { BulletListType } from '../constants/BulletListType'; import { createNumberDefinition, createObjectDefinition } from './definitionCreators'; import { getObjectKeys, updateMetadata } from 'roosterjs-content-model-dom'; +import { NumberingListType } from '../constants/NumberingListType'; import type { ContentModelListItemFormat, ContentModelListItemLevelFormat, @@ -28,7 +29,7 @@ const RomanValues: Record = { IV: 4, I: 1, }; -const OrderedMap: Record = { +const OrderedMap: Record = { [NumberingListType.Decimal]: 'decimal', [NumberingListType.DecimalDash]: '"${Number}- "', [NumberingListType.DecimalParenthesis]: '"${Number}) "', @@ -50,7 +51,7 @@ const OrderedMap: Record = { [NumberingListType.UpperRomanParenthesis]: '"${UpperRoman}) "', [NumberingListType.UpperRomanDoubleParenthesis]: '"(${UpperRoman}) "', }; -const UnorderedMap: Record = { +const UnorderedMap: Record = { [BulletListType.Disc]: 'disc', [BulletListType.Square]: '"∎ "', [BulletListType.Circle]: 'circle', diff --git a/packages-content-model/roosterjs-content-model-core/lib/metadata/updateTableMetadata.ts b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateTableMetadata.ts index 4ecfa7ac824..9f345be483e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/metadata/updateTableMetadata.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/metadata/updateTableMetadata.ts @@ -1,4 +1,4 @@ -import { TableBorderFormat } from 'roosterjs-content-model-types'; +import { TableBorderFormat } from '../constants/TableBorderFormat'; import { updateMetadata } from 'roosterjs-content-model-dom'; import { createBooleanDefinition, @@ -31,8 +31,8 @@ const TableFormatDefinition = createObjectDefinition = { bgColorEven: null, bgColorOdd: '#ABABAB20', headerRowColor: '#ABABAB', - tableBorderFormat: TableBorderFormat.DEFAULT, + tableBorderFormat: TableBorderFormat.Default, verticalAlign: null, }; @@ -116,15 +116,15 @@ type ShouldUseTransparentBorder = (indexProp: { lastColumn: boolean; }) => [boolean, boolean, boolean, boolean]; -const BorderFormatters: Record = { - [TableBorderFormat.DEFAULT]: _ => [false, false, false, false], - [TableBorderFormat.LIST_WITH_SIDE_BORDERS]: ({ lastColumn, firstColumn }) => [ +const BorderFormatters: Record = { + [TableBorderFormat.Default]: _ => [false, false, false, false], + [TableBorderFormat.ListWithSideBorders]: ({ lastColumn, firstColumn }) => [ false, !lastColumn, false, !firstColumn, ], - [TableBorderFormat.FIRST_COLUMN_HEADER_EXTERNAL]: ({ + [TableBorderFormat.FirstColumnHeaderExternal]: ({ firstColumn, firstRow, lastColumn, @@ -135,37 +135,37 @@ const BorderFormatters: Record = !lastRow && !firstRow, !firstColumn, ], - [TableBorderFormat.NO_HEADER_BORDERS]: ({ firstRow, firstColumn, lastColumn }) => [ + [TableBorderFormat.NoHeaderBorders]: ({ firstRow, firstColumn, lastColumn }) => [ firstRow, firstRow || lastColumn, false, firstRow || firstColumn, ], - [TableBorderFormat.NO_SIDE_BORDERS]: ({ firstColumn, lastColumn }) => [ + [TableBorderFormat.NoSideBorders]: ({ firstColumn, lastColumn }) => [ false, lastColumn, false, firstColumn, ], - [TableBorderFormat.ESPECIAL_TYPE_1]: ({ firstRow, firstColumn }) => [ + [TableBorderFormat.EspecialType1]: ({ firstRow, firstColumn }) => [ firstColumn && !firstRow, firstRow, firstColumn && !firstRow, firstRow && !firstColumn, ], - [TableBorderFormat.ESPECIAL_TYPE_2]: ({ firstRow, firstColumn }) => [ + [TableBorderFormat.EspecialType2]: ({ firstRow, firstColumn }) => [ !firstRow, firstRow || !firstColumn, !firstRow, !firstColumn, ], - [TableBorderFormat.ESPECIAL_TYPE_3]: ({ firstColumn, firstRow }) => [ + [TableBorderFormat.EspecialType3]: ({ firstColumn, firstRow }) => [ true, firstRow || !firstColumn, !firstRow, true, ], - [TableBorderFormat.CLEAR]: () => [true, true, true, true], + [TableBorderFormat.Clear]: () => [true, true, true, true], }; /* @@ -181,10 +181,11 @@ function formatCells( rows.forEach((row, rowIndex) => { row.cells.forEach((cell, colIndex) => { // Format Borders - if (!metaOverrides.borderOverrides[rowIndex][colIndex]) { - const transparentBorderMatrix = BorderFormatters[ - format.tableBorderFormat as TableBorderFormat - ]({ + if ( + !metaOverrides.borderOverrides[rowIndex][colIndex] && + typeof format.tableBorderFormat == 'number' + ) { + const transparentBorderMatrix = BorderFormatters[format.tableBorderFormat]?.({ firstRow: rowIndex === 0, lastRow: rowIndex === rows.length - 1, firstColumn: colIndex === 0, @@ -198,7 +199,7 @@ function formatCells( format.verticalBorderColor, ]; - transparentBorderMatrix.forEach((alwaysUseTransparent, i) => { + transparentBorderMatrix?.forEach((alwaysUseTransparent, i) => { const borderColor = (!alwaysUseTransparent && formatColor[i]) || ''; cell.format[BorderKeys[i]] = combineBorderValue({ diff --git a/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts index 939fec274ed..64ba07e807a 100644 --- a/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/updateListMetadataTest.ts @@ -1,11 +1,11 @@ +import { BulletListType } from '../../lib/constants/BulletListType'; import { createModelToDomContext } from 'roosterjs-content-model-dom'; +import { NumberingListType } from '../../lib/constants/NumberingListType'; import { - BulletListType, ContentModelListItemFormat, ContentModelWithDataset, ListMetadataFormat, ModelToDomContext, - NumberingListType, } from 'roosterjs-content-model-types'; import { listItemMetadataApplier, @@ -392,7 +392,7 @@ describe('listItemMetadataApplier', () => { }); describe('OrderedListStyleValue', () => { - function runTest(formatNum: NumberingListType, itemNum: number, result: string) { + function runTest(formatNum: number, itemNum: number, result: string) { context.listFormat.nodeStack = [ { node: {} as Node, @@ -489,7 +489,7 @@ describe('listItemMetadataApplier', () => { }); describe('UnorderedListStyleValue', () => { - function runTest(formatNum: BulletListType, result: string) { + function runTest(formatNum: number, result: string) { context.listFormat.nodeStack = [ { node: {} as Node, @@ -740,7 +740,7 @@ describe('listLevelMetadataApplier', () => { }); describe('OrderedListStyleValue', () => { - function runTest(formatNum: NumberingListType, itemNum: number, result: string) { + function runTest(formatNum: number, itemNum: number, result: string) { context.listFormat.nodeStack = [ { node: {} as Node, @@ -802,7 +802,7 @@ describe('listLevelMetadataApplier', () => { }); describe('UnorderedListStyleValue', () => { - function runTest(formatNum: BulletListType, result: string) { + function runTest(formatNum: number, result: string) { context.listFormat.nodeStack = [ { node: {} as Node, diff --git a/packages-content-model/roosterjs-content-model-core/test/metadata/updateTableMetadataTest.ts b/packages-content-model/roosterjs-content-model-core/test/metadata/updateTableMetadataTest.ts index a4ecc891d51..f698510f986 100644 --- a/packages-content-model/roosterjs-content-model-core/test/metadata/updateTableMetadataTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/metadata/updateTableMetadataTest.ts @@ -1,9 +1,6 @@ +import { ContentModelTable, TableMetadataFormat } from 'roosterjs-content-model-types'; +import { TableBorderFormat } from '../../lib/constants/TableBorderFormat'; import { updateTableMetadata } from '../../lib/metadata/updateTableMetadata'; -import { - ContentModelTable, - TableBorderFormat, - TableMetadataFormat, -} from 'roosterjs-content-model-types'; describe('updateTableMetadata', () => { it('No value', () => { @@ -64,7 +61,7 @@ describe('updateTableMetadata', () => { hasBandedRows: true, bgColorEven: 'yellow', bgColorOdd: 'gray', - tableBorderFormat: TableBorderFormat.DEFAULT, + tableBorderFormat: TableBorderFormat.Default, verticalAlign: 'top', }; const table: ContentModelTable = { @@ -104,7 +101,7 @@ describe('updateTableMetadata', () => { hasBandedRows: true, bgColorEven: 'yellow', bgColorOdd: 'gray', - tableBorderFormat: TableBorderFormat.DEFAULT, + tableBorderFormat: TableBorderFormat.Default, verticalAlign: 'top', }; const table: ContentModelTable = { @@ -147,7 +144,7 @@ describe('updateTableMetadata', () => { hasBandedRows: true, bgColorEven: 'yellow', bgColorOdd: 'gray', - tableBorderFormat: TableBorderFormat.DEFAULT, + tableBorderFormat: TableBorderFormat.Default, verticalAlign: 'top', }; const table: ContentModelTable = { @@ -195,7 +192,7 @@ describe('updateTableMetadata', () => { hasBandedRows: true, bgColorEven: 'yellow', bgColorOdd: 'gray', - tableBorderFormat: TableBorderFormat.DEFAULT, + tableBorderFormat: TableBorderFormat.Default, verticalAlign: 'top', }; const table: ContentModelTable = { @@ -267,7 +264,7 @@ describe('updateTableMetadata', () => { hasBandedRows: true, bgColorEven: 'yellow', bgColorOdd: 'gray', - tableBorderFormat: TableBorderFormat.DEFAULT, + tableBorderFormat: TableBorderFormat.Default, verticalAlign: 'top', }; const table: ContentModelTable = { diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/table/applyTableFormatTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/table/applyTableFormatTest.ts index 419354eb2fd..1e4bfe552c6 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/table/applyTableFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/table/applyTableFormatTest.ts @@ -1,9 +1,9 @@ import { applyTableFormat } from '../../../lib/publicApi/table/applyTableFormat'; +import { TableBorderFormat } from '../../../lib/constants/TableBorderFormat'; import { ContentModelTable, ContentModelTableCell, ContentModelTableRow, - TableBorderFormat, TableMetadataFormat, } from 'roosterjs-content-model-types'; @@ -135,7 +135,7 @@ describe('applyTableFormat', () => { hasHeaderRow: false, headerRowColor: null, hasFirstColumn: false, - tableBorderFormat: TableBorderFormat.FIRST_COLUMN_HEADER_EXTERNAL, + tableBorderFormat: TableBorderFormat.FirstColumnHeaderExternal, }, [ [TC, TC, TC, TC], @@ -183,7 +183,7 @@ describe('applyTableFormat', () => { hasHeaderRow: false, headerRowColor: null, hasFirstColumn: false, - tableBorderFormat: TableBorderFormat.NO_HEADER_BORDERS, + tableBorderFormat: TableBorderFormat.NoHeaderBorders, }, [ [TC, TC, TC, TC], @@ -232,7 +232,7 @@ describe('applyTableFormat', () => { hasHeaderRow: false, headerRowColor: null, hasFirstColumn: false, - tableBorderFormat: TableBorderFormat.NO_SIDE_BORDERS, + tableBorderFormat: TableBorderFormat.NoSideBorders, }, [ [TC, TC, TC, TC], @@ -280,7 +280,7 @@ describe('applyTableFormat', () => { hasHeaderRow: false, headerRowColor: null, hasFirstColumn: false, - tableBorderFormat: TableBorderFormat.ESPECIAL_TYPE_1, + tableBorderFormat: TableBorderFormat.EspecialType1, }, [ [TC, TC, TC, TC], @@ -328,7 +328,7 @@ describe('applyTableFormat', () => { hasHeaderRow: false, headerRowColor: null, hasFirstColumn: false, - tableBorderFormat: TableBorderFormat.ESPECIAL_TYPE_2, + tableBorderFormat: TableBorderFormat.EspecialType2, }, [ [TC, TC, TC, TC], @@ -376,7 +376,7 @@ describe('applyTableFormat', () => { hasHeaderRow: false, headerRowColor: null, hasFirstColumn: false, - tableBorderFormat: TableBorderFormat.ESPECIAL_TYPE_3, + tableBorderFormat: TableBorderFormat.EspecialType3, }, [ [TC, TC, TC, TC], @@ -423,7 +423,7 @@ describe('applyTableFormat', () => { hasHeaderRow: false, headerRowColor: null, hasFirstColumn: false, - tableBorderFormat: TableBorderFormat.CLEAR, + tableBorderFormat: TableBorderFormat.Clear, }, [ [TC, TC, TC, TC], diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/listProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/listProcessorTest.ts index f1c5f42e485..fc8447c9f4c 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/listProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/listProcessorTest.ts @@ -1,14 +1,11 @@ import * as stackFormat from '../../../lib/domToModel/utils/stackFormat'; +import { BulletListType } from 'roosterjs-content-model-core/lib/constants/BulletListType'; import { childProcessor as originalChildProcessor } from '../../../lib/domToModel/processors/childProcessor'; import { createContentModelDocument } from '../../../lib/modelApi/creators/createContentModelDocument'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; +import { DomToModelContext, ElementProcessor } from 'roosterjs-content-model-types'; import { listProcessor } from '../../../lib/domToModel/processors/listProcessor'; -import { - BulletListType, - DomToModelContext, - ElementProcessor, - NumberingListType, -} from 'roosterjs-content-model-types'; +import { NumberingListType } from 'roosterjs-content-model-core/lib/constants/NumberingListType'; describe('listProcessor', () => { let context: DomToModelContext; diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts index edde168a1f1..19de98dc6f7 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleListTest.ts @@ -1,14 +1,11 @@ +import { BulletListType } from 'roosterjs-content-model-core/lib/constants/BulletListType'; +import { ContentModelListItem, ModelToDomContext } from 'roosterjs-content-model-types'; import { createListItem } from '../../../lib/modelApi/creators/createListItem'; import { createListLevel } from '../../../lib/modelApi/creators/createListLevel'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; import { handleList } from '../../../lib/modelToDom/handlers/handleList'; -import { - BulletListType, - ContentModelListItem, - ModelToDomContext, - NumberingListType, -} from 'roosterjs-content-model-types'; +import { NumberingListType } from 'roosterjs-content-model-core/lib/constants/NumberingListType'; describe('handleList without format handlers', () => { let context: ModelToDomContext; diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts index 2df7b0bed03..45bdb0514ae 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/metadata/ListMetadataFormat.ts @@ -1,158 +1,14 @@ -/** - * Enum used to control the different types of bullet list - */ -export enum BulletListType { - /** - * Minimum value of the enum - */ - Min = 1, - /** - * Bullet triggered by * - */ - Disc = 1, - /** - * Bullet triggered by - - */ - Dash = 2, - /** - * Bullet triggered by -- - */ - Square = 3, - /** - * Bullet triggered by > - */ - ShortArrow = 4, - /** - * Bullet triggered by -> - */ - LongArrow = 5, - /** - * Bullet triggered by => - */ - UnfilledArrow = 6, - /** - * Bullet triggered by — - */ - Hyphen = 7, - /** - * Bullet triggered by --> - */ - DoubleLongArrow = 8, - /** - * Bullet type circle - */ - Circle = 9, - /** - * Maximum value of the enum - */ - Max = 9, -} - -/** - * Enum used to control the different types of numbering list - */ -export enum NumberingListType { - /** - * Minimum value of the enum - */ - Min = 1, - /** - * Numbering triggered by 1. - */ - Decimal = 1, - /** - * Numbering triggered by 1- - */ - DecimalDash = 2, - /** - * Numbering triggered by 1) - */ - DecimalParenthesis = 3, - /** - * Numbering triggered by (1) - */ - DecimalDoubleParenthesis = 4, - /** - * Numbering triggered by a. - */ - LowerAlpha = 5, - /** - * Numbering triggered by a) - */ - LowerAlphaParenthesis = 6, - /** - * Numbering triggered by (a) - */ - LowerAlphaDoubleParenthesis = 7, - /** - * Numbering triggered by a- - */ - LowerAlphaDash = 8, - /** - * Numbering triggered by A. - */ - UpperAlpha = 9, - /** - * Numbering triggered by A) - */ - UpperAlphaParenthesis = 10, - /** - * Numbering triggered by (A) - */ - UpperAlphaDoubleParenthesis = 11, - /** - * Numbering triggered by A- - */ - UpperAlphaDash = 12, - /** - * Numbering triggered by i. - */ - LowerRoman = 13, - /** - * Numbering triggered by i) - */ - LowerRomanParenthesis = 14, - /** - * Numbering triggered by (i) - */ - LowerRomanDoubleParenthesis = 15, - /** - * Numbering triggered by i- - */ - LowerRomanDash = 16, - /** - * Numbering triggered by I. - */ - UpperRoman = 17, - /** - * Numbering triggered by I) - */ - UpperRomanParenthesis = 18, - /** - * Numbering triggered by (I) - */ - UpperRomanDoubleParenthesis = 19, - /** - * Numbering triggered by I- - */ - UpperRomanDash = 20, - /** - * Maximum value of the enum - */ - Max = 20, -} - /** * Format of list / list item that stored as metadata */ export type ListMetadataFormat = { /** - * Style type for Ordered list + * Style type for Ordered list. Use value of constant NumberingListType as value. */ - orderedStyleType?: NumberingListType; + orderedStyleType?: number; /** - * Style type for Unordered list + * Style type for Unordered list. Use value of constant BulletListType as value. */ - unorderedStyleType?: BulletListType; + unorderedStyleType?: number; }; diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/metadata/TableMetadataFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/metadata/TableMetadataFormat.ts index 79954b0be86..394581cc8f7 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/metadata/TableMetadataFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/metadata/TableMetadataFormat.ts @@ -1,85 +1,3 @@ -/** - * Table format border - */ -export enum TableBorderFormat { - /** - * All border of the table are displayed - * __ __ __ - * |__|__|__| - * |__|__|__| - * |__|__|__| - */ - DEFAULT, - - /** - * Middle vertical border are not displayed - * __ __ __ - * |__ __ __| - * |__ __ __| - * |__ __ __| - */ - LIST_WITH_SIDE_BORDERS, - - /** - * All borders except header rows borders are displayed - * __ __ __ - * __|__|__ - * __|__|__ - */ - NO_HEADER_BORDERS, - - /** - * The left and right border of the table are not displayed - * __ __ __ - * __|__|__ - * __|__|__ - * __|__|__ - */ - NO_SIDE_BORDERS, - - /** - * Only the borders that divides the header row, first column and externals are displayed - * __ __ __ - * |__ __ __| - * | | | - * |__|__ __| - */ - FIRST_COLUMN_HEADER_EXTERNAL, - - /** - * The header row has no vertical border, except for the first one - * The first column has no horizontal border, except for the first one - * __ __ __ - * |__ __ __ - * | |__|__| - * | |__|__| - */ - ESPECIAL_TYPE_1, - - /** - * The header row has no vertical border, except for the first one - * The only horizontal border of the table is the top and bottom of header row - * __ __ __ - * |__ __ __ - * | | | - * | | | - */ - ESPECIAL_TYPE_2, - - /** - * The only borders are the bottom of header row and the right border of first column - * __ __ __ - * | - * | - */ - ESPECIAL_TYPE_3, - - /** - * No border - */ - CLEAR, -} - /** * Format of table that stored as metadata */ @@ -135,9 +53,9 @@ export type TableMetadataFormat = { bgColorOdd?: string | null; /** - * Table Borders Type + * Table Borders Type. Use value of constant TableBorderFormat as value */ - tableBorderFormat?: TableBorderFormat; + tableBorderFormat?: number; /** * Vertical alignment for each row */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index 851d8cdd922..5015e3925c7 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -50,12 +50,8 @@ export { FloatFormat } from './format/formatParts/FloatFormat'; export { EntityInfoFormat } from './format/formatParts/EntityInfoFormat'; export { DatasetFormat } from './format/metadata/DatasetFormat'; -export { TableMetadataFormat, TableBorderFormat } from './format/metadata/TableMetadataFormat'; -export { - ListMetadataFormat, - NumberingListType, - BulletListType, -} from './format/metadata/ListMetadataFormat'; +export { TableMetadataFormat } from './format/metadata/TableMetadataFormat'; +export { ListMetadataFormat } from './format/metadata/ListMetadataFormat'; export { ImageResizeMetadataFormat, ImageCropMetadataFormat, From 32280bf34b57f12ff288736ddc34f2c1cc65d3c3 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 14 Nov 2023 09:37:23 -0800 Subject: [PATCH 048/111] Directly create Content Model editor (#2206) --- .../controls/ContentModelEditorMainPane.tsx | 3 +- .../test/publicApi/link/insertLinkTest.ts | 3 +- .../lib/corePlugin/ContentModelCachePlugin.ts | 22 +- .../corePlugin/ContentModelCopyPastePlugin.ts | 20 +- .../corePlugin/ContentModelFormatPlugin.ts | 31 +- .../ContentModelTypeInContainerPlugin.ts | 28 -- .../createStandaloneEditorCorePlugins.ts | 22 ++ .../editor/createContentModelEditorCore.ts | 70 ----- .../createStandaloneEditorDefaultSettings.ts | 42 +++ .../editor/promoteToContentModelEditorCore.ts | 87 ----- .../lib/editor/standaloneCoreApiMap.ts | 21 ++ .../roosterjs-content-model-core/lib/index.ts | 9 +- .../corePlugin/ContentModelCachePluginTest.ts | 65 +++- .../ContentModelCopyPastePluginTest.ts | 7 +- .../ContentModelFormatPluginTest.ts | 221 +++++++------ .../createContentModelEditorCoreTest.ts | 237 -------------- .../promoteToContentModelEditorCoreTest.ts | 180 ----------- .../test/publicApi/model/pasteTest.ts | 5 +- .../lib/coreApi/coreApiMap.ts | 8 +- .../lib/coreApi/switchShadowEdit.ts | 111 ------- .../lib/corePlugins/CopyPastePlugin.ts | 296 ------------------ .../lib/corePlugins/DOMEventPlugin.ts | 16 +- .../lib/corePlugins/EditPlugin.ts | 11 +- .../lib/corePlugins/EntityPlugin.ts | 11 +- .../lib/corePlugins/ImageSelection.ts | 11 +- .../lib/corePlugins/LifecyclePlugin.ts | 16 +- .../lib/corePlugins/MouseUpPlugin.ts | 11 +- .../lib/corePlugins/NormalizeTablePlugin.ts | 11 +- .../corePlugins/PendingFormatStatePlugin.ts | 12 +- .../lib/corePlugins/TypeInContainerPlugin.ts | 99 ------ .../lib/corePlugins/UndoPlugin.ts | 12 +- .../lib/corePlugins/createCorePlugins.ts | 65 ++-- .../lib/editor/ContentModelEditor.ts | 54 +--- .../lib/editor/createEditorCore.ts | 34 +- .../lib/editor/isContentModelEditor.ts | 2 +- .../lib/index.ts | 1 - .../lib/publicTypes/IContentModelEditor.ts | 7 +- .../test/editor/ContentModelEditorTest.ts | 21 +- .../test/editor/createEditorCoreTest.ts | 198 ++++++++++++ .../test/editor/isContentModelEditorTest.ts | 37 +-- .../test/paste/e2e/testUtils.ts | 8 +- .../lib/editor/StandaloneEditorCore.ts | 19 +- .../lib/editor/StandaloneEditorCorePlugins.ts | 28 ++ .../lib/editor/StandaloneEditorOptions.ts | 8 + .../lib/index.ts | 2 + .../lib/createContentModelEditor.ts | 3 +- 46 files changed, 762 insertions(+), 1423 deletions(-) delete mode 100644 packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelTypeInContainerPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts delete mode 100644 packages-content-model/roosterjs-content-model-core/lib/editor/createContentModelEditorCore.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts delete mode 100644 packages-content-model/roosterjs-content-model-core/lib/editor/promoteToContentModelEditorCore.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts delete mode 100644 packages-content-model/roosterjs-content-model-core/test/editor/createContentModelEditorCoreTest.ts delete mode 100644 packages-content-model/roosterjs-content-model-core/test/editor/promoteToContentModelEditorCoreTest.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/switchShadowEdit.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/CopyPastePlugin.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/TypeInContainerPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 8ca58743441..704414e9081 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -17,7 +17,6 @@ import { arrayPush } from 'roosterjs-editor-dom'; import { ContentModelEditor } from 'roosterjs-content-model-editor'; import { ContentModelEditPlugin } from 'roosterjs-content-model-plugins'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; -import { createContentModelEditorCore } from 'roosterjs-content-model-core'; import { createEmojiPlugin, createPasteOptionPlugin, RibbonPlugin } from 'roosterjs-react'; import { EditorOptions, EditorPlugin } from 'roosterjs-editor-types'; import { PartialTheme } from '@fluentui/react/lib/Theme'; @@ -184,7 +183,7 @@ class ContentModelEditorMainPane extends MainPaneBase { this.toggleablePlugins = null; this.setState({ editorCreator: (div: HTMLDivElement, options: EditorOptions) => - new ContentModelEditor(div, createContentModelEditorCore, { + new ContentModelEditor(div, { ...options, cacheModel: this.state.initState.cacheModel, }), diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts index 0a76059a86a..a8b122ddde8 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts @@ -1,7 +1,6 @@ import insertLink from '../../../lib/publicApi/link/insertLink'; import { ChangeSource } from 'roosterjs-content-model-core'; import { ContentModelEditor } from 'roosterjs-content-model-editor'; -import { createContentModelEditorCore } from 'roosterjs-content-model-core'; import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { PluginEventType } from 'roosterjs-editor-types'; import { @@ -331,7 +330,7 @@ describe('insertLink', () => { getName: () => 'mock', onPluginEvent: onPluginEvent, }; - const editor = new ContentModelEditor(div, createContentModelEditorCore, { + const editor = new ContentModelEditor(div, { plugins: [mockedPlugin], }); diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts index 107e36a6b15..72d272f827b 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin.ts @@ -1,10 +1,12 @@ import { areSameSelection } from './utils/areSameSelection'; +import { contentModelDomIndexer } from './utils/contentModelDomIndexer'; import { isCharacterValue } from '../publicApi/domUtils/eventUtils'; import { PluginEventType } from 'roosterjs-editor-types'; import type { ContentModelCachePluginState, ContentModelContentChangedEvent, IStandaloneEditor, + StandaloneEditorOptions, } from 'roosterjs-content-model-types'; import type { IEditor, @@ -16,15 +18,18 @@ import type { /** * ContentModel cache plugin manages cached Content Model, and refresh the cache when necessary */ -export class ContentModelCachePlugin implements PluginWithState { +class ContentModelCachePlugin implements PluginWithState { private editor: (IEditor & IStandaloneEditor) | null = null; + private state: ContentModelCachePluginState; /** * Construct a new instance of ContentModelEditPlugin class - * @param state State of this plugin + * @param option The editor option */ - constructor(private state: ContentModelCachePluginState) { - // TODO: Remove tempState parameter once we have standalone Content Model editor + constructor(option: StandaloneEditorOptions) { + this.state = { + domIndexer: option.cacheModel ? contentModelDomIndexer : undefined, + }; } /** @@ -188,9 +193,10 @@ export class ContentModelCachePlugin implements PluginWithState { + return new ContentModelCachePlugin(option); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts index c0ce1892653..665713e2f5c 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts @@ -22,20 +22,26 @@ import type { IEditor, PluginWithState, ClipboardData, + EditorOptions, } from 'roosterjs-editor-types'; /** * Copy and paste plugin for handling onCopy and onPaste event */ -export class ContentModelCopyPastePlugin implements PluginWithState { +class ContentModelCopyPastePlugin implements PluginWithState { private editor: (IStandaloneEditor & IEditor) | null = null; private disposer: (() => void) | null = null; + private state: CopyPastePluginState; /** * Construct a new instance of CopyPastePlugin - * @param options The editor options + * @param option The editor option */ - constructor(private state: CopyPastePluginState) {} + constructor(option: EditorOptions) { + this.state = { + allowedCustomPasteType: option.allowedCustomPasteType || [], + }; + } /** * Get a friendly name of this plugin @@ -289,8 +295,10 @@ export const onNodeCreated: OnNodeCreated = (_, node): void => { /** * @internal * Create a new instance of ContentModelCopyPastePlugin - * @param state The plugin state object + * @param option The editor option */ -export function createContentModelCopyPastePlugin(state: CopyPastePluginState) { - return new ContentModelCopyPastePlugin(state); +export function createContentModelCopyPastePlugin( + option: EditorOptions +): PluginWithState { + return new ContentModelCopyPastePlugin(option); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts index c5f516e1d9a..7630f9fe210 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts @@ -7,6 +7,7 @@ import type { IEditor, PluginEvent, PluginWithState } from 'roosterjs-editor-typ import type { ContentModelFormatPluginState, IStandaloneEditor, + StandaloneEditorOptions, } from 'roosterjs-content-model-types'; // During IME input, KeyDown event will have "Process" as key @@ -27,16 +28,30 @@ const CursorMovingKeys = new Set([ * This includes: * 1. Handle pending format changes when selection is collapsed */ -export class ContentModelFormatPlugin implements PluginWithState { +class ContentModelFormatPlugin implements PluginWithState { private editor: (IStandaloneEditor & IEditor) | null = null; private hasDefaultFormat = false; + private state: ContentModelFormatPluginState; /** * Construct a new instance of ContentModelEditPlugin class - * @param state State of this plugin + * @param option The editor option */ - constructor(private state: ContentModelFormatPluginState) { - // TODO: Remove tempState parameter once we have standalone Content Model editor + constructor(option: StandaloneEditorOptions) { + const format = option.defaultFormat || {}; + this.state = { + defaultFormat: { + fontWeight: format.bold ? 'bold' : undefined, + italic: format.italic || undefined, + underline: format.underline || undefined, + fontFamily: format.fontFamily || undefined, + fontSize: format.fontSize || undefined, + textColor: format.textColors?.lightModeColor || format.textColor || undefined, + backgroundColor: + format.backgroundColors?.lightModeColor || format.backgroundColor || undefined, + }, + pendingFormat: null, + }; } /** @@ -163,8 +178,10 @@ export class ContentModelFormatPlugin implements PluginWithState { + return new ContentModelFormatPlugin(option); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelTypeInContainerPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelTypeInContainerPlugin.ts deleted file mode 100644 index a128c862f50..00000000000 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelTypeInContainerPlugin.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { EditorPlugin } from 'roosterjs-editor-types'; - -/** - * Dummy plugin, just to skip original TypeInContainerPlugin's behavior - */ -export class ContentModelTypeInContainerPlugin implements EditorPlugin { - /** - * Get name of this plugin - */ - getName() { - return 'ContentModelTypeInContainer'; - } - - /** - * The first method that editor will call to a plugin when editor is initializing. - * It will pass in the editor instance, plugin should take this chance to save the - * editor reference so that it can call to any editor method or format API later. - * @param editor The editor object - */ - initialize() {} - - /** - * The last method that editor will call to a plugin before it is disposed. - * Plugin can take this chance to clear the reference to editor. After this method is - * called, plugin should not call to any editor method since it will result in error. - */ - dispose() {} -} diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts new file mode 100644 index 00000000000..a6ab3134409 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts @@ -0,0 +1,22 @@ +import { createContentModelCachePlugin } from './ContentModelCachePlugin'; +import { createContentModelCopyPastePlugin } from './ContentModelCopyPastePlugin'; +import { createContentModelFormatPlugin } from './ContentModelFormatPlugin'; +import type { + StandaloneEditorCorePlugins, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; + +/** + * Create core plugins for standalone editor + * @param options Options of editor + */ +export function createStandaloneEditorCorePlugins( + options: StandaloneEditorOptions +): StandaloneEditorCorePlugins { + return { + cache: createContentModelCachePlugin(options), + format: createContentModelFormatPlugin(options), + copyPaste: createContentModelCopyPastePlugin(options), + typeInContainer: null!, // TODO: remove this plugin since we don't need it any more + }; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createContentModelEditorCore.ts deleted file mode 100644 index d82667d2a90..00000000000 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createContentModelEditorCore.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { contentModelDomIndexer } from '../corePlugin/utils/contentModelDomIndexer'; -import { ContentModelTypeInContainerPlugin } from '../corePlugin/ContentModelTypeInContainerPlugin'; -import { createContentModelCachePlugin } from '../corePlugin/ContentModelCachePlugin'; -import { createContentModelCopyPastePlugin } from '../corePlugin/ContentModelCopyPastePlugin'; -import { createContentModelFormatPlugin } from '../corePlugin/ContentModelFormatPlugin'; -import { promoteToContentModelEditorCore } from './promoteToContentModelEditorCore'; -import type { CoreCreator, EditorCore, EditorOptions } from 'roosterjs-editor-types'; -import type { - ContentModelPluginState, - StandaloneEditorCore, - StandaloneEditorOptions, -} from 'roosterjs-content-model-types'; - -/** - * Editor Core creator for Content Model editor - * @param contentDiv Container DIV of editor - * @param options Options for creating editor - * @param baseCreator Base core creator used for creating base EditorCore - */ -export function createContentModelEditorCore( - contentDiv: HTMLDivElement, - options: EditorOptions & StandaloneEditorOptions, - baseCreator: CoreCreator -): EditorCore & StandaloneEditorCore { - const pluginState = getPluginState(options); - const modifiedOptions: EditorOptions & StandaloneEditorOptions = { - ...options, - plugins: [ - createContentModelCachePlugin(pluginState.cache), - createContentModelFormatPlugin(pluginState.format), - ...(options.plugins || []), - ], - corePluginOverride: { - typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: createContentModelCopyPastePlugin(pluginState.copyPaste), - ...options.corePluginOverride, - }, - }; - - const core = baseCreator(contentDiv, modifiedOptions) as EditorCore & StandaloneEditorCore; - - promoteToContentModelEditorCore(core, modifiedOptions, pluginState); - - return core; -} - -function getPluginState(options: EditorOptions & StandaloneEditorOptions): ContentModelPluginState { - const format = options.defaultFormat || {}; - return { - cache: { - domIndexer: options.cacheModel ? contentModelDomIndexer : undefined, - }, - copyPaste: { - allowedCustomPasteType: options.allowedCustomPasteType || [], - }, - format: { - defaultFormat: { - fontWeight: format.bold ? 'bold' : undefined, - italic: format.italic || undefined, - underline: format.underline || undefined, - fontFamily: format.fontFamily || undefined, - fontSize: format.fontSize || undefined, - textColor: format.textColors?.lightModeColor || format.textColor || undefined, - backgroundColor: - format.backgroundColors?.lightModeColor || format.backgroundColor || undefined, - }, - pendingFormat: null, - }, - }; -} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts new file mode 100644 index 00000000000..b46db72068f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts @@ -0,0 +1,42 @@ +import { createDomToModelConfig, createModelToDomConfig } from 'roosterjs-content-model-dom'; +import { listItemMetadataApplier, listLevelMetadataApplier } from '../metadata/updateListMetadata'; +import { tablePreProcessor } from '../override/tablePreProcessor'; +import type { + DomToModelOption, + ModelToDomOption, + StandaloneEditorDefaultSettings, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; + +/** + * Create default DOM and Content Model conversion settings for a standalone editor + * @param options The editor options + */ +export function createStandaloneEditorDefaultSettings( + options: StandaloneEditorOptions +): StandaloneEditorDefaultSettings { + const defaultDomToModelOptions: (DomToModelOption | undefined)[] = [ + { + processorOverride: { + table: tablePreProcessor, + }, + }, + options.defaultDomToModelOptions, + ]; + const defaultModelToDomOptions: (ModelToDomOption | undefined)[] = [ + { + metadataAppliers: { + listItem: listItemMetadataApplier, + listLevel: listLevelMetadataApplier, + }, + }, + options.defaultModelToDomOptions, + ]; + + return { + defaultDomToModelOptions, + defaultModelToDomOptions, + defaultDomToModelConfig: createDomToModelConfig(defaultDomToModelOptions), + defaultModelToDomConfig: createModelToDomConfig(defaultModelToDomOptions), + }; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/promoteToContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/promoteToContentModelEditorCore.ts deleted file mode 100644 index 99902794182..00000000000 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/promoteToContentModelEditorCore.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { createContentModel } from '../coreApi/createContentModel'; -import { createDomToModelConfig, createModelToDomConfig } from 'roosterjs-content-model-dom'; -import { createEditorContext } from '../coreApi/createEditorContext'; -import { formatContentModel } from '../coreApi/formatContentModel'; -import { getDOMSelection } from '../coreApi/getDOMSelection'; -import { listItemMetadataApplier, listLevelMetadataApplier } from '../metadata/updateListMetadata'; -import { setContentModel } from '../coreApi/setContentModel'; -import { setDOMSelection } from '../coreApi/setDOMSelection'; -import { switchShadowEdit } from '../coreApi/switchShadowEdit'; -import { tablePreProcessor } from '../override/tablePreProcessor'; -import type { - ContentModelPluginState, - StandaloneEditorCore, - StandaloneEditorOptions, -} from 'roosterjs-content-model-types'; -import type { EditorCore, EditorOptions } from 'roosterjs-editor-types'; - -/** - * Creator Content Model Editor Core from Editor Core - * @param core The original EditorCore object - * @param options Options of this editor - */ -export function promoteToContentModelEditorCore( - core: EditorCore, - options: EditorOptions & StandaloneEditorOptions, - pluginState: ContentModelPluginState -) { - const cmCore = core as EditorCore & StandaloneEditorCore; - - promoteCorePluginState(cmCore, pluginState); - promoteContentModelInfo(cmCore, options); - promoteCoreApi(cmCore); - promoteEnvironment(cmCore); -} - -function promoteCorePluginState( - cmCore: StandaloneEditorCore, - pluginState: ContentModelPluginState -) { - Object.assign(cmCore, pluginState); -} - -function promoteContentModelInfo(cmCore: StandaloneEditorCore, options: StandaloneEditorOptions) { - cmCore.defaultDomToModelOptions = [ - { - processorOverride: { - table: tablePreProcessor, - }, - }, - options.defaultDomToModelOptions, - ]; - cmCore.defaultModelToDomOptions = [ - { - metadataAppliers: { - listItem: listItemMetadataApplier, - listLevel: listLevelMetadataApplier, - }, - }, - options.defaultModelToDomOptions, - ]; - cmCore.defaultDomToModelConfig = createDomToModelConfig(cmCore.defaultDomToModelOptions); - cmCore.defaultModelToDomConfig = createModelToDomConfig(cmCore.defaultModelToDomOptions); -} - -function promoteCoreApi(cmCore: StandaloneEditorCore) { - cmCore.api.createEditorContext = createEditorContext; - cmCore.api.createContentModel = createContentModel; - cmCore.api.setContentModel = setContentModel; - cmCore.api.switchShadowEdit = switchShadowEdit; - cmCore.api.getDOMSelection = getDOMSelection; - cmCore.api.setDOMSelection = setDOMSelection; - cmCore.api.formatContentModel = formatContentModel; - cmCore.originalApi.createEditorContext = createEditorContext; - cmCore.originalApi.createContentModel = createContentModel; - cmCore.originalApi.setContentModel = setContentModel; - cmCore.originalApi.getDOMSelection = getDOMSelection; - cmCore.originalApi.setDOMSelection = setDOMSelection; - cmCore.originalApi.formatContentModel = formatContentModel; -} - -function promoteEnvironment(cmCore: StandaloneEditorCore) { - cmCore.environment = {}; - - // It is ok to use global window here since the environment should always be the same for all windows in one session - cmCore.environment.isMac = window.navigator.appVersion.indexOf('Mac') != -1; - cmCore.environment.isAndroid = /android/i.test(window.navigator.userAgent); -} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts new file mode 100644 index 00000000000..aecb5a4fb50 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts @@ -0,0 +1,21 @@ +import { createContentModel } from '../coreApi/createContentModel'; +import { createEditorContext } from '../coreApi/createEditorContext'; +import { formatContentModel } from '../coreApi/formatContentModel'; +import { getDOMSelection } from '../coreApi/getDOMSelection'; +import { setContentModel } from '../coreApi/setContentModel'; +import { setDOMSelection } from '../coreApi/setDOMSelection'; +import { switchShadowEdit } from '../coreApi/switchShadowEdit'; +import type { StandaloneCoreApiMap } from 'roosterjs-content-model-types'; + +/** + * Core API map for Standalone Content Model Editor + */ +export const standaloneCoreApiMap: StandaloneCoreApiMap = { + createContentModel: createContentModel, + createEditorContext: createEditorContext, + formatContentModel: formatContentModel, + getDOMSelection: getDOMSelection, + setContentModel: setContentModel, + setDOMSelection: setDOMSelection, + switchShadowEdit: switchShadowEdit, +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/index.ts b/packages-content-model/roosterjs-content-model-core/lib/index.ts index 0659c0aee86..149f6c5c67b 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/index.ts @@ -40,15 +40,12 @@ export { updateTableCellMetadata } from './metadata/updateTableCellMetadata'; export { updateTableMetadata } from './metadata/updateTableMetadata'; export { updateListMetadata } from './metadata/updateListMetadata'; -export { promoteToContentModelEditorCore } from './editor/promoteToContentModelEditorCore'; -export { createContentModelEditorCore } from './editor/createContentModelEditorCore'; +export { standaloneCoreApiMap } from './editor/standaloneCoreApiMap'; +export { createStandaloneEditorDefaultSettings } from './editor/createStandaloneEditorDefaultSettings'; export { ChangeSource } from './constants/ChangeSource'; export { BulletListType } from './constants/BulletListType'; export { NumberingListType } from './constants/NumberingListType'; export { TableBorderFormat } from './constants/TableBorderFormat'; -export { ContentModelCachePlugin } from './corePlugin/ContentModelCachePlugin'; -export { ContentModelCopyPastePlugin } from './corePlugin/ContentModelCopyPastePlugin'; -export { ContentModelFormatPlugin } from './corePlugin/ContentModelFormatPlugin'; -export { ContentModelTypeInContainerPlugin } from './corePlugin/ContentModelTypeInContainerPlugin'; +export { createStandaloneEditorCorePlugins } from './corePlugin/createStandaloneEditorCorePlugins'; diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts index ca503a4feaf..801e7066e3c 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCachePluginTest.ts @@ -1,5 +1,5 @@ -import { ContentModelCachePlugin } from '../../lib/corePlugin/ContentModelCachePlugin'; -import { IEditor, PluginEventType } from 'roosterjs-editor-types'; +import { createContentModelCachePlugin } from '../../lib/corePlugin/ContentModelCachePlugin'; +import { IEditor, PluginEventType, PluginWithState } from 'roosterjs-editor-types'; import { ContentModelCachePluginState, ContentModelDomIndexer, @@ -7,8 +7,7 @@ import { } from 'roosterjs-content-model-types'; describe('ContentModelCachePlugin', () => { - let plugin: ContentModelCachePlugin; - let state: ContentModelCachePluginState; + let plugin: PluginWithState; let editor: IStandaloneEditor & IEditor; let addEventListenerSpy: jasmine.Spy; @@ -29,7 +28,6 @@ describe('ContentModelCachePlugin', () => { reconcileSelection: reconcileSelectionSpy, } as any; - state = {}; editor = ({ getDOMSelection: getDOMSelectionSpy, isInShadowEdit: isInShadowEditSpy, @@ -41,7 +39,7 @@ describe('ContentModelCachePlugin', () => { }, } as any) as IStandaloneEditor & IEditor; - plugin = new ContentModelCachePlugin(state); + plugin = createContentModelCachePlugin({}); plugin.initialize(editor); } @@ -70,9 +68,10 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(state).toEqual({ + expect(plugin.getState()).toEqual({ cachedModel: undefined, cachedSelection: undefined, + domIndexer: undefined, }); }); @@ -84,10 +83,15 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined }); + expect(plugin.getState()).toEqual({ + cachedModel: undefined, + cachedSelection: undefined, + domIndexer: undefined, + }); }); it('Other key with collapsed selection', () => { + const state = plugin.getState(); state.cachedSelection = { type: 'range', range: { collapsed: true } as any, @@ -102,10 +106,12 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedSelection: { type: 'range', range: { collapsed: true } as any }, + domIndexer: undefined, }); }); it('Expanded selection with text input', () => { + const state = plugin.getState(); state.cachedSelection = { type: 'range', range: { collapsed: false } as any, @@ -118,10 +124,15 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined }); + expect(state).toEqual({ + cachedModel: undefined, + cachedSelection: undefined, + domIndexer: undefined, + }); }); it('Expanded selection with arrow input', () => { + const state = plugin.getState(); state.cachedSelection = { type: 'range', range: { collapsed: false } as any, @@ -139,10 +150,12 @@ describe('ContentModelCachePlugin', () => { type: 'range', range: { collapsed: false } as any, }, + domIndexer: undefined, }); }); it('Table selection', () => { + const state = plugin.getState(); state.cachedSelection = { type: 'table', } as any; @@ -154,10 +167,15 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined }); + expect(state).toEqual({ + cachedModel: undefined, + cachedSelection: undefined, + domIndexer: undefined, + }); }); it('Image selection', () => { + const state = plugin.getState(); state.cachedSelection = { type: 'image', } as any; @@ -169,10 +187,15 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined }); + expect(state).toEqual({ + cachedModel: undefined, + cachedSelection: undefined, + domIndexer: undefined, + }); }); it('Do not clear cache when in shadow edit', () => { + const state = plugin.getState(); isInShadowEditSpy.and.returnValue(true); plugin.onPluginEvent({ @@ -182,7 +205,9 @@ describe('ContentModelCachePlugin', () => { } as any, }); - expect(state).toEqual({}); + expect(state).toEqual({ + domIndexer: undefined, + }); }); }); @@ -193,6 +218,7 @@ describe('ContentModelCachePlugin', () => { }); it('No cached range, no cached model', () => { + const state = plugin.getState(); state.cachedModel = undefined; state.cachedSelection = undefined; @@ -207,12 +233,14 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, + domIndexer: undefined, }); }); it('No cached range, has cached model', () => { const selection = 'MockedRange' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = undefined; @@ -227,12 +255,14 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, + domIndexer: undefined, }); }); it('No cached range, has cached model, reconcile succeed', () => { const selection = 'MockedRange' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = undefined; @@ -257,6 +287,7 @@ describe('ContentModelCachePlugin', () => { const oldRangeEx = 'MockedRangeOld' as any; const newRangeEx = 'MockedRangeNew' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = oldRangeEx; @@ -270,6 +301,7 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, + domIndexer: undefined, }); }); @@ -277,6 +309,7 @@ describe('ContentModelCachePlugin', () => { const oldRangeEx = 'MockedRangeOld' as any; const newRangeEx = 'MockedRangeNew' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = oldRangeEx; @@ -307,6 +340,7 @@ describe('ContentModelCachePlugin', () => { it('Same range', () => { const selection = 'MockedRange' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = selection; @@ -332,6 +366,7 @@ describe('ContentModelCachePlugin', () => { const oldRangeEx = 'MockedRangeOld' as any; const newRangeEx = 'MockedRangeNew' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = oldRangeEx; @@ -357,6 +392,7 @@ describe('ContentModelCachePlugin', () => { const oldRangeEx = 'MockedRangeOld' as any; const newRangeEx = 'MockedRangeNew' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = oldRangeEx; @@ -387,6 +423,7 @@ describe('ContentModelCachePlugin', () => { it('No domIndexer, no model in event', () => { const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = undefined; @@ -399,6 +436,7 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, + domIndexer: undefined, }); expect(reconcileSelectionSpy).not.toHaveBeenCalled(); }); @@ -407,6 +445,7 @@ describe('ContentModelCachePlugin', () => { const oldRangeEx = 'MockedRangeOld' as any; const newRangeEx = 'MockedRangeNew' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = oldRangeEx; @@ -423,6 +462,7 @@ describe('ContentModelCachePlugin', () => { expect(state).toEqual({ cachedModel: undefined, cachedSelection: undefined, + domIndexer: undefined, }); expect(reconcileSelectionSpy).not.toHaveBeenCalled(); }); @@ -431,6 +471,7 @@ describe('ContentModelCachePlugin', () => { const oldRangeEx = 'MockedRangeOld' as any; const newRangeEx = 'MockedRangeNew' as any; const model = 'MockedModel' as any; + const state = plugin.getState(); state.cachedModel = model; state.cachedSelection = oldRangeEx; diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts index 40ae5ce1799..26fc2ab2e6c 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelCopyPastePluginTest.ts @@ -17,13 +17,14 @@ import { IStandaloneEditor, } from 'roosterjs-content-model-types'; import { - ContentModelCopyPastePlugin, + createContentModelCopyPastePlugin, onNodeCreated, } from '../../lib/corePlugin/ContentModelCopyPastePlugin'; import { ClipboardData, ColorTransformDirection, DOMEventHandlerFunction, + EditorPlugin, IEditor, } from 'roosterjs-editor-types'; @@ -36,7 +37,7 @@ const allowedCustomPasteType = ['Test']; describe('ContentModelCopyPastePlugin |', () => { let editor: IEditor = null!; - let plugin: ContentModelCopyPastePlugin; + let plugin: EditorPlugin; let domEvents: Record = {}; let div: HTMLDivElement; @@ -93,7 +94,7 @@ describe('ContentModelCopyPastePlugin |', () => { spyOn(addRangeToSelection, 'addRangeToSelection'); - plugin = new ContentModelCopyPastePlugin({ + plugin = createContentModelCopyPastePlugin({ allowedCustomPasteType, }); editor = ({ diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts index ba2bfeb59fb..a9eaa784f5b 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts @@ -1,11 +1,7 @@ import * as applyPendingFormat from '../../lib/corePlugin/utils/applyPendingFormat'; -import { ContentModelFormatPlugin } from '../../lib/corePlugin/ContentModelFormatPlugin'; +import { createContentModelFormatPlugin } from '../../lib/corePlugin/ContentModelFormatPlugin'; import { IEditor, PluginEventType } from 'roosterjs-editor-types'; -import { - ContentModelFormatPluginState, - IStandaloneEditor, - PendingFormat, -} from 'roosterjs-content-model-types'; +import { IStandaloneEditor } from 'roosterjs-content-model-types'; import { addSegment, createContentModelDocument, @@ -26,11 +22,7 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, isDarkMode: () => false, } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: ({} as any) as PendingFormat, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({}); plugin.initialize(editor); plugin.onPluginEvent({ @@ -41,7 +33,7 @@ describe('ContentModelFormatPlugin', () => { plugin.dispose(); expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); - expect(state.pendingFormat).toBeNull(); + expect(plugin.getState().pendingFormat).toBeNull(); }); it('no selection, trigger input event', () => { @@ -52,16 +44,15 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, getEnvironment: () => ({}), } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: { - format: mockedFormat, - } as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({}); const model = createContentModelDocument(); - plugin.initialize(editor); + const state = plugin.getState(); + + (state.pendingFormat = { + format: mockedFormat, + } as any), + plugin.initialize(editor); plugin.onPluginEvent({ eventType: PluginEventType.Input, @@ -90,14 +81,14 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, getEnvironment: () => ({}), } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: { - format: mockedFormat, - } as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({}); plugin.initialize(editor); + + const state = plugin.getState(); + + state.pendingFormat = { + format: mockedFormat, + } as any; plugin.onPluginEvent({ eventType: PluginEventType.Input, rawEvent: ({ data: 'a', isComposing: true } as any) as InputEvent, @@ -107,7 +98,7 @@ describe('ContentModelFormatPlugin', () => { expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); expect(state.pendingFormat).toEqual({ format: mockedFormat, - }); + } as any); }); it('with pending format and selection, trigger CompositionEnd event', () => { @@ -124,13 +115,12 @@ describe('ContentModelFormatPlugin', () => { triggerPluginEvent, getVisibleViewport, } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: { - format: mockedFormat, - } as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({}); + const state = plugin.getState(); + + state.pendingFormat = { + format: mockedFormat, + } as any; plugin.initialize(editor); plugin.onPluginEvent({ @@ -154,14 +144,16 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, cacheContentModel: () => {}, } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: { - format: mockedFormat, - } as any, - }; - const plugin = new ContentModelFormatPlugin(state); + + const plugin = createContentModelFormatPlugin({}); plugin.initialize(editor); + + const state = plugin.getState(); + + state.pendingFormat = { + format: mockedFormat, + } as any; + plugin.onPluginEvent({ eventType: PluginEventType.KeyDown, rawEvent: ({ which: 17 } as any) as KeyboardEvent, @@ -171,7 +163,7 @@ describe('ContentModelFormatPlugin', () => { expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); expect(state.pendingFormat).toEqual({ format: mockedFormat, - }); + } as any); }); it('Content changed event', () => { @@ -184,13 +176,13 @@ describe('ContentModelFormatPlugin', () => { }, cacheContentModel: () => {}, } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: { - format: mockedFormat, - } as any, - }; - const plugin = new ContentModelFormatPlugin(state); + + const plugin = createContentModelFormatPlugin({}); + const state = plugin.getState(); + + state.pendingFormat = { + format: mockedFormat, + } as any; spyOn(plugin as any, 'canApplyPendingFormat').and.returnValue(false); @@ -213,13 +205,13 @@ describe('ContentModelFormatPlugin', () => { createContentModel: () => model, cacheContentModel: () => {}, } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: { - format: mockedFormat, - } as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({}); + + const state = plugin.getState(); + + state.pendingFormat = { + format: mockedFormat, + } as any; spyOn(plugin as any, 'canApplyPendingFormat').and.returnValue(false); @@ -243,13 +235,12 @@ describe('ContentModelFormatPlugin', () => { cacheContentModel: () => {}, getEnvironment: () => ({}), } as any) as IStandaloneEditor & IEditor; - const state = { - defaultFormat: {}, - pendingFormat: { - format: mockedFormat, - } as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({}); + const state = plugin.getState(); + + state.pendingFormat = { + format: mockedFormat, + } as any; spyOn(plugin as any, 'canApplyPendingFormat').and.returnValue(true); @@ -263,7 +254,7 @@ describe('ContentModelFormatPlugin', () => { expect(applyPendingFormat.applyPendingFormat).not.toHaveBeenCalled(); expect(state.pendingFormat).toEqual({ format: mockedFormat, - }); + } as any); expect((plugin as any).canApplyPendingFormat).toHaveBeenCalledTimes(1); }); }); @@ -297,11 +288,11 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, text input, under editor directly', () => { - const state: ContentModelFormatPluginState = { - defaultFormat: { fontFamily: 'Arial' }, - pendingFormat: null, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({ + defaultFormat: { + fontFamily: 'Arial', + }, + }); const rawEvent = { key: 'a' } as any; getDOMSelection.and.returnValue({ @@ -346,16 +337,24 @@ describe('ContentModelFormatPlugin for default format', () => { }); expect(context).toEqual({ - newPendingFormat: { fontFamily: 'Arial' }, + newPendingFormat: { + fontFamily: 'Arial', + fontWeight: undefined, + italic: undefined, + underline: undefined, + fontSize: undefined, + textColor: undefined, + backgroundColor: undefined, + }, }); }); it('Expanded range, text input, under editor directly', () => { - const state = { - defaultFormat: { fontFamily: 'Arial' }, - pendingFormat: null as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({ + defaultFormat: { + fontFamily: 'Arial', + }, + }); const rawEvent = { key: 'a' } as any; const context = {} as any; @@ -404,11 +403,11 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, IME input, under editor directly', () => { - const state = { - defaultFormat: { fontFamily: 'Arial' }, - pendingFormat: null as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({ + defaultFormat: { + fontFamily: 'Arial', + }, + }); const rawEvent = { key: 'Process' } as any; const context = {} as any; @@ -452,16 +451,24 @@ describe('ContentModelFormatPlugin for default format', () => { }); expect(context).toEqual({ - newPendingFormat: { fontFamily: 'Arial' }, + newPendingFormat: { + fontFamily: 'Arial', + fontWeight: undefined, + italic: undefined, + underline: undefined, + fontSize: undefined, + textColor: undefined, + backgroundColor: undefined, + }, }); }); it('Collapsed range, other input, under editor directly', () => { - const state: ContentModelFormatPluginState = { - defaultFormat: { fontFamily: 'Arial' }, - pendingFormat: null as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({ + defaultFormat: { + fontFamily: 'Arial', + }, + }); const rawEvent = { key: 'Up' } as any; const context = {} as any; @@ -508,11 +515,11 @@ describe('ContentModelFormatPlugin for default format', () => { }); it('Collapsed range, normal input, not under editor directly, no style', () => { - const state = { - defaultFormat: { fontFamily: 'Arial' }, - pendingFormat: null as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({ + defaultFormat: { + fontFamily: 'Arial', + }, + }); const rawEvent = { key: 'a' } as any; const div = document.createElement('div'); const context = {} as any; @@ -558,16 +565,24 @@ describe('ContentModelFormatPlugin for default format', () => { }); expect(context).toEqual({ - newPendingFormat: { fontFamily: 'Arial' }, + newPendingFormat: { + fontFamily: 'Arial', + fontWeight: undefined, + italic: undefined, + underline: undefined, + fontSize: undefined, + textColor: undefined, + backgroundColor: undefined, + }, }); }); it('Collapsed range, text input, under editor directly, has pending format', () => { - const state = { - defaultFormat: { fontFamily: 'Arial' }, - pendingFormat: null as any, - }; - const plugin = new ContentModelFormatPlugin(state); + const plugin = createContentModelFormatPlugin({ + defaultFormat: { + fontFamily: 'Arial', + }, + }); const rawEvent = { key: 'a' } as any; const context = {} as any; @@ -617,7 +632,15 @@ describe('ContentModelFormatPlugin for default format', () => { }); expect(context).toEqual({ - newPendingFormat: { fontFamily: 'Arial', fontSize: '10pt' }, + newPendingFormat: { + fontFamily: 'Arial', + fontSize: '10pt', + fontWeight: undefined, + italic: undefined, + underline: undefined, + textColor: undefined, + backgroundColor: undefined, + }, }); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/createContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/createContentModelEditorCoreTest.ts deleted file mode 100644 index 138e9e8e4e5..00000000000 --- a/packages-content-model/roosterjs-content-model-core/test/editor/createContentModelEditorCoreTest.ts +++ /dev/null @@ -1,237 +0,0 @@ -import * as ContentModelCachePlugin from '../../lib/corePlugin/ContentModelCachePlugin'; -import * as ContentModelCopyPastePlugin from '../../lib/corePlugin/ContentModelCopyPastePlugin'; -import * as ContentModelFormatPlugin from '../../lib/corePlugin/ContentModelFormatPlugin'; -import * as createEditorCore from 'roosterjs-content-model-editor/lib/editor/createEditorCore'; -import * as promoteToContentModelEditorCore from '../../lib/editor/promoteToContentModelEditorCore'; -import { contentModelDomIndexer } from '../../lib/corePlugin/utils/contentModelDomIndexer'; -import { ContentModelTypeInContainerPlugin } from '../../lib/corePlugin/ContentModelTypeInContainerPlugin'; -import { createContentModelEditorCore } from '../../lib/editor/createContentModelEditorCore'; -import { EditorOptions } from 'roosterjs-editor-types'; -import { StandaloneEditorOptions } from 'roosterjs-content-model-types'; - -const mockedSwitchShadowEdit = 'SHADOWEDIT' as any; -const mockedFormatPlugin = 'FORMATPLUGIN' as any; -const mockedCachePlugin = 'CACHPLUGIN' as any; -const mockedCopyPastePlugin = 'COPYPASTE' as any; -const mockedCopyPastePlugin2 = 'COPYPASTE2' as any; - -describe('createContentModelEditorCore', () => { - let createEditorCoreSpy: jasmine.Spy; - let promoteToContentModelEditorCoreSpy: jasmine.Spy; - let mockedCore: any; - let contentDiv: any; - - beforeEach(() => { - contentDiv = { - style: {}, - } as any; - - mockedCore = { - lifecycle: { - experimentalFeatures: [], - }, - api: { - switchShadowEdit: mockedSwitchShadowEdit, - }, - originalApi: { - a: 'b', - }, - contentDiv, - } as any; - - createEditorCoreSpy = spyOn(createEditorCore, 'createEditorCore').and.returnValue( - mockedCore - ); - promoteToContentModelEditorCoreSpy = spyOn( - promoteToContentModelEditorCore, - 'promoteToContentModelEditorCore' - ); - spyOn(ContentModelFormatPlugin, 'createContentModelFormatPlugin').and.returnValue( - mockedFormatPlugin - ); - spyOn(ContentModelCachePlugin, 'createContentModelCachePlugin').and.returnValue( - mockedCachePlugin - ); - spyOn(ContentModelCopyPastePlugin, 'createContentModelCopyPastePlugin').and.returnValue( - mockedCopyPastePlugin - ); - }); - - it('No additional option', () => { - const core = createContentModelEditorCore(contentDiv, {}, createEditorCoreSpy); - - const expectedOptions = { - plugins: [mockedCachePlugin, mockedFormatPlugin], - corePluginOverride: { - typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: mockedCopyPastePlugin, - }, - }; - const expectedPluginState: any = { - cache: { domIndexer: undefined }, - copyPaste: { allowedCustomPasteType: [] }, - format: { - defaultFormat: { - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, - }, - pendingFormat: null, - }, - }; - - expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, expectedOptions); - expect(promoteToContentModelEditorCoreSpy).toHaveBeenCalledWith( - core, - expectedOptions, - expectedPluginState - ); - }); - - it('With additional option', () => { - const defaultDomToModelOptions = { a: '1' } as any; - const defaultModelToDomOptions = { b: '2' } as any; - - const options = { - defaultDomToModelOptions, - defaultModelToDomOptions, - corePluginOverride: { - copyPaste: mockedCopyPastePlugin2, - }, - }; - const core = createContentModelEditorCore(contentDiv, options, createEditorCoreSpy); - - const expectedOptions = { - defaultDomToModelOptions, - defaultModelToDomOptions, - plugins: [mockedCachePlugin, mockedFormatPlugin], - corePluginOverride: { - typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: mockedCopyPastePlugin2, - }, - }; - const expectedPluginState: any = { - cache: { domIndexer: undefined }, - copyPaste: { allowedCustomPasteType: [] }, - format: { - defaultFormat: { - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, - }, - pendingFormat: null, - }, - }; - - expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, expectedOptions); - expect(promoteToContentModelEditorCoreSpy).toHaveBeenCalledWith( - core, - expectedOptions, - expectedPluginState - ); - }); - - it('With default format', () => { - const options = { - defaultFormat: { - bold: true, - italic: true, - underline: true, - fontFamily: 'Arial', - fontSize: '10pt', - textColor: 'red', - backgroundColor: 'blue', - }, - }; - - const core = createContentModelEditorCore(contentDiv, options, createEditorCoreSpy); - - const expectedOptions = { - plugins: [mockedCachePlugin, mockedFormatPlugin], - corePluginOverride: { - typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: mockedCopyPastePlugin, - }, - defaultFormat: { - bold: true, - italic: true, - underline: true, - fontFamily: 'Arial', - fontSize: '10pt', - textColor: 'red', - backgroundColor: 'blue', - }, - }; - const expectedPluginState: any = { - cache: { domIndexer: undefined }, - copyPaste: { allowedCustomPasteType: [] }, - format: { - defaultFormat: { - fontWeight: 'bold', - italic: true, - underline: true, - fontFamily: 'Arial', - fontSize: '10pt', - textColor: 'red', - backgroundColor: 'blue', - }, - pendingFormat: null, - }, - }; - - expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, expectedOptions); - expect(promoteToContentModelEditorCoreSpy).toHaveBeenCalledWith( - core, - expectedOptions, - expectedPluginState - ); - }); - - it('Allow dom indexer', () => { - const options: StandaloneEditorOptions & EditorOptions = { - cacheModel: true, - }; - - const core = createContentModelEditorCore(contentDiv, options, createEditorCoreSpy); - - const expectedOptions = { - plugins: [mockedCachePlugin, mockedFormatPlugin], - corePluginOverride: { - typeInContainer: new ContentModelTypeInContainerPlugin(), - copyPaste: mockedCopyPastePlugin, - }, - cacheModel: true, - }; - const expectedPluginState: any = { - cache: { domIndexer: contentModelDomIndexer }, - copyPaste: { allowedCustomPasteType: [] }, - format: { - defaultFormat: { - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, - }, - pendingFormat: null, - }, - }; - - expect(createEditorCoreSpy).toHaveBeenCalledWith(contentDiv, expectedOptions); - expect(promoteToContentModelEditorCoreSpy).toHaveBeenCalledWith( - core, - expectedOptions, - expectedPluginState - ); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/promoteToContentModelEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/promoteToContentModelEditorCoreTest.ts deleted file mode 100644 index 3b9d35f8840..00000000000 --- a/packages-content-model/roosterjs-content-model-core/test/editor/promoteToContentModelEditorCoreTest.ts +++ /dev/null @@ -1,180 +0,0 @@ -import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; -import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; -import { ContentModelPluginState } from 'roosterjs-content-model-types'; -import { createContentModel } from '../../lib/coreApi/createContentModel'; -import { createEditorContext } from '../../lib/coreApi/createEditorContext'; -import { EditorCore } from 'roosterjs-editor-types'; -import { formatContentModel } from '../../lib/coreApi/formatContentModel'; -import { getDOMSelection } from '../../lib/coreApi/getDOMSelection'; -import { promoteToContentModelEditorCore } from '../../lib/editor/promoteToContentModelEditorCore'; -import { setContentModel } from '../../lib/coreApi/setContentModel'; -import { setDOMSelection } from '../../lib/coreApi/setDOMSelection'; -import { switchShadowEdit } from '../../lib/coreApi/switchShadowEdit'; -import { tablePreProcessor } from '../../lib/override/tablePreProcessor'; -import { - listItemMetadataApplier, - listLevelMetadataApplier, -} from '../../lib/metadata/updateListMetadata'; - -describe('promoteToContentModelEditorCore', () => { - let pluginState: ContentModelPluginState; - let core: EditorCore; - const mockedSwitchShadowEdit = 'SHADOWEDIT' as any; - const mockedDomToModelConfig = { - config: 'mockedDomToModelConfig', - } as any; - const mockedModelToDomConfig = { - config: 'mockedModelToDomConfig', - } as any; - - const baseResult: any = { - contentDiv: null!, - darkColorHandler: null!, - domEvent: null!, - edit: null!, - entity: null!, - getVisibleViewport: null!, - lifecycle: null!, - pendingFormatState: null!, - trustedHTMLHandler: null!, - undo: null!, - sizeTransformer: null!, - zoomScale: 1, - plugins: [], - }; - - beforeEach(() => { - pluginState = { - cache: {}, - copyPaste: { allowedCustomPasteType: [] }, - format: { - defaultFormat: {}, - pendingFormat: null, - }, - }; - core = { - ...baseResult, - api: { - switchShadowEdit: mockedSwitchShadowEdit, - } as any, - originalApi: { - switchShadowEdit: mockedSwitchShadowEdit, - } as any, - copyPaste: { allowedCustomPasteType: [] }, - }; - - spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue( - mockedDomToModelConfig - ); - spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue( - mockedModelToDomConfig - ); - }); - - it('No additional option', () => { - promoteToContentModelEditorCore(core, {}, pluginState); - - expect(core).toEqual({ - ...baseResult, - api: { - switchShadowEdit, - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - originalApi: { - switchShadowEdit: mockedSwitchShadowEdit, - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - defaultDomToModelOptions: [ - { processorOverride: { table: tablePreProcessor } }, - undefined, - ], - defaultModelToDomOptions: [ - { - metadataAppliers: { - listItem: listItemMetadataApplier, - listLevel: listLevelMetadataApplier, - }, - }, - undefined, - ], - defaultDomToModelConfig: mockedDomToModelConfig, - defaultModelToDomConfig: mockedModelToDomConfig, - format: { - defaultFormat: {}, - pendingFormat: null, - }, - cache: {}, - copyPaste: { allowedCustomPasteType: [] }, - environment: { isMac: false, isAndroid: false }, - } as any); - }); - - it('With additional option', () => { - const defaultDomToModelOptions = { a: '1' } as any; - const defaultModelToDomOptions = { b: '2' } as any; - const mockedPlugin = 'PLUGIN' as any; - const options = { - defaultDomToModelOptions, - defaultModelToDomOptions, - corePluginOverride: { - copyPaste: mockedPlugin, - }, - }; - - promoteToContentModelEditorCore(core, options, pluginState); - - expect(core).toEqual({ - ...baseResult, - api: { - switchShadowEdit, - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - originalApi: { - switchShadowEdit: mockedSwitchShadowEdit, - createEditorContext, - createContentModel, - setContentModel, - getDOMSelection, - setDOMSelection, - formatContentModel, - }, - defaultDomToModelOptions: [ - { processorOverride: { table: tablePreProcessor } }, - defaultDomToModelOptions, - ], - defaultModelToDomOptions: [ - { - metadataAppliers: { - listItem: listItemMetadataApplier, - listLevel: listLevelMetadataApplier, - }, - }, - defaultModelToDomOptions, - ], - defaultDomToModelConfig: mockedDomToModelConfig, - defaultModelToDomConfig: mockedModelToDomConfig, - format: { - defaultFormat: {}, - pendingFormat: null, - }, - cache: {}, - copyPaste: { allowedCustomPasteType: [] }, - environment: { isMac: false, isAndroid: false }, - } as any); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/model/pasteTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/pasteTest.ts index 12782283455..b0e61a6a330 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/model/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/model/pasteTest.ts @@ -12,7 +12,6 @@ import * as WordDesktopFile from '../../../../roosterjs-content-model-plugins/li import { ContentModelEditor } from 'roosterjs-content-model-editor'; import { ContentModelPastePlugin } from '../../../../roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin'; import { createContentModelDocument, tableProcessor } from 'roosterjs-content-model-dom'; -import { createContentModelEditorCore } from 'roosterjs-content-model-core'; import { ContentModelDocument, DomToModelOption, @@ -231,7 +230,7 @@ describe('paste with content model & paste plugin', () => { beforeEach(() => { div = document.createElement('div'); document.body.appendChild(div); - editor = new ContentModelEditor(div, createContentModelEditorCore, { + editor = new ContentModelEditor(div, { plugins: [new ContentModelPastePlugin()], }); spyOn(addParserF, 'default').and.callThrough(); @@ -378,7 +377,7 @@ describe('paste with content model & paste plugin', () => { }; let eventChecker: BeforePasteEvent = {}; - editor = new ContentModelEditor(div!, createContentModelEditorCore, { + editor = new ContentModelEditor(div!, { plugins: [ { initialize: () => {}, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts index dd12197703f..eea4772dbef 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts @@ -16,15 +16,16 @@ import { selectImage } from './selectImage'; import { selectRange } from './selectRange'; import { selectTable } from './selectTable'; import { setContent } from './setContent'; -import { switchShadowEdit } from './switchShadowEdit'; +import { standaloneCoreApiMap } from 'roosterjs-content-model-core'; import { transformColor } from './transformColor'; import { triggerEvent } from './triggerEvent'; -import type { CoreApiMap } from 'roosterjs-editor-types'; +import type { ContentModelCoreApiMap } from '../publicTypes/ContentModelEditorCore'; /** * @internal */ -export const coreApiMap: CoreApiMap = { +export const coreApiMap: ContentModelCoreApiMap = { + ...standaloneCoreApiMap, attachDomEvent, addUndoSnapshot, createPasteFragment, @@ -41,7 +42,6 @@ export const coreApiMap: CoreApiMap = { select, selectRange, setContent, - switchShadowEdit, transformColor, triggerEvent, selectTable, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/switchShadowEdit.ts deleted file mode 100644 index 5d3dd632aaf..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/switchShadowEdit.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { - createRange, - getSelectionPath, - moveContentWithEntityPlaceholders, - restoreContentWithEntityPlaceholder, -} from 'roosterjs-editor-dom'; -import type { EditorCore, SelectionRangeEx, SwitchShadowEdit } from 'roosterjs-editor-types'; - -/** - * @internal - */ -export const switchShadowEdit: SwitchShadowEdit = (core: EditorCore, isOn: boolean): void => { - const { lifecycle, contentDiv } = core; - let { - shadowEditEntities, - shadowEditFragment, - shadowEditSelectionPath, - shadowEditTableSelectionPath, - shadowEditImageSelectionPath, - } = lifecycle; - const wasInShadowEdit = !!shadowEditFragment; - - const getShadowEditSelectionPath = ( - selectionType: SelectionRangeTypes, - shadowEditSelection?: SelectionRangeEx - ) => { - return ( - (shadowEditSelection?.type == selectionType && - shadowEditSelection.ranges - .map(range => getSelectionPath(contentDiv, range)) - .map(w => w!!)) || - null - ); - }; - - if (isOn) { - if (!wasInShadowEdit) { - const selection = core.api.getSelectionRangeEx(core); - const range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); - - shadowEditSelectionPath = range && getSelectionPath(contentDiv, range); - shadowEditTableSelectionPath = getShadowEditSelectionPath( - SelectionRangeTypes.TableSelection, - selection - ); - shadowEditImageSelectionPath = getShadowEditSelectionPath( - SelectionRangeTypes.ImageSelection, - selection - ); - - shadowEditEntities = {}; - shadowEditFragment = moveContentWithEntityPlaceholders(contentDiv, shadowEditEntities); - - core.api.triggerEvent( - core, - { - eventType: PluginEventType.EnteredShadowEdit, - fragment: shadowEditFragment, - selectionPath: shadowEditSelectionPath, - }, - false /*broadcast*/ - ); - - lifecycle.shadowEditFragment = shadowEditFragment; - lifecycle.shadowEditSelectionPath = shadowEditSelectionPath; - lifecycle.shadowEditTableSelectionPath = shadowEditTableSelectionPath; - lifecycle.shadowEditImageSelectionPath = shadowEditImageSelectionPath; - lifecycle.shadowEditEntities = shadowEditEntities; - } - - if (lifecycle.shadowEditFragment) { - restoreContentWithEntityPlaceholder( - lifecycle.shadowEditFragment, - contentDiv, - lifecycle.shadowEditEntities, - true /*insertClonedNode*/ - ); - } - } else { - lifecycle.shadowEditFragment = null; - lifecycle.shadowEditSelectionPath = null; - lifecycle.shadowEditEntities = null; - - if (wasInShadowEdit) { - core.api.triggerEvent( - core, - { - eventType: PluginEventType.LeavingShadowEdit, - }, - false /*broadcast*/ - ); - - if (shadowEditFragment) { - restoreContentWithEntityPlaceholder( - shadowEditFragment, - contentDiv, - shadowEditEntities - ); - } - - if (shadowEditSelectionPath) { - core.domEvent.selectionRange = createRange( - contentDiv, - shadowEditSelectionPath.start, - shadowEditSelectionPath.end - ); - } - } - } -}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/CopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/CopyPastePlugin.ts deleted file mode 100644 index b968d9a12cb..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/CopyPastePlugin.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { forEachSelectedCell } from './utils/forEachSelectedCell'; -import { removeCellsOutsideSelection } from './utils/removeCellsOutsideSelection'; -import { - addRangeToSelection, - createElement, - extractClipboardEvent, - moveChildNodes, - Browser, - setHtmlWithMetadata, - createRange, - VTable, - isWholeTableSelected, -} from 'roosterjs-editor-dom'; -import type { - CopyPastePluginState, - EditorOptions, - IEditor, - PluginWithState, - SelectionRangeEx, - TableSelection, -} from 'roosterjs-editor-types'; -import { - ChangeSource, - GetContentMode, - PluginEventType, - KnownCreateElementDataIndex, - SelectionRangeTypes, - TableOperation, -} from 'roosterjs-editor-types'; - -/** - * @internal - * Copy and paste plugin for handling onCopy and onPaste event - */ -export default class CopyPastePlugin implements PluginWithState { - private editor: IEditor | null = null; - private disposer: (() => void) | null = null; - private state: CopyPastePluginState; - - /** - * Construct a new instance of CopyPastePlugin - * @param options The editor options - */ - constructor(options: EditorOptions) { - this.state = { - allowedCustomPasteType: options.allowedCustomPasteType || [], - }; - } - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'CopyPaste'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - this.disposer = this.editor.addDomEventHandler({ - paste: e => this.onPaste(e), - copy: e => this.onCutCopy(e, false /*isCut*/), - cut: e => this.onCutCopy(e, true /*isCut*/), - }); - } - - /** - * Dispose this plugin - */ - dispose() { - if (this.disposer) { - this.disposer(); - } - this.disposer = null; - this.editor = null; - } - - /** - * Get plugin state object - */ - getState() { - return this.state; - } - - private onCutCopy(event: Event, isCut: boolean) { - if (this.editor) { - const selection = this.editor.getSelectionRangeEx(); - if (selection && !selection.areAllCollapsed) { - const html = this.editor.getContent(GetContentMode.RawHTMLWithSelection); - const tempDiv = this.getTempDiv(this.editor, true /*forceInLightMode*/); - const metadata = setHtmlWithMetadata( - tempDiv, - html, - this.editor.getTrustedHTMLHandler() - ); - let newRange: Range | null = null; - - if ( - selection.type === SelectionRangeTypes.TableSelection && - selection.coordinates - ) { - const table = tempDiv.querySelector( - `#${selection.table.id}` - ) as HTMLTableElement; - newRange = this.createTableRange(table, selection.coordinates); - if (isCut) { - this.deleteTableContent( - this.editor, - selection.table, - selection.coordinates - ); - } - } else if (selection.type === SelectionRangeTypes.ImageSelection) { - const image = tempDiv.querySelector('#' + selection.image.id); - - if (image) { - newRange = createRange(image); - if (isCut) { - this.deleteImage(this.editor, selection.image.id); - } - } - } else { - newRange = - metadata?.type === SelectionRangeTypes.Normal - ? createRange(tempDiv, metadata.start, metadata.end) - : null; - } - if (newRange) { - const cutCopyEvent = this.editor.triggerPluginEvent( - PluginEventType.BeforeCutCopy, - { - clonedRoot: tempDiv, - range: newRange, - rawEvent: event as ClipboardEvent, - isCut, - } - ); - - if (cutCopyEvent.range) { - addRangeToSelection(newRange); - } - - this.editor.runAsync(editor => { - this.cleanUpAndRestoreSelection(tempDiv, selection, !isCut /* isCopy */); - - if (isCut) { - editor.addUndoSnapshot(() => { - const position = editor.deleteSelectedContent(); - editor.focus(); - editor.select(position); - }, ChangeSource.Cut); - } - }); - } - } - } - } - - private onPaste = (event: Event) => { - let range: Range | null = null; - if (this.editor) { - const editor = this.editor; - extractClipboardEvent( - event as ClipboardEvent, - clipboardData => { - if (editor && !editor.isDisposed()) { - editor.paste(clipboardData); - } - }, - { - allowedCustomPasteType: this.state.allowedCustomPasteType, - getTempDiv: () => { - range = editor.getSelectionRange() ?? null; - return this.getTempDiv(editor); - }, - removeTempDiv: div => { - if (range) { - this.cleanUpAndRestoreSelection(div, range, false /* isCopy */); - } - }, - }, - this.editor.getSelectionRange() ?? undefined - ); - } - }; - - private getTempDiv(editor: IEditor, forceInLightMode?: boolean) { - const div = editor.getCustomData( - 'CopyPasteTempDiv', - () => { - const tempDiv = createElement( - KnownCreateElementDataIndex.CopyPasteTempDiv, - editor.getDocument() - ) as HTMLDivElement; - - editor.getDocument().body.appendChild(tempDiv); - - return tempDiv; - }, - tempDiv => tempDiv.parentNode?.removeChild(tempDiv) - ); - - if (forceInLightMode) { - div.style.backgroundColor = 'white'; - div.style.color = 'black'; - } - - div.style.display = ''; - div.focus(); - - return div; - } - - private cleanUpAndRestoreSelection( - tempDiv: HTMLDivElement, - range: Range | SelectionRangeEx, - isCopy: boolean - ) { - if (!!(range)?.type || (range).type == 0) { - const selection = range; - switch (selection.type) { - case SelectionRangeTypes.TableSelection: - case SelectionRangeTypes.ImageSelection: - this.editor?.select(selection); - break; - case SelectionRangeTypes.Normal: - const range = selection.ranges?.[0]; - this.restoreRange(range, isCopy); - break; - } - } else { - this.restoreRange(range, isCopy); - } - - tempDiv.style.backgroundColor = ''; - tempDiv.style.color = ''; - tempDiv.style.display = 'none'; - moveChildNodes(tempDiv); - } - - private restoreRange(range: Range, isCopy: boolean) { - if (range && this.editor) { - if (isCopy && Browser.isAndroid) { - range.collapse(); - } - this.editor.select(range); - } - } - - private createTableRange(table: HTMLTableElement, selection: TableSelection) { - const clonedVTable = new VTable(table as HTMLTableElement); - clonedVTable.selection = selection; - removeCellsOutsideSelection(clonedVTable); - clonedVTable.writeBack(); - return createRange(clonedVTable.table); - } - - private deleteTableContent( - editor: IEditor, - table: HTMLTableElement, - selection: TableSelection - ) { - const selectedVTable = new VTable(table); - selectedVTable.selection = selection; - - forEachSelectedCell(selectedVTable, cell => { - if (cell?.td) { - cell.td.innerHTML = editor.getTrustedHTMLHandler()('
                                                          '); - } - }); - - const wholeTableSelected = isWholeTableSelected(selectedVTable, selection); - const isWholeColumnSelected = - table.rows.length - 1 === selection.lastCell.y && selection.firstCell.y === 0; - if (wholeTableSelected) { - selectedVTable.edit(TableOperation.DeleteTable); - selectedVTable.writeBack(); - } else if (isWholeColumnSelected) { - selectedVTable.edit(TableOperation.DeleteColumn); - selectedVTable.writeBack(); - } - if (wholeTableSelected || isWholeColumnSelected) { - table.style.removeProperty('width'); - table.style.removeProperty('height'); - } - } - - private deleteImage(editor: IEditor, imageId: string) { - editor.queryElements('#' + imageId, node => { - editor.deleteNode(node); - }); - } -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/DOMEventPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/DOMEventPlugin.ts index 206ab44193e..e8434c8d646 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/DOMEventPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/DOMEventPlugin.ts @@ -11,7 +11,6 @@ import type { } from 'roosterjs-editor-types'; /** - * @internal * DOMEventPlugin handles customized DOM events, including: * 1. Keyboard event * 2. Mouse event @@ -22,7 +21,7 @@ import type { * 7. Scroll event * It contains special handling for Safari since Safari cannot get correct selection when onBlur event is triggered in editor. */ -export default class DOMEventPlugin implements PluginWithState { +class DOMEventPlugin implements PluginWithState { private editor: IEditor | null = null; private disposer: (() => void) | null = null; private state: DOMEventPluginState; @@ -257,3 +256,16 @@ export default class DOMEventPlugin implements PluginWithState { return !!(>source)?.getContextMenuItems; } + +/** + * @internal + * Create a new instance of DOMEventPlugin. + * @param option The editor option + * @param contentDiv The editor content DIV element + */ +export function createDOMEventPlugin( + option: EditorOptions, + contentDiv: HTMLDivElement +): PluginWithState { + return new DOMEventPlugin(option, contentDiv); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EditPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EditPlugin.ts index ea627186823..83fb33ea025 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EditPlugin.ts @@ -9,10 +9,9 @@ import type { } from 'roosterjs-editor-types'; /** - * @internal * Edit Component helps handle Content edit features */ -export default class EditPlugin implements PluginWithState { +class EditPlugin implements PluginWithState { private editor: IEditor | null = null; private state: EditPluginState; @@ -94,3 +93,11 @@ export default class EditPlugin implements PluginWithState { } } } + +/** + * @internal + * Create a new instance of EditPlugin. + */ +export function createEditPlugin(): PluginWithState { + return new EditPlugin(); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityPlugin.ts index df8d8198e33..e87553dff97 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityPlugin.ts @@ -59,10 +59,9 @@ const REMOVE_ENTITY_OPERATIONS: (EntityOperation | CompatibleEntityOperation)[] ]; /** - * @internal * Entity Plugin helps handle all operations related to an entity and generate entity specified events */ -export default class EntityPlugin implements PluginWithState { +class EntityPlugin implements PluginWithState { private editor: IEditor | null = null; private state: EntityPluginState; @@ -388,3 +387,11 @@ const workaroundSelectionIssueForIE = Browser.isIE }); } : () => {}; + +/** + * @internal + * Create a new instance of EntityPlugin. + */ +export function createEntityPlugin(): PluginWithState { + return new EntityPlugin(); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ImageSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ImageSelection.ts index d5e98228bcb..9871e9ce162 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ImageSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ImageSelection.ts @@ -7,10 +7,9 @@ const Delete = 'Delete'; const mouseMiddleButton = 1; /** - * @internal * Detect image selection and help highlight the image */ -export default class ImageSelection implements EditorPlugin { +class ImageSelection implements EditorPlugin { private editor: IEditor | null = null; /** @@ -98,3 +97,11 @@ export default class ImageSelection implements EditorPlugin { } } } + +/** + * @internal + * Create a new instance of ImageSelection. + */ +export function createImageSelection(): EditorPlugin { + return new ImageSelection(); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts index cd17471380c..62adfa22918 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts @@ -22,10 +22,9 @@ const DARK_MODE_DEFAULT_FORMAT = { }; /** - * @internal * Lifecycle plugin handles editor initialization and disposing */ -export default class LifecyclePlugin implements PluginWithState { +class LifecyclePlugin implements PluginWithState { private editor: IEditor | null = null; private state: LifecyclePluginState; private initialContent: string; @@ -186,3 +185,16 @@ export default class LifecyclePlugin implements PluginWithState { + return new LifecyclePlugin(option, contentDiv); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/MouseUpPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/MouseUpPlugin.ts index 2f072e3b10d..d1064c3203e 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/MouseUpPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/MouseUpPlugin.ts @@ -2,11 +2,10 @@ import { PluginEventType } from 'roosterjs-editor-types'; import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; /** - * @internal * MouseUpPlugin help trigger MouseUp event even when mouse up happens outside editor * as long as the mouse was pressed within Editor before */ -export default class MouseUpPlugin implements EditorPlugin { +class MouseUpPlugin implements EditorPlugin { private editor: IEditor | null = null; private mouseUpEventListerAdded: boolean = false; private mouseDownX: number | null = null; @@ -70,3 +69,11 @@ export default class MouseUpPlugin implements EditorPlugin { } }; } + +/** + * @internal + * Create a new instance of MouseUpPlugin. + */ +export function createMouseUpPlugin(): EditorPlugin { + return new MouseUpPlugin(); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts index 79ce990da14..f0f3cecda33 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/NormalizeTablePlugin.ts @@ -9,7 +9,6 @@ import { import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; /** - * @internal * TODO: Rename this plugin since it is not only for table now * * NormalizeTable plugin makes sure each table in editor has TBODY/THEAD/TFOOT tag around TR tags @@ -19,7 +18,7 @@ import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types' * deeply coupled with DOM structure. So we need to always make sure there is already TBODY tag whenever * new table is inserted, to make sure the selection path we created is correct. */ -export default class NormalizeTablePlugin implements EditorPlugin { +class NormalizeTablePlugin implements EditorPlugin { private editor: IEditor | null = null; /** @@ -178,3 +177,11 @@ function normalizeListsForExport(root: ParentNode) { } }); } + +/** + * @internal + * Create a new instance of NormalizeTablePlugin. + */ +export function createNormalizeTablePlugin(): EditorPlugin { + return new NormalizeTablePlugin(); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/PendingFormatStatePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/PendingFormatStatePlugin.ts index 4a726f9f504..db1311ab3d4 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/PendingFormatStatePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/PendingFormatStatePlugin.ts @@ -11,11 +11,9 @@ import type { const ZERO_WIDTH_SPACE = '\u200B'; /** - * @internal * PendingFormatStatePlugin handles pending format state management */ -export default class PendingFormatStatePlugin - implements PluginWithState { +class PendingFormatStatePlugin implements PluginWithState { private editor: IEditor | null = null; private state: PendingFormatStatePluginState; @@ -182,3 +180,11 @@ export default class PendingFormatStatePlugin return span; } } + +/** + * @internal + * Create a new instance of PendingFormatStatePlugin. + */ +export function createPendingFormatStatePlugin(): PluginWithState { + return new PendingFormatStatePlugin(); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/TypeInContainerPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/TypeInContainerPlugin.ts deleted file mode 100644 index 227c1f2f3f4..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/TypeInContainerPlugin.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { PluginEventType } from 'roosterjs-editor-types'; -import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; -import { - Browser, - findClosestElementAncestor, - getTagOfNode, - isCtrlOrMetaPressed, - Position, -} from 'roosterjs-editor-dom'; - -/** - * @internal - * Typing Component helps to ensure typing is always happening under a DOM container - */ -export default class TypeInContainerPlugin implements EditorPlugin { - private editor: IEditor | null = null; - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'TypeInContainer'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - } - - /** - * Dispose this plugin - */ - dispose() { - this.editor = null; - } - - private isRangeEmpty(range: Range) { - if ( - range.collapsed && - range.startContainer.nodeType === Node.ELEMENT_NODE && - getTagOfNode(range.startContainer) == 'DIV' && - !range.startContainer.firstChild - ) { - return true; - } - return false; - } - - /** - * Handle events triggered from editor - * @param event PluginEvent object - */ - onPluginEvent(event: PluginEvent) { - // We need to check if the ctrl key or the meta key is pressed, - // browsers like Safari fire the "keypress" event when the meta key is pressed. - if ( - event.eventType == PluginEventType.KeyPress && - this.editor && - !(event.rawEvent && isCtrlOrMetaPressed(event.rawEvent)) - ) { - // If normalization was not possible before the keypress, - // check again after the keyboard event has been processed by browser native behavior. - // - // This handles the case where the keyboard event that first inserts content happens when - // there is already content under the selection (e.g. Ctrl+a -> type new content). - // - // Only schedule when the range is not collapsed to catch this edge case. - const range = this.editor.getSelectionRange(); - - const styledAncestor = - range && - findClosestElementAncestor(range.startContainer, undefined /* root */, '[style]'); - - if (!range || (!this.isRangeEmpty(range) && this.editor.contains(styledAncestor))) { - return; - } - - if (range.collapsed) { - this.editor.ensureTypeInContainer(Position.getStart(range), event.rawEvent); - } else { - const callback = () => { - const focusedPosition = this.editor?.getFocusedPosition(); - if (focusedPosition) { - this.editor?.ensureTypeInContainer(focusedPosition, event.rawEvent); - } - }; - - if (Browser.isMobileOrTablet) { - this.editor.getDocument().defaultView?.setTimeout(callback, 100); - } else { - this.editor.runAsync(callback); - } - } - } - } -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts index 4cd9c12de9b..ffa9c2390dd 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts @@ -24,10 +24,9 @@ import { const MAX_SIZE_LIMIT = 1e7; /** - * @internal * Provides snapshot based undo service for Editor */ -export default class UndoPlugin implements PluginWithState { +class UndoPlugin implements PluginWithState { private editor: IEditor | null = null; private lastKeyPress: number | null = null; private state: UndoPluginState; @@ -277,3 +276,12 @@ function createUndoSnapshotServiceBridge( } : undefined; } + +/** + * @internal + * Create a new instance of UndoPlugin. + * @param option The editor option + */ +export function createUndoPlugin(option: EditorOptions): PluginWithState { + return new UndoPlugin(option); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts index b1d6d5b6d4d..ec9e7db7945 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts @@ -1,22 +1,27 @@ -import CopyPastePlugin from './CopyPastePlugin'; -import DOMEventPlugin from './DOMEventPlugin'; -import EditPlugin from './EditPlugin'; -import EntityPlugin from './EntityPlugin'; -import ImageSelection from './ImageSelection'; -import LifecyclePlugin from './LifecyclePlugin'; -import MouseUpPlugin from './MouseUpPlugin'; -import NormalizeTablePlugin from './NormalizeTablePlugin'; -import PendingFormatStatePlugin from './PendingFormatStatePlugin'; -import TypeInContainerPlugin from './TypeInContainerPlugin'; -import UndoPlugin from './UndoPlugin'; -import type { CorePlugins, EditorOptions, PluginState } from 'roosterjs-editor-types'; +import { createDOMEventPlugin } from './DOMEventPlugin'; +import { createEditPlugin } from './EditPlugin'; +import { createEntityPlugin } from './EntityPlugin'; +import { createImageSelection } from './ImageSelection'; +import { createLifecyclePlugin } from './LifecyclePlugin'; +import { createMouseUpPlugin } from './MouseUpPlugin'; +import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; +import { createPendingFormatStatePlugin } from './PendingFormatStatePlugin'; +import { createStandaloneEditorCorePlugins } from 'roosterjs-content-model-core'; +import { createUndoPlugin } from './UndoPlugin'; +import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; +import type { CorePlugins, PluginState } from 'roosterjs-editor-types'; +import type { + ContentModelPluginState, + StandaloneEditorCorePlugins, +} from 'roosterjs-content-model-types'; /** * @internal */ -export interface CreateCorePluginResponse extends CorePlugins { - _placeholder: null; -} +export type CreateCorePluginResponse = CorePlugins & + StandaloneEditorCorePlugins & { + _placeholder: null; + }; /** * @internal @@ -26,25 +31,25 @@ export interface CreateCorePluginResponse extends CorePlugins { */ export function createCorePlugins( contentDiv: HTMLDivElement, - options: EditorOptions + options: ContentModelEditorOptions ): CreateCorePluginResponse { const map = options.corePluginOverride || {}; + // The order matters, some plugin needs to be put before/after others to make sure event // can be handled in right order return { - typeInContainer: map.typeInContainer || new TypeInContainerPlugin(), - edit: map.edit || new EditPlugin(), - pendingFormatState: map.pendingFormatState || new PendingFormatStatePlugin(), + ...createStandaloneEditorCorePlugins(options), + edit: map.edit || createEditPlugin(), + pendingFormatState: map.pendingFormatState || createPendingFormatStatePlugin(), _placeholder: null, typeAfterLink: null!, //deprecated after firefox update - undo: map.undo || new UndoPlugin(options), - domEvent: map.domEvent || new DOMEventPlugin(options, contentDiv), - mouseUp: map.mouseUp || new MouseUpPlugin(), - copyPaste: map.copyPaste || new CopyPastePlugin(options), - entity: map.entity || new EntityPlugin(), - imageSelection: map.imageSelection || new ImageSelection(), - normalizeTable: map.normalizeTable || new NormalizeTablePlugin(), - lifecycle: map.lifecycle || new LifecyclePlugin(options, contentDiv), + undo: map.undo || createUndoPlugin(options), + domEvent: map.domEvent || createDOMEventPlugin(options, contentDiv), + mouseUp: map.mouseUp || createMouseUpPlugin(), + entity: map.entity || createEntityPlugin(), + imageSelection: map.imageSelection || createImageSelection(), + normalizeTable: map.normalizeTable || createNormalizeTablePlugin(), + lifecycle: map.lifecycle || createLifecyclePlugin(options, contentDiv), }; } @@ -53,7 +58,9 @@ export function createCorePlugins( * Get plugin state of core plugins * @param corePlugins CorePlugins object */ -export function getPluginState(corePlugins: CorePlugins): PluginState { +export function getPluginState( + corePlugins: CorePlugins & StandaloneEditorCorePlugins +): PluginState & ContentModelPluginState { return { domEvent: corePlugins.domEvent.getState(), pendingFormatState: corePlugins.pendingFormatState.getState(), @@ -62,5 +69,7 @@ export function getPluginState(corePlugins: CorePlugins): PluginState { undo: corePlugins.undo.getState(), entity: corePlugins.entity.getState(), copyPaste: corePlugins.copyPaste.getState(), + cache: corePlugins.cache.getState(), + format: corePlugins.format.getState(), }; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index d7e3a7dd25f..983c4442f23 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -13,12 +13,9 @@ import type { BlockElement, ClipboardData, ContentChangedData, - CoreCreator, DOMEventHandler, DarkColorHandler, DefaultFormat, - EditorCore, - EditorOptions, EditorUndoState, ExperimentalFeatures, GenericContentEditFeature, @@ -96,22 +93,14 @@ export class ContentModelEditor implements IContentModelEditor { * @param contentDiv The DIV HTML element which will be the container element of editor * @param options An optional options object to customize the editor */ - constructor( - contentDiv: HTMLDivElement, - createContentModelEditorCore: ( - contentDiv: HTMLDivElement, - options: ContentModelEditorOptions, - baseCreator: CoreCreator - ) => ContentModelEditorCore, - options: ContentModelEditorOptions = {} - ) { - this.core = createContentModelEditorCore(contentDiv, options, createEditorCore); + constructor(contentDiv: HTMLDivElement, options: ContentModelEditorOptions = {}) { + this.core = createEditorCore(contentDiv, options); this.core.plugins.forEach(plugin => plugin.initialize(this)); this.ensureTypeInContainer( new Position(this.core.contentDiv, PositionType.Begin).normalize() ); - if (options.cacheModel && this.isContentModelEditor()) { + if (options.cacheModel) { // Create an initial content model to cache // TODO: Once we have standalone editor and get rid of `ensureTypeInContainer` function, we can set init content // using content model and cache the model directly @@ -119,13 +108,6 @@ export class ContentModelEditor implements IContentModelEditor { } } - /** - * Check if current editor can be used as ContentModelEditor - */ - isContentModelEditor(): boolean { - return !!this.core && this.isContentModelEditorCore(this.core); - } - /** * Create Content Model from DOM tree in this editor * @param option The option to customize the behavior of DOM to Content Model conversion @@ -134,7 +116,7 @@ export class ContentModelEditor implements IContentModelEditor { option?: DomToModelOption, selectionOverride?: DOMSelection ): ContentModelDocument { - const core = this.getContentModelEditorCore(); + const core = this.getCore(); return core.api.createContentModel(core, option, selectionOverride); } @@ -150,7 +132,7 @@ export class ContentModelEditor implements IContentModelEditor { option?: ModelToDomOption, onNodeCreated?: OnNodeCreated ): DOMSelection | null { - const core = this.getContentModelEditorCore(); + const core = this.getCore(); return core.api.setContentModel(core, model, option, onNodeCreated); } @@ -159,14 +141,14 @@ export class ContentModelEditor implements IContentModelEditor { * Get current running environment, such as if editor is running on Mac */ getEnvironment(): EditorEnvironment { - return this.getContentModelEditorCore().environment; + return this.getCore().environment; } /** * Get current DOM selection */ getDOMSelection(): DOMSelection | null { - const core = this.getContentModelEditorCore(); + const core = this.getCore(); return core.api.getDOMSelection(core); } @@ -177,7 +159,7 @@ export class ContentModelEditor implements IContentModelEditor { * @param selection The selection to set */ setDOMSelection(selection: DOMSelection) { - const core = this.getContentModelEditorCore(); + const core = this.getCore(); core.api.setDOMSelection(core, selection); } @@ -194,7 +176,7 @@ export class ContentModelEditor implements IContentModelEditor { formatter: ContentModelFormatter, options?: FormatWithContentModelOptions ): void { - const core = this.getContentModelEditorCore(); + const core = this.getCore(); core.api.formatContentModel(core, formatter, options); } @@ -203,7 +185,7 @@ export class ContentModelEditor implements IContentModelEditor { * Get pending format of editor if any, or return null */ getPendingFormat(): ContentModelSegmentFormat | null { - return this.getContentModelEditorCore().format.pendingFormat?.format ?? null; + return this.getCore().format.pendingFormat?.format ?? null; } /** @@ -1116,24 +1098,10 @@ export class ContentModelEditor implements IContentModelEditor { * @returns the current EditorCore object * @throws a standard Error if there's no core object */ - private getCore(): EditorCore { + private getCore(): ContentModelEditorCore { if (!this.core) { throw new Error('Editor is already disposed'); } return this.core; } - - private getContentModelEditorCore(): ContentModelEditorCore { - const core = this.getCore(); - - if (!this.isContentModelEditorCore(core)) { - throw new Error('Current editor is not promoted to Content Model editor'); - } - - return core; - } - - private isContentModelEditorCore(core: EditorCore): core is ContentModelEditorCore { - return !!(core as ContentModelEditorCore).api.formatContentModel; - } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts index ed50f72e549..f947e83c2ce 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts @@ -1,15 +1,22 @@ import { arrayPush, getIntersectedRect, getObjectKeys } from 'roosterjs-editor-dom'; import { coreApiMap } from '../coreApi/coreApiMap'; import { createCorePlugins, getPluginState } from '../corePlugins/createCorePlugins'; +import { createStandaloneEditorDefaultSettings } from 'roosterjs-content-model-core'; import { DarkColorHandlerImpl } from './DarkColorHandlerImpl'; -import type { CoreCreator, EditorCore, EditorOptions, EditorPlugin } from 'roosterjs-editor-types'; +import type { EditorPlugin } from 'roosterjs-editor-types'; +import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; +import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; /** - * Create a new instance of Editor Core + * @internal + * Create a new instance of Content Model Editor Core * @param contentDiv The DIV HTML element which will be the container element of editor * @param options An optional options object to customize the editor */ -export const createEditorCore: CoreCreator = (contentDiv, options) => { +export function createEditorCore( + contentDiv: HTMLDivElement, + options: ContentModelEditorOptions +): ContentModelEditorCore { const corePlugins = createCorePlugins(contentDiv, options); const plugins: EditorPlugin[] = []; @@ -37,7 +44,7 @@ export const createEditorCore: CoreCreator = (content ); }); - const core: EditorCore = { + const core: ContentModelEditorCore = { contentDiv, api: { ...coreApiMap, @@ -46,14 +53,29 @@ export const createEditorCore: CoreCreator = (content originalApi: { ...coreApiMap }, plugins: plugins.filter(x => !!x), ...pluginState, - trustedHTMLHandler: options.trustedHTMLHandler || ((html: string) => html), + trustedHTMLHandler: options.trustedHTMLHandler || defaultTrustHtmlHandler, zoomScale: zoomScale, sizeTransformer: options.sizeTransformer || ((size: number) => size / zoomScale), getVisibleViewport, imageSelectionBorderColor: options.imageSelectionBorderColor, darkColorHandler: new DarkColorHandlerImpl(contentDiv, pluginState.lifecycle.getDarkColor), disposeErrorHandler: options.disposeErrorHandler, + + ...createStandaloneEditorDefaultSettings(options), + + environment: { + // It is ok to use global window here since the environment should always be the same for all windows in one session + isMac: window.navigator.appVersion.indexOf('Mac') != -1, + isAndroid: /android/i.test(window.navigator.userAgent), + }, }; return core; -}; +} + +/** + * @internal Export for test only + */ +export function defaultTrustHtmlHandler(html: string) { + return html; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts index d1ad620f218..c2436986be0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/isContentModelEditor.ts @@ -9,5 +9,5 @@ import type { IEditor } from 'roosterjs-editor-types'; export function isContentModelEditor(editor: IEditor): editor is IContentModelEditor { const contentModelEditor = editor as IContentModelEditor; - return !!contentModelEditor.isContentModelEditor?.(); + return !!contentModelEditor.createContentModel; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 5580319c5a0..4df6ac8d8a2 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -6,4 +6,3 @@ export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IC export { ContentModelEditor } from './editor/ContentModelEditor'; export { isContentModelEditor } from './editor/isContentModelEditor'; -export { createEditorCore } from './editor/createEditorCore'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index e27b0aa9627..0eabedea19b 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -5,12 +5,7 @@ import type { StandaloneEditorOptions, IStandaloneEditor } from 'roosterjs-conte * An interface of editor with Content Model support. * (This interface is still under development, and may still be changed in the future with some breaking changes) */ -export interface IContentModelEditor extends IEditor, IStandaloneEditor { - /** - * Check if current editor can be used as ContentModelEditor - */ - isContentModelEditor(): boolean; -} +export interface IContentModelEditor extends IEditor, IStandaloneEditor {} /** * Options for Content Model editor diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts index 1c542f0d197..d6bec9c55c0 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -5,7 +5,6 @@ import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/d import { ContentModelDocument, EditorContext } from 'roosterjs-content-model-types'; import { ContentModelEditor } from '../../lib/editor/ContentModelEditor'; import { ContentModelEditorCore } from '../../lib/publicTypes/ContentModelEditorCore'; -import { createContentModelEditorCore } from 'roosterjs-content-model-core'; import { EditorPlugin, PluginEventType } from 'roosterjs-editor-types'; const editorContext: EditorContext = { @@ -26,7 +25,7 @@ describe('ContentModelEditor', () => { spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue(mockedConfig); const div = document.createElement('div'); - const editor = new ContentModelEditor(div, createContentModelEditorCore); + const editor = new ContentModelEditor(div); spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); @@ -57,7 +56,7 @@ describe('ContentModelEditor', () => { spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue(mockedConfig); const div = document.createElement('div'); - const editor = new ContentModelEditor(div, createContentModelEditorCore); + const editor = new ContentModelEditor(div); spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); @@ -92,7 +91,7 @@ describe('ContentModelEditor', () => { spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue(mockedConfig); const div = document.createElement('div'); - const editor = new ContentModelEditor(div, createContentModelEditorCore); + const editor = new ContentModelEditor(div); spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); @@ -129,7 +128,7 @@ describe('ContentModelEditor', () => { spyOn(createModelToDomContext, 'createModelToDomConfig').and.returnValue(mockedConfig); const div = document.createElement('div'); - const editor = new ContentModelEditor(div, createContentModelEditorCore); + const editor = new ContentModelEditor(div); spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); @@ -169,7 +168,7 @@ describe('ContentModelEditor', () => { } }, }; - const editor = new ContentModelEditor(div, createContentModelEditorCore, { + const editor = new ContentModelEditor(div, { plugins: [plugin], }); editor.dispose(); @@ -191,7 +190,7 @@ describe('ContentModelEditor', () => { it('get model with cache', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div, createContentModelEditorCore); + const editor = new ContentModelEditor(div); const cachedModel = 'MODEL' as any; (editor as any).core.cache.cachedModel = cachedModel; @@ -206,7 +205,7 @@ describe('ContentModelEditor', () => { it('formatContentModel', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div, createContentModelEditorCore); + const editor = new ContentModelEditor(div); const core = (editor as any).core; const formatContentModelSpy = spyOn(core.api, 'formatContentModel'); const callback = jasmine.createSpy('callback'); @@ -219,7 +218,7 @@ describe('ContentModelEditor', () => { it('default format', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div, createContentModelEditorCore, { + const editor = new ContentModelEditor(div, { defaultFormat: { bold: true, italic: true, @@ -252,7 +251,7 @@ describe('ContentModelEditor', () => { it('getPendingFormat', () => { const div = document.createElement('div'); - const editor = new ContentModelEditor(div, createContentModelEditorCore); + const editor = new ContentModelEditor(div); const core: ContentModelEditorCore = (editor as any).core; const mockedFormat = 'FORMAT' as any; @@ -269,7 +268,7 @@ describe('ContentModelEditor', () => { const div = document.createElement('div'); div.style.fontFamily = 'Arial'; - const editor = new ContentModelEditor(div, createContentModelEditorCore); + const editor = new ContentModelEditor(div); expect(div.style.fontFamily).toBe('Arial'); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts new file mode 100644 index 00000000000..fb733cecbab --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts @@ -0,0 +1,198 @@ +import * as ContentModelCachePlugin from 'roosterjs-content-model-core/lib/corePlugin/ContentModelCachePlugin'; +import * as ContentModelCopyPastePlugin from 'roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin'; +import * as ContentModelFormatPlugin from 'roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin'; +import * as createStandaloneEditorDefaultSettings from 'roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings'; +import * as DOMEventPlugin from '../../lib/corePlugins/DOMEventPlugin'; +import * as EditPlugin from '../../lib/corePlugins/EditPlugin'; +import * as EntityPlugin from '../../lib/corePlugins/EntityPlugin'; +import * as ImageSelection from '../../lib/corePlugins/ImageSelection'; +import * as LifecyclePlugin from '../../lib/corePlugins/LifecyclePlugin'; +import * as MouseUpPlugin from '../../lib/corePlugins/MouseUpPlugin'; +import * as NormalizeTablePlugin from '../../lib/corePlugins/NormalizeTablePlugin'; +import * as PendingFormatStatePlugin from '../../lib/corePlugins/PendingFormatStatePlugin'; +import * as UndoPlugin from '../../lib/corePlugins/UndoPlugin'; +import { coreApiMap } from '../../lib/coreApi/coreApiMap'; +import { createEditorCore, defaultTrustHtmlHandler } from '../../lib/editor/createEditorCore'; + +const mockedDomEventState = 'DOMEVENTSTATE' as any; +const mockedPendingFormatState = 'PENDINGFORMATSTATE' as any; +const mockedEditState = 'EDITSTATE' as any; +const mockedLifecycleState = 'LIFECYCLESTATE' as any; +const mockedUndoState = 'UNDOSTATE' as any; +const mockedEntityState = 'ENTITYSTATE' as any; +const mockedCopyPasteState = 'COPYPASTESTATE' as any; +const mockedCacheState = 'CACHESTATE' as any; +const mockedFormatState = 'FORMATSTATE' as any; + +const mockedFormatPlugin = { + getState: () => mockedFormatState, +} as any; +const mockedCachePlugin = { + getState: () => mockedCacheState, +} as any; +const mockedCopyPastePlugin = { + getState: () => mockedCopyPasteState, +} as any; +const mockedEditPlugin = { + getState: () => mockedEditState, +} as any; +const mockedPendingFormatStatePlugin = { + getState: () => mockedPendingFormatState, +} as any; +const mockedUndoPlugin = { + getState: () => mockedUndoState, +} as any; +const mockedDOMEventPlugin = { + getState: () => mockedDomEventState, +} as any; +const mockedMouseUpPlugin = 'MouseUpPlugin' as any; +const mockedEntityPlugin = { + getState: () => mockedEntityState, +} as any; +const mockedImageSelection = 'ImageSelection' as any; +const mockedNormalizeTablePlugin = 'NormalizeTablePlugin' as any; +const mockedLifecyclePlugin = { + getState: () => mockedLifecycleState, +} as any; +const mockedDefaultSettings = { + settings: 'SETTINGS', +} as any; + +// const mockedSwitchShadowEdit = 'SHADOWEDIT' as any; + +describe('createEditorCore', () => { + let contentDiv: any; + + beforeEach(() => { + contentDiv = { + style: {}, + } as any; + + spyOn(ContentModelFormatPlugin, 'createContentModelFormatPlugin').and.returnValue( + mockedFormatPlugin + ); + spyOn(ContentModelCachePlugin, 'createContentModelCachePlugin').and.returnValue( + mockedCachePlugin + ); + spyOn(ContentModelCopyPastePlugin, 'createContentModelCopyPastePlugin').and.returnValue( + mockedCopyPastePlugin + ); + spyOn(EditPlugin, 'createEditPlugin').and.returnValue(mockedEditPlugin); + spyOn(PendingFormatStatePlugin, 'createPendingFormatStatePlugin').and.returnValue( + mockedPendingFormatStatePlugin + ); + spyOn(UndoPlugin, 'createUndoPlugin').and.returnValue(mockedUndoPlugin); + spyOn(DOMEventPlugin, 'createDOMEventPlugin').and.returnValue(mockedDOMEventPlugin); + spyOn(MouseUpPlugin, 'createMouseUpPlugin').and.returnValue(mockedMouseUpPlugin); + spyOn(EntityPlugin, 'createEntityPlugin').and.returnValue(mockedEntityPlugin); + spyOn(ImageSelection, 'createImageSelection').and.returnValue(mockedImageSelection); + spyOn(NormalizeTablePlugin, 'createNormalizeTablePlugin').and.returnValue( + mockedNormalizeTablePlugin + ); + spyOn(LifecyclePlugin, 'createLifecyclePlugin').and.returnValue(mockedLifecyclePlugin); + spyOn( + createStandaloneEditorDefaultSettings, + 'createStandaloneEditorDefaultSettings' + ).and.returnValue(mockedDefaultSettings); + }); + + it('No additional option', () => { + const core = createEditorCore(contentDiv, {}); + expect(core).toEqual({ + contentDiv, + api: coreApiMap, + originalApi: coreApiMap, + plugins: [ + mockedCachePlugin, + mockedFormatPlugin, + mockedCopyPastePlugin, + mockedEditPlugin, + mockedPendingFormatStatePlugin, + mockedUndoPlugin, + mockedDOMEventPlugin, + mockedMouseUpPlugin, + mockedEntityPlugin, + mockedImageSelection, + mockedNormalizeTablePlugin, + mockedLifecyclePlugin, + ], + domEvent: mockedDomEventState, + pendingFormatState: mockedPendingFormatState, + edit: mockedEditState, + lifecycle: mockedLifecycleState, + undo: mockedUndoState, + entity: mockedEntityState, + copyPaste: mockedCopyPasteState, + cache: mockedCacheState, + format: mockedFormatState, + trustedHTMLHandler: defaultTrustHtmlHandler, + zoomScale: 1, + sizeTransformer: jasmine.anything(), + getVisibleViewport: jasmine.anything(), + imageSelectionBorderColor: undefined, + darkColorHandler: jasmine.anything(), + disposeErrorHandler: undefined, + ...mockedDefaultSettings, + environment: { + isMac: false, + isAndroid: false, + }, + }); + }); + + it('With additional option', () => { + const defaultDomToModelOptions = { a: '1' } as any; + const defaultModelToDomOptions = { b: '2' } as any; + + const options = { + defaultDomToModelOptions, + defaultModelToDomOptions, + }; + const core = createEditorCore(contentDiv, options); + + expect( + createStandaloneEditorDefaultSettings.createStandaloneEditorDefaultSettings + ).toHaveBeenCalledWith(options); + + expect(core).toEqual({ + contentDiv, + api: coreApiMap, + originalApi: coreApiMap, + plugins: [ + mockedCachePlugin, + mockedFormatPlugin, + mockedCopyPastePlugin, + mockedEditPlugin, + mockedPendingFormatStatePlugin, + mockedUndoPlugin, + mockedDOMEventPlugin, + mockedMouseUpPlugin, + mockedEntityPlugin, + mockedImageSelection, + mockedNormalizeTablePlugin, + mockedLifecyclePlugin, + ], + domEvent: mockedDomEventState, + pendingFormatState: mockedPendingFormatState, + edit: mockedEditState, + lifecycle: mockedLifecycleState, + undo: mockedUndoState, + entity: mockedEntityState, + copyPaste: mockedCopyPasteState, + cache: mockedCacheState, + format: mockedFormatState, + trustedHTMLHandler: defaultTrustHtmlHandler, + zoomScale: 1, + sizeTransformer: jasmine.anything(), + getVisibleViewport: jasmine.anything(), + imageSelectionBorderColor: undefined, + darkColorHandler: jasmine.anything(), + disposeErrorHandler: undefined, + ...mockedDefaultSettings, + environment: { + isMac: false, + isAndroid: false, + }, + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts index 644565eeb54..19020bb14da 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/isContentModelEditorTest.ts @@ -1,50 +1,19 @@ -import * as createContentModelEditorCore from 'roosterjs-content-model-core/lib/editor/createContentModelEditorCore'; import { ContentModelEditor } from '../../lib/editor/ContentModelEditor'; -import { ContentModelEditorCore } from '../../lib/publicTypes/ContentModelEditorCore'; -import { ContentModelEditorOptions } from '../../lib/publicTypes/IContentModelEditor'; -import { createEditorCore } from '../../lib/editor/createEditorCore'; +import { Editor } from 'roosterjs-editor-core'; import { isContentModelEditor } from '../../lib/editor/isContentModelEditor'; describe('isContentModelEditor', () => { it('Legacy editor', () => { const div = document.createElement('div'); - const option: ContentModelEditorOptions = { - initialContent: 'test', - }; - const mockedCreateContentModelEditorCore = jasmine - .createSpy('createContentModelEditorCore') - .and.callFake( - (contentDiv, options, baseCreator) => - baseCreator(contentDiv, options) as ContentModelEditorCore - ); - const editor = new ContentModelEditor(div, mockedCreateContentModelEditorCore, option); + const editor = new Editor(div); const result = isContentModelEditor(editor); - expect(mockedCreateContentModelEditorCore).toHaveBeenCalledWith( - div, - option, - createEditorCore - ); expect(result).toBeFalse(); }); it('Content Model editor', () => { const div = document.createElement('div'); - const option: ContentModelEditorOptions = { - initialContent: 'test', - }; - const createContentModelEditorCoreSpy = spyOn( - createContentModelEditorCore, - 'createContentModelEditorCore' - ).and.callThrough(); - const editor = new ContentModelEditor( - div, - createContentModelEditorCore.createContentModelEditorCore, - option - ); - - expect(createContentModelEditorCoreSpy).toHaveBeenCalledWith(div, option, createEditorCore); - + const editor = new ContentModelEditor(div); const result = isContentModelEditor(editor); expect(result).toBeTrue(); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts index da9683644df..025c0d7882e 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts @@ -1,4 +1,4 @@ -import { cloneModel, createContentModelEditorCore } from 'roosterjs-content-model-core'; +import { cloneModel } from 'roosterjs-content-model-core'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { ContentModelPastePlugin } from '../../../lib/paste/ContentModelPastePlugin'; import { @@ -24,11 +24,7 @@ export function initEditor(id: string): IContentModelEditor { }, }; - let editor = new ContentModelEditor( - node as HTMLDivElement, - createContentModelEditorCore, - options - ); + let editor = new ContentModelEditor(node as HTMLDivElement, options); return editor; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts index 8be1e5cf3d1..4d7d643554b 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts @@ -133,7 +133,9 @@ export interface StandaloneCoreApiMap { /** * Represents the core data structure of a Content Model editor */ -export interface StandaloneEditorCore extends ContentModelPluginState { +export interface StandaloneEditorCore + extends ContentModelPluginState, + StandaloneEditorDefaultSettings { /** * The content DIV element of this editor */ @@ -149,6 +151,16 @@ export interface StandaloneEditorCore extends ContentModelPluginState { */ readonly originalApi: StandaloneCoreApiMap; + /** + * Editor running environment + */ + environment: EditorEnvironment; +} + +/** + * Default DOM and Content Model conversion settings for an editor + */ +export interface StandaloneEditorDefaultSettings { /** * Default DOM to Content Model options */ @@ -170,9 +182,4 @@ export interface StandaloneEditorCore extends ContentModelPluginState { * will be used for setting content model if there is no other customized options */ defaultModelToDomConfig: ModelToDomSettings; - - /** - * Editor running environment - */ - environment: EditorEnvironment; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts new file mode 100644 index 00000000000..97e5617e66b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts @@ -0,0 +1,28 @@ +import type { ContentModelCachePluginState } from '../pluginState/ContentModelCachePluginState'; +import type { ContentModelFormatPluginState } from '../pluginState/ContentModelFormatPluginState'; +import type { CopyPastePluginState, EditorPlugin, PluginWithState } from 'roosterjs-editor-types'; + +/** + * Core plugins for standalone editor + */ +export interface StandaloneEditorCorePlugins { + /** + * ContentModel cache plugin manages cached Content Model, and refresh the cache when necessary + */ + readonly cache: PluginWithState; + + /** + * ContentModelFormat plugins helps editor to do formatting on top of content model. + */ + readonly format: PluginWithState; + + /** + * TypeInContainer plugin makes sure user is always type under a container element under editor DIV + */ + readonly typeInContainer: EditorPlugin; + + /** + * Copy and paste plugin for handling onCopy and onPaste event + */ + readonly copyPaste: PluginWithState; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts index 3f17c706d55..d442756beb7 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts @@ -1,3 +1,4 @@ +import type { DefaultFormat } from 'roosterjs-editor-types'; import type { DomToModelOption } from '../context/DomToModelOption'; import type { ModelToDomOption } from '../context/ModelToDomOption'; @@ -19,4 +20,11 @@ export interface StandaloneEditorOptions { * Reuse existing DOM structure if possible, and update the model when content or selection is changed */ cacheModel?: boolean; + + /** + * Default format of editor content. This will be applied to empty content. + * If there is already content inside editor, format of existing content will not be changed. + * Default value is the computed style of editor content DIV + */ + defaultFormat?: DefaultFormat; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index 5015e3925c7..8f44f8220d3 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -204,7 +204,9 @@ export { FormatContentModel, StandaloneCoreApiMap, StandaloneEditorCore, + StandaloneEditorDefaultSettings, } from './editor/StandaloneEditorCore'; +export { StandaloneEditorCorePlugins } from './editor/StandaloneEditorCorePlugins'; export { ContentModelCachePluginState } from './pluginState/ContentModelCachePluginState'; export { ContentModelPluginState } from './pluginState/ContentModelPluginState'; diff --git a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts index bcddc67c081..821d628f9e6 100644 --- a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts @@ -1,6 +1,5 @@ import { ContentModelEditor } from 'roosterjs-content-model-editor'; import { ContentModelEditPlugin, ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; -import { createContentModelEditorCore } from 'roosterjs-content-model-core'; import { getDarkColor } from 'roosterjs-color-utils'; import type { EditorPlugin } from 'roosterjs-editor-types'; import type { @@ -34,5 +33,5 @@ export function createContentModelEditor( textColor: '#000000', }, }; - return new ContentModelEditor(contentDiv, createContentModelEditorCore, options); + return new ContentModelEditor(contentDiv, options); } From c90af83c0df4a280b0cbc2c9ff7ed923c5e973d3 Mon Sep 17 00:00:00 2001 From: Bryan Valverde U Date: Wed, 15 Nov 2023 08:13:57 -0600 Subject: [PATCH 049/111] Fix Delimiter Nested Entity Scenario (#2207) * init * comment --- .../ContentEdit/features/entityFeatures.ts | 21 ++-- .../features/inlineEntityFeatureTest.ts | 114 +++++++++++++++++- 2 files changed, 126 insertions(+), 9 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/entityFeatures.ts b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/entityFeatures.ts index f62bafe4763..ba16265f6f6 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/entityFeatures.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ContentEdit/features/entityFeatures.ts @@ -463,8 +463,8 @@ function cacheGetCheckBefore(event: PluginKeyboardEvent, checkBefore?: boolean): } function getRelatedElements(delimiter: HTMLElement, checkBefore: boolean, editor: IEditor) { - let entity: Element | null = null; - let delimiterPair: Element | null = null; + let entity: HTMLElement | null = null; + let delimiterPair: HTMLElement | null = null; const traverser = getBlockTraverser(editor, delimiter); if (!traverser) { return { delimiterPair, entity }; @@ -486,11 +486,18 @@ function getRelatedElements(delimiter: HTMLElement, checkBefore: boolean, editor entity = entity || getElementFromInline(current, entitySelector); delimiterPair = delimiterPair || getElementFromInline(current, selector); - // If we found the entity but the next inline after the entity is not a delimiter, - // it means that the delimiter pair got removed or is invalid, return null instead. - if (entity && !delimiterPair && !getElementFromInline(current, entitySelector)) { - delimiterPair = null; - break; + if (entity) { + // If we found the entity but the next inline after the entity is not a delimiter, + // it means that the delimiter pair got removed or is invalid, return null instead. + if (!delimiterPair && !getElementFromInline(current, entitySelector)) { + delimiterPair = null; + break; + } + // If the delimiter is not editable keep looking for a editable one, by setting the value as null, + // in case the entity is wrapping another inline readonly entity + if (delimiterPair && !delimiterPair.isContentEditable) { + delimiterPair = null; + } } current = traverseFn(traverser); } diff --git a/packages/roosterjs-editor-plugins/test/ContentEdit/features/inlineEntityFeatureTest.ts b/packages/roosterjs-editor-plugins/test/ContentEdit/features/inlineEntityFeatureTest.ts index cec8afcfa65..554eb0df6bb 100644 --- a/packages/roosterjs-editor-plugins/test/ContentEdit/features/inlineEntityFeatureTest.ts +++ b/packages/roosterjs-editor-plugins/test/ContentEdit/features/inlineEntityFeatureTest.ts @@ -1,5 +1,6 @@ import * as addDelimiters from 'roosterjs-editor-dom/lib/delimiter/addDelimiters'; import * as getComputedStyles from 'roosterjs-editor-dom/lib/utils/getComputedStyles'; +import { BlockElement, Entity, IEditor, Keys, PluginKeyDownEvent } from 'roosterjs-editor-types'; import { EntityFeatures } from '../../../lib/plugins/ContentEdit/features/entityFeatures'; import { commitEntity, @@ -9,7 +10,6 @@ import { Position, PositionContentSearcher, } from 'roosterjs-editor-dom'; -import { Entity, IEditor, Keys, PluginKeyDownEvent, BlockElement } from 'roosterjs-editor-types'; describe('Content Edit Features |', () => { const { moveBetweenDelimitersFeature, removeEntityBetweenDelimiters } = EntityFeatures; @@ -37,6 +37,7 @@ describe('Content Edit Features |', () => { cleanUp(); defaultEvent = {}; testContainer = document.createElement('div'); + testContainer.setAttribute('contenteditable', 'true'); document.body.appendChild(testContainer); wrapper = document.createElement('span'); @@ -387,10 +388,44 @@ describe('Content Edit Features |', () => { restoreSelection(); }); + it('DelimiterBefore, should handle and handle, nested entity no shiftKey', () => { + setupNestedEntityScenario(entity, delimiterBefore, delimiterAfter); + + event = runTest(delimiterBefore, true /* expected */, event); + + spyOnSelection(); + moveBetweenDelimitersFeature.handleEvent(event, editor); + + expect(select).toHaveBeenCalledWith(new Position(delimiterAfter!, 1)); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(extendSpy).toHaveBeenCalledTimes(0); + + restoreSelection(); + }); + it('DelimiterBefore, should handle and handle, no shiftKey elements wrapped in B', () => { wrapElementInB(delimiterBefore); wrapElementInB(entity.wrapper); wrapElementInB(delimiterAfter); + + event = runTest(delimiterBefore, true /* expected */, event); + + spyOnSelection(); + moveBetweenDelimitersFeature.handleEvent(event, editor); + + expect(select).toHaveBeenCalledWith(new Position(delimiterAfter!, 1)); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(extendSpy).toHaveBeenCalledTimes(0); + + restoreSelection(); + }); + + it('DelimiterBefore, should handle and handle, no shiftKey elements wrapped in B and nestedEntity', () => { + wrapElementInB(delimiterBefore); + wrapElementInB(entity.wrapper); + wrapElementInB(delimiterAfter); + setupNestedEntityScenario(entity, delimiterBefore, delimiterAfter); + event = runTest(delimiterBefore, true /* expected */, event); spyOnSelection(); @@ -424,7 +459,29 @@ describe('Content Edit Features |', () => { restoreSelection(); }); - it('DelimiterBefore, should handle and handle, with shiftKey, elements wrapped in B', () => { + it('DelimiterBefore, should handle and handle, with shiftKey and nested entity', () => { + event = { + ...event, + rawEvent: { + ...event.rawEvent, + shiftKey: true, + }, + }; + setupNestedEntityScenario(entity, delimiterBefore, delimiterAfter); + + event = runTest(delimiterBefore, true /* expected */, event); + + spyOnSelection(); + + moveBetweenDelimitersFeature.handleEvent(event, editor); + + expect(extendSpy).toHaveBeenCalledWith(testContainer, 3); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + + restoreSelection(); + }); + + it('DelimiterBefore, should handle and handle, with shiftKey, elements wrapped in B and nested entity', () => { event = { ...event, rawEvent: { @@ -436,6 +493,8 @@ describe('Content Edit Features |', () => { wrapElementInB(delimiterBefore); wrapElementInB(entity.wrapper); wrapElementInB(delimiterAfter); + setupNestedEntityScenario(entity, delimiterBefore, delimiterAfter); + event = runTest(delimiterBefore, true /* expected */, event); spyOnSelection(); @@ -518,6 +577,25 @@ describe('Content Edit Features |', () => { restoreSelection(); }); + it('DelimiterBefore, shouldHandle and Handle, cursor at end of element before delimiter before and nested entity', () => { + const bold = document.createElement('b'); + bold.append(document.createTextNode('Bold')); + testContainer.insertBefore(bold, delimiterBefore); + setupNestedEntityScenario(entity, delimiterBefore, delimiterAfter); + + event = runTest(new Position(bold.firstChild!, 4), true /* expected */, event); + + spyOnSelection(); + + moveBetweenDelimitersFeature.handleEvent(event, editor); + + expect(select).toHaveBeenCalledWith(new Position(delimiterAfter!, 1)); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(extendSpy).toHaveBeenCalledTimes(0); + + restoreSelection(); + }); + it('DelimiterBefore, should not Handle, cursor is not not at the start of the element after delimiter after', () => { const bold = document.createElement('b'); bold.append(document.createTextNode('Bold')); @@ -547,6 +625,21 @@ describe('Content Edit Features |', () => { runTest(delimiterBefore, true /* expected */, event); }); + it('DelimiterBefore, Inline Readonly Entity with multiple Inline Elements and nested scenario', () => { + const b = document.createElement('b'); + b.appendChild(document.createTextNode('Bold')); + + entity.wrapper.appendChild(b); + entity.wrapper.appendChild(b.cloneNode(true)); + + wrapElementInB(delimiterBefore); + wrapElementInB(entity.wrapper); + wrapElementInB(delimiterAfter); + setupNestedEntityScenario(entity, delimiterBefore, delimiterAfter); + + runTest(delimiterBefore, true /* expected */, event); + }); + it('DelimiterBefore, should not Handle, getBlockElementAtCursor returned inline', () => { const div = document.createElement('div'); div.appendChild(document.createTextNode('New block')); @@ -790,6 +883,22 @@ describe('Content Edit Features |', () => { } }); +function setupNestedEntityScenario( + entity: Entity, + delimiterBefore: Element | null, + delimiterAfter: Element | null +) { + const wrapperClone = entity.wrapper.cloneNode(true /* deep */); + while (entity.wrapper.firstChild) { + entity.wrapper.removeChild(entity.wrapper.firstChild); + } + entity.wrapper.append( + delimiterBefore!.cloneNode(true /* deep */), + wrapperClone, + delimiterAfter!.cloneNode(true) + ); +} + function wrapElementInB(delimiterBefore: Element | null) { const element = delimiterBefore?.insertAdjacentElement( 'beforebegin', @@ -823,6 +932,7 @@ function addEntityBeforeEach(entity: Entity, wrapper: HTMLElement) { type: 'Test', wrapper, }; + wrapper.setAttribute('contenteditable', 'false'); commitEntity(wrapper, 'test', true, 'test'); addDelimiters.default(wrapper); From 5bface27ba6ef73c75e9491f86beeeffe3c405f0 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 15 Nov 2023 09:01:50 -0800 Subject: [PATCH 050/111] Standalone editor: Remove dependency to EditorCore (#2208) * Remove dependency to EditorCore * fix test * improve --------- Co-authored-by: Bryan Valverde U --- .../controls/ContentModelEditorMainPane.tsx | 57 +- demo/scripts/controls/MainPane.tsx | 49 +- demo/scripts/controls/MainPaneBase.tsx | 56 +- .../editor/ContentModelRooster.tsx | 95 ++++ .../lib/coreApi/createContentModel.ts | 3 +- .../lib/coreApi/formatContentModel.ts | 8 +- .../lib/coreApi/getDOMSelection.ts | 9 +- .../lib/coreApi/switchShadowEdit.ts | 8 +- .../corePlugin/ContentModelCopyPastePlugin.ts | 12 +- .../createStandaloneEditorCorePlugins.ts | 1 - .../lib/editor/standaloneCoreApiMap.ts | 4 +- .../lib/coreApi/addUndoSnapshot.ts | 21 +- .../lib/coreApi/attachDomEvent.ts | 10 +- .../lib/coreApi/coreApiMap.ts | 6 +- .../lib/coreApi/createPasteFragment.ts | 152 ------ .../lib/coreApi/ensureTypeInContainer.ts | 8 +- .../lib/coreApi/focus.ts | 6 +- .../lib/coreApi/getContent.ts | 10 +- .../lib/coreApi/getPendableFormatState.ts | 16 +- .../lib/coreApi/getSelectionRange.ts | 9 +- .../lib/coreApi/getSelectionRangeEx.ts | 7 +- .../lib/coreApi/getStyleBasedFormatState.ts | 9 +- .../lib/coreApi/hasFocus.ts | 6 +- .../lib/coreApi/insertNode.ts | 27 +- .../lib/coreApi/restoreUndoSnapshot.ts | 4 +- .../lib/coreApi/select.ts | 7 +- .../lib/coreApi/selectImage.ts | 11 +- .../lib/coreApi/selectRange.ts | 15 +- .../lib/coreApi/selectTable.ts | 15 +- .../lib/coreApi/setContent.ts | 14 +- .../lib/coreApi/transformColor.ts | 19 +- .../lib/coreApi/triggerEvent.ts | 11 +- .../lib/corePlugins/DOMEventPlugin.ts | 6 +- .../lib/corePlugins/LifecyclePlugin.ts | 8 +- .../lib/corePlugins/UndoPlugin.ts | 34 +- .../lib/corePlugins/createCorePlugins.ts | 20 +- .../lib/editor/ContentModelEditor.ts | 40 +- .../lib/editor/createEditorCore.ts | 2 +- .../lib/index.ts | 6 +- .../publicTypes/ContentModelCorePlugins.ts | 69 +++ .../lib/publicTypes/ContentModelEditorCore.ts | 33 +- .../lib/publicTypes/IContentModelEditor.ts | 125 ++++- .../lib/editor/StandaloneEditorCore.ts | 494 +++++++++++++++++- .../lib/editor/StandaloneEditorCorePlugins.ts | 7 +- .../lib/editor/StandaloneEditorOptions.ts | 6 + .../lib/index.ts | 22 + .../pluginState/ContentModelPluginState.ts | 18 +- 47 files changed, 1096 insertions(+), 479 deletions(-) create mode 100644 demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/createPasteFragment.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 704414e9081..c7bc65464d3 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -7,19 +7,26 @@ import ContentModelFormatPainterPlugin from './contentModel/plugins/ContentModel import ContentModelFormatStatePlugin from './sidePane/formatState/ContentModelFormatStatePlugin'; import ContentModelPanePlugin from './sidePane/contentModel/ContentModelPanePlugin'; import ContentModelRibbon from './ribbonButtons/contentModel/ContentModelRibbon'; +import ContentModelRooster from './contentModel/editor/ContentModelRooster'; import getToggleablePlugins from './getToggleablePlugins'; -import MainPaneBase from './MainPaneBase'; +import MainPaneBase, { MainPaneBaseState } from './MainPaneBase'; import SampleEntityPlugin from './sampleEntity/SampleEntityPlugin'; import SidePane from './sidePane/SidePane'; import SnapshotPlugin from './sidePane/snapshot/SnapshotPlugin'; import TitleBar from './titleBar/TitleBar'; import { arrayPush } from 'roosterjs-editor-dom'; -import { ContentModelEditor } from 'roosterjs-content-model-editor'; import { ContentModelEditPlugin } from 'roosterjs-content-model-plugins'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; import { createEmojiPlugin, createPasteOptionPlugin, RibbonPlugin } from 'roosterjs-react'; -import { EditorOptions, EditorPlugin } from 'roosterjs-editor-types'; +import { EditorPlugin } from 'roosterjs-editor-types'; +import { getDarkColor } from 'roosterjs-color-utils'; import { PartialTheme } from '@fluentui/react/lib/Theme'; +import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; +import { + ContentModelEditor, + ContentModelEditorOptions, + IContentModelEditor, +} from 'roosterjs-content-model-editor'; const styles = require('./ContentModelEditorMainPane.scss'); @@ -77,7 +84,11 @@ const DarkTheme: PartialTheme = { }, }; -class ContentModelEditorMainPane extends MainPaneBase { +interface ContentModelMainPaneState extends MainPaneBaseState { + editorCreator: (div: HTMLDivElement, options: ContentModelEditorOptions) => IContentModelEditor; +} + +class ContentModelEditorMainPane extends MainPaneBase { private formatStatePlugin: ContentModelFormatStatePlugin; private editorOptionPlugin: ContentModelEditorOptionsPlugin; private eventViewPlugin: ContentModelEventViewPlugin; @@ -182,7 +193,7 @@ class ContentModelEditorMainPane extends MainPaneBase { resetEditor() { this.toggleablePlugins = null; this.setState({ - editorCreator: (div: HTMLDivElement, options: EditorOptions) => + editorCreator: (div: HTMLDivElement, options: ContentModelEditorOptions) => new ContentModelEditor(div, { ...options, cacheModel: this.state.initState.cacheModel, @@ -190,6 +201,42 @@ class ContentModelEditorMainPane extends MainPaneBase { }); } + renderEditor() { + const styles = this.getStyles(); + const allPlugins = this.getPlugins(); + const editorStyles = { + transform: `scale(${this.state.scale})`, + transformOrigin: this.state.isRtl ? 'right top' : 'left top', + height: `calc(${100 / this.state.scale}%)`, + width: `calc(${100 / this.state.scale}%)`, + }; + + this.updateContentPlugin.forceUpdate(); + + return ( +
                                                          +
                                                          + {this.state.editorCreator && ( + + )} +
                                                          +
                                                          + ); + } + getTheme(isDark: boolean): PartialTheme { return isDark ? DarkTheme : LightTheme; } diff --git a/demo/scripts/controls/MainPane.tsx b/demo/scripts/controls/MainPane.tsx index ec18675e5b2..ad5e38acdc4 100644 --- a/demo/scripts/controls/MainPane.tsx +++ b/demo/scripts/controls/MainPane.tsx @@ -5,7 +5,7 @@ import EditorOptionsPlugin from './sidePane/editorOptions/EditorOptionsPlugin'; import EventViewPlugin from './sidePane/eventViewer/EventViewPlugin'; import FormatStatePlugin from './sidePane/formatState/FormatStatePlugin'; import getToggleablePlugins from './getToggleablePlugins'; -import MainPaneBase from './MainPaneBase'; +import MainPaneBase, { MainPaneBaseState } from './MainPaneBase'; import SampleEntityPlugin from './sampleEntity/SampleEntityPlugin'; import SidePane from './sidePane/SidePane'; import SnapshotPlugin from './sidePane/snapshot/SnapshotPlugin'; @@ -13,10 +13,12 @@ import TitleBar from './titleBar/TitleBar'; import { arrayPush } from 'roosterjs-editor-dom'; import { darkMode, DarkModeButtonStringKey } from './ribbonButtons/darkMode'; import { Editor } from 'roosterjs-editor-core'; -import { EditorOptions, EditorPlugin } from 'roosterjs-editor-types'; +import { EditorOptions, EditorPlugin, IEditor } from 'roosterjs-editor-types'; import { ExportButtonStringKey, exportContent } from './ribbonButtons/export'; +import { getDarkColor } from 'roosterjs-color-utils'; import { PartialTheme } from '@fluentui/react/lib/Theme'; import { popout, PopoutButtonStringKey } from './ribbonButtons/popout'; +import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; import { zoom, ZoomButtonStringKey } from './ribbonButtons/zoom'; import { createRibbonPlugin, @@ -28,6 +30,7 @@ import { AllButtonStringKeys, getButtons, AllButtonKeys, + Rooster, } from 'roosterjs-react'; const styles = require('./MainPane.scss'); @@ -92,7 +95,11 @@ const DarkTheme: PartialTheme = { }, }; -class MainPane extends MainPaneBase { +interface MainPaneState extends MainPaneBaseState { + editorCreator: (div: HTMLDivElement, options: EditorOptions) => IEditor; +} + +class MainPane extends MainPaneBase { private formatStatePlugin: FormatStatePlugin; private editorOptionPlugin: EditorOptionsPlugin; private eventViewPlugin: EventViewPlugin; @@ -204,6 +211,42 @@ class MainPane extends MainPaneBase { return isDark ? DarkTheme : LightTheme; } + renderEditor() { + const styles = this.getStyles(); + const allPlugins = this.getPlugins(); + const editorStyles = { + transform: `scale(${this.state.scale})`, + transformOrigin: this.state.isRtl ? 'right top' : 'left top', + height: `calc(${100 / this.state.scale}%)`, + width: `calc(${100 / this.state.scale}%)`, + }; + + this.updateContentPlugin.forceUpdate(); + + return ( +
                                                          +
                                                          + {this.state.editorCreator && ( + + )} +
                                                          +
                                                          + ); + } + private getSidePanePlugins() { return [ this.formatStatePlugin, diff --git a/demo/scripts/controls/MainPaneBase.tsx b/demo/scripts/controls/MainPaneBase.tsx index f68d5acf7fb..00b375a339d 100644 --- a/demo/scripts/controls/MainPaneBase.tsx +++ b/demo/scripts/controls/MainPaneBase.tsx @@ -4,18 +4,12 @@ import BuildInPluginState from './BuildInPluginState'; import SidePane from './sidePane/SidePane'; import SnapshotPlugin from './sidePane/snapshot/SnapshotPlugin'; import { Border } from 'roosterjs-content-model-types'; -import { EditorOptions, EditorPlugin, IEditor } from 'roosterjs-editor-types'; -import { getDarkColor } from 'roosterjs-color-utils'; +import { EditorPlugin } from 'roosterjs-editor-types'; import { PartialTheme, ThemeProvider } from '@fluentui/react/lib/Theme'; import { registerWindowForCss, unregisterWindowForCss } from '../utils/cssMonitor'; import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; import { WindowProvider } from '@fluentui/react/lib/WindowProvider'; -import { - createUpdateContentPlugin, - Rooster, - UpdateContentPlugin, - UpdateMode, -} from 'roosterjs-react'; +import { createUpdateContentPlugin, UpdateContentPlugin, UpdateMode } from 'roosterjs-react'; export interface MainPaneBaseState { showSidePane: boolean; @@ -23,7 +17,6 @@ export interface MainPaneBaseState { initState: BuildInPluginState; scale: number; isDarkMode: boolean; - editorCreator: (div: HTMLDivElement, options: EditorOptions) => IEditor; isRtl: boolean; tableBorderFormat?: Border; } @@ -34,9 +27,12 @@ const POPOUT_FEATURES = 'menubar=no,statusbar=no,width=1200,height=800'; const POPOUT_URL = 'about:blank'; const POPOUT_TARGET = '_blank'; -export default abstract class MainPaneBase extends React.Component<{}, MainPaneBaseState> { +export default abstract class MainPaneBase extends React.Component< + {}, + T +> { private mouseX: number; - private static instance: MainPaneBase; + private static instance: MainPaneBase; private popoutRoot: HTMLElement; protected sidePane = React.createRef(); @@ -70,6 +66,8 @@ export default abstract class MainPaneBase extends React.Component<{}, MainPaneB abstract getTheme(isDark: boolean): PartialTheme; + abstract renderEditor(): JSX.Element; + render() { const styles = this.getStyles(); @@ -187,42 +185,6 @@ export default abstract class MainPaneBase extends React.Component<{}, MainPaneB ); } - private renderEditor() { - const styles = this.getStyles(); - const allPlugins = this.getPlugins(); - const editorStyles = { - transform: `scale(${this.state.scale})`, - transformOrigin: this.state.isRtl ? 'right top' : 'left top', - height: `calc(${100 / this.state.scale}%)`, - width: `calc(${100 / this.state.scale}%)`, - }; - - this.updateContentPlugin.forceUpdate(); - - return ( -
                                                          -
                                                          - {this.state.editorCreator && ( - - )} -
                                                          -
                                                          - ); - } - private renderSidePaneButton() { const styles = this.getStyles(); diff --git a/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx b/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx new file mode 100644 index 00000000000..eeaa8475c7f --- /dev/null +++ b/demo/scripts/controls/contentModel/editor/ContentModelRooster.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { createUIUtilities, ReactEditorPlugin } from 'roosterjs-react'; +import { divProperties, getNativeProps } from '@fluentui/react/lib/Utilities'; +import { useTheme } from '@fluentui/react/lib/Theme'; +import { + ContentModelEditor, + ContentModelEditorOptions, + IContentModelEditor, +} from 'roosterjs-content-model-editor'; +import type { EditorPlugin } from 'roosterjs-editor-types'; + +/** + * Properties for Rooster react component + */ +export interface ContentModelRoosterProps + extends ContentModelEditorOptions, + React.HTMLAttributes { + /** + * Creator function used for creating the instance of roosterjs editor. + * Use this callback when you have your own sub class of roosterjs Editor or force trigging a reset of editor + */ + editorCreator?: ( + div: HTMLDivElement, + options: ContentModelEditorOptions + ) => IContentModelEditor; + + /** + * Whether editor should get focus once it is created + * Changing of this value after editor is created will not reset editor + */ + focusOnInit?: boolean; +} + +/** + * Main component of react wrapper for roosterjs + * @param props Properties of this component + * @returns The react component + */ +export default function ContentModelRooster(props: ContentModelRoosterProps) { + const editorDiv = React.useRef(null); + const editor = React.useRef(null); + const theme = useTheme(); + + const { focusOnInit, editorCreator, zoomScale, inDarkMode, plugins } = props; + + React.useEffect(() => { + if (plugins && editorDiv.current) { + const uiUtilities = createUIUtilities(editorDiv.current, theme); + + plugins.forEach(plugin => { + if (isReactEditorPlugin(plugin)) { + plugin.setUIUtilities(uiUtilities); + } + }); + } + }, [theme, editorCreator]); + + React.useEffect(() => { + if (editorDiv.current) { + editor.current = (editorCreator || defaultEditorCreator)(editorDiv.current, props); + } + + if (focusOnInit) { + editor.current?.focus(); + } + + return () => { + if (editor.current) { + editor.current.dispose(); + editor.current = null; + } + }; + }, [editorCreator]); + + React.useEffect(() => { + editor.current?.setDarkModeState(!!inDarkMode); + }, [inDarkMode]); + + React.useEffect(() => { + if (zoomScale) { + editor.current?.setZoomScale(zoomScale); + } + }, [zoomScale]); + + const divProps = getNativeProps>(props, divProperties); + return
                                                          ; +} + +function defaultEditorCreator(div: HTMLDivElement, options: ContentModelEditorOptions) { + return new ContentModelEditor(div, options); +} + +function isReactEditorPlugin(plugin: EditorPlugin): plugin is ReactEditorPlugin { + return !!(plugin as ReactEditorPlugin)?.setUIUtilities; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts index a4428a172ac..8d160466b20 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/createContentModel.ts @@ -4,7 +4,6 @@ import { createDomToModelContextWithConfig, domToContentModel, } from 'roosterjs-content-model-dom'; -import type { EditorCore } from 'roosterjs-editor-types'; import type { DOMSelection, DomToModelOption, @@ -43,7 +42,7 @@ export const createContentModel: CreateContentModel = (core, option, selectionOv }; function internalCreateContentModel( - core: StandaloneEditorCore & EditorCore, + core: StandaloneEditorCore, selection?: DOMSelection, option?: DomToModelOption ) { diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts index 86e3365d913..4c3f48a7166 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts @@ -1,6 +1,6 @@ import { ChangeSource } from '../constants/ChangeSource'; import { ColorTransformDirection, EntityOperation, PluginEventType } from 'roosterjs-editor-types'; -import type { EditorCore, Entity } from 'roosterjs-editor-types'; +import type { Entity } from 'roosterjs-editor-types'; import type { ContentModelContentChangedEvent, DOMSelection, @@ -81,7 +81,7 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) } }; -function handleNewEntities(core: EditorCore, context: FormatWithContentModelContext) { +function handleNewEntities(core: StandaloneEditorCore, context: FormatWithContentModelContext) { // TODO: Ideally we can trigger NewEntity event here. But to be compatible with original editor code, we don't do it here for now. // Once Content Model Editor can be standalone, we can change this behavior to move triggering NewEntity event code // from EntityPlugin to here @@ -107,7 +107,7 @@ const EntityOperationMap: Record = { removeFromStart: EntityOperation.RemoveFromStart, }; -function handleDeletedEntities(core: EditorCore, context: FormatWithContentModelContext) { +function handleDeletedEntities(core: StandaloneEditorCore, context: FormatWithContentModelContext) { context.deletedEntities.forEach( ({ entity: { @@ -139,7 +139,7 @@ function handleDeletedEntities(core: EditorCore, context: FormatWithContentModel ); } -function handleImages(core: EditorCore, context: FormatWithContentModelContext) { +function handleImages(core: StandaloneEditorCore, context: FormatWithContentModelContext) { if (context.newImages.length > 0) { const viewport = core.getVisibleViewport(); diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts index 9297df45613..559a7f98d8e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts @@ -1,6 +1,9 @@ import { SelectionRangeTypes } from 'roosterjs-editor-types'; -import type { EditorCore } from 'roosterjs-editor-types'; -import type { DOMSelection, GetDOMSelection } from 'roosterjs-content-model-types'; +import type { + DOMSelection, + GetDOMSelection, + StandaloneEditorCore, +} from 'roosterjs-content-model-types'; /** * @internal @@ -9,7 +12,7 @@ export const getDOMSelection: GetDOMSelection = core => { return core.cache.cachedSelection ?? getNewSelection(core); }; -function getNewSelection(core: EditorCore): DOMSelection | null { +function getNewSelection(core: StandaloneEditorCore): DOMSelection | null { // TODO: Get rid of getSelectionRangeEx when we have standalone editor const rangeEx = core.api.getSelectionRangeEx(core); diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts index 42329e710a2..0e393ec8e1f 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts @@ -1,17 +1,17 @@ import { iterateSelections } from '../publicApi/selection/iterateSelections'; import { PluginEventType } from 'roosterjs-editor-types'; -import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; -import type { EditorCore, SelectionPath, SwitchShadowEdit } from 'roosterjs-editor-types'; +import type { SwitchShadowEdit } from 'roosterjs-content-model-types'; +import type { SelectionPath } from 'roosterjs-editor-types'; /** * @internal * Switch the Shadow Edit mode of editor On/Off - * @param editorCore The EditorCore object + * @param editorCore The StandaloneEditorCore object * @param isOn True to switch On, False to switch Off */ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { // TODO: Use strong-typed editor core object - const core = editorCore as StandaloneEditorCore & EditorCore; + const core = editorCore; if (isOn != !!core.lifecycle.shadowEditFragment) { if (isOn) { diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts index 665713e2f5c..03576c2c392 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts @@ -16,13 +16,17 @@ import { toArray, wrap, } from 'roosterjs-content-model-dom'; -import type { DOMSelection, IStandaloneEditor, OnNodeCreated } from 'roosterjs-content-model-types'; +import type { + DOMSelection, + IStandaloneEditor, + OnNodeCreated, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; import type { CopyPastePluginState, IEditor, PluginWithState, ClipboardData, - EditorOptions, } from 'roosterjs-editor-types'; /** @@ -37,7 +41,7 @@ class ContentModelCopyPastePlugin implements PluginWithState { * @param option The editor option */ export function createContentModelCopyPastePlugin( - option: EditorOptions + option: StandaloneEditorOptions ): PluginWithState { return new ContentModelCopyPastePlugin(option); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts index a6ab3134409..499a4606369 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts @@ -17,6 +17,5 @@ export function createStandaloneEditorCorePlugins( cache: createContentModelCachePlugin(options), format: createContentModelFormatPlugin(options), copyPaste: createContentModelCopyPastePlugin(options), - typeInContainer: null!, // TODO: remove this plugin since we don't need it any more }; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts index aecb5a4fb50..059d317eccc 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts @@ -5,12 +5,12 @@ import { getDOMSelection } from '../coreApi/getDOMSelection'; import { setContentModel } from '../coreApi/setContentModel'; import { setDOMSelection } from '../coreApi/setDOMSelection'; import { switchShadowEdit } from '../coreApi/switchShadowEdit'; -import type { StandaloneCoreApiMap } from 'roosterjs-content-model-types'; +import type { PortedCoreApiMap } from 'roosterjs-content-model-types'; /** * Core API map for Standalone Content Model Editor */ -export const standaloneCoreApiMap: StandaloneCoreApiMap = { +export const standaloneCoreApiMap: PortedCoreApiMap = { createContentModel: createContentModel, createEditorContext: createEditorContext, formatContentModel: formatContentModel, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts index aa7919c803f..ac38b4dea87 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts @@ -2,33 +2,28 @@ import { getSelectionPath, Position } from 'roosterjs-editor-dom'; import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; import type { EntityState, - AddUndoSnapshot, - ChangeSource, - ContentChangedData, ContentChangedEvent, ContentMetadata, - EditorCore, - NodePosition, SelectionRangeEx, } from 'roosterjs-editor-types'; -import type { CompatibleChangeSource } from 'roosterjs-editor-types/lib/compatibleTypes'; +import type { AddUndoSnapshot, StandaloneEditorCore } from 'roosterjs-content-model-types'; /** * @internal * Call an editing callback with adding undo snapshots around, and trigger a ContentChanged event if change source is specified. * Undo snapshot will not be added if this call is nested inside another addUndoSnapshot() call. - * @param core The EditorCore object + * @param core The StandaloneEditorCore object * @param callback The editing callback, accepting current selection start and end position, returns an optional object used as the data field of ContentChangedEvent. * @param changeSource The ChangeSource string of ContentChangedEvent. @default ChangeSource.Format. Set to null to avoid triggering ContentChangedEvent * @param canUndoByBackspace True if this action can be undone when user press Backspace key (aka Auto Complete). * @param additionalData @optional parameter to provide additional data related to the ContentChanged Event. */ export const addUndoSnapshot: AddUndoSnapshot = ( - core: EditorCore, - callback: ((start: NodePosition | null, end: NodePosition | null) => any) | null, - changeSource: ChangeSource | CompatibleChangeSource | string | null, - canUndoByBackspace: boolean, - additionalData?: ContentChangedData + core, + callback, + changeSource, + canUndoByBackspace, + additionalData ) => { const undoState = core.undo; const isNested = undoState.isNested; @@ -84,7 +79,7 @@ export const addUndoSnapshot: AddUndoSnapshot = ( }; function addUndoSnapshotInternal( - core: EditorCore, + core: StandaloneEditorCore, canUndoByBackspace: boolean, entityStates?: EntityState[] ) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/attachDomEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/attachDomEvent.ts index 0ca6916e4e6..c7e0dbc92f6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/attachDomEvent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/attachDomEvent.ts @@ -1,24 +1,20 @@ import { getObjectKeys } from 'roosterjs-editor-dom'; +import type { AttachDomEvent } from 'roosterjs-content-model-types'; import type { - AttachDomEvent, DOMEventHandler, DOMEventHandlerObject, - EditorCore, PluginDomEvent, } from 'roosterjs-editor-types'; /** * @internal * Attach a DOM event to the editor content DIV - * @param core The EditorCore object + * @param core The StandaloneEditorCore object * @param eventName The DOM event name * @param pluginEventType Optional event type. When specified, editor will trigger a plugin event with this name when the DOM event is triggered * @param beforeDispatch Optional callback function to be invoked when the DOM event is triggered before trigger plugin event */ -export const attachDomEvent: AttachDomEvent = ( - core: EditorCore, - eventMap: Record -) => { +export const attachDomEvent: AttachDomEvent = (core, eventMap) => { const disposers = getObjectKeys(eventMap || {}).map(key => { const { pluginEventType, beforeDispatch } = extractHandler(eventMap[key]); const eventName = key as keyof HTMLElementEventMap; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts index eea4772dbef..03e85fc2073 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts @@ -1,6 +1,5 @@ import { addUndoSnapshot } from './addUndoSnapshot'; import { attachDomEvent } from './attachDomEvent'; -import { createPasteFragment } from './createPasteFragment'; import { ensureTypeInContainer } from './ensureTypeInContainer'; import { focus } from './focus'; import { getContent } from './getContent'; @@ -19,16 +18,15 @@ import { setContent } from './setContent'; import { standaloneCoreApiMap } from 'roosterjs-content-model-core'; import { transformColor } from './transformColor'; import { triggerEvent } from './triggerEvent'; -import type { ContentModelCoreApiMap } from '../publicTypes/ContentModelEditorCore'; +import type { StandaloneCoreApiMap } from 'roosterjs-content-model-types'; /** * @internal */ -export const coreApiMap: ContentModelCoreApiMap = { +export const coreApiMap: StandaloneCoreApiMap = { ...standaloneCoreApiMap, attachDomEvent, addUndoSnapshot, - createPasteFragment, ensureTypeInContainer, focus, getContent, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/createPasteFragment.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/createPasteFragment.ts deleted file mode 100644 index 5dcc65d82a7..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/createPasteFragment.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { PasteType, PluginEventType } from 'roosterjs-editor-types'; -import { - applyFormat, - applyTextStyle, - createDefaultHtmlSanitizerOptions, - getPasteType, - handleImagePaste, - handleTextPaste, - moveChildNodes, - retrieveMetadataFromClipboard, - sanitizePasteContent, -} from 'roosterjs-editor-dom'; -import type { - BeforePasteEvent, - ClipboardData, - CreatePasteFragment, - EditorCore, - NodePosition, - DefaultFormat, -} from 'roosterjs-editor-types'; - -/** - * @internal - * Create a DocumentFragment for paste from a ClipboardData - * @param core The EditorCore object. - * @param clipboardData Clipboard data retrieved from clipboard - * @param position The position to paste to - * @param pasteAsText True to force use plain text as the content to paste, false to choose HTML or Image if any - * @param applyCurrentStyle True if apply format of current selection to the pasted content, - * false to keep original format - * @param pasteAsImage True if the image should be pasted as image - */ -export const createPasteFragment: CreatePasteFragment = ( - core: EditorCore, - clipboardData: ClipboardData, - position: NodePosition | null, - pasteAsText: boolean, - applyCurrentStyle: boolean, - pasteAsImage: boolean = false -) => { - if (!clipboardData) { - return null; - } - - const pasteType = getPasteType(pasteAsText, applyCurrentStyle, pasteAsImage); - - // Step 1: Prepare BeforePasteEvent object - const event = createBeforePasteEvent(core, clipboardData, pasteType); - return createFragmentFromClipboardData( - core, - clipboardData, - position, - pasteAsText, - applyCurrentStyle, - pasteAsImage, - event - ); -}; - -function createBeforePasteEvent( - core: EditorCore, - clipboardData: ClipboardData, - pasteType: PasteType -): BeforePasteEvent { - const options = createDefaultHtmlSanitizerOptions(); - - // Remove "caret-color" style generated by Safari to make sure caret shows in right color after paste - options.cssStyleCallbacks['caret-color'] = () => false; - - return { - eventType: PluginEventType.BeforePaste, - clipboardData, - fragment: core.contentDiv.ownerDocument.createDocumentFragment(), - sanitizingOption: options, - htmlBefore: '', - htmlAfter: '', - htmlAttributes: {}, - pasteType: pasteType, - }; -} - -/** - * Create a DocumentFragment for paste from a ClipboardData - * @param core The EditorCore object. - * @param clipboardData Clipboard data retrieved from clipboard - * @param position The position to paste to - * @param pasteAsText True to force use plain text as the content to paste, false to choose HTML or Image if any - * @param applyCurrentStyle True if apply format of current selection to the pasted content, - * @param pasteAsImage Whether to force paste as image - * @param event Event to trigger. - * false to keep original format - */ -function createFragmentFromClipboardData( - core: EditorCore, - clipboardData: ClipboardData, - position: NodePosition | null, - pasteAsText: boolean, - applyCurrentStyle: boolean, - pasteAsImage: boolean, - event: BeforePasteEvent -) { - const { fragment } = event; - const { rawHtml, text, imageDataUri } = clipboardData; - const doc: Document | undefined = rawHtml - ? new DOMParser().parseFromString(core.trustedHTMLHandler(rawHtml), 'text/html') - : undefined; - - // Step 2: Retrieve Metadata from Html and the Html that was copied. - retrieveMetadataFromClipboard(doc, event, core.trustedHTMLHandler); - - // Step 3: Fill the BeforePasteEvent object, especially the fragment for paste - if ((pasteAsImage && imageDataUri) || (!pasteAsText && !text && imageDataUri)) { - // Paste image - handleImagePaste(imageDataUri, fragment); - } else if (!pasteAsText && rawHtml && doc ? doc.body : false) { - moveChildNodes(fragment, doc?.body); - - if (applyCurrentStyle && position) { - const format = getCurrentFormat(core, position.node); - applyTextStyle(fragment, node => applyFormat(node, format)); - } - } else if (text) { - // Paste text - handleTextPaste(text, position, fragment); - } - - // Step 4: Trigger BeforePasteEvent so that plugins can do proper change before paste, when the type of paste is different than Plain Text - if (event.pasteType !== PasteType.AsPlainText) { - core.api.triggerEvent(core, event, true /*broadcast*/); - } - - // Step 5. Sanitize the fragment before paste to make sure the content is safe - sanitizePasteContent(event, position); - - return fragment; -} - -function getCurrentFormat(core: EditorCore, node: Node): DefaultFormat { - const pendableFormat = core.api.getPendableFormatState(core, true /** forceGetStateFromDOM*/); - const styleBasedFormat = core.api.getStyleBasedFormatState(core, node); - return { - fontFamily: styleBasedFormat.fontName, - fontSize: styleBasedFormat.fontSize, - textColor: styleBasedFormat.textColor, - backgroundColor: styleBasedFormat.backgroundColor, - textColors: styleBasedFormat.textColors, - backgroundColors: styleBasedFormat.backgroundColors, - bold: pendableFormat.isBold, - italic: pendableFormat.isItalic, - underline: pendableFormat.isUnderline, - }; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts index 3860ac2a4d7..40552de4afd 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts @@ -1,5 +1,4 @@ import { ContentPosition, KnownCreateElementDataIndex, PositionType } from 'roosterjs-editor-types'; -import type { EditorCore, EnsureTypeInContainer, NodePosition } from 'roosterjs-editor-types'; import { applyFormat, createElement, @@ -10,17 +9,14 @@ import { Position, safeInstanceOf, } from 'roosterjs-editor-dom'; +import type { EnsureTypeInContainer } from 'roosterjs-content-model-types'; /** * @internal * When typing goes directly under content div, many things can go wrong * We fix it by wrapping it with a div and reposition cursor within the div */ -export const ensureTypeInContainer: EnsureTypeInContainer = ( - core: EditorCore, - position: NodePosition, - keyboardEvent?: KeyboardEvent -) => { +export const ensureTypeInContainer: EnsureTypeInContainer = (core, position, keyboardEvent) => { const table = findClosestElementAncestor(position.node, core.contentDiv, 'table'); let td: HTMLElement | null; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts index 4490f5a24a0..7291611b19a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts @@ -1,13 +1,13 @@ import { createRange, getFirstLeafNode } from 'roosterjs-editor-dom'; import { PositionType } from 'roosterjs-editor-types'; -import type { EditorCore, Focus } from 'roosterjs-editor-types'; +import type { Focus } from 'roosterjs-content-model-types'; /** * @internal * Focus to editor. If there is a cached selection range, use it as current selection - * @param core The EditorCore object + * @param core The StandaloneEditorCore object */ -export const focus: Focus = (core: EditorCore) => { +export const focus: Focus = core => { if (!core.lifecycle.shadowEditFragment) { if ( !core.api.hasFocus(core) || diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts index 8135e2866e3..346b0cf4664 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts @@ -1,5 +1,4 @@ import { ColorTransformDirection, GetContentMode, PluginEventType } from 'roosterjs-editor-types'; -import type { EditorCore, GetContent } from 'roosterjs-editor-types'; import { createRange, getHtmlWithSelectionPath, @@ -7,19 +6,16 @@ import { getTextContent, safeInstanceOf, } from 'roosterjs-editor-dom'; -import type { CompatibleGetContentMode } from 'roosterjs-editor-types/lib/compatibleTypes'; +import type { GetContent } from 'roosterjs-content-model-types'; /** * @internal * Get current editor content as HTML string - * @param core The EditorCore object + * @param core The StandaloneEditorCore object * @param mode specify what kind of HTML content to retrieve * @returns HTML string representing current editor content */ -export const getContent: GetContent = ( - core: EditorCore, - mode: GetContentMode | CompatibleGetContentMode -): string => { +export const getContent: GetContent = (core, mode): string => { let content: string | null = ''; const triggerExtractContentEvent = mode == GetContentMode.CleanHTML; const includeSelectionMarker = mode == GetContentMode.RawHTMLWithSelection; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getPendableFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getPendableFormatState.ts index f68e1a1ad5b..a99b12dc7f8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getPendableFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getPendableFormatState.ts @@ -1,22 +1,18 @@ import { contains, getObjectKeys, getTagOfNode, Position } from 'roosterjs-editor-dom'; import { NodeType } from 'roosterjs-editor-types'; import type { PendableFormatNames } from 'roosterjs-editor-dom'; -import type { - EditorCore, - GetPendableFormatState, - NodePosition, - PendableFormatState, -} from 'roosterjs-editor-types'; +import type { NodePosition, PendableFormatState } from 'roosterjs-editor-types'; +import type { GetPendableFormatState, StandaloneEditorCore } from 'roosterjs-content-model-types'; /** * @internal - * @param core The EditorCore object + * @param core The StandaloneEditorCore object * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. * @returns The cached format state if it exists. If the cached position do not exist, search for pendable elements in the DOM tree and return the pendable format state. */ export const getPendableFormatState: GetPendableFormatState = ( - core: EditorCore, - forceGetStateFromDOM: boolean + core, + forceGetStateFromDOM ): PendableFormatState => { const range = core.api.getSelectionRange(core, true /* tryGetFromCache*/); const cachedPendableFormatState = core.pendingFormatState.pendableFormatState; @@ -76,7 +72,7 @@ const CssFalsyCheckers: Record { +export const getSelectionRange: GetSelectionRange = (core, tryGetFromCache: boolean) => { let result: Range | null = null; if (core.lifecycle.shadowEditFragment) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts index 049546a1813..dae2e1ca849 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts @@ -1,14 +1,15 @@ import { contains, createRange, findClosestElementAncestor } from 'roosterjs-editor-dom'; import { SelectionRangeTypes } from 'roosterjs-editor-types'; -import type { EditorCore, GetSelectionRangeEx, SelectionRangeEx } from 'roosterjs-editor-types'; +import type { GetSelectionRangeEx } from 'roosterjs-content-model-types'; +import type { SelectionRangeEx } from 'roosterjs-editor-types'; /** * @internal * Get current or cached selection range - * @param core The EditorCore object + * @param core The StandaloneEditorCore object * @returns A Range object of the selection range */ -export const getSelectionRangeEx: GetSelectionRangeEx = (core: EditorCore) => { +export const getSelectionRangeEx: GetSelectionRangeEx = core => { const result: SelectionRangeEx | null = null; if (core.lifecycle.shadowEditFragment) { const { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts index 0be0504b38e..dfb857a9f05 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts @@ -1,17 +1,14 @@ import { contains, getComputedStyles } from 'roosterjs-editor-dom'; import { NodeType } from 'roosterjs-editor-types'; -import type { EditorCore, GetStyleBasedFormatState } from 'roosterjs-editor-types'; +import type { GetStyleBasedFormatState } from 'roosterjs-content-model-types'; /** * @internal * Get style based format state from current selection, including font name/size and colors - * @param core The EditorCore objects + * @param core The StandaloneEditorCore objects * @param node The node to get style from */ -export const getStyleBasedFormatState: GetStyleBasedFormatState = ( - core: EditorCore, - node: Node | null -) => { +export const getStyleBasedFormatState: GetStyleBasedFormatState = (core, node) => { if (!node) { return {}; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/hasFocus.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/hasFocus.ts index 35ad6eb49a8..c5a67d878cc 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/hasFocus.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/hasFocus.ts @@ -1,13 +1,13 @@ import { contains } from 'roosterjs-editor-dom'; -import type { EditorCore, HasFocus } from 'roosterjs-editor-types'; +import type { HasFocus } from 'roosterjs-content-model-types'; /** * @internal * Check if the editor has focus now - * @param core The EditorCore object + * @param core The StandaloneEditorCore object * @returns True if the editor has focus, otherwise false */ -export const hasFocus: HasFocus = (core: EditorCore) => { +export const hasFocus: HasFocus = core => { const activeElement = core.contentDiv.ownerDocument.activeElement; return !!( activeElement && contains(core.contentDiv, activeElement, true /*treatSameNodeAsContain*/) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts index 8176d491d6a..18f9bf74963 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts @@ -1,10 +1,4 @@ -import type { - BlockElement, - EditorCore, - InsertNode, - InsertOption, - NodePosition, -} from 'roosterjs-editor-types'; +import type { BlockElement, InsertOption, NodePosition } from 'roosterjs-editor-types'; import { ContentPosition, ColorTransformDirection, @@ -27,9 +21,10 @@ import { splitTextNode, splitParentNode, } from 'roosterjs-editor-dom'; +import type { InsertNode, StandaloneEditorCore } from 'roosterjs-content-model-types'; function getInitialRange( - core: EditorCore, + core: StandaloneEditorCore, option: InsertOption ): { range: Range | null; rangeToRestore: Range | null } { // Selection start replaces based on the current selection. @@ -51,11 +46,11 @@ function getInitialRange( /** * @internal * Insert a DOM node into editor content - * @param core The EditorCore object. No op if null. + * @param core The StandaloneEditorCore object. No op if null. * @param option An insert option object to specify how to insert the node */ export const insertNode: InsertNode = ( - core: EditorCore, + core: StandaloneEditorCore, node: Node, option: InsertOption | null ) => { @@ -199,7 +194,11 @@ export const insertNode: InsertNode = ( return true; }; -function adjustInsertPositionRegionRoot(core: EditorCore, range: Range, position: NodePosition) { +function adjustInsertPositionRegionRoot( + core: StandaloneEditorCore, + range: Range, + position: NodePosition +) { const region = getRegionsFromRange(core.contentDiv, range, RegionType.Table)[0]; let node: Node | null = position.node; @@ -223,7 +222,11 @@ function adjustInsertPositionRegionRoot(core: EditorCore, range: Range, position return position; } -function adjustInsertPositionNewLine(blockElement: BlockElement, core: EditorCore, pos: Position) { +function adjustInsertPositionNewLine( + blockElement: BlockElement, + core: StandaloneEditorCore, + pos: Position +) { let tempPos = new Position(blockElement.getEndNode(), PositionType.After); if (safeInstanceOf(tempPos.node, 'HTMLTableRowElement')) { const div = core.contentDiv.ownerDocument.createElement('div'); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/restoreUndoSnapshot.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/restoreUndoSnapshot.ts index 4433ebc3b7c..c0d20589ea3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/restoreUndoSnapshot.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/restoreUndoSnapshot.ts @@ -1,6 +1,6 @@ import { EntityOperation, PluginEventType } from 'roosterjs-editor-types'; import { getEntityFromElement, getEntitySelector, queryElements } from 'roosterjs-editor-dom'; -import type { EditorCore, RestoreUndoSnapshot } from 'roosterjs-editor-types'; +import type { RestoreUndoSnapshot } from 'roosterjs-content-model-types'; /** * @internal @@ -8,7 +8,7 @@ import type { EditorCore, RestoreUndoSnapshot } from 'roosterjs-editor-types'; * @param core The editor core object * @param step Steps to move, can be 0, positive or negative */ -export const restoreUndoSnapshot: RestoreUndoSnapshot = (core: EditorCore, step: number) => { +export const restoreUndoSnapshot: RestoreUndoSnapshot = (core, step) => { if (core.undo.hasNewContent && step < 0) { core.api.addUndoSnapshot( core, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts index a3179bfea8e..f6aad98ee84 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts @@ -1,10 +1,9 @@ import { contains, createRange, safeInstanceOf } from 'roosterjs-editor-dom'; import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; +import type { Select, StandaloneEditorCore } from 'roosterjs-content-model-types'; import type { - EditorCore, NodePosition, PositionType, - Select, SelectionPath, SelectionRangeEx, TableSelection, @@ -44,7 +43,7 @@ export const select: Select = (core, arg1, arg2, arg3, arg4) => { }; function buildRangeEx( - core: EditorCore, + core: StandaloneEditorCore, arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, arg2?: NodePosition | number | PositionType | TableSelection | null, arg3?: Node, @@ -97,7 +96,7 @@ function buildRangeEx( return rangeEx; } -function applyRangeEx(core: EditorCore, rangeEx: SelectionRangeEx | null) { +function applyRangeEx(core: StandaloneEditorCore, rangeEx: SelectionRangeEx | null) { switch (rangeEx?.type) { case SelectionRangeTypes.TableSelection: if (contains(core.contentDiv, rangeEx.table)) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts index 44bcfb974e6..058e3c35bf6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts @@ -7,7 +7,8 @@ import { removeImportantStyleRule, setGlobalCssStyles, } from 'roosterjs-editor-dom'; -import type { EditorCore, ImageSelectionRange, SelectImage } from 'roosterjs-editor-types'; +import type { ImageSelectionRange } from 'roosterjs-editor-types'; +import type { SelectImage, StandaloneEditorCore } from 'roosterjs-content-model-types'; const IMAGE_ID = 'imageSelected'; const CONTENT_DIV_ID = 'contentDiv_'; @@ -20,7 +21,7 @@ const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; * @param image Image to select * @returns Selected image information */ -export const selectImage: SelectImage = (core: EditorCore, image: HTMLImageElement | null) => { +export const selectImage: SelectImage = (core, image: HTMLImageElement | null) => { unselect(core); let selection: ImageSelectionRange | null = null; @@ -46,20 +47,20 @@ export const selectImage: SelectImage = (core: EditorCore, image: HTMLImageEleme return selection; }; -const select = (core: EditorCore, image: HTMLImageElement) => { +const select = (core: StandaloneEditorCore, image: HTMLImageElement) => { removeImportantStyleRule(image, ['border', 'margin']); const borderCSS = buildBorderCSS(core, image.id); setGlobalCssStyles(core.contentDiv.ownerDocument, borderCSS, STYLE_ID + core.contentDiv.id); }; -const buildBorderCSS = (core: EditorCore, imageId: string): string => { +const buildBorderCSS = (core: StandaloneEditorCore, imageId: string): string => { const divId = core.contentDiv.id; const color = core.imageSelectionBorderColor || DEFAULT_SELECTION_BORDER_COLOR; return `#${divId} #${imageId} {outline-style: auto!important;outline-color: ${color}!important;caret-color: transparent!important;}`; }; -const unselect = (core: EditorCore) => { +const unselect = (core: StandaloneEditorCore) => { const doc = core.contentDiv.ownerDocument; removeGlobalCssStyle(doc, STYLE_ID + core.contentDiv.id); }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts index d4816eb43fc..4bd2334a91a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts @@ -1,5 +1,3 @@ -import { hasFocus } from './hasFocus'; -import type { EditorCore, SelectRange } from 'roosterjs-editor-types'; import { contains, getPendableFormatState, @@ -8,25 +6,22 @@ import { addRangeToSelection, getObjectKeys, } from 'roosterjs-editor-dom'; +import type { SelectRange, StandaloneEditorCore } from 'roosterjs-content-model-types'; /** * @internal * Change the editor selection to the given range - * @param core The EditorCore object + * @param core The StandaloneEditorCore object * @param range The range to select * @param skipSameRange When set to true, do nothing if the given range is the same with current selection * in editor, otherwise it will always remove current selection range and set to the given one. * This parameter is always treat as true in Edge to avoid some weird runtime exception. */ -export const selectRange: SelectRange = ( - core: EditorCore, - range: Range, - skipSameRange?: boolean -) => { +export const selectRange: SelectRange = (core, range, skipSameRange) => { if (!core.lifecycle.shadowEditSelectionPath && contains(core.contentDiv, range)) { addRangeToSelection(range, skipSameRange); - if (!hasFocus(core)) { + if (!core.api.hasFocus(core)) { core.domEvent.selectionRange = range; } @@ -45,7 +40,7 @@ export const selectRange: SelectRange = ( /** * Restore cached pending format state (if exist) to current selection */ -function restorePendingFormatState(core: EditorCore) { +function restorePendingFormatState(core: StandaloneEditorCore) { const { contentDiv, pendingFormatState, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectTable.ts index 3d74d61b6bd..f852786e8a2 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectTable.ts @@ -11,7 +11,8 @@ import { toArray, VTable, } from 'roosterjs-editor-dom'; -import type { EditorCore, TableSelection, SelectTable, Coordinates } from 'roosterjs-editor-types'; +import type { TableSelection, Coordinates } from 'roosterjs-editor-types'; +import type { SelectTable, StandaloneEditorCore } from 'roosterjs-content-model-types'; const TABLE_ID = 'tableSelected'; const CONTENT_DIV_ID = 'contentDiv_'; @@ -23,17 +24,13 @@ const MAX_RULE_SELECTOR_LENGTH = 9000; /** * @internal * Select a table and save data of the selected range - * @param core The EditorCore object + * @param core The StandaloneEditorCore object * @param table table to select * @param coordinates first and last cell of the selection, if this parameter is null, instead of * selecting, will unselect the table. * @returns true if successful */ -export const selectTable: SelectTable = ( - core: EditorCore, - table: HTMLTableElement | null, - coordinates?: TableSelection -) => { +export const selectTable: SelectTable = (core, table, coordinates) => { unselect(core); if (areValidCoordinates(coordinates) && table) { @@ -194,7 +191,7 @@ function handleTableSelected( } function select( - core: EditorCore, + core: StandaloneEditorCore, table: HTMLTableElement, coordinates: TableSelection ): { ranges: Range[]; isWholeTableSelected: boolean } { @@ -211,7 +208,7 @@ function select( return { ranges, isWholeTableSelected }; } -const unselect = (core: EditorCore) => { +const unselect = (core: StandaloneEditorCore) => { const doc = core.contentDiv.ownerDocument; removeGlobalCssStyle(doc, STYLE_ID + core.contentDiv.id); }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts index 58e8eb86c72..571eb1c2ef7 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts @@ -10,24 +10,20 @@ import { queryElements, restoreContentWithEntityPlaceholder, } from 'roosterjs-editor-dom'; -import type { ContentMetadata, EditorCore, SetContent } from 'roosterjs-editor-types'; +import type { ContentMetadata } from 'roosterjs-editor-types'; +import type { SetContent, StandaloneEditorCore } from 'roosterjs-content-model-types'; /** * @internal * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered * if triggerContentChangedEvent is set to true - * @param core The EditorCore object + * @param core The StandaloneEditorCore object * @param content HTML content to set in * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true * @param metadata @optional Metadata of the content that helps editor know the selection and color mode. * If not passed, we will treat content as in light mode without selection */ -export const setContent: SetContent = ( - core: EditorCore, - content: string, - triggerContentChangedEvent: boolean, - metadata?: ContentMetadata -) => { +export const setContent: SetContent = (core, content, triggerContentChangedEvent, metadata) => { let contentChanged = false; if (core.contentDiv.innerHTML != content) { core.api.triggerEvent( @@ -81,7 +77,7 @@ export const setContent: SetContent = ( } }; -function selectContentMetadata(core: EditorCore, metadata: ContentMetadata | undefined) { +function selectContentMetadata(core: StandaloneEditorCore, metadata: ContentMetadata | undefined) { if (!core.lifecycle.shadowEditSelectionPath && metadata) { core.domEvent.tableSelectionRange = null; core.domEvent.imageSelectionRange = null; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/transformColor.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/transformColor.ts index c0b89cc38c1..6f8daea177d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/transformColor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/transformColor.ts @@ -1,11 +1,10 @@ import { ColorTransformDirection } from 'roosterjs-editor-types'; -import type { EditorCore, TransformColor } from 'roosterjs-editor-types'; -import type { CompatibleColorTransformDirection } from 'roosterjs-editor-types/lib/compatibleTypes'; +import type { TransformColor } from 'roosterjs-content-model-types'; /** * @internal * Edit and transform color of elements between light mode and dark mode - * @param core The EditorCore object + * @param core The StandaloneEditorCore object * @param rootNode The root HTML elements to transform * @param includeSelf True to transform the root node as well, otherwise false * @param callback The callback function to invoke before do color transformation @@ -14,13 +13,13 @@ import type { CompatibleColorTransformDirection } from 'roosterjs-editor-types/l * Pass true to this value to force do color transformation even editor core is in light mode */ export const transformColor: TransformColor = ( - core: EditorCore, - rootNode: Node | null, - includeSelf: boolean, - callback: (() => void) | null, - direction: ColorTransformDirection | CompatibleColorTransformDirection, - forceTransform?: boolean, - fromDarkMode: boolean = false + core, + rootNode, + includeSelf, + callback, + direction, + forceTransform, + fromDarkMode = false ) => { const { darkColorHandler, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/triggerEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/triggerEvent.ts index 7fc7272bf7d..70f65066e93 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/triggerEvent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/triggerEvent.ts @@ -1,5 +1,6 @@ import { PluginEventType } from 'roosterjs-editor-types'; -import type { EditorCore, EditorPlugin, PluginEvent, TriggerEvent } from 'roosterjs-editor-types'; +import type { TriggerEvent } from 'roosterjs-content-model-types'; +import type { EditorPlugin, PluginEvent } from 'roosterjs-editor-types'; import type { CompatiblePluginEventType } from 'roosterjs-editor-types/lib/compatibleTypes'; const allowedEventsInShadowEdit: (PluginEventType | CompatiblePluginEventType)[] = [ @@ -12,15 +13,11 @@ const allowedEventsInShadowEdit: (PluginEventType | CompatiblePluginEventType)[] /** * @internal * Trigger a plugin event - * @param core The EditorCore object + * @param core The StandaloneEditorCore object * @param pluginEvent The event object to trigger * @param broadcast Set to true to skip the shouldHandleEventExclusively check */ -export const triggerEvent: TriggerEvent = ( - core: EditorCore, - pluginEvent: PluginEvent, - broadcast: boolean -) => { +export const triggerEvent: TriggerEvent = (core, pluginEvent, broadcast) => { if ( (!core.lifecycle.shadowEditFragment || allowedEventsInShadowEdit.indexOf(pluginEvent.eventType) >= 0) && diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/DOMEventPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/DOMEventPlugin.ts index e8434c8d646..94edb96f213 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/DOMEventPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/DOMEventPlugin.ts @@ -1,10 +1,10 @@ import { arrayPush, Browser, isCharacterValue } from 'roosterjs-editor-dom'; import { ChangeSource, Keys, PluginEventType } from 'roosterjs-editor-types'; +import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; import type { ContextMenuProvider, DOMEventHandler, DOMEventPluginState, - EditorOptions, EditorPlugin, IEditor, PluginWithState, @@ -31,7 +31,7 @@ class DOMEventPlugin implements PluginWithState { * @param options The editor options * @param contentDiv The editor content DIV */ - constructor(options: EditorOptions, contentDiv: HTMLDivElement) { + constructor(options: ContentModelEditorOptions, contentDiv: HTMLDivElement) { this.state = { isInIME: false, scrollContainer: options.scrollContainer || contentDiv, @@ -264,7 +264,7 @@ function isContextMenuProvider(source: EditorPlugin): source is ContextMenuProvi * @param contentDiv The editor content DIV element */ export function createDOMEventPlugin( - option: EditorOptions, + option: ContentModelEditorOptions, contentDiv: HTMLDivElement ): PluginWithState { return new DOMEventPlugin(option, contentDiv); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts index 62adfa22918..b7af12db247 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts @@ -1,12 +1,12 @@ import { ChangeSource, PluginEventType } from 'roosterjs-editor-types'; import { getObjectKeys, setColor } from 'roosterjs-editor-dom'; import type { - EditorOptions, IEditor, LifecyclePluginState, PluginWithState, PluginEvent, } from 'roosterjs-editor-types'; +import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; const CONTENT_EDITABLE_ATTRIBUTE_NAME = 'contenteditable'; @@ -37,7 +37,7 @@ class LifecyclePlugin implements PluginWithState { * @param options The editor options * @param contentDiv The editor content DIV */ - constructor(options: EditorOptions, contentDiv: HTMLDivElement) { + constructor(options: ContentModelEditorOptions, contentDiv: HTMLDivElement) { this.initialContent = options.initialContent || contentDiv.innerHTML || ''; // Make the container editable and set its selection styles @@ -101,7 +101,7 @@ class LifecyclePlugin implements PluginWithState { defaultFormat, isDarkMode: !!options.inDarkMode, getDarkColor, - onExternalContentTransform: options.onExternalContentTransform ?? null, + onExternalContentTransform: null, experimentalFeatures: options.experimentalFeatures || [], shadowEditFragment: null, shadowEditEntities: null, @@ -193,7 +193,7 @@ class LifecyclePlugin implements PluginWithState { * @param contentDiv The editor content DIV element */ export function createLifecyclePlugin( - option: EditorOptions, + option: ContentModelEditorOptions, contentDiv: HTMLDivElement ): PluginWithState { return new LifecyclePlugin(option, contentDiv); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts index ffa9c2390dd..446705a7b00 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts @@ -1,7 +1,6 @@ import { ChangeSource, Keys, PluginEventType } from 'roosterjs-editor-types'; import type { ContentChangedEvent, - EditorOptions, IEditor, PluginEvent, PluginWithState, @@ -18,6 +17,7 @@ import { moveCurrentSnapshot, canUndoAutoComplete, } from 'roosterjs-editor-dom'; +import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; // Max stack size that cannot be exceeded. When exceeded, old undo history will be dropped // to keep size under limit. This is kept at 10MB @@ -35,12 +35,9 @@ class UndoPlugin implements PluginWithState { * Construct a new instance of UndoPlugin * @param options The wrapper of the state object */ - constructor(options: EditorOptions) { + constructor(options: ContentModelEditorOptions) { this.state = { - snapshotsService: - options.undoMetadataSnapshotService || - createUndoSnapshotServiceBridge(options.undoSnapshotService) || - createUndoSnapshots(), + snapshotsService: options.undoMetadataSnapshotService || createUndoSnapshots(), isRestoring: false, hasNewContent: false, isNested: false, @@ -256,32 +253,13 @@ function createUndoSnapshots(): UndoSnapshotsService { }; } -function createUndoSnapshotServiceBridge( - service: UndoSnapshotsService | undefined -): UndoSnapshotsService | undefined { - let html: string | null; - return service - ? { - canMove: (delta: number) => service.canMove(delta), - move: (delta: number): Snapshot | null => - (html = service.move(delta)) ? { html, metadata: null, knownColors: [] } : null, - addSnapshot: (snapshot: Snapshot, isAutoCompleteSnapshot: boolean) => - service.addSnapshot( - snapshot.html + - (snapshot.metadata ? `` : ''), - isAutoCompleteSnapshot - ), - clearRedo: () => service.clearRedo(), - canUndoAutoComplete: () => service.canUndoAutoComplete(), - } - : undefined; -} - /** * @internal * Create a new instance of UndoPlugin. * @param option The editor option */ -export function createUndoPlugin(option: EditorOptions): PluginWithState { +export function createUndoPlugin( + option: ContentModelEditorOptions +): PluginWithState { return new UndoPlugin(option); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts index ec9e7db7945..e5bfdf55821 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts @@ -8,20 +8,17 @@ import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; import { createPendingFormatStatePlugin } from './PendingFormatStatePlugin'; import { createStandaloneEditorCorePlugins } from 'roosterjs-content-model-core'; import { createUndoPlugin } from './UndoPlugin'; +import type { ContentModelCorePlugins } from '../publicTypes/ContentModelCorePlugins'; import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; -import type { CorePlugins, PluginState } from 'roosterjs-editor-types'; -import type { - ContentModelPluginState, - StandaloneEditorCorePlugins, -} from 'roosterjs-content-model-types'; +import type { PluginState } from 'roosterjs-editor-types'; +import type { ContentModelPluginState } from 'roosterjs-content-model-types'; /** * @internal */ -export type CreateCorePluginResponse = CorePlugins & - StandaloneEditorCorePlugins & { - _placeholder: null; - }; +export interface CreateCorePluginResponse extends ContentModelCorePlugins { + _placeholder: null; +} /** * @internal @@ -42,7 +39,6 @@ export function createCorePlugins( edit: map.edit || createEditPlugin(), pendingFormatState: map.pendingFormatState || createPendingFormatStatePlugin(), _placeholder: null, - typeAfterLink: null!, //deprecated after firefox update undo: map.undo || createUndoPlugin(options), domEvent: map.domEvent || createDOMEventPlugin(options, contentDiv), mouseUp: map.mouseUp || createMouseUpPlugin(), @@ -56,10 +52,10 @@ export function createCorePlugins( /** * @internal * Get plugin state of core plugins - * @param corePlugins CorePlugins object + * @param corePlugins ContentModelCorePlugins object */ export function getPluginState( - corePlugins: CorePlugins & StandaloneEditorCorePlugins + corePlugins: ContentModelCorePlugins ): PluginState & ContentModelPluginState { return { domEvent: corePlugins.domEvent.getState(), diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 983c4442f23..8f789793bef 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -1,4 +1,5 @@ import { createEditorCore } from './createEditorCore'; +import { paste } from 'roosterjs-content-model-core'; import { ChangeSource, ColorTransformDirection, @@ -419,36 +420,17 @@ export class ContentModelEditor implements IContentModelEditor { applyCurrentFormat: boolean = false, pasteAsImage: boolean = false ) { - const core = this.getCore(); - if (!clipboardData) { - return; - } - - if (clipboardData.snapshotBeforePaste) { - // Restore original content before paste a new one - this.setContent(clipboardData.snapshotBeforePaste); - } else { - clipboardData.snapshotBeforePaste = this.getContent( - GetContentMode.RawHTMLWithSelection - ); - } - - const range = this.getSelectionRange(); - const pos = range && Position.getStart(range); - const fragment = core.api.createPasteFragment( - core, + paste( + this, clipboardData, - pos, - pasteAsText, - applyCurrentFormat, - pasteAsImage + pasteAsText + ? 'asPlainText' + : applyCurrentFormat + ? 'mergeFormat' + : pasteAsImage + ? 'asImage' + : 'normal' ); - if (fragment) { - this.addUndoSnapshot(() => { - this.insertNode(fragment); - return clipboardData; - }, ChangeSource.Paste); - } } //#endregion @@ -1095,7 +1077,7 @@ export class ContentModelEditor implements IContentModelEditor { } /** - * @returns the current EditorCore object + * @returns the current ContentModelEditorCore object * @throws a standard Error if there's no core object */ private getCore(): ContentModelEditorCore { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts index f947e83c2ce..b382ca799ad 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts @@ -55,7 +55,7 @@ export function createEditorCore( ...pluginState, trustedHTMLHandler: options.trustedHTMLHandler || defaultTrustHtmlHandler, zoomScale: zoomScale, - sizeTransformer: options.sizeTransformer || ((size: number) => size / zoomScale), + sizeTransformer: (size: number) => size / zoomScale, getVisibleViewport, imageSelectionBorderColor: options.imageSelectionBorderColor, darkColorHandler: new DarkColorHandlerImpl(contentDiv, pluginState.lifecycle.getDarkColor), diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 4df6ac8d8a2..43a31ab5983 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -1,8 +1,6 @@ -export { - ContentModelCoreApiMap, - ContentModelEditorCore, -} from './publicTypes/ContentModelEditorCore'; +export { ContentModelEditorCore } from './publicTypes/ContentModelEditorCore'; export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; +export { ContentModelCorePlugins } from './publicTypes/ContentModelCorePlugins'; export { ContentModelEditor } from './editor/ContentModelEditor'; export { isContentModelEditor } from './editor/isContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts new file mode 100644 index 00000000000..16a88ffdc38 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts @@ -0,0 +1,69 @@ +import type { StandaloneEditorCorePlugins } from 'roosterjs-content-model-types'; +import type { + CopyPastePluginState, + DOMEventPluginState, + EditPluginState, + EditorPlugin, + EntityPluginState, + LifecyclePluginState, + PendingFormatStatePluginState, + PluginWithState, + UndoPluginState, +} from 'roosterjs-editor-types'; + +/** + * An interface for Content Model editor core plugins. + */ +export interface ContentModelCorePlugins extends StandaloneEditorCorePlugins { + /** + * Edit plugin handles ContentEditFeatures + */ + readonly edit: PluginWithState; + + /** + * Undo plugin provides the ability to undo/redo + */ + readonly undo: PluginWithState; + + /** + * DomEvent plugin helps handle additional DOM events such as IME composition, cut, drop. + */ + readonly domEvent: PluginWithState; + + /** + * PendingFormatStatePlugin handles pending format state management + */ + readonly pendingFormatState: PluginWithState; + + /** + * MouseUpPlugin help trigger MouseUp event even when mouse up happens outside editor + * as long as the mouse was pressed within Editor before + */ + readonly mouseUp: EditorPlugin; + + /** + * Copy and paste plugin for handling onCopy and onPaste event + */ + readonly copyPaste: PluginWithState; + /** + * Entity Plugin handles all operations related to an entity and generate entity specified events + */ + + readonly entity: PluginWithState; + + /** + * Image selection Plugin detects image selection and help highlight the image + */ + + readonly imageSelection: EditorPlugin; + + /** + * NormalizeTable plugin makes sure each table in editor has TBODY/THEAD/TFOOT tag around TR tags + */ + readonly normalizeTable: EditorPlugin; + + /** + * Lifecycle plugin handles editor initialization and disposing + */ + readonly lifecycle: PluginWithState; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts index 7a16d2ee5af..b6407bea00d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts @@ -1,23 +1,36 @@ -import type { CoreApiMap, EditorCore } from 'roosterjs-editor-types'; +import type { EditorPlugin, SizeTransformer } from 'roosterjs-editor-types'; import type { StandaloneCoreApiMap, StandaloneEditorCore } from 'roosterjs-content-model-types'; -/** - * The interface for the map of core API for Content Model editor. - * Editor can call call API from this map under ContentModelEditorCore object - */ -export interface ContentModelCoreApiMap extends CoreApiMap, StandaloneCoreApiMap {} - /** * Represents the core data structure of a Content Model editor */ -export interface ContentModelEditorCore extends EditorCore, StandaloneEditorCore { +export interface ContentModelEditorCore extends StandaloneEditorCore { /** * Core API map of this editor */ - readonly api: ContentModelCoreApiMap; + readonly api: StandaloneCoreApiMap; /** * Original API map of this editor. Overridden core API can use API from this map to call the original version of core API. */ - readonly originalApi: ContentModelCoreApiMap; + readonly originalApi: StandaloneCoreApiMap; + + /* + * Current zoom scale, default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using this property + * to let editor behave correctly especially for those mouse drag/drop behaviors + */ + zoomScale: number; + + /** + * @deprecated Use zoomScale instead + */ + sizeTransformer: SizeTransformer; + + /** + * A callback to be invoked when any exception is thrown during disposing editor + * @param plugin The plugin that causes exception + * @param error The error object we got + */ + disposeErrorHandler?: (plugin: EditorPlugin, error: Error) => void; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index 0eabedea19b..38d6616bae6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -1,5 +1,19 @@ -import type { EditorOptions, IEditor } from 'roosterjs-editor-types'; -import type { StandaloneEditorOptions, IStandaloneEditor } from 'roosterjs-content-model-types'; +import type { ContentModelCorePlugins } from './ContentModelCorePlugins'; +import type { + DefaultFormat, + EditorPlugin, + ExperimentalFeatures, + IEditor, + Rect, + Snapshot, + TrustedHTMLHandler, + UndoSnapshotsService, +} from 'roosterjs-editor-types'; +import type { + StandaloneEditorOptions, + IStandaloneEditor, + StandaloneCoreApiMap, +} from 'roosterjs-content-model-types'; /** * An interface of editor with Content Model support. @@ -10,4 +24,109 @@ export interface IContentModelEditor extends IEditor, IStandaloneEditor {} /** * Options for Content Model editor */ -export interface ContentModelEditorOptions extends EditorOptions, StandaloneEditorOptions {} +export interface ContentModelEditorOptions extends StandaloneEditorOptions { + /** + * List of plugins. + * The order of plugins here determines in what order each event will be dispatched. + * Plugins not appear in this list will not be added to editor, including built-in plugins. + * Default value is empty array. + */ + plugins?: EditorPlugin[]; + + /** + * Default format of editor content. This will be applied to empty content. + * If there is already content inside editor, format of existing content will not be changed. + * Default value is the computed style of editor content DIV + */ + defaultFormat?: DefaultFormat; + + /** + * Undo snapshot service based on content metadata. Use this parameter to customize the undo snapshot service. + * When this property is set, value of undoSnapshotService will be ignored. + */ + undoMetadataSnapshotService?: UndoSnapshotsService; + + /** + * Initial HTML content + * Default value is whatever already inside the editor content DIV + */ + initialContent?: string; + + /** + * A function map to override default core API implementation + * Default value is null + */ + coreApiOverride?: Partial; + + /** + * A plugin map to override default core Plugin implementation + * Default value is null + */ + corePluginOverride?: Partial; + + /** + * If the editor is currently in dark mode + */ + inDarkMode?: boolean; + + /** + * A util function to transform light mode color to dark mode color + * Default value is to return the original light color + */ + getDarkColor?: (lightColor: string) => string; + + /** + * Whether to skip the adjust editor process when for light/dark mode + */ + doNotAdjustEditorColor?: boolean; + + /** + * The scroll container to get scroll event from. + * By default, the scroll container will be the same with editor content DIV + */ + scrollContainer?: HTMLElement; + + /** + * Specify the enabled experimental features + */ + experimentalFeatures?: ExperimentalFeatures[]; + + /** + * By default, we will stop propagation of a printable keyboard event + * (a keyboard event which is caused by printable char input). + * Set this option to true to override this behavior in case you still need the event + * to be handled by ancestor nodes of editor. + */ + allowKeyboardEventPropagation?: boolean; + + /** + * Customized trusted type handler used for sanitizing HTML string before assign to DOM tree + * This is required when trusted-type Content-Security-Policy (CSP) is enabled. + * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types + */ + trustedHTMLHandler?: TrustedHTMLHandler; + + /** + * Current zoom scale, @default value is 1 + * When editor is put under a zoomed container, need to pass the zoom scale number using this property + * to let editor behave correctly especially for those mouse drag/drop behaviors + */ + zoomScale?: number; + + /** + * Retrieves the visible viewport of the Editor. The default viewport is the Rect of the scrollContainer. + */ + getVisibleViewport?: () => Rect | null; + + /** + * Color of the border of a selectedImage. Default color: '#DB626C' + */ + imageSelectionBorderColor?: string; + + /** + * A callback to be invoked when any exception is thrown during disposing editor + * @param plugin The plugin that causes exception + * @param error The error object we got + */ + disposeErrorHandler?: (plugin: EditorPlugin, error: Error) => void; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts index 4d7d643554b..4091970dec2 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts @@ -1,4 +1,29 @@ -import type { EditorCore, SwitchShadowEdit } from 'roosterjs-editor-types'; +import type { + CompatibleColorTransformDirection, + CompatibleGetContentMode, +} from 'roosterjs-editor-types/lib/compatibleTypes'; +import type { + ColorTransformDirection, + ContentChangedData, + ContentMetadata, + DOMEventHandler, + DarkColorHandler, + EditorPlugin, + GetContentMode, + ImageSelectionRange, + InsertOption, + NodePosition, + PendableFormatState, + PluginEvent, + PositionType, + Rect, + SelectionPath, + SelectionRangeEx, + StyleBasedFormatState, + TableSelection, + TableSelectionRange, + TrustedHTMLHandler, +} from 'roosterjs-editor-types'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { ContentModelPluginState } from '../pluginState/ContentModelPluginState'; import type { DOMSelection } from '../selection/DOMSelection'; @@ -17,7 +42,7 @@ import type { * Create a EditorContext object used by ContentModel API * @param core The StandaloneEditorCore object */ -export type CreateEditorContext = (core: StandaloneEditorCore & EditorCore) => EditorContext; +export type CreateEditorContext = (core: StandaloneEditorCore) => EditorContext; /** * Create Content Model from DOM tree in this editor @@ -26,7 +51,7 @@ export type CreateEditorContext = (core: StandaloneEditorCore & EditorCore) => E * @param selectionOverride When passed, use this selection range instead of current selection in editor */ export type CreateContentModel = ( - core: StandaloneEditorCore & EditorCore, + core: StandaloneEditorCore, option?: DomToModelOption, selectionOverride?: DOMSelection ) => ContentModelDocument; @@ -35,7 +60,7 @@ export type CreateContentModel = ( * Get current DOM selection from editor * @param core The StandaloneEditorCore object */ -export type GetDOMSelection = (core: StandaloneEditorCore & EditorCore) => DOMSelection | null; +export type GetDOMSelection = (core: StandaloneEditorCore) => DOMSelection | null; /** * Set content with content model. This is the replacement of core API getSelectionRangeEx @@ -45,7 +70,7 @@ export type GetDOMSelection = (core: StandaloneEditorCore & EditorCore) => DOMSe * @param onNodeCreated An optional callback that will be called when a DOM node is created */ export type SetContentModel = ( - core: StandaloneEditorCore & EditorCore, + core: StandaloneEditorCore, model: ContentModelDocument, option?: ModelToDomOption, onNodeCreated?: OnNodeCreated @@ -56,10 +81,7 @@ export type SetContentModel = ( * @param core The StandaloneEditorCore object * @param selection The selection to set */ -export type SetDOMSelection = ( - core: StandaloneEditorCore & EditorCore, - selection: DOMSelection -) => void; +export type SetDOMSelection = (core: StandaloneEditorCore, selection: DOMSelection) => void; /** * The general API to do format change with Content Model @@ -71,16 +93,249 @@ export type SetDOMSelection = ( * @param options More options, see FormatWithContentModelOptions */ export type FormatContentModel = ( - core: StandaloneEditorCore & EditorCore, + core: StandaloneEditorCore, formatter: ContentModelFormatter, options?: FormatWithContentModelOptions ) => void; /** - * The interface for the map of core API for Content Model editor. - * Editor can call call API from this map under StandaloneEditorCore object + * Switch the Shadow Edit mode of editor On/Off + * @param core The StandaloneEditorCore object + * @param isOn True to switch On, False to switch Off + */ +export type SwitchShadowEdit = (core: StandaloneEditorCore, isOn: boolean) => void; + +/** + * TODO: Remove this Core API and use setDOMSelection instead + * Select content according to the given information. + * There are a bunch of allowed combination of parameters. See IEditor.select for more details + * @param core The editor core object + * @param arg1 A DOM Range, or SelectionRangeEx, or NodePosition, or Node, or Selection Path + * @param arg2 (optional) A NodePosition, or an offset number, or a PositionType, or a TableSelection, or null + * @param arg3 (optional) A Node + * @param arg4 (optional) An offset number, or a PositionType + */ +export type Select = ( + core: StandaloneEditorCore, + arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, + arg2?: NodePosition | number | PositionType | TableSelection | null, + arg3?: Node, + arg4?: number | PositionType +) => boolean; + +/** + * Trigger a plugin event + * @param core The StandaloneEditorCore object + * @param pluginEvent The event object to trigger + * @param broadcast Set to true to skip the shouldHandleEventExclusively check + */ +export type TriggerEvent = ( + core: StandaloneEditorCore, + pluginEvent: PluginEvent, + broadcast: boolean +) => void; + +/** + * Get current selection range + * @param core The StandaloneEditorCore object + * @returns A Range object of the selection range + */ +export type GetSelectionRangeEx = (core: StandaloneEditorCore) => SelectionRangeEx; + +/** + * Edit and transform color of elements between light mode and dark mode + * @param core The StandaloneEditorCore object + * @param rootNode The root HTML node to transform + * @param includeSelf True to transform the root node as well, otherwise false + * @param callback The callback function to invoke before do color transformation + * @param direction To specify the transform direction, light to dark, or dark to light + * @param forceTransform By default this function will only work when editor core is in dark mode. + * Pass true to this value to force do color transformation even editor core is in light mode + * @param fromDarkModel Whether the given content is already in dark mode + */ +export type TransformColor = ( + core: StandaloneEditorCore, + rootNode: Node | null, + includeSelf: boolean, + callback: (() => void) | null, + direction: ColorTransformDirection | CompatibleColorTransformDirection, + forceTransform?: boolean, + fromDarkMode?: boolean +) => void; + +/** + * Call an editing callback with adding undo snapshots around, and trigger a ContentChanged event if change source is specified. + * Undo snapshot will not be added if this call is nested inside another addUndoSnapshot() call. + * @param core The StandaloneEditorCore object + * @param callback The editing callback, accepting current selection start and end position, returns an optional object used as the data field of ContentChangedEvent. + * @param changeSource The ChangeSource string of ContentChangedEvent. @default ChangeSource.Format. Set to null to avoid triggering ContentChangedEvent + * @param canUndoByBackspace True if this action can be undone when user press Backspace key (aka Auto Complete). + * @param additionalData Optional parameter to provide additional data related to the ContentChanged Event. + */ +export type AddUndoSnapshot = ( + core: StandaloneEditorCore, + callback: ((start: NodePosition | null, end: NodePosition | null) => any) | null, + changeSource: string | null, + canUndoByBackspace: boolean, + additionalData?: ContentChangedData +) => void; + +/** + * Change the editor selection to the given range + * @param core The StandaloneEditorCore object + * @param range The range to select + * @param skipSameRange When set to true, do nothing if the given range is the same with current selection + * in editor, otherwise it will always remove current selection range and set to the given one. + * This parameter is always treated as true in Edge to avoid some weird runtime exception. + */ +export type SelectRange = ( + core: StandaloneEditorCore, + range: Range, + skipSameRange?: boolean +) => boolean; + +/** + * Select a table and save data of the selected range + * @param core The StandaloneEditorCore object + * @param image image to select + * @returns true if successful + */ +export type SelectImage = ( + core: StandaloneEditorCore, + image: HTMLImageElement | null +) => ImageSelectionRange | null; + +/** + * Select a table and save data of the selected range + * @param core The StandaloneEditorCore object + * @param table table to select + * @param coordinates first and last cell of the selection, if this parameter is null, instead of + * selecting, will unselect the table. + * @returns true if successful + */ +export type SelectTable = ( + core: StandaloneEditorCore, + table: HTMLTableElement | null, + coordinates?: TableSelection +) => TableSelectionRange | null; + +/** + * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered + * if triggerContentChangedEvent is set to true + * @param core The StandaloneEditorCore object + * @param content HTML content to set in + * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true + */ +export type SetContent = ( + core: StandaloneEditorCore, + content: string, + triggerContentChangedEvent: boolean, + metadata?: ContentMetadata +) => void; + +/** + * Get current or cached selection range + * @param core The StandaloneEditorCore object + * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now + * @returns A Range object of the selection range + */ +export type GetSelectionRange = ( + core: StandaloneEditorCore, + tryGetFromCache: boolean +) => Range | null; + +/** + * Check if the editor has focus now + * @param core The StandaloneEditorCore object + * @returns True if the editor has focus, otherwise false + */ +export type HasFocus = (core: StandaloneEditorCore) => boolean; + +/** + * Focus to editor. If there is a cached selection range, use it as current selection + * @param core The StandaloneEditorCore object + */ +export type Focus = (core: StandaloneEditorCore) => void; + +/** + * Insert a DOM node into editor content + * @param core The StandaloneEditorCore object. No op if null. + * @param option An insert option object to specify how to insert the node + */ +export type InsertNode = ( + core: StandaloneEditorCore, + node: Node, + option: InsertOption | null +) => boolean; + +/** + * Get the pendable format such as underline and bold + * @param core The StandaloneEditorCore object + * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. + * @return The pending format state of editor. + */ +export type GetPendableFormatState = ( + core: StandaloneEditorCore, + forceGetStateFromDOM: boolean +) => PendableFormatState; + +/** + * Attach a DOM event to the editor content DIV + * @param core The StandaloneEditorCore object + * @param eventMap A map from event name to its handler + */ +export type AttachDomEvent = ( + core: StandaloneEditorCore, + eventMap: Record +) => () => void; + +/** + * Get current editor content as HTML string + * @param core The StandaloneEditorCore object + * @param mode specify what kind of HTML content to retrieve + * @returns HTML string representing current editor content + */ +export type GetContent = ( + core: StandaloneEditorCore, + mode: GetContentMode | CompatibleGetContentMode +) => string; + +/** + * Get style based format state from current selection, including font name/size and colors + * @param core The StandaloneEditorCore objects + * @param node The node to get style from */ -export interface StandaloneCoreApiMap { +export type GetStyleBasedFormatState = ( + core: StandaloneEditorCore, + node: Node | null +) => StyleBasedFormatState; + +/** + * Restore an undo snapshot into editor + * @param core The StandaloneEditorCore object + * @param step Steps to move, can be 0, positive or negative + */ +export type RestoreUndoSnapshot = (core: StandaloneEditorCore, step: number) => void; + +/** + * Ensure user will type into a container element rather than into the editor content DIV directly + * @param core The StandaloneEditorCore object. + * @param position The position that user is about to type to + * @param keyboardEvent Optional keyboard event object + * @param deprecated Deprecated parameter, not used + */ +export type EnsureTypeInContainer = ( + core: StandaloneEditorCore, + position: NodePosition, + keyboardEvent?: KeyboardEvent, + deprecated?: boolean +) => void; + +/** + * Temp interface + * TODO: Port other core API + */ +export interface PortedCoreApiMap { /** * Create a EditorContext object used by ContentModel API * @param core The StandaloneEditorCore object @@ -126,10 +381,191 @@ export interface StandaloneCoreApiMap { */ formatContentModel: FormatContentModel; - // TODO: This is copied from legacy editor core, will be ported to use new types later + /** + * Switch the Shadow Edit mode of editor On/Off + * @param core The StandaloneEditorCore object + * @param isOn True to switch On, False to switch Off + */ switchShadowEdit: SwitchShadowEdit; } +/** + * Temp interface + * TODO: Port these core API + */ +export interface UnportedCoreApiMap { + /** + * Select content according to the given information. + * There are a bunch of allowed combination of parameters. See IEditor.select for more details + * @param core The editor core object + * @param arg1 A DOM Range, or SelectionRangeEx, or NodePosition, or Node, or Selection Path + * @param arg2 (optional) A NodePosition, or an offset number, or a PositionType, or a TableSelection, or null + * @param arg3 (optional) A Node + * @param arg4 (optional) An offset number, or a PositionType + */ + select: Select; + + /** + * Trigger a plugin event + * @param core The StandaloneEditorCore object + * @param pluginEvent The event object to trigger + * @param broadcast Set to true to skip the shouldHandleEventExclusively check + */ + triggerEvent: TriggerEvent; + + /** + * Get current or cached selection range + * @param core The StandaloneEditorCore object + * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now + * @returns A Range object of the selection range + */ + getSelectionRangeEx: GetSelectionRangeEx; + + /** + * Edit and transform color of elements between light mode and dark mode + * @param core The StandaloneEditorCore object + * @param rootNode The root HTML element to transform + * @param includeSelf True to transform the root node as well, otherwise false + * @param callback The callback function to invoke before do color transformation + * @param direction To specify the transform direction, light to dark, or dark to light + * @param forceTransform By default this function will only work when editor core is in dark mode. + * Pass true to this value to force do color transformation even editor core is in light mode + * @param fromDarkModel Whether the given content is already in dark mode + */ + transformColor: TransformColor; + + /** + * Call an editing callback with adding undo snapshots around, and trigger a ContentChanged event if change source is specified. + * Undo snapshot will not be added if this call is nested inside another addUndoSnapshot() call. + * @param core The StandaloneEditorCore object + * @param callback The editing callback, accepting current selection start and end position, returns an optional object used as the data field of ContentChangedEvent. + * @param changeSource The ChangeSource string of ContentChangedEvent. @default ChangeSource.Format. Set to null to avoid triggering ContentChangedEvent + * @param canUndoByBackspace True if this action can be undone when user presses Backspace key (aka Auto Complete). + */ + addUndoSnapshot: AddUndoSnapshot; + + /** + * Change the editor selection to the given range + * @param core The StandaloneEditorCore object + * @param range The range to select + * @param skipSameRange When set to true, do nothing if the given range is the same with current selection + * in editor, otherwise it will always remove current selection range and set to the given one. + * This parameter is always treated as true in Edge to avoid some weird runtime exception. + */ + selectRange: SelectRange; + + /** + * Select a image and save data of the selected range + * @param core The StandaloneEditorCore object + * @param image image to select + * @param imageId the id of the image element + * @returns true if successful + */ + selectImage: SelectImage; + + /** + * Select a table and save data of the selected range + * @param core The StandaloneEditorCore object + * @param table table to select + * @param coordinates first and last cell of the selection, if this parameter is null, instead of + * selecting, will unselect the table. + * @param shouldAddStyles Whether need to update the style elements + * @returns true if successful + */ + selectTable: SelectTable; + + /** + * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered + * if triggerContentChangedEvent is set to true + * @param core The StandaloneEditorCore object + * @param content HTML content to set in + * @param triggerContentChangedEvent True to trigger a ContentChanged event. Default value is true + */ + setContent: SetContent; + + /** + * Get current or cached selection range + * @param core The StandaloneEditorCore object + * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now + * @returns A Range object of the selection range + */ + getSelectionRange: GetSelectionRange; + + /** + * Check if the editor has focus now + * @param core The StandaloneEditorCore object + * @returns True if the editor has focus, otherwise false + */ + hasFocus: HasFocus; + + /** + * Focus to editor. If there is a cached selection range, use it as current selection + * @param core The StandaloneEditorCore object + */ + focus: Focus; + + /** + * Insert a DOM node into editor content + * @param core The StandaloneEditorCore object. No op if null. + * @param option An insert option object to specify how to insert the node + */ + insertNode: InsertNode; + + /** + * Get the pendable format such as underline and bold + * @param core The StandaloneEditorCore object + *@param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. + * @return The pending format state of editor. + */ + getPendableFormatState: GetPendableFormatState; + + /** + * Attach a DOM event to the editor content DIV + * @param core The StandaloneEditorCore object + * @param eventName The DOM event name + * @param pluginEventType Optional event type. When specified, editor will trigger a plugin event with this name when the DOM event is triggered + * @param beforeDispatch Optional callback function to be invoked when the DOM event is triggered before trigger plugin event + */ + attachDomEvent: AttachDomEvent; + + /** + * Get current editor content as HTML string + * @param core The StandaloneEditorCore object + * @param mode specify what kind of HTML content to retrieve + * @returns HTML string representing current editor content + */ + getContent: GetContent; + + /** + * Get style based format state from current selection, including font name/size and colors + * @param core The StandaloneEditorCore objects + * @param node The node to get style from + */ + getStyleBasedFormatState: GetStyleBasedFormatState; + + /** + * Restore an undo snapshot into editor + * @param core The editor core object + * @param step Steps to move, can be 0, positive or negative + */ + restoreUndoSnapshot: RestoreUndoSnapshot; + + /** + * Ensure user will type into a container element rather than into the editor content DIV directly + * @param core The EditorCore object. + * @param position The position that user is about to type to + * @param keyboardEvent Optional keyboard event object + * @param deprecated Deprecated parameter, not used + */ + ensureTypeInContainer: EnsureTypeInContainer; +} + +/** + * The interface for the map of core API for Content Model editor. + * Editor can call call API from this map under StandaloneEditorCore object + */ +export interface StandaloneCoreApiMap extends PortedCoreApiMap, UnportedCoreApiMap {} + /** * Represents the core data structure of a Content Model editor */ @@ -151,10 +587,38 @@ export interface StandaloneEditorCore */ readonly originalApi: StandaloneCoreApiMap; + /** + * An array of editor plugins. + */ + readonly plugins: EditorPlugin[]; + /** * Editor running environment */ - environment: EditorEnvironment; + readonly environment: EditorEnvironment; + + /** + * Dark model handler for the editor, used for variable-based solution. + * If keep it null, editor will still use original dataset-based dark mode solution. + */ + darkColorHandler: DarkColorHandler; + + /** + * Retrieves the Visible Viewport of the editor. + */ + getVisibleViewport: () => Rect | null; + + /** + * Color of the border of a selectedImage. Default color: '#DB626C' + */ + imageSelectionBorderColor?: string; + + /** + * A handler to convert HTML string to a trust HTML string. + * By default it will just return the original HTML string directly. + * To override, pass your own trusted HTML handler to EditorOptions.trustedHTMLHandler + */ + readonly trustedHTMLHandler: TrustedHTMLHandler; } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts index 97e5617e66b..8374a4de9e8 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts @@ -1,6 +1,6 @@ import type { ContentModelCachePluginState } from '../pluginState/ContentModelCachePluginState'; import type { ContentModelFormatPluginState } from '../pluginState/ContentModelFormatPluginState'; -import type { CopyPastePluginState, EditorPlugin, PluginWithState } from 'roosterjs-editor-types'; +import type { CopyPastePluginState, PluginWithState } from 'roosterjs-editor-types'; /** * Core plugins for standalone editor @@ -16,11 +16,6 @@ export interface StandaloneEditorCorePlugins { */ readonly format: PluginWithState; - /** - * TypeInContainer plugin makes sure user is always type under a container element under editor DIV - */ - readonly typeInContainer: EditorPlugin; - /** * Copy and paste plugin for handling onCopy and onPaste event */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts index d442756beb7..aee553066ec 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts @@ -27,4 +27,10 @@ export interface StandaloneEditorOptions { * Default value is the computed style of editor content DIV */ defaultFormat?: DefaultFormat; + + /** + * Allowed custom content type when paste besides text/plain, text/html and images + * Only text types are supported, and do not add "text/" prefix to the type values + */ + allowedCustomPasteType?: string[]; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index 8f44f8220d3..2ceb546c7bd 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -205,6 +205,28 @@ export { StandaloneCoreApiMap, StandaloneEditorCore, StandaloneEditorDefaultSettings, + SwitchShadowEdit, + Select, + TriggerEvent, + GetSelectionRangeEx, + TransformColor, + AddUndoSnapshot, + SelectRange, + PortedCoreApiMap, + UnportedCoreApiMap, + SelectImage, + SelectTable, + SetContent, + GetSelectionRange, + HasFocus, + Focus, + InsertNode, + GetPendableFormatState, + AttachDomEvent, + GetContent, + GetStyleBasedFormatState, + RestoreUndoSnapshot, + EnsureTypeInContainer, } from './editor/StandaloneEditorCore'; export { StandaloneEditorCorePlugins } from './editor/StandaloneEditorCorePlugins'; diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts index b31f0382035..d84b5bb1e4a 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts @@ -1,4 +1,12 @@ -import type { CopyPastePluginState } from 'roosterjs-editor-types'; +import type { + CopyPastePluginState, + DOMEventPluginState, + EditPluginState, + EntityPluginState, + LifecyclePluginState, + PendingFormatStatePluginState, + UndoPluginState, +} from 'roosterjs-editor-types'; import type { ContentModelCachePluginState } from './ContentModelCachePluginState'; import type { ContentModelFormatPluginState } from './ContentModelFormatPluginState'; @@ -21,4 +29,12 @@ export interface ContentModelPluginState { * Plugin state for ContentModelFormatPlugin */ format: ContentModelFormatPluginState; + + // Plugins copied from legacy editor + lifecycle: LifecyclePluginState; + domEvent: DOMEventPluginState; + entity: EntityPluginState; + pendingFormatState: PendingFormatStatePluginState; + undo: UndoPluginState; + edit: EditPluginState; } From 8c567d314ffb407168fd0bd2439d708806b2e67e Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 15 Nov 2023 21:07:21 -0800 Subject: [PATCH 051/111] Standalone Editor: Remove legacy plugin: PendingFormatStatePlugin (#2209) * Remove dependency to EditorCore * fix test * improve * Standalone editor: Remove PendingFormatStatePlugin * fix build --- .../lib/coreApi/coreApiMap.ts | 2 - .../lib/coreApi/getStyleBasedFormatState.ts | 20 +- .../lib/coreApi/selectRange.ts | 48 +---- .../corePlugins/PendingFormatStatePlugin.ts | 190 ------------------ .../lib/corePlugins/createCorePlugins.ts | 8 +- .../lib/editor/ContentModelEditor.ts | 3 +- .../utils}/getPendableFormatState.ts | 22 +- .../publicTypes/ContentModelCorePlugins.ts | 6 - .../test/editor/createEditorCoreTest.ts | 14 -- .../lib/editor/StandaloneEditorCore.ts | 20 -- .../lib/index.ts | 1 - .../pluginState/ContentModelPluginState.ts | 2 - 12 files changed, 13 insertions(+), 323 deletions(-) delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/PendingFormatStatePlugin.ts rename packages-content-model/roosterjs-content-model-editor/lib/{coreApi => editor/utils}/getPendableFormatState.ts (81%) diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts index 03e85fc2073..0cc7b7d5b34 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts @@ -3,7 +3,6 @@ import { attachDomEvent } from './attachDomEvent'; import { ensureTypeInContainer } from './ensureTypeInContainer'; import { focus } from './focus'; import { getContent } from './getContent'; -import { getPendableFormatState } from './getPendableFormatState'; import { getSelectionRange } from './getSelectionRange'; import { getSelectionRangeEx } from './getSelectionRangeEx'; import { getStyleBasedFormatState } from './getStyleBasedFormatState'; @@ -33,7 +32,6 @@ export const coreApiMap: StandaloneCoreApiMap = { getSelectionRange, getSelectionRangeEx, getStyleBasedFormatState, - getPendableFormatState, hasFocus, insertNode, restoreUndoSnapshot, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts index dfb857a9f05..230236ca8e0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getStyleBasedFormatState.ts @@ -13,18 +13,6 @@ export const getStyleBasedFormatState: GetStyleBasedFormatState = (core, node) = return {}; } - let override: string[] = []; - const pendableFormatSpan = core.pendingFormatState.pendableFormatSpan; - - if (pendableFormatSpan) { - override = [ - pendableFormatSpan.style.fontFamily, - pendableFormatSpan.style.fontSize, - pendableFormatSpan.style.color, - pendableFormatSpan.style.backgroundColor, - ]; - } - const styles = node ? getComputedStyles(node, [ 'font-family', @@ -63,12 +51,12 @@ export const getStyleBasedFormatState: GetStyleBasedFormatState = (core, node) = styleBackColor = styleBackColor || styles[3]; } - const textColor = darkColorHandler.parseColorValue(override[2] || styleTextColor); - const backColor = darkColorHandler.parseColorValue(override[3] || styleBackColor); + const textColor = darkColorHandler.parseColorValue(styleTextColor); + const backColor = darkColorHandler.parseColorValue(styleBackColor); return { - fontName: override[0] || styles[0], - fontSize: override[1] || styles[1], + fontName: styles[0], + fontSize: styles[1], textColor: textColor.lightModeColor, backgroundColor: backColor.lightModeColor, textColors: textColor.darkModeColor diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts index 4bd2334a91a..adb720c958f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts @@ -1,12 +1,5 @@ -import { - contains, - getPendableFormatState, - Position, - PendableFormatCommandMap, - addRangeToSelection, - getObjectKeys, -} from 'roosterjs-editor-dom'; -import type { SelectRange, StandaloneEditorCore } from 'roosterjs-content-model-types'; +import { contains, addRangeToSelection } from 'roosterjs-editor-dom'; +import type { SelectRange } from 'roosterjs-content-model-types'; /** * @internal @@ -25,45 +18,8 @@ export const selectRange: SelectRange = (core, range, skipSameRange) => { core.domEvent.selectionRange = range; } - if (range.collapsed) { - // If selected, and current selection is collapsed, - // need to restore pending format state if exists. - restorePendingFormatState(core); - } - return true; } else { return false; } }; - -/** - * Restore cached pending format state (if exist) to current selection - */ -function restorePendingFormatState(core: StandaloneEditorCore) { - const { - contentDiv, - pendingFormatState, - api: { getSelectionRange }, - } = core; - - if (pendingFormatState.pendableFormatState) { - const document = contentDiv.ownerDocument; - const formatState = getPendableFormatState(document); - getObjectKeys(PendableFormatCommandMap).forEach(key => { - if (!!pendingFormatState.pendableFormatState?.[key] != formatState[key]) { - document.execCommand( - PendableFormatCommandMap[key], - false /* showUI */, - undefined /* value */ - ); - } - }); - - const range = getSelectionRange(core, true /*tryGetFromCache*/); - const position: Position | null = range && Position.getStart(range); - if (position) { - pendingFormatState.pendableFormatPosition = position; - } - } -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/PendingFormatStatePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/PendingFormatStatePlugin.ts deleted file mode 100644 index db1311ab3d4..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/PendingFormatStatePlugin.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { ChangeSource, Keys, PluginEventType, PositionType } from 'roosterjs-editor-types'; -import { isCharacterValue, Position, setColor } from 'roosterjs-editor-dom'; -import type { - IEditor, - NodePosition, - PendingFormatStatePluginState, - PluginEvent, - PluginWithState, -} from 'roosterjs-editor-types'; - -const ZERO_WIDTH_SPACE = '\u200B'; - -/** - * PendingFormatStatePlugin handles pending format state management - */ -class PendingFormatStatePlugin implements PluginWithState { - private editor: IEditor | null = null; - private state: PendingFormatStatePluginState; - - /** - * Construct a new instance of PendingFormatStatePlugin - * @param options The editor options - * @param contentDiv The editor content DIV - */ - constructor() { - this.state = { - pendableFormatPosition: null, - pendableFormatState: null, - pendableFormatSpan: null, - }; - } - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'PendingFormatState'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - } - - /** - * Dispose this plugin - */ - dispose() { - this.editor = null; - this.clear(); - } - - /** - * Get plugin state object - */ - getState() { - return this.state; - } - - /** - * Handle events triggered from editor - * @param event PluginEvent object - */ - onPluginEvent(event: PluginEvent) { - switch (event.eventType) { - case PluginEventType.PendingFormatStateChanged: - // Got PendingFormatStateChanged event, cache current position and pending format if a format is passed in - // otherwise clear existing pending format. - if (event.formatState) { - this.state.pendableFormatPosition = this.getCurrentPosition(); - this.state.pendableFormatState = event.formatState; - this.state.pendableFormatSpan = event.formatCallback - ? this.createPendingFormatSpan(event.formatCallback) - : null; - } else { - this.clear(); - } - - break; - case PluginEventType.KeyDown: - case PluginEventType.MouseDown: - case PluginEventType.ContentChanged: - let currentPosition: NodePosition | null = null; - if ( - this.editor && - event.eventType == PluginEventType.KeyDown && - isCharacterValue(event.rawEvent) && - this.state.pendableFormatSpan - ) { - this.state.pendableFormatSpan.removeAttribute('contentEditable'); - this.editor.insertNode(this.state.pendableFormatSpan); - this.editor.select( - this.state.pendableFormatSpan, - PositionType.Begin, - this.state.pendableFormatSpan, - PositionType.End - ); - this.clear(); - } else if ( - (event.eventType == PluginEventType.KeyDown && - event.rawEvent.which >= Keys.PAGEUP && - event.rawEvent.which <= Keys.DOWN) || - (this.state.pendableFormatPosition && - (currentPosition = this.getCurrentPosition()) && - !this.state.pendableFormatPosition.equalTo(currentPosition)) || - (event.eventType == PluginEventType.ContentChanged && - (event.source == ChangeSource.SwitchToDarkMode || - event.source == ChangeSource.SwitchToLightMode)) - ) { - // If content or position is changed (by keyboard, mouse, or code), - // check if current position is still the same with the cached one (if exist), - // and clear cached format if position is changed since it is out-of-date now - this.clear(); - } - - break; - } - } - - private clear() { - this.state.pendableFormatPosition = null; - this.state.pendableFormatState = null; - this.state.pendableFormatSpan = null; - } - - private getCurrentPosition() { - const range = this.editor?.getSelectionRange(); - return (range && Position.getStart(range).normalize()) ?? null; - } - - private createPendingFormatSpan( - callback: (element: HTMLElement, isInnerNode?: boolean) => any - ) { - let span = this.state.pendableFormatSpan; - - if (!span && this.editor) { - const currentStyle = this.editor.getStyleBasedFormatState(); - const doc = this.editor.getDocument(); - const isDarkMode = this.editor.isDarkMode(); - - span = doc.createElement('span'); - span.contentEditable = 'true'; - span.appendChild(doc.createTextNode(ZERO_WIDTH_SPACE)); - - span.style.setProperty('font-family', currentStyle.fontName ?? null); - span.style.setProperty('font-size', currentStyle.fontSize ?? null); - - const darkColorHandler = this.editor.getDarkColorHandler(); - - if (currentStyle.textColors || currentStyle.textColor) { - setColor( - span, - (currentStyle.textColors || currentStyle.textColor)!, - false /*isBackground*/, - isDarkMode, - false /*shouldAdaptFontColor*/, - darkColorHandler - ); - } - - if (currentStyle.backgroundColors || currentStyle.backgroundColor) { - setColor( - span, - (currentStyle.backgroundColors || currentStyle.backgroundColor)!, - true /*isBackground*/, - isDarkMode, - false /*shouldAdaptFontColor*/, - darkColorHandler - ); - } - } - - if (span) { - callback(span); - } - - return span; - } -} - -/** - * @internal - * Create a new instance of PendingFormatStatePlugin. - */ -export function createPendingFormatStatePlugin(): PluginWithState { - return new PendingFormatStatePlugin(); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts index e5bfdf55821..26a8bee9c9c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts @@ -5,12 +5,10 @@ import { createImageSelection } from './ImageSelection'; import { createLifecyclePlugin } from './LifecyclePlugin'; import { createMouseUpPlugin } from './MouseUpPlugin'; import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; -import { createPendingFormatStatePlugin } from './PendingFormatStatePlugin'; import { createStandaloneEditorCorePlugins } from 'roosterjs-content-model-core'; import { createUndoPlugin } from './UndoPlugin'; import type { ContentModelCorePlugins } from '../publicTypes/ContentModelCorePlugins'; import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; -import type { PluginState } from 'roosterjs-editor-types'; import type { ContentModelPluginState } from 'roosterjs-content-model-types'; /** @@ -37,7 +35,6 @@ export function createCorePlugins( return { ...createStandaloneEditorCorePlugins(options), edit: map.edit || createEditPlugin(), - pendingFormatState: map.pendingFormatState || createPendingFormatStatePlugin(), _placeholder: null, undo: map.undo || createUndoPlugin(options), domEvent: map.domEvent || createDOMEventPlugin(options, contentDiv), @@ -54,12 +51,9 @@ export function createCorePlugins( * Get plugin state of core plugins * @param corePlugins ContentModelCorePlugins object */ -export function getPluginState( - corePlugins: ContentModelCorePlugins -): PluginState & ContentModelPluginState { +export function getPluginState(corePlugins: ContentModelCorePlugins): ContentModelPluginState { return { domEvent: corePlugins.domEvent.getState(), - pendingFormatState: corePlugins.pendingFormatState.getState(), edit: corePlugins.edit.getState(), lifecycle: corePlugins.lifecycle.getState(), undo: corePlugins.undo.getState(), diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 8f789793bef..5504c9bfa1b 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -1,4 +1,5 @@ import { createEditorCore } from './createEditorCore'; +import { getPendableFormatState } from './utils/getPendableFormatState'; import { paste } from 'roosterjs-content-model-core'; import { ChangeSource, @@ -905,7 +906,7 @@ export class ContentModelEditor implements IContentModelEditor { */ getPendableFormatState(forceGetStateFromDOM: boolean = false): PendableFormatState { const core = this.getCore(); - return core.api.getPendableFormatState(core, forceGetStateFromDOM); + return getPendableFormatState(core); } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getPendableFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts similarity index 81% rename from packages-content-model/roosterjs-content-model-editor/lib/coreApi/getPendableFormatState.ts rename to packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts index a99b12dc7f8..61071fdfd22 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getPendableFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts @@ -2,7 +2,7 @@ import { contains, getObjectKeys, getTagOfNode, Position } from 'roosterjs-edito import { NodeType } from 'roosterjs-editor-types'; import type { PendableFormatNames } from 'roosterjs-editor-dom'; import type { NodePosition, PendableFormatState } from 'roosterjs-editor-types'; -import type { GetPendableFormatState, StandaloneEditorCore } from 'roosterjs-content-model-types'; +import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; /** * @internal @@ -10,26 +10,12 @@ import type { GetPendableFormatState, StandaloneEditorCore } from 'roosterjs-con * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. * @returns The cached format state if it exists. If the cached position do not exist, search for pendable elements in the DOM tree and return the pendable format state. */ -export const getPendableFormatState: GetPendableFormatState = ( - core, - forceGetStateFromDOM -): PendableFormatState => { +export function getPendableFormatState(core: StandaloneEditorCore): PendableFormatState { const range = core.api.getSelectionRange(core, true /* tryGetFromCache*/); - const cachedPendableFormatState = core.pendingFormatState.pendableFormatState; - const cachedPosition = core.pendingFormatState.pendableFormatPosition?.normalize(); const currentPosition = range && Position.getStart(range).normalize(); - const isSamePosition = - currentPosition && - cachedPosition && - range.collapsed && - currentPosition.equalTo(cachedPosition); - if (range && cachedPendableFormatState && isSamePosition && !forceGetStateFromDOM) { - return cachedPendableFormatState; - } else { - return currentPosition ? queryCommandStateFromDOM(core, currentPosition) : {}; - } -}; + return currentPosition ? queryCommandStateFromDOM(core, currentPosition) : {}; +} const PendableStyleCheckers: Record< PendableFormatNames, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts index 16a88ffdc38..786be796fae 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts @@ -6,7 +6,6 @@ import type { EditorPlugin, EntityPluginState, LifecyclePluginState, - PendingFormatStatePluginState, PluginWithState, UndoPluginState, } from 'roosterjs-editor-types'; @@ -30,11 +29,6 @@ export interface ContentModelCorePlugins extends StandaloneEditorCorePlugins { */ readonly domEvent: PluginWithState; - /** - * PendingFormatStatePlugin handles pending format state management - */ - readonly pendingFormatState: PluginWithState; - /** * MouseUpPlugin help trigger MouseUp event even when mouse up happens outside editor * as long as the mouse was pressed within Editor before diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts index fb733cecbab..26cabd521c8 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts @@ -9,13 +9,11 @@ import * as ImageSelection from '../../lib/corePlugins/ImageSelection'; import * as LifecyclePlugin from '../../lib/corePlugins/LifecyclePlugin'; import * as MouseUpPlugin from '../../lib/corePlugins/MouseUpPlugin'; import * as NormalizeTablePlugin from '../../lib/corePlugins/NormalizeTablePlugin'; -import * as PendingFormatStatePlugin from '../../lib/corePlugins/PendingFormatStatePlugin'; import * as UndoPlugin from '../../lib/corePlugins/UndoPlugin'; import { coreApiMap } from '../../lib/coreApi/coreApiMap'; import { createEditorCore, defaultTrustHtmlHandler } from '../../lib/editor/createEditorCore'; const mockedDomEventState = 'DOMEVENTSTATE' as any; -const mockedPendingFormatState = 'PENDINGFORMATSTATE' as any; const mockedEditState = 'EDITSTATE' as any; const mockedLifecycleState = 'LIFECYCLESTATE' as any; const mockedUndoState = 'UNDOSTATE' as any; @@ -36,9 +34,6 @@ const mockedCopyPastePlugin = { const mockedEditPlugin = { getState: () => mockedEditState, } as any; -const mockedPendingFormatStatePlugin = { - getState: () => mockedPendingFormatState, -} as any; const mockedUndoPlugin = { getState: () => mockedUndoState, } as any; @@ -58,8 +53,6 @@ const mockedDefaultSettings = { settings: 'SETTINGS', } as any; -// const mockedSwitchShadowEdit = 'SHADOWEDIT' as any; - describe('createEditorCore', () => { let contentDiv: any; @@ -78,9 +71,6 @@ describe('createEditorCore', () => { mockedCopyPastePlugin ); spyOn(EditPlugin, 'createEditPlugin').and.returnValue(mockedEditPlugin); - spyOn(PendingFormatStatePlugin, 'createPendingFormatStatePlugin').and.returnValue( - mockedPendingFormatStatePlugin - ); spyOn(UndoPlugin, 'createUndoPlugin').and.returnValue(mockedUndoPlugin); spyOn(DOMEventPlugin, 'createDOMEventPlugin').and.returnValue(mockedDOMEventPlugin); spyOn(MouseUpPlugin, 'createMouseUpPlugin').and.returnValue(mockedMouseUpPlugin); @@ -107,7 +97,6 @@ describe('createEditorCore', () => { mockedFormatPlugin, mockedCopyPastePlugin, mockedEditPlugin, - mockedPendingFormatStatePlugin, mockedUndoPlugin, mockedDOMEventPlugin, mockedMouseUpPlugin, @@ -117,7 +106,6 @@ describe('createEditorCore', () => { mockedLifecyclePlugin, ], domEvent: mockedDomEventState, - pendingFormatState: mockedPendingFormatState, edit: mockedEditState, lifecycle: mockedLifecycleState, undo: mockedUndoState, @@ -163,7 +151,6 @@ describe('createEditorCore', () => { mockedFormatPlugin, mockedCopyPastePlugin, mockedEditPlugin, - mockedPendingFormatStatePlugin, mockedUndoPlugin, mockedDOMEventPlugin, mockedMouseUpPlugin, @@ -173,7 +160,6 @@ describe('createEditorCore', () => { mockedLifecyclePlugin, ], domEvent: mockedDomEventState, - pendingFormatState: mockedPendingFormatState, edit: mockedEditState, lifecycle: mockedLifecycleState, undo: mockedUndoState, diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts index 4091970dec2..3b8dcbd6d2a 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts @@ -13,7 +13,6 @@ import type { ImageSelectionRange, InsertOption, NodePosition, - PendableFormatState, PluginEvent, PositionType, Rect, @@ -268,17 +267,6 @@ export type InsertNode = ( option: InsertOption | null ) => boolean; -/** - * Get the pendable format such as underline and bold - * @param core The StandaloneEditorCore object - * @param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. - * @return The pending format state of editor. - */ -export type GetPendableFormatState = ( - core: StandaloneEditorCore, - forceGetStateFromDOM: boolean -) => PendableFormatState; - /** * Attach a DOM event to the editor content DIV * @param core The StandaloneEditorCore object @@ -511,14 +499,6 @@ export interface UnportedCoreApiMap { */ insertNode: InsertNode; - /** - * Get the pendable format such as underline and bold - * @param core The StandaloneEditorCore object - *@param forceGetStateFromDOM If set to true, will force get the format state from DOM tree. - * @return The pending format state of editor. - */ - getPendableFormatState: GetPendableFormatState; - /** * Attach a DOM event to the editor content DIV * @param core The StandaloneEditorCore object diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index 2ceb546c7bd..b8809675c67 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -221,7 +221,6 @@ export { HasFocus, Focus, InsertNode, - GetPendableFormatState, AttachDomEvent, GetContent, GetStyleBasedFormatState, diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts index d84b5bb1e4a..abcebabd23e 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts @@ -4,7 +4,6 @@ import type { EditPluginState, EntityPluginState, LifecyclePluginState, - PendingFormatStatePluginState, UndoPluginState, } from 'roosterjs-editor-types'; import type { ContentModelCachePluginState } from './ContentModelCachePluginState'; @@ -34,7 +33,6 @@ export interface ContentModelPluginState { lifecycle: LifecyclePluginState; domEvent: DOMEventPluginState; entity: EntityPluginState; - pendingFormatState: PendingFormatStatePluginState; undo: UndoPluginState; edit: EditPluginState; } From 700e8e8b6c846d98d14c2e7f6c407055d699020d Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Thu, 16 Nov 2023 11:33:09 -0800 Subject: [PATCH 052/111] Standalone editor: Merge DOMEventPlugin and MouseUpPlugin (#2210) * Remove dependency to EditorCore * fix test * improve * Standalone editor: Remove PendingFormatStatePlugin * fix build * Merge DOMEventPlugin and MouseUpPlugin --- .../lib/corePlugin}/DOMEventPlugin.ts | 89 ++- .../createStandaloneEditorCorePlugins.ts | 5 +- .../test/corePlugin/DomEventPluginTest.ts | 576 ++++++++++++++++++ .../lib/corePlugins/MouseUpPlugin.ts | 79 --- .../lib/corePlugins/createCorePlugins.ts | 6 +- .../lib/editor/createEditorCore.ts | 10 +- .../publicTypes/ContentModelCorePlugins.ts | 12 - .../lib/publicTypes/IContentModelEditor.ts | 22 - .../test/editor/createEditorCoreTest.ts | 13 +- .../lib/editor/StandaloneEditorCorePlugins.ts | 6 + .../lib/editor/StandaloneEditorOptions.ts | 16 +- .../lib/index.ts | 1 + .../lib/parameter/EditorEnvironment.ts | 5 + .../pluginState/ContentModelPluginState.ts | 8 +- .../lib/pluginState/DOMEventPluginState.ts | 60 ++ 15 files changed, 752 insertions(+), 156 deletions(-) rename packages-content-model/{roosterjs-content-model-editor/lib/corePlugins => roosterjs-content-model-core/lib/corePlugin}/DOMEventPlugin.ts (78%) create mode 100644 packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/MouseUpPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/pluginState/DOMEventPluginState.ts diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/DOMEventPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts similarity index 78% rename from packages-content-model/roosterjs-content-model-editor/lib/corePlugins/DOMEventPlugin.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts index 94edb96f213..3561e1526d9 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/DOMEventPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts @@ -1,10 +1,13 @@ -import { arrayPush, Browser, isCharacterValue } from 'roosterjs-editor-dom'; import { ChangeSource, Keys, PluginEventType } from 'roosterjs-editor-types'; -import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; +import { isCharacterValue } from '../publicApi/domUtils/eventUtils'; +import type { + DOMEventPluginState, + IStandaloneEditor, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; import type { ContextMenuProvider, DOMEventHandler, - DOMEventPluginState, EditorPlugin, IEditor, PluginWithState, @@ -22,7 +25,7 @@ import type { * It contains special handling for Safari since Safari cannot get correct selection when onBlur event is triggered in editor. */ class DOMEventPlugin implements PluginWithState { - private editor: IEditor | null = null; + private editor: (IStandaloneEditor & IEditor) | null = null; private disposer: (() => void) | null = null; private state: DOMEventPluginState; @@ -31,16 +34,18 @@ class DOMEventPlugin implements PluginWithState { * @param options The editor options * @param contentDiv The editor content DIV */ - constructor(options: ContentModelEditorOptions, contentDiv: HTMLDivElement) { + constructor(options: StandaloneEditorOptions, contentDiv: HTMLDivElement) { this.state = { isInIME: false, scrollContainer: options.scrollContainer || contentDiv, selectionRange: null, - stopPrintableKeyboardEventPropagation: !options.allowKeyboardEventPropagation, contextMenuProviders: options.plugins?.filter>(isContextMenuProvider) || [], tableSelectionRange: null, imageSelectionRange: null, + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, }; } @@ -56,7 +61,7 @@ class DOMEventPlugin implements PluginWithState { * @param editor Editor instance */ initialize(editor: IEditor) { - this.editor = editor; + this.editor = editor as IStandaloneEditor & IEditor; const document = this.editor.getDocument(); //Record @@ -69,7 +74,7 @@ class DOMEventPlugin implements PluginWithState { keyup: this.getEventHandler(PluginEventType.KeyUp), // 2. Mouse event - mousedown: PluginEventType.MouseDown, + mousedown: this.onMouseDown, contextmenu: this.onContextMenuEvent, // 3. IME state management @@ -89,19 +94,16 @@ class DOMEventPlugin implements PluginWithState { focus: this.onFocus, // 6. Input event - [Browser.isIE ? 'textinput' : 'input']: this.getEventHandler(PluginEventType.Input), + input: this.getEventHandler(PluginEventType.Input), }; + const env = this.editor.getEnvironment(); + // 7. onBlur handlers - if (Browser.isSafari) { + if (env.isSafari) { document.addEventListener('mousedown', this.onMouseDownDocument, true /*useCapture*/); document.addEventListener('keydown', this.onKeyDownDocument); document.defaultView?.addEventListener('blur', this.cacheSelection); - } else if (Browser.isIEOrEdge) { - type EventHandlersIE = { - beforedeactivate: DOMEventHandler; - }; - (eventHandlers as EventHandlersIE).beforedeactivate = this.cacheSelection; } else { eventHandlers.blur = this.cacheSelection; } @@ -118,8 +120,10 @@ class DOMEventPlugin implements PluginWithState { * Dispose this plugin */ dispose() { + this.removeMouseUpEventListener(); + const document = this.editor?.getDocument(); - if (document && Browser.isSafari) { + if (document) { document.removeEventListener( 'mousedown', this.onMouseDownDocument, @@ -208,12 +212,10 @@ class DOMEventPlugin implements PluginWithState { ? this.onInputEvent(event) : this.onKeyboardEvent(event); - return this.state.stopPrintableKeyboardEventPropagation - ? { - pluginEventType: eventType, - beforeDispatch, - } - : eventType; + return { + pluginEventType: eventType, + beforeDispatch, + }; } private onKeyboardEvent = (event: KeyboardEvent) => { @@ -228,8 +230,39 @@ class DOMEventPlugin implements PluginWithState { event.stopPropagation(); }; + private onMouseDown = (event: MouseEvent) => { + if (this.editor) { + if (!this.state.mouseUpEventListerAdded) { + this.editor + .getDocument() + .addEventListener('mouseup', this.onMouseUp, true /*setCapture*/); + this.state.mouseUpEventListerAdded = true; + this.state.mouseDownX = event.pageX; + this.state.mouseDownY = event.pageY; + } + + this.editor.triggerPluginEvent(PluginEventType.MouseDown, { + rawEvent: event, + }); + } + }; + + private onMouseUp = (rawEvent: MouseEvent) => { + if (this.editor) { + this.removeMouseUpEventListener(); + this.editor.triggerPluginEvent(PluginEventType.MouseUp, { + rawEvent, + isClicking: + this.state.mouseDownX == rawEvent.pageX && + this.state.mouseDownY == rawEvent.pageY, + }); + } + }; + private onContextMenuEvent = (event: MouseEvent) => { const allItems: any[] = []; + + // TODO: Remove dependency to ContentSearcher const searcher = this.editor?.getContentSearcherOfCursor(); const elementBeforeCursor = searcher?.getInlineElementBefore(); @@ -243,7 +276,8 @@ class DOMEventPlugin implements PluginWithState { if (allItems.length > 0) { allItems.push(null); } - arrayPush(allItems, items); + + allItems.push(...items); } }); this.editor?.triggerPluginEvent(PluginEventType.ContextMenu, { @@ -251,6 +285,13 @@ class DOMEventPlugin implements PluginWithState { items: allItems, }); }; + + private removeMouseUpEventListener() { + if (this.editor && this.state.mouseUpEventListerAdded) { + this.state.mouseUpEventListerAdded = false; + this.editor.getDocument().removeEventListener('mouseup', this.onMouseUp, true); + } + } } function isContextMenuProvider(source: EditorPlugin): source is ContextMenuProvider { @@ -264,7 +305,7 @@ function isContextMenuProvider(source: EditorPlugin): source is ContextMenuProvi * @param contentDiv The editor content DIV element */ export function createDOMEventPlugin( - option: ContentModelEditorOptions, + option: StandaloneEditorOptions, contentDiv: HTMLDivElement ): PluginWithState { return new DOMEventPlugin(option, contentDiv); diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts index 499a4606369..64b800c7592 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts @@ -1,6 +1,7 @@ import { createContentModelCachePlugin } from './ContentModelCachePlugin'; import { createContentModelCopyPastePlugin } from './ContentModelCopyPastePlugin'; import { createContentModelFormatPlugin } from './ContentModelFormatPlugin'; +import { createDOMEventPlugin } from './DOMEventPlugin'; import type { StandaloneEditorCorePlugins, StandaloneEditorOptions, @@ -11,11 +12,13 @@ import type { * @param options Options of editor */ export function createStandaloneEditorCorePlugins( - options: StandaloneEditorOptions + options: StandaloneEditorOptions, + contentDiv: HTMLDivElement ): StandaloneEditorCorePlugins { return { cache: createContentModelCachePlugin(options), format: createContentModelFormatPlugin(options), copyPaste: createContentModelCopyPastePlugin(options), + domEvent: createDOMEventPlugin(options, contentDiv), }; } diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts new file mode 100644 index 00000000000..b9105edad93 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts @@ -0,0 +1,576 @@ +import * as eventUtils from '../../lib/publicApi/domUtils/eventUtils'; +import { ChangeSource, IEditor, PluginEventType, PluginWithState } from 'roosterjs-editor-types'; +import { createDOMEventPlugin } from '../../lib/corePlugin/DOMEventPlugin'; +import { DOMEventPluginState, IStandaloneEditor } from 'roosterjs-content-model-types'; + +const getDocument = () => document; + +describe('DOMEventPlugin', () => { + it('init and dispose', () => { + const addEventListener = jasmine.createSpy('addEventListener'); + const removeEventListener = jasmine.createSpy('removeEventListener'); + const div = { + addEventListener, + removeEventListener, + }; + const plugin = createDOMEventPlugin({}, div); + const disposer = jasmine.createSpy('disposer'); + const addDomEventHandler = jasmine + .createSpy('addDomEventHandler') + .and.returnValue(disposer); + const state = plugin.getState(); + const editor = ({ + getDocument, + addDomEventHandler, + getEnvironment: () => ({}), + } as any) as IStandaloneEditor & IEditor; + + plugin.initialize(editor); + + expect(addEventListener).toHaveBeenCalledTimes(1); + expect(addEventListener.calls.argsFor(0)[0]).toBe('scroll'); + + expect(state).toEqual({ + isInIME: false, + scrollContainer: div, + selectionRange: null, + contextMenuProviders: [], + tableSelectionRange: null, + imageSelectionRange: null, + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + }); + expect(addDomEventHandler).toHaveBeenCalled(); + expect(removeEventListener).not.toHaveBeenCalled(); + expect(disposer).not.toHaveBeenCalled(); + + plugin.dispose(); + + expect(removeEventListener).toHaveBeenCalled(); + expect(disposer).toHaveBeenCalled(); + }); + + it('init with different options', () => { + const addEventListener1 = jasmine.createSpy('addEventListener1'); + const addEventListener2 = jasmine.createSpy('addEventListener2'); + const div = { + addEventListener: addEventListener1, + }; + const divScrollContainer = { + addEventListener: addEventListener2, + removeEventListener: jasmine.createSpy('removeEventListener'), + }; + const plugin = createDOMEventPlugin( + { + scrollContainer: divScrollContainer, + }, + div + ); + const state = plugin.getState(); + + const addDomEventHandler = jasmine + .createSpy('addDomEventHandler') + .and.returnValue(jasmine.createSpy('disposer')); + plugin.initialize(({ + getDocument, + addDomEventHandler, + getEnvironment: () => ({}), + })); + + expect(addEventListener1).not.toHaveBeenCalledTimes(1); + expect(addEventListener2).toHaveBeenCalledTimes(1); + expect(addEventListener2.calls.argsFor(0)[0]).toBe('scroll'); + + expect(state).toEqual({ + isInIME: false, + scrollContainer: divScrollContainer, + selectionRange: null, + contextMenuProviders: [], + tableSelectionRange: null, + imageSelectionRange: null, + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + }); + + expect(addDomEventHandler).toHaveBeenCalled(); + + plugin.dispose(); + }); +}); + +describe('DOMEventPlugin verify event handlers while disallow keyboard event propagation', () => { + let eventMap: Record; + let plugin: PluginWithState; + + beforeEach(() => { + const div = { + addEventListener: jasmine.createSpy('addEventListener1'), + removeEventListener: jasmine.createSpy('removeEventListener'), + }; + + plugin = createDOMEventPlugin({}, div); + plugin.initialize(({ + getDocument, + addDomEventHandler: (map: Record) => { + eventMap = map; + return jasmine.createSpy('disposer'); + }, + getEnvironment: () => ({}), + })); + }); + + afterEach(() => { + plugin.dispose(); + eventMap = undefined!; + }); + + it('check events are mapped', () => { + expect(eventMap).toBeDefined(); + expect(eventMap.keypress.pluginEventType).toBe(PluginEventType.KeyPress); + expect(eventMap.keydown.pluginEventType).toBe(PluginEventType.KeyDown); + expect(eventMap.keyup.pluginEventType).toBe(PluginEventType.KeyUp); + expect(eventMap.input.pluginEventType).toBe(PluginEventType.Input); + expect(eventMap.keypress.beforeDispatch).toBeDefined(); + expect(eventMap.keydown.beforeDispatch).toBeDefined(); + expect(eventMap.keyup.beforeDispatch).toBeDefined(); + expect(eventMap.input.beforeDispatch).toBeDefined(); + expect(eventMap.mousedown).toBeDefined(); + expect(eventMap.contextmenu).toBeDefined(); + expect(eventMap.compositionstart).toBeDefined(); + expect(eventMap.compositionend).toBeDefined(); + expect(eventMap.dragstart).toBeDefined(); + expect(eventMap.drop).toBeDefined(); + expect(eventMap.focus).toBeDefined(); + expect(eventMap.mouseup).not.toBeDefined(); + }); + + it('verify keydown event for non-character value', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(false); + const stopPropagation = jasmine.createSpy(); + eventMap.keydown.beforeDispatch(({ + stopPropagation, + })); + expect(stopPropagation).not.toHaveBeenCalled(); + }); + + it('verify keydown event for character value', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); + const stopPropagation = jasmine.createSpy(); + eventMap.keydown.beforeDispatch(({ + stopPropagation, + })); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it('verify input event for non-character value', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(false); + const stopPropagation = jasmine.createSpy(); + eventMap.input.beforeDispatch(({ + stopPropagation, + })); + expect(stopPropagation).toHaveBeenCalled(); + }); + + it('verify input event for character value', () => { + spyOn(eventUtils, 'isCharacterValue').and.returnValue(true); + const stopPropagation = jasmine.createSpy(); + eventMap.input.beforeDispatch(({ + stopPropagation, + })); + expect(stopPropagation).toHaveBeenCalled(); + }); +}); + +describe('DOMEventPlugin handle mouse down and mouse up event', () => { + let plugin: PluginWithState; + let addEventListener: jasmine.Spy; + let removeEventListener: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; + let eventMap: Record; + let scrollContainer: HTMLElement; + let onMouseUp: Function; + + beforeEach(() => { + addEventListener = jasmine + .createSpy('addEventListener') + .and.callFake((eventName, handler, useCapture) => { + expect(eventName).toBe('mouseup'); + expect(useCapture).toBe(true); + + onMouseUp = handler; + }); + removeEventListener = jasmine.createSpy('.removeEventListener'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + scrollContainer = { + addEventListener: () => {}, + removeEventListener: () => {}, + } as any; + plugin = createDOMEventPlugin( + { + scrollContainer, + }, + null! + ); + plugin.initialize(({ + getDocument: () => ({ + addEventListener, + removeEventListener, + }), + triggerPluginEvent, + getEnvironment: () => ({}), + addDomEventHandler: (map: Record) => { + eventMap = map; + return jasmine.createSpy('disposer'); + }, + })); + }); + + afterEach(() => { + plugin.dispose(); + }); + + it('Trigger mouse down event', () => { + const mockedEvent = { + pageX: 100, + pageY: 200, + }; + eventMap.mousedown(mockedEvent); + expect(addEventListener).toHaveBeenCalledTimes(1); + expect(addEventListener.calls.argsFor(0)[0]).toBe('mouseup'); + expect(addEventListener.calls.argsFor(0)[2]).toBe(true); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + selectionRange: null, + contextMenuProviders: [], + tableSelectionRange: null, + imageSelectionRange: null, + mouseDownX: 100, + mouseDownY: 200, + mouseUpEventListerAdded: true, + }); + }); + + it('Trigger mouse up event, isClicking', () => { + expect(eventMap.mouseup).toBeUndefined(); + const mockedEvent = { + pageX: 100, + pageY: 200, + }; + eventMap.mousedown(mockedEvent); + + expect(eventMap.mouseup).toBeUndefined(); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + selectionRange: null, + contextMenuProviders: [], + tableSelectionRange: null, + imageSelectionRange: null, + mouseDownX: 100, + mouseDownY: 200, + mouseUpEventListerAdded: true, + }); + expect(addEventListener).toHaveBeenCalled(); + + onMouseUp(mockedEvent); + + expect(removeEventListener).toHaveBeenCalled(); + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.MouseUp, { + rawEvent: mockedEvent, + isClicking: true, + }); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + selectionRange: null, + contextMenuProviders: [], + tableSelectionRange: null, + imageSelectionRange: null, + mouseDownX: 100, + mouseDownY: 200, + mouseUpEventListerAdded: false, + }); + }); + + it('Trigger mouse up event, isClicking = false', () => { + expect(eventMap.mouseup).toBeUndefined(); + const mockedEvent1 = { + pageX: 100, + pageY: 200, + }; + const mockedEvent2 = { + pageX: 100, + pageY: 300, + }; + eventMap.mousedown(mockedEvent1); + + expect(eventMap.mouseup).toBeUndefined(); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + selectionRange: null, + contextMenuProviders: [], + tableSelectionRange: null, + imageSelectionRange: null, + mouseDownX: 100, + mouseDownY: 200, + mouseUpEventListerAdded: true, + }); + expect(addEventListener).toHaveBeenCalled(); + + onMouseUp(mockedEvent2); + + expect(removeEventListener).toHaveBeenCalled(); + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.MouseUp, { + rawEvent: mockedEvent2, + isClicking: false, + }); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + selectionRange: null, + contextMenuProviders: [], + tableSelectionRange: null, + imageSelectionRange: null, + mouseDownX: 100, + mouseDownY: 200, + mouseUpEventListerAdded: false, + }); + }); +}); + +describe('DOMEventPlugin handle other event', () => { + let plugin: PluginWithState; + let addEventListener: jasmine.Spy; + let removeEventListener: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; + let eventMap: Record; + let scrollContainer: HTMLElement; + let getElementAtCursorSpy: jasmine.Spy; + let editor: IEditor; + + beforeEach(() => { + addEventListener = jasmine.createSpy('addEventListener'); + removeEventListener = jasmine.createSpy('.removeEventListener'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + getElementAtCursorSpy = jasmine.createSpy('getElementAtCursor'); + + scrollContainer = { + addEventListener: () => {}, + removeEventListener: () => {}, + } as any; + plugin = createDOMEventPlugin( + { + scrollContainer, + }, + null! + ); + + editor = ({ + getDocument: () => ({ + addEventListener, + removeEventListener, + }), + triggerPluginEvent, + getEnvironment: () => ({}), + addDomEventHandler: (map: Record) => { + eventMap = map; + return jasmine.createSpy('disposer'); + }, + getElementAtCursor: getElementAtCursorSpy, + }); + plugin.initialize(editor); + }); + + afterEach(() => { + plugin.dispose(); + }); + + it('Trigger compositionstart and compositionend event', () => { + eventMap.compositionstart(); + expect(plugin.getState()).toEqual({ + isInIME: true, + scrollContainer: scrollContainer, + selectionRange: null, + contextMenuProviders: [], + tableSelectionRange: null, + imageSelectionRange: null, + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + }); + + expect(triggerPluginEvent).not.toHaveBeenCalled(); + + const mockedEvent = 'EVENT' as any; + eventMap.compositionend(mockedEvent); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + selectionRange: null, + contextMenuProviders: [], + tableSelectionRange: null, + imageSelectionRange: null, + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + }); + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.CompositionEnd, { + rawEvent: mockedEvent, + }); + }); + + it('Trigger onDragStart event', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const mockedEvent = { + preventDefault: preventDefaultSpy, + } as any; + + getElementAtCursorSpy.and.returnValue({ + isContentEditable: true, + }); + eventMap.dragstart(mockedEvent); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + selectionRange: null, + contextMenuProviders: [], + tableSelectionRange: null, + imageSelectionRange: null, + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + }); + + expect(triggerPluginEvent).not.toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('Trigger onDragStart event on readonly element', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const mockedEvent = { + preventDefault: preventDefaultSpy, + } as any; + + getElementAtCursorSpy.and.returnValue({ + isContentEditable: false, + }); + eventMap.dragstart(mockedEvent); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + selectionRange: null, + contextMenuProviders: [], + tableSelectionRange: null, + imageSelectionRange: null, + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + }); + + expect(triggerPluginEvent).not.toHaveBeenCalled(); + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('Trigger onDrop event', () => { + const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + editor.runAsync = (callback: Function) => callback(editor); + editor.addUndoSnapshot = addUndoSnapshotSpy; + + eventMap.drop(); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + selectionRange: null, + contextMenuProviders: [], + tableSelectionRange: null, + imageSelectionRange: null, + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + }); + expect(addUndoSnapshotSpy).toHaveBeenCalledWith(jasmine.anything(), ChangeSource.Drop); + }); + + it('Trigger onFocus event', () => { + const selectSpy = jasmine.createSpy('select'); + editor.select = selectSpy; + + const state = plugin.getState(); + const mockedRange = 'RANGE' as any; + + state.skipReselectOnFocus = false; + state.selectionRange = mockedRange; + + eventMap.focus(); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + selectionRange: null, + contextMenuProviders: [], + tableSelectionRange: null, + imageSelectionRange: null, + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + skipReselectOnFocus: false, + }); + expect(selectSpy).toHaveBeenCalledWith(mockedRange); + }); + + it('Trigger onFocus event, skip reselect', () => { + const selectSpy = jasmine.createSpy('select'); + editor.select = selectSpy; + + const state = plugin.getState(); + const mockedRange = 'RANGE' as any; + + state.skipReselectOnFocus = true; + state.selectionRange = mockedRange; + + eventMap.focus(); + expect(plugin.getState()).toEqual({ + isInIME: false, + scrollContainer: scrollContainer, + selectionRange: null, + contextMenuProviders: [], + tableSelectionRange: null, + imageSelectionRange: null, + mouseDownX: null, + mouseDownY: null, + mouseUpEventListerAdded: false, + skipReselectOnFocus: true, + }); + expect(selectSpy).not.toHaveBeenCalled(); + }); + + it('Trigger contextmenu event, skip reselect', () => { + editor.getContentSearcherOfCursor = () => null!; + const state = plugin.getState(); + const mockedItems1 = ['Item1', 'Item2']; + const mockedItems2 = ['Item3', 'Item4']; + + state.contextMenuProviders = [ + { + getContextMenuItems: () => mockedItems1, + } as any, + { + getContextMenuItems: () => mockedItems2, + } as any, + ]; + + const mockedEvent = { + target: {}, + }; + + eventMap.contextmenu(mockedEvent); + + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.ContextMenu, { + rawEvent: mockedEvent, + items: ['Item1', 'Item2', null, 'Item3', 'Item4'], + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/MouseUpPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/MouseUpPlugin.ts deleted file mode 100644 index d1064c3203e..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/MouseUpPlugin.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { PluginEventType } from 'roosterjs-editor-types'; -import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; - -/** - * MouseUpPlugin help trigger MouseUp event even when mouse up happens outside editor - * as long as the mouse was pressed within Editor before - */ -class MouseUpPlugin implements EditorPlugin { - private editor: IEditor | null = null; - private mouseUpEventListerAdded: boolean = false; - private mouseDownX: number | null = null; - private mouseDownY: number | null = null; - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'MouseUp'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - } - - /** - * Dispose this plugin - */ - dispose() { - this.removeMouseUpEventListener(); - this.editor = null; - } - - /** - * Handle events triggered from editor - * @param event PluginEvent object - */ - onPluginEvent(event: PluginEvent) { - if ( - this.editor && - event.eventType == PluginEventType.MouseDown && - !this.mouseUpEventListerAdded - ) { - this.editor - .getDocument() - .addEventListener('mouseup', this.onMouseUp, true /*setCapture*/); - this.mouseUpEventListerAdded = true; - this.mouseDownX = event.rawEvent.pageX; - this.mouseDownY = event.rawEvent.pageY; - } - } - private removeMouseUpEventListener() { - if (this.editor && this.mouseUpEventListerAdded) { - this.mouseUpEventListerAdded = false; - this.editor.getDocument().removeEventListener('mouseup', this.onMouseUp, true); - } - } - - private onMouseUp = (rawEvent: MouseEvent) => { - if (this.editor) { - this.removeMouseUpEventListener(); - this.editor.triggerPluginEvent(PluginEventType.MouseUp, { - rawEvent, - isClicking: this.mouseDownX == rawEvent.pageX && this.mouseDownY == rawEvent.pageY, - }); - } - }; -} - -/** - * @internal - * Create a new instance of MouseUpPlugin. - */ -export function createMouseUpPlugin(): EditorPlugin { - return new MouseUpPlugin(); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts index 26a8bee9c9c..7e6943ebc95 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts @@ -1,9 +1,7 @@ -import { createDOMEventPlugin } from './DOMEventPlugin'; import { createEditPlugin } from './EditPlugin'; import { createEntityPlugin } from './EntityPlugin'; import { createImageSelection } from './ImageSelection'; import { createLifecyclePlugin } from './LifecyclePlugin'; -import { createMouseUpPlugin } from './MouseUpPlugin'; import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; import { createStandaloneEditorCorePlugins } from 'roosterjs-content-model-core'; import { createUndoPlugin } from './UndoPlugin'; @@ -33,12 +31,10 @@ export function createCorePlugins( // The order matters, some plugin needs to be put before/after others to make sure event // can be handled in right order return { - ...createStandaloneEditorCorePlugins(options), + ...createStandaloneEditorCorePlugins(options, contentDiv), edit: map.edit || createEditPlugin(), _placeholder: null, undo: map.undo || createUndoPlugin(options), - domEvent: map.domEvent || createDOMEventPlugin(options, contentDiv), - mouseUp: map.mouseUp || createMouseUpPlugin(), entity: map.entity || createEntityPlugin(), imageSelection: map.imageSelection || createImageSelection(), normalizeTable: map.normalizeTable || createNormalizeTablePlugin(), diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts index b382ca799ad..ac4241f7cc6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts @@ -44,6 +44,9 @@ export function createEditorCore( ); }); + // It is ok to use global window here since the environment should always be the same for all windows in one session + const userAgent = window.navigator.userAgent; + const core: ContentModelEditorCore = { contentDiv, api: { @@ -64,9 +67,12 @@ export function createEditorCore( ...createStandaloneEditorDefaultSettings(options), environment: { - // It is ok to use global window here since the environment should always be the same for all windows in one session isMac: window.navigator.appVersion.indexOf('Mac') != -1, - isAndroid: /android/i.test(window.navigator.userAgent), + isAndroid: /android/i.test(userAgent), + isSafari: + userAgent.indexOf('Safari') >= 0 && + userAgent.indexOf('Chrome') < 0 && + userAgent.indexOf('Android') < 0, }, }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts index 786be796fae..c02378cfd7a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts @@ -1,7 +1,6 @@ import type { StandaloneEditorCorePlugins } from 'roosterjs-content-model-types'; import type { CopyPastePluginState, - DOMEventPluginState, EditPluginState, EditorPlugin, EntityPluginState, @@ -24,17 +23,6 @@ export interface ContentModelCorePlugins extends StandaloneEditorCorePlugins { */ readonly undo: PluginWithState; - /** - * DomEvent plugin helps handle additional DOM events such as IME composition, cut, drop. - */ - readonly domEvent: PluginWithState; - - /** - * MouseUpPlugin help trigger MouseUp event even when mouse up happens outside editor - * as long as the mouse was pressed within Editor before - */ - readonly mouseUp: EditorPlugin; - /** * Copy and paste plugin for handling onCopy and onPaste event */ diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index 38d6616bae6..9be82c2b66f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -25,14 +25,6 @@ export interface IContentModelEditor extends IEditor, IStandaloneEditor {} * Options for Content Model editor */ export interface ContentModelEditorOptions extends StandaloneEditorOptions { - /** - * List of plugins. - * The order of plugins here determines in what order each event will be dispatched. - * Plugins not appear in this list will not be added to editor, including built-in plugins. - * Default value is empty array. - */ - plugins?: EditorPlugin[]; - /** * Default format of editor content. This will be applied to empty content. * If there is already content inside editor, format of existing content will not be changed. @@ -80,25 +72,11 @@ export interface ContentModelEditorOptions extends StandaloneEditorOptions { */ doNotAdjustEditorColor?: boolean; - /** - * The scroll container to get scroll event from. - * By default, the scroll container will be the same with editor content DIV - */ - scrollContainer?: HTMLElement; - /** * Specify the enabled experimental features */ experimentalFeatures?: ExperimentalFeatures[]; - /** - * By default, we will stop propagation of a printable keyboard event - * (a keyboard event which is caused by printable char input). - * Set this option to true to override this behavior in case you still need the event - * to be handled by ancestor nodes of editor. - */ - allowKeyboardEventPropagation?: boolean; - /** * Customized trusted type handler used for sanitizing HTML string before assign to DOM tree * This is required when trusted-type Content-Security-Policy (CSP) is enabled. diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts index 26cabd521c8..1f597bdf9f5 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts @@ -2,12 +2,11 @@ import * as ContentModelCachePlugin from 'roosterjs-content-model-core/lib/coreP import * as ContentModelCopyPastePlugin from 'roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin'; import * as ContentModelFormatPlugin from 'roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin'; import * as createStandaloneEditorDefaultSettings from 'roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings'; -import * as DOMEventPlugin from '../../lib/corePlugins/DOMEventPlugin'; +import * as DOMEventPlugin from 'roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin'; import * as EditPlugin from '../../lib/corePlugins/EditPlugin'; import * as EntityPlugin from '../../lib/corePlugins/EntityPlugin'; import * as ImageSelection from '../../lib/corePlugins/ImageSelection'; import * as LifecyclePlugin from '../../lib/corePlugins/LifecyclePlugin'; -import * as MouseUpPlugin from '../../lib/corePlugins/MouseUpPlugin'; import * as NormalizeTablePlugin from '../../lib/corePlugins/NormalizeTablePlugin'; import * as UndoPlugin from '../../lib/corePlugins/UndoPlugin'; import { coreApiMap } from '../../lib/coreApi/coreApiMap'; @@ -40,7 +39,6 @@ const mockedUndoPlugin = { const mockedDOMEventPlugin = { getState: () => mockedDomEventState, } as any; -const mockedMouseUpPlugin = 'MouseUpPlugin' as any; const mockedEntityPlugin = { getState: () => mockedEntityState, } as any; @@ -73,7 +71,6 @@ describe('createEditorCore', () => { spyOn(EditPlugin, 'createEditPlugin').and.returnValue(mockedEditPlugin); spyOn(UndoPlugin, 'createUndoPlugin').and.returnValue(mockedUndoPlugin); spyOn(DOMEventPlugin, 'createDOMEventPlugin').and.returnValue(mockedDOMEventPlugin); - spyOn(MouseUpPlugin, 'createMouseUpPlugin').and.returnValue(mockedMouseUpPlugin); spyOn(EntityPlugin, 'createEntityPlugin').and.returnValue(mockedEntityPlugin); spyOn(ImageSelection, 'createImageSelection').and.returnValue(mockedImageSelection); spyOn(NormalizeTablePlugin, 'createNormalizeTablePlugin').and.returnValue( @@ -96,10 +93,9 @@ describe('createEditorCore', () => { mockedCachePlugin, mockedFormatPlugin, mockedCopyPastePlugin, + mockedDOMEventPlugin, mockedEditPlugin, mockedUndoPlugin, - mockedDOMEventPlugin, - mockedMouseUpPlugin, mockedEntityPlugin, mockedImageSelection, mockedNormalizeTablePlugin, @@ -124,6 +120,7 @@ describe('createEditorCore', () => { environment: { isMac: false, isAndroid: false, + isSafari: false, }, }); }); @@ -150,10 +147,9 @@ describe('createEditorCore', () => { mockedCachePlugin, mockedFormatPlugin, mockedCopyPastePlugin, + mockedDOMEventPlugin, mockedEditPlugin, mockedUndoPlugin, - mockedDOMEventPlugin, - mockedMouseUpPlugin, mockedEntityPlugin, mockedImageSelection, mockedNormalizeTablePlugin, @@ -178,6 +174,7 @@ describe('createEditorCore', () => { environment: { isMac: false, isAndroid: false, + isSafari: false, }, }); }); diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts index 8374a4de9e8..72199d2578e 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts @@ -1,3 +1,4 @@ +import type { DOMEventPluginState } from '../pluginState/DOMEventPluginState'; import type { ContentModelCachePluginState } from '../pluginState/ContentModelCachePluginState'; import type { ContentModelFormatPluginState } from '../pluginState/ContentModelFormatPluginState'; import type { CopyPastePluginState, PluginWithState } from 'roosterjs-editor-types'; @@ -20,4 +21,9 @@ export interface StandaloneEditorCorePlugins { * Copy and paste plugin for handling onCopy and onPaste event */ readonly copyPaste: PluginWithState; + + /** + * DomEvent plugin helps handle additional DOM events such as IME composition, cut, drop. + */ + readonly domEvent: PluginWithState; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts index aee553066ec..fe3d8beac6f 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts @@ -1,4 +1,4 @@ -import type { DefaultFormat } from 'roosterjs-editor-types'; +import type { DefaultFormat, EditorPlugin } from 'roosterjs-editor-types'; import type { DomToModelOption } from '../context/DomToModelOption'; import type { ModelToDomOption } from '../context/ModelToDomOption'; @@ -21,6 +21,14 @@ export interface StandaloneEditorOptions { */ cacheModel?: boolean; + /** + * List of plugins. + * The order of plugins here determines in what order each event will be dispatched. + * Plugins not appear in this list will not be added to editor, including built-in plugins. + * Default value is empty array. + */ + plugins?: EditorPlugin[]; + /** * Default format of editor content. This will be applied to empty content. * If there is already content inside editor, format of existing content will not be changed. @@ -33,4 +41,10 @@ export interface StandaloneEditorOptions { * Only text types are supported, and do not add "text/" prefix to the type values */ allowedCustomPasteType?: string[]; + + /** + * The scroll container to get scroll event from. + * By default, the scroll container will be the same with editor content DIV + */ + scrollContainer?: HTMLElement; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index b8809675c67..c3b562ef901 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -235,6 +235,7 @@ export { ContentModelFormatPluginState, PendingFormat, } from './pluginState/ContentModelFormatPluginState'; +export { DOMEventPluginState } from './pluginState/DOMEventPluginState'; export { EditorEnvironment } from './parameter/EditorEnvironment'; export { diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts index 242c2145bf8..ec554db2bd8 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/EditorEnvironment.ts @@ -11,4 +11,9 @@ export interface EditorEnvironment { * Whether editor is running on Android */ isAndroid?: boolean; + + /** + * Whether editor is running on Safari browser + */ + isSafari?: boolean; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts index abcebabd23e..5d725106a7f 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts @@ -1,6 +1,5 @@ import type { CopyPastePluginState, - DOMEventPluginState, EditPluginState, EntityPluginState, LifecyclePluginState, @@ -8,6 +7,7 @@ import type { } from 'roosterjs-editor-types'; import type { ContentModelCachePluginState } from './ContentModelCachePluginState'; import type { ContentModelFormatPluginState } from './ContentModelFormatPluginState'; +import type { DOMEventPluginState } from './DOMEventPluginState'; /** * Temporary core plugin state for Content Model editor @@ -29,9 +29,13 @@ export interface ContentModelPluginState { */ format: ContentModelFormatPluginState; + /** + * Plugin state for DOMEventPlugin + */ + domEvent: DOMEventPluginState; + // Plugins copied from legacy editor lifecycle: LifecyclePluginState; - domEvent: DOMEventPluginState; entity: EntityPluginState; undo: UndoPluginState; edit: EditPluginState; diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/DOMEventPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/DOMEventPluginState.ts new file mode 100644 index 00000000000..38c5c6d7dd8 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/DOMEventPluginState.ts @@ -0,0 +1,60 @@ +import type { + ContextMenuProvider, + ImageSelectionRange, + TableSelectionRange, +} from 'roosterjs-editor-types'; + +/** + * The state object for DOMEventPlugin + */ +export interface DOMEventPluginState { + /** + * Whether editor is in IME input sequence + */ + isInIME: boolean; + + /** + * Scroll container of editor + */ + scrollContainer: HTMLElement; + + /** + * Cached selection range + */ + selectionRange: Range | null; + + /** + * Table selection range + */ + tableSelectionRange: TableSelectionRange | null; + + /** + * Context menu providers, that can provide context menu items + */ + contextMenuProviders: ContextMenuProvider[]; + + /** + * Image selection range + */ + imageSelectionRange: ImageSelectionRange | null; + + /** + * When set to true, onFocus event will not trigger reselect cached range + */ + skipReselectOnFocus?: boolean; + + /** + * Whether mouse up event handler is added + */ + mouseUpEventListerAdded: boolean; + + /** + * X-coordinate when mouse down happens + */ + mouseDownX: number | null; + + /** + * X-coordinate when mouse down happens + */ + mouseDownY: number | null; +} From faae1807adda2db625f3075617d5737e89e6f5fc Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 20 Nov 2023 15:48:48 -0800 Subject: [PATCH 053/111] Standalone Editor: CreateStandaloneEditorCore (#2218) --- .../lib/coreApi/formatContentModel.ts | 2 +- .../lib/coreApi/getVisibleViewport.ts | 69 +++ .../createStandaloneEditorCorePlugins.ts | 1 + .../lib/editor/DarkColorHandlerImpl.ts | 36 +- .../lib/editor/createStandaloneEditorCore.ts | 76 +++ .../createStandaloneEditorDefaultSettings.ts | 1 + .../lib/editor/standaloneCoreApiMap.ts | 3 + .../roosterjs-content-model-core/lib/index.ts | 5 +- .../roosterjs-content-model-core/package.json | 1 + .../test/coreApi/formatContentModelTest.ts | 2 +- .../test/coreApi/getVisibleViewportTest.ts | 38 ++ .../test/editor/DarkColorHandlerImplTest.ts | 549 ++++++++++++++++++ .../backgroundColorFormatHandlerTest.ts | 11 +- .../segment/textColorFormatHandlerTest.ts | 11 +- .../lib/coreApi/coreApiMap.ts | 6 +- .../lib/corePlugins/createCorePlugins.ts | 14 +- .../lib/editor/ContentModelEditor.ts | 4 +- .../lib/editor/createEditorCore.ts | 78 +-- .../lib/index.ts | 5 +- .../publicTypes/ContentModelCorePlugins.ts | 15 +- .../lib/publicTypes/IContentModelEditor.ts | 31 +- .../test/editor/createEditorCoreTest.ts | 14 +- .../test/paste/e2e/testUtils.ts | 16 +- .../lib/editor/StandaloneEditorCore.ts | 29 +- .../lib/editor/StandaloneEditorOptions.ts | 27 +- .../lib/index.ts | 6 +- ...tate.ts => StandaloneEditorPluginState.ts} | 11 +- 27 files changed, 902 insertions(+), 159 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/coreApi/getVisibleViewport.ts rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-core}/lib/editor/DarkColorHandlerImpl.ts (85%) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/coreApi/getVisibleViewportTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts rename packages-content-model/roosterjs-content-model-types/lib/pluginState/{ContentModelPluginState.ts => StandaloneEditorPluginState.ts} (79%) diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts index 4c3f48a7166..08bd752028d 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts @@ -141,7 +141,7 @@ function handleDeletedEntities(core: StandaloneEditorCore, context: FormatWithCo function handleImages(core: StandaloneEditorCore, context: FormatWithContentModelContext) { if (context.newImages.length > 0) { - const viewport = core.getVisibleViewport(); + const viewport = core.api.getVisibleViewport(core); if (viewport) { const { left, right } = viewport; diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getVisibleViewport.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getVisibleViewport.ts new file mode 100644 index 00000000000..de6dc3d30c3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getVisibleViewport.ts @@ -0,0 +1,69 @@ +import type { Rect } from 'roosterjs-editor-types'; +import type { GetVisibleViewport } from 'roosterjs-content-model-types'; + +/** + * @internal + * Retrieves the rect of the visible viewport of the editor. + * @param core The StandaloneEditorCore object + */ +export const getVisibleViewport: GetVisibleViewport = core => { + const scrollContainer = core.domEvent.scrollContainer; + + return getIntersectedRect( + scrollContainer == core.contentDiv ? [scrollContainer] : [scrollContainer, core.contentDiv] + ); +}; + +/** + * Get the intersected Rect of elements provided + * + * @example + * The result of the following Elements Rects would be: + { + top: Element2.top, + bottom: Element1.bottom, + left: Element2.left, + right: Element2.right + } + +-------------------------+ + | Element 1 | + | +-----------------+ | + | | Element2 | | + | | | | + | | | | + +-------------------------+ + | | + +-----------------+ + + * @param elements Elements to use. + * @param additionalRects additional rects to use + * @returns If the Rect is valid return the rect, if not, return null. + */ +function getIntersectedRect(elements: HTMLElement[], additionalRects: Rect[] = []): Rect | null { + const rects = elements + .map(element => normalizeRect(element.getBoundingClientRect())) + .concat(additionalRects) + .filter((rect: Rect | null): rect is Rect => !!rect); + + const result: Rect = { + top: Math.max(...rects.map(r => r.top)), + bottom: Math.min(...rects.map(r => r.bottom)), + left: Math.max(...rects.map(r => r.left)), + right: Math.min(...rects.map(r => r.right)), + }; + + return result.top < result.bottom && result.left < result.right ? result : null; +} + +function normalizeRect(clientRect: DOMRect): Rect | null { + const { left, right, top, bottom } = + clientRect || { left: 0, right: 0, top: 0, bottom: 0 }; + return left === 0 && right === 0 && top === 0 && bottom === 0 + ? null + : { + left: Math.round(left), + right: Math.round(right), + top: Math.round(top), + bottom: Math.round(bottom), + }; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts index 64b800c7592..5e7e38c683c 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts @@ -8,6 +8,7 @@ import type { } from 'roosterjs-content-model-types'; /** + * @internal * Create core plugins for standalone editor * @param options Options of editor */ diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts similarity index 85% rename from packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts rename to packages-content-model/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts index 0f9687fcc6e..cda3a6bb61d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts @@ -1,3 +1,4 @@ +import * as Color from 'color'; import { getObjectKeys, parseColor, setColor } from 'roosterjs-editor-dom'; import type { ColorKeyAndValue, @@ -5,6 +6,7 @@ import type { ModeIndependentColor, } from 'roosterjs-editor-types'; +const DefaultLightness = 21.25; // Lightness for #333333 const VARIABLE_REGEX = /^\s*var\(\s*(\-\-[a-zA-Z0-9\-_]+)\s*(?:,\s*(.*))?\)\s*$/; const VARIABLE_PREFIX = 'var('; const COLOR_VAR_PREFIX = 'darkColor'; @@ -28,8 +30,11 @@ const ColorAttributeName: { [key in ColorAttributeEnum]: string }[] = [ */ export class DarkColorHandlerImpl implements DarkColorHandler { private knownColors: Record> = {}; + readonly baseLightness: number; - constructor(private contentDiv: HTMLElement, private getDarkColor: (color: string) => string) {} + constructor(private contentDiv: HTMLElement, baseDarkColor?: string) { + this.baseLightness = getLightness(baseDarkColor); + } /** * Get a copy of known colors @@ -61,7 +66,7 @@ export class DarkColorHandlerImpl implements DarkColorHandler { colorKey || `--${COLOR_VAR_PREFIX}_${lightModeColor.replace(/[^\d\w]/g, '_')}`; if (!this.knownColors[colorKey]) { - darkModeColor = darkModeColor || this.getDarkColor(lightModeColor); + darkModeColor = darkModeColor || getDarkColor(lightModeColor, this.baseLightness); this.knownColors[colorKey] = { lightModeColor, darkModeColor }; this.contentDiv.style.setProperty(colorKey, darkModeColor); @@ -171,3 +176,30 @@ export class DarkColorHandlerImpl implements DarkColorHandler { }); } } + +function getDarkColor(color: string, baseLightness: number): string { + try { + const computedColor = Color(color || undefined); + const colorLab = computedColor.lab().array(); + const newLValue = (100 - colorLab[0]) * ((100 - baseLightness) / 100) + baseLightness; + color = Color.lab(newLValue, colorLab[1], colorLab[2]) + .rgb() + .alpha(computedColor.alpha()) + .toString(); + } catch {} + + return color; +} + +function getLightness(color?: string): number { + let result = DefaultLightness; + + if (color) { + try { + const computedColor = Color(color || undefined); + result = computedColor.lab().array()[0]; + } catch {} + } + + return result; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts new file mode 100644 index 00000000000..d23409d641a --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts @@ -0,0 +1,76 @@ +import { createStandaloneEditorCorePlugins } from '../corePlugin/createStandaloneEditorCorePlugins'; +import { createStandaloneEditorDefaultSettings } from './createStandaloneEditorDefaultSettings'; +import { DarkColorHandlerImpl } from './DarkColorHandlerImpl'; +import { standaloneCoreApiMap } from './standaloneCoreApiMap'; +import type { + EditorEnvironment, + StandaloneEditorCore, + StandaloneEditorCorePlugins, + StandaloneEditorOptions, + UnportedCoreApiMap, + UnportedCorePluginState, +} from 'roosterjs-content-model-types'; + +/** + * A temporary function to create Standalone Editor core + * @param contentDiv Editor content DIV + * @param options Editor options + */ +export function createStandaloneEditorCore( + contentDiv: HTMLDivElement, + options: StandaloneEditorOptions, + unportedCoreApiMap: UnportedCoreApiMap, + unportedCorePluginState: UnportedCorePluginState +): StandaloneEditorCore { + const corePlugins = createStandaloneEditorCorePlugins(options, contentDiv); + + return { + contentDiv, + api: { ...standaloneCoreApiMap, ...unportedCoreApiMap, ...options.coreApiOverride }, + originalApi: { ...standaloneCoreApiMap, ...unportedCoreApiMap }, + plugins: [ + corePlugins.cache, + corePlugins.format, + corePlugins.copyPaste, + corePlugins.domEvent, + // TODO: Add additional plugins here + ], + environment: createEditorEnvironment(), + darkColorHandler: new DarkColorHandlerImpl(contentDiv, options.baseDarkColor), + imageSelectionBorderColor: options.imageSelectionBorderColor, // TODO: Move to Selection core plugin + trustedHTMLHandler: options.trustedHTMLHandler || defaultTrustHtmlHandler, + ...createStandaloneEditorDefaultSettings(options), + ...getPluginState(corePlugins), + ...unportedCorePluginState, + }; +} + +function createEditorEnvironment(): EditorEnvironment { + // It is ok to use global window here since the environment should always be the same for all windows in one session + const userAgent = window.navigator.userAgent; + + return { + isMac: window.navigator.appVersion.indexOf('Mac') != -1, + isAndroid: /android/i.test(userAgent), + isSafari: + userAgent.indexOf('Safari') >= 0 && + userAgent.indexOf('Chrome') < 0 && + userAgent.indexOf('Android') < 0, + }; +} + +/** + * @internal export for test only + */ +export function defaultTrustHtmlHandler(html: string) { + return html; +} + +function getPluginState(corePlugins: StandaloneEditorCorePlugins) { + return { + domEvent: corePlugins.domEvent.getState(), + copyPaste: corePlugins.copyPaste.getState(), + cache: corePlugins.cache.getState(), + format: corePlugins.format.getState(), + }; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts index b46db72068f..e7a600fd4bd 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings.ts @@ -9,6 +9,7 @@ import type { } from 'roosterjs-content-model-types'; /** + * @internal * Create default DOM and Content Model conversion settings for a standalone editor * @param options The editor options */ diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts index 059d317eccc..5092a757f71 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts @@ -2,12 +2,14 @@ import { createContentModel } from '../coreApi/createContentModel'; import { createEditorContext } from '../coreApi/createEditorContext'; import { formatContentModel } from '../coreApi/formatContentModel'; import { getDOMSelection } from '../coreApi/getDOMSelection'; +import { getVisibleViewport } from '../coreApi/getVisibleViewport'; import { setContentModel } from '../coreApi/setContentModel'; import { setDOMSelection } from '../coreApi/setDOMSelection'; import { switchShadowEdit } from '../coreApi/switchShadowEdit'; import type { PortedCoreApiMap } from 'roosterjs-content-model-types'; /** + * @internal * Core API map for Standalone Content Model Editor */ export const standaloneCoreApiMap: PortedCoreApiMap = { @@ -18,4 +20,5 @@ export const standaloneCoreApiMap: PortedCoreApiMap = { setContentModel: setContentModel, setDOMSelection: setDOMSelection, switchShadowEdit: switchShadowEdit, + getVisibleViewport: getVisibleViewport, }; diff --git a/packages-content-model/roosterjs-content-model-core/lib/index.ts b/packages-content-model/roosterjs-content-model-core/lib/index.ts index 149f6c5c67b..ec683666bc8 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/index.ts @@ -40,12 +40,9 @@ export { updateTableCellMetadata } from './metadata/updateTableCellMetadata'; export { updateTableMetadata } from './metadata/updateTableMetadata'; export { updateListMetadata } from './metadata/updateListMetadata'; -export { standaloneCoreApiMap } from './editor/standaloneCoreApiMap'; -export { createStandaloneEditorDefaultSettings } from './editor/createStandaloneEditorDefaultSettings'; - export { ChangeSource } from './constants/ChangeSource'; export { BulletListType } from './constants/BulletListType'; export { NumberingListType } from './constants/NumberingListType'; export { TableBorderFormat } from './constants/TableBorderFormat'; -export { createStandaloneEditorCorePlugins } from './corePlugin/createStandaloneEditorCorePlugins'; +export { createStandaloneEditorCore } from './editor/createStandaloneEditorCore'; diff --git a/packages-content-model/roosterjs-content-model-core/package.json b/packages-content-model/roosterjs-content-model-core/package.json index c037037dea2..40d2aab4f68 100644 --- a/packages-content-model/roosterjs-content-model-core/package.json +++ b/packages-content-model/roosterjs-content-model-core/package.json @@ -3,6 +3,7 @@ "description": "Content Model for roosterjs (Under development)", "dependencies": { "tslib": "^2.3.1", + "color": "^3.0.0", "roosterjs-editor-types": "", "roosterjs-editor-dom": "", "roosterjs-content-model-dom": "", diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts index b518e96e7e2..fab0ddf50ec 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts @@ -429,7 +429,7 @@ describe('formatContentModel', () => { const getVisibleViewportSpy = jasmine .createSpy('getVisibleViewport') .and.returnValue({ top: 100, bottom: 200, left: 100, right: 200 }); - core.getVisibleViewport = getVisibleViewportSpy; + core.api.getVisibleViewport = getVisibleViewportSpy; formatContentModel( core, diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/getVisibleViewportTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/getVisibleViewportTest.ts new file mode 100644 index 00000000000..785895374bb --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/getVisibleViewportTest.ts @@ -0,0 +1,38 @@ +import { getVisibleViewport } from '../../lib/coreApi/getVisibleViewport'; + +describe('getVisibleViewport', () => { + it('scrollContainer is same with contentDiv', () => { + const div = { + getBoundingClientRect: () => ({ left: 100, right: 200, top: 300, bottom: 400 }), + }; + const core = { + contentDiv: div, + domEvent: { + scrollContainer: div, + }, + } as any; + + const result = getVisibleViewport(core); + + expect(result).toEqual({ left: 100, right: 200, top: 300, bottom: 400 }); + }); + + it('scrollContainer is different than contentDiv', () => { + const div1 = { + getBoundingClientRect: () => ({ left: 100, right: 200, top: 300, bottom: 400 }), + }; + const div2 = { + getBoundingClientRect: () => ({ left: 150, right: 250, top: 350, bottom: 450 }), + }; + const core = { + contentDiv: div1, + domEvent: { + scrollContainer: div2, + }, + } as any; + + const result = getVisibleViewport(core); + + expect(result).toEqual({ left: 150, right: 200, top: 350, bottom: 400 }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts new file mode 100644 index 00000000000..0372c39c164 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts @@ -0,0 +1,549 @@ +import { ColorKeyAndValue } from 'roosterjs-editor-types'; +import { DarkColorHandlerImpl } from '../../lib/editor/DarkColorHandlerImpl'; + +describe('DarkColorHandlerImpl.ctor', () => { + it('No additional param', () => { + const div = document.createElement('div'); + const handler = new DarkColorHandlerImpl(div); + + expect(handler).toBeDefined(); + expect(handler.baseLightness).toBe(21.25); + }); + + it('With customized base color', () => { + const div = document.createElement('div'); + const handler = new DarkColorHandlerImpl(div, '#555555'); + + expect(handler).toBeDefined(); + expect(Math.round(handler.baseLightness)).toBe(36); + }); + + it('Calculate color using customized base color', () => { + const div = document.createElement('div'); + const handler = new DarkColorHandlerImpl(div, '#555555'); + + const darkColor = handler.registerColor('red', true); + const parsedColor = handler.parseColorValue(darkColor); + + expect(darkColor).toBe('var(--darkColor_red, red)'); + expect(parsedColor).toEqual({ + key: '--darkColor_red', + lightModeColor: 'red', + darkModeColor: 'rgb(255, 72, 40)', + }); + }); +}); + +describe('DarkColorHandlerImpl.parseColorValue', () => { + let div: HTMLElement; + let handler: DarkColorHandlerImpl; + + beforeEach(() => { + div = document.createElement('div'); + handler = new DarkColorHandlerImpl(div); + }); + + function runTest(input: string, expectedOutput: ColorKeyAndValue) { + const result = handler.parseColorValue(input); + + expect(result).toEqual(expectedOutput); + } + + it('empty color', () => { + runTest(null!, { + key: undefined, + lightModeColor: '', + darkModeColor: undefined, + }); + }); + + it('simple color', () => { + runTest('aa', { + key: undefined, + lightModeColor: 'aa', + darkModeColor: undefined, + }); + }); + + it('var color without fallback', () => { + runTest('var(--bb)', { + key: undefined, + lightModeColor: '', + darkModeColor: undefined, + }); + }); + + it('var color with fallback', () => { + runTest('var(--bb,cc)', { + key: '--bb', + lightModeColor: 'cc', + darkModeColor: undefined, + }); + }); + + it('var color with fallback, has dark color', () => { + (handler as any).knownColors = { + '--bb': { + lightModeColor: 'dd', + darkModeColor: 'ee', + }, + }; + runTest('var(--bb,cc)', { + key: '--bb', + lightModeColor: 'cc', + darkModeColor: 'ee', + }); + }); + + function runDarkTest(input: string, expectedOutput: ColorKeyAndValue) { + const result = handler.parseColorValue(input, true); + + expect(result).toEqual(expectedOutput); + } + + it('simple color in dark mode', () => { + runDarkTest('aa', { + key: undefined, + lightModeColor: '', + darkModeColor: undefined, + }); + }); + + it('var color in dark mode', () => { + runDarkTest('var(--aa, bb)', { + key: '--aa', + lightModeColor: 'bb', + darkModeColor: undefined, + }); + }); + + it('known simple color in dark mode', () => { + (handler as any).knownColors = { + '--bb': { + lightModeColor: '#ff0000', + darkModeColor: '#00ffff', + }, + }; + runDarkTest('#00ffff', { + key: undefined, + lightModeColor: '#ff0000', + darkModeColor: '#00ffff', + }); + }); +}); + +describe('DarkColorHandlerImpl.registerColor', () => { + let setProperty: jasmine.Spy; + let handler: DarkColorHandlerImpl; + + beforeEach(() => { + setProperty = jasmine.createSpy('setProperty'); + const div = ({ + style: { + setProperty, + }, + } as any) as HTMLElement; + handler = new DarkColorHandlerImpl(div); + }); + + function runTest( + input: string, + isDark: boolean, + darkColor: string | undefined, + expectedOutput: string, + expectedKnownColors: Record, + expectedSetPropertyCalls: [string, string][] + ) { + const result = handler.registerColor(input, isDark, darkColor); + + expect(result).toEqual(expectedOutput); + expect((handler as any).knownColors).toEqual(expectedKnownColors); + expect(setProperty).toHaveBeenCalledTimes(expectedSetPropertyCalls.length); + + expectedSetPropertyCalls.forEach(v => { + expect(setProperty).toHaveBeenCalledWith(...v); + }); + } + + it('empty color, light mode', () => { + runTest('', false, undefined, '', {}, []); + }); + + it('simple color, light mode', () => { + runTest('red', false, undefined, 'red', {}, []); + }); + + it('empty color, dark mode', () => { + runTest('', true, undefined, '', {}, []); + }); + + it('simple color, dark mode', () => { + runTest( + 'red', + true, + undefined, + 'var(--darkColor_red, red)', + { + '--darkColor_red': { + lightModeColor: 'red', + darkModeColor: 'rgb(255, 39, 17)', + }, + }, + [['--darkColor_red', 'rgb(255, 39, 17)']] + ); + }); + + it('simple color, dark mode, with dark color', () => { + runTest( + 'red', + true, + 'blue', + 'var(--darkColor_red, red)', + { + '--darkColor_red': { + lightModeColor: 'red', + darkModeColor: 'blue', + }, + }, + [['--darkColor_red', 'blue']] + ); + }); + + it('var color, light mode', () => { + runTest('var(--aa, bb)', false, undefined, 'bb', {}, []); + }); + + it('var color, dark mode', () => { + runTest( + 'var(--aa, red)', + true, + undefined, + 'var(--aa, red)', + { + '--aa': { + lightModeColor: 'red', + darkModeColor: 'rgb(255, 39, 17)', + }, + }, + [['--aa', 'rgb(255, 39, 17)']] + ); + }); + + it('var color, dark mode with dark color', () => { + runTest( + 'var(--aa, bb)', + true, + 'cc', + 'var(--aa, bb)', + { + '--aa': { + lightModeColor: 'bb', + darkModeColor: 'cc', + }, + }, + [['--aa', 'cc']] + ); + }); + + it('var color, dark mode with dark color and existing dark color', () => { + (handler as any).knownColors['--aa'] = { + lightModeColor: 'dd', + darkModeColor: 'ee', + }; + runTest( + 'var(--aa, bb)', + true, + 'cc', + 'var(--aa, bb)', + { + '--aa': { + lightModeColor: 'dd', + darkModeColor: 'ee', + }, + }, + [] + ); + }); +}); + +describe('DarkColorHandlerImpl.reset', () => { + it('Reset', () => { + const removeProperty = jasmine.createSpy('removeProperty'); + const div = ({ + style: { + removeProperty, + }, + } as any) as HTMLElement; + const handler = new DarkColorHandlerImpl(div, null!); + + (handler as any).knownColors = { + '--aa': { + lightModeColor: 'bb', + darkModeColor: 'cc', + }, + '--dd': { + lightModeColor: 'ee', + darkModeColor: 'ff', + }, + }; + + handler.reset(); + + expect((handler as any).knownColors).toEqual({}); + expect(removeProperty).toHaveBeenCalledTimes(2); + expect(removeProperty).toHaveBeenCalledWith('--aa'); + expect(removeProperty).toHaveBeenCalledWith('--dd'); + }); +}); + +describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { + it('Not found', () => { + const div = ({} as any) as HTMLElement; + const handler = new DarkColorHandlerImpl(div, null!); + + const result = handler.findLightColorFromDarkColor('#010203'); + + expect(result).toEqual(null); + }); + + it('Found: HEX to RGB', () => { + const div = ({} as any) as HTMLElement; + const handler = new DarkColorHandlerImpl(div, null!); + + (handler as any).knownColors = { + '--bb': { + lightModeColor: 'bb', + darkModeColor: 'rgb(4,5,6)', + }, + '--aa': { + lightModeColor: 'aa', + darkModeColor: 'rgb(1,2,3)', + }, + }; + + const result = handler.findLightColorFromDarkColor('#010203'); + + expect(result).toEqual('aa'); + }); + + it('Found: HEX to HEX', () => { + const div = ({} as any) as HTMLElement; + const handler = new DarkColorHandlerImpl(div, null!); + + (handler as any).knownColors = { + '--bb': { + lightModeColor: 'bb', + darkModeColor: 'rgb(4,5,6)', + }, + '--aa': { + lightModeColor: 'aa', + darkModeColor: '#010203', + }, + }; + + const result = handler.findLightColorFromDarkColor('#010203'); + + expect(result).toEqual('aa'); + }); + + it('Found: RGB to HEX', () => { + const div = ({} as any) as HTMLElement; + const handler = new DarkColorHandlerImpl(div, null!); + + (handler as any).knownColors = { + '--bb': { + lightModeColor: 'bb', + darkModeColor: 'rgb(4,5,6)', + }, + '--aa': { + lightModeColor: 'aa', + darkModeColor: '#010203', + }, + }; + + const result = handler.findLightColorFromDarkColor('rgb(1,2,3)'); + + expect(result).toEqual('aa'); + }); + + it('Found: RGB to RGB', () => { + const div = ({} as any) as HTMLElement; + const handler = new DarkColorHandlerImpl(div, null!); + + (handler as any).knownColors = { + '--bb': { + lightModeColor: 'bb', + darkModeColor: 'rgb(4,5,6)', + }, + '--aa': { + lightModeColor: 'aa', + darkModeColor: 'rgb(1, 2, 3)', + }, + }; + + const result = handler.findLightColorFromDarkColor('rgb(1,2,3)'); + + expect(result).toEqual('aa'); + }); +}); + +describe('DarkColorHandlerImpl.transformElementColor', () => { + let parseColorSpy: jasmine.Spy; + let registerColorSpy: jasmine.Spy; + let handler: DarkColorHandlerImpl; + let contentDiv: HTMLDivElement; + + beforeEach(() => { + contentDiv = document.createElement('div'); + handler = new DarkColorHandlerImpl(contentDiv); + + parseColorSpy = spyOn(handler, 'parseColorValue').and.callThrough(); + registerColorSpy = spyOn(handler, 'registerColor').and.callThrough(); + }); + + it('No color, light to dark', () => { + const span = document.createElement('span'); + handler.transformElementColor(span, false, true); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(2); + expect(parseColorSpy).toHaveBeenCalledWith(null, false); + expect(registerColorSpy).not.toHaveBeenCalled(); + }); + + it('Has simple color in HTML, light to dark', () => { + const span = document.createElement('span'); + + span.setAttribute('color', 'red'); + + handler.transformElementColor(span, false, true); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(3); + expect(parseColorSpy).toHaveBeenCalledWith('red', false); + expect(parseColorSpy).toHaveBeenCalledWith(null, false); + expect(registerColorSpy).toHaveBeenCalledTimes(1); + expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); + }); + + it('Has simple color in CSS, light to dark', () => { + const span = document.createElement('span'); + + span.style.color = 'red'; + + handler.transformElementColor(span, false, true); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(3); + expect(parseColorSpy).toHaveBeenCalledWith('red', false); + expect(parseColorSpy).toHaveBeenCalledWith(null, false); + expect(registerColorSpy).toHaveBeenCalledTimes(1); + expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); + }); + + it('Has color in both text and background, light to dark', () => { + const span = document.createElement('span'); + + span.style.color = 'red'; + span.style.backgroundColor = 'green'; + + handler.transformElementColor(span, false, true); + + expect(span.outerHTML).toBe( + '' + ); + expect(parseColorSpy).toHaveBeenCalledTimes(4); + expect(parseColorSpy).toHaveBeenCalledWith('red', false); + expect(parseColorSpy).toHaveBeenCalledWith('green', false); + expect(parseColorSpy).toHaveBeenCalledWith('red'); + expect(parseColorSpy).toHaveBeenCalledWith('green'); + expect(registerColorSpy).toHaveBeenCalledTimes(2); + expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); + expect(registerColorSpy).toHaveBeenCalledWith('green', true, undefined); + }); + + it('Has var-based color, light to dark', () => { + const span = document.createElement('span'); + + span.style.color = 'var(--darkColor_red, red)'; + + handler.transformElementColor(span, false, true); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(3); + expect(parseColorSpy).toHaveBeenCalledWith('var(--darkColor_red, red)', false); + expect(parseColorSpy).toHaveBeenCalledWith('red'); + expect(parseColorSpy).toHaveBeenCalledWith(null, false); + expect(registerColorSpy).toHaveBeenCalledTimes(1); + expect(registerColorSpy).toHaveBeenCalledWith('red', true, undefined); + }); + + it('No color, dark to light', () => { + const span = document.createElement('span'); + handler.transformElementColor(span, true, false); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(2); + expect(parseColorSpy).toHaveBeenCalledWith(null, true); + expect(registerColorSpy).not.toHaveBeenCalled(); + }); + + it('Has simple color in HTML, dark to light', () => { + const span = document.createElement('span'); + + span.setAttribute('color', 'red'); + + handler.transformElementColor(span, true, false); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(2); + expect(parseColorSpy).toHaveBeenCalledWith('red', true); + expect(parseColorSpy).toHaveBeenCalledWith(null, true); + expect(registerColorSpy).not.toHaveBeenCalled(); + }); + + it('Has simple color in CSS, dark to light', () => { + const span = document.createElement('span'); + + span.style.color = 'red'; + + handler.transformElementColor(span, true, false); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(2); + expect(parseColorSpy).toHaveBeenCalledWith('red', true); + expect(parseColorSpy).toHaveBeenCalledWith(null, true); + expect(registerColorSpy).not.toHaveBeenCalled(); + }); + + it('Has color in both text and background, dark to light', () => { + const span = document.createElement('span'); + + span.style.color = 'red'; + span.style.backgroundColor = 'green'; + + handler.transformElementColor(span, true, false); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(2); + expect(parseColorSpy).toHaveBeenCalledWith('red', true); + expect(parseColorSpy).toHaveBeenCalledWith('green', true); + expect(registerColorSpy).not.toHaveBeenCalled(); + }); + + it('Has var-based color, dark to light', () => { + const span = document.createElement('span'); + + span.style.color = 'var(--darkColor_red, red)'; + + handler.transformElementColor(span, true, false); + + expect(span.outerHTML).toBe(''); + expect(parseColorSpy).toHaveBeenCalledTimes(3); + expect(parseColorSpy).toHaveBeenCalledWith('var(--darkColor_red, red)', true); + expect(parseColorSpy).toHaveBeenCalledWith('red'); + expect(parseColorSpy).toHaveBeenCalledWith(null, true); + expect(registerColorSpy).toHaveBeenCalledTimes(1); + expect(registerColorSpy).toHaveBeenCalledWith('red', false, undefined); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts index be3f5925e60..959beb060f1 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/backgroundColorFormatHandlerTest.ts @@ -1,7 +1,6 @@ import { backgroundColorFormatHandler } from '../../../lib/formatHandlers/common/backgroundColorFormatHandler'; import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; -import { DarkColorHandlerImpl } from 'roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl'; import { DeprecatedColors } from '../../../lib/formatHandlers/utils/color'; import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; import { @@ -104,14 +103,14 @@ describe('backgroundColorFormatHandler.apply', () => { it('Simple color in dark mode', () => { format.backgroundColor = 'red'; context.isDarkMode = true; - context.darkColorHandler = new DarkColorHandlerImpl(div, s => 'darkMock:' + s); + context.darkColorHandler = { + registerColor: (color: string, isDarkMode: boolean) => + isDarkMode ? `var(--darkColor_${color}, ${color})` : color, + } as any; backgroundColorFormatHandler.apply(format, div, context); - const expectedResult = [ - '
                                                          ', - '
                                                          ', - ]; + const expectedResult = ['
                                                          ']; expectHtml(div.outerHTML, expectedResult); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts index 74ded2e190c..68bc1ecad41 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/textColorFormatHandlerTest.ts @@ -1,6 +1,5 @@ import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; -import { DarkColorHandlerImpl } from 'roosterjs-content-model-editor/lib/editor/DarkColorHandlerImpl'; import { defaultHTMLStyleMap } from '../../../lib/config/defaultHTMLStyleMap'; import { DeprecatedColors } from '../../../lib'; import { expectHtml } from 'roosterjs-editor-dom/test/DomTestHelper'; @@ -108,7 +107,10 @@ describe('textColorFormatHandler.apply', () => { beforeEach(() => { div = document.createElement('div'); context = createModelToDomContext(); - context.darkColorHandler = new DarkColorHandlerImpl(div, s => 'darkMock: ' + s); + context.darkColorHandler = { + registerColor: (color: string, isDarkMode: boolean) => + isDarkMode ? `var(--darkColor_${color}, ${color})` : color, + } as any; format = {}; }); @@ -133,10 +135,7 @@ describe('textColorFormatHandler.apply', () => { textColorFormatHandler.apply(format, div, context); - const expectedResult = [ - '
                                                          ', - '
                                                          ', - ]; + const expectedResult = ['
                                                          ']; expectHtml(div.outerHTML, expectedResult); }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts index 0cc7b7d5b34..f0b17b15c2f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts @@ -14,16 +14,14 @@ import { selectImage } from './selectImage'; import { selectRange } from './selectRange'; import { selectTable } from './selectTable'; import { setContent } from './setContent'; -import { standaloneCoreApiMap } from 'roosterjs-content-model-core'; import { transformColor } from './transformColor'; import { triggerEvent } from './triggerEvent'; -import type { StandaloneCoreApiMap } from 'roosterjs-content-model-types'; +import type { UnportedCoreApiMap } from 'roosterjs-content-model-types'; /** * @internal */ -export const coreApiMap: StandaloneCoreApiMap = { - ...standaloneCoreApiMap, +export const coreApiMap: UnportedCoreApiMap = { attachDomEvent, addUndoSnapshot, ensureTypeInContainer, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts index 7e6943ebc95..216b3d23fdd 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts @@ -3,16 +3,15 @@ import { createEntityPlugin } from './EntityPlugin'; import { createImageSelection } from './ImageSelection'; import { createLifecyclePlugin } from './LifecyclePlugin'; import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; -import { createStandaloneEditorCorePlugins } from 'roosterjs-content-model-core'; import { createUndoPlugin } from './UndoPlugin'; -import type { ContentModelCorePlugins } from '../publicTypes/ContentModelCorePlugins'; +import type { UnportedCorePlugins } from '../publicTypes/ContentModelCorePlugins'; +import type { UnportedCorePluginState } from 'roosterjs-content-model-types'; import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; -import type { ContentModelPluginState } from 'roosterjs-content-model-types'; /** * @internal */ -export interface CreateCorePluginResponse extends ContentModelCorePlugins { +export interface CreateCorePluginResponse extends UnportedCorePlugins { _placeholder: null; } @@ -31,7 +30,6 @@ export function createCorePlugins( // The order matters, some plugin needs to be put before/after others to make sure event // can be handled in right order return { - ...createStandaloneEditorCorePlugins(options, contentDiv), edit: map.edit || createEditPlugin(), _placeholder: null, undo: map.undo || createUndoPlugin(options), @@ -47,15 +45,11 @@ export function createCorePlugins( * Get plugin state of core plugins * @param corePlugins ContentModelCorePlugins object */ -export function getPluginState(corePlugins: ContentModelCorePlugins): ContentModelPluginState { +export function getPluginState(corePlugins: UnportedCorePlugins): UnportedCorePluginState { return { - domEvent: corePlugins.domEvent.getState(), edit: corePlugins.edit.getState(), lifecycle: corePlugins.lifecycle.getState(), undo: corePlugins.undo.getState(), entity: corePlugins.entity.getState(), - copyPaste: corePlugins.copyPaste.getState(), - cache: corePlugins.cache.getState(), - format: corePlugins.format.getState(), }; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 5504c9bfa1b..e9bf1d56ff0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -1074,7 +1074,9 @@ export class ContentModelEditor implements IContentModelEditor { * Retrieves the rect of the visible viewport of the editor. */ getVisibleViewport(): Rect | null { - return this.getCore().getVisibleViewport(); + const core = this.getCore(); + + return core.api.getVisibleViewport(core); } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts index ac4241f7cc6..d8fa0d0b6b4 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts @@ -1,9 +1,7 @@ -import { arrayPush, getIntersectedRect, getObjectKeys } from 'roosterjs-editor-dom'; import { coreApiMap } from '../coreApi/coreApiMap'; import { createCorePlugins, getPluginState } from '../corePlugins/createCorePlugins'; -import { createStandaloneEditorDefaultSettings } from 'roosterjs-content-model-core'; -import { DarkColorHandlerImpl } from './DarkColorHandlerImpl'; -import type { EditorPlugin } from 'roosterjs-editor-types'; +import { createStandaloneEditorCore } from 'roosterjs-content-model-core'; +import { getObjectKeys } from 'roosterjs-content-model-dom'; import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; @@ -18,70 +16,34 @@ export function createEditorCore( options: ContentModelEditorOptions ): ContentModelEditorCore { const corePlugins = createCorePlugins(contentDiv, options); - const plugins: EditorPlugin[] = []; - - getObjectKeys(corePlugins).forEach(name => { - if (name == '_placeholder') { - if (options.plugins) { - arrayPush(plugins, options.plugins); - } - } else { - plugins.push(corePlugins[name]); - } - }); - const pluginState = getPluginState(corePlugins); - const zoomScale: number = (options.zoomScale ?? -1) > 0 ? options.zoomScale! : 1; - const getVisibleViewport = - options.getVisibleViewport || - (() => { - const scrollContainer = pluginState.domEvent.scrollContainer; - return getIntersectedRect( - scrollContainer == core.contentDiv - ? [scrollContainer] - : [scrollContainer, core.contentDiv] - ); - }); + const zoomScale: number = (options.zoomScale ?? -1) > 0 ? options.zoomScale! : 1; - // It is ok to use global window here since the environment should always be the same for all windows in one session - const userAgent = window.navigator.userAgent; + const standaloneEditorCore = createStandaloneEditorCore( + contentDiv, + options, + coreApiMap, + pluginState + ); const core: ContentModelEditorCore = { - contentDiv, - api: { - ...coreApiMap, - ...(options.coreApiOverride || {}), - }, - originalApi: { ...coreApiMap }, - plugins: plugins.filter(x => !!x), + ...standaloneEditorCore, ...pluginState, - trustedHTMLHandler: options.trustedHTMLHandler || defaultTrustHtmlHandler, zoomScale: zoomScale, sizeTransformer: (size: number) => size / zoomScale, - getVisibleViewport, - imageSelectionBorderColor: options.imageSelectionBorderColor, - darkColorHandler: new DarkColorHandlerImpl(contentDiv, pluginState.lifecycle.getDarkColor), disposeErrorHandler: options.disposeErrorHandler, - - ...createStandaloneEditorDefaultSettings(options), - - environment: { - isMac: window.navigator.appVersion.indexOf('Mac') != -1, - isAndroid: /android/i.test(userAgent), - isSafari: - userAgent.indexOf('Safari') >= 0 && - userAgent.indexOf('Chrome') < 0 && - userAgent.indexOf('Android') < 0, - }, }; - return core; -} + getObjectKeys(corePlugins).forEach(name => { + if (name == '_placeholder') { + if (options.plugins) { + core.plugins.push(...options.plugins.filter(x => !!x)); + } + } else if (corePlugins[name]) { + core.plugins.push(corePlugins[name]); + } + }); -/** - * @internal Export for test only - */ -export function defaultTrustHtmlHandler(html: string) { - return html; + return core; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index 43a31ab5983..ac1905956cb 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -1,6 +1,9 @@ export { ContentModelEditorCore } from './publicTypes/ContentModelEditorCore'; export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; -export { ContentModelCorePlugins } from './publicTypes/ContentModelCorePlugins'; +export { + ContentModelCorePlugins, + UnportedCorePlugins, +} from './publicTypes/ContentModelCorePlugins'; export { ContentModelEditor } from './editor/ContentModelEditor'; export { isContentModelEditor } from './editor/isContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts index c02378cfd7a..4c8cd1948a4 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts @@ -1,6 +1,5 @@ import type { StandaloneEditorCorePlugins } from 'roosterjs-content-model-types'; import type { - CopyPastePluginState, EditPluginState, EditorPlugin, EntityPluginState, @@ -10,9 +9,10 @@ import type { } from 'roosterjs-editor-types'; /** - * An interface for Content Model editor core plugins. + * An interface for unported core plugins + * TODO: Port these plugins */ -export interface ContentModelCorePlugins extends StandaloneEditorCorePlugins { +export interface UnportedCorePlugins { /** * Edit plugin handles ContentEditFeatures */ @@ -23,10 +23,6 @@ export interface ContentModelCorePlugins extends StandaloneEditorCorePlugins { */ readonly undo: PluginWithState; - /** - * Copy and paste plugin for handling onCopy and onPaste event - */ - readonly copyPaste: PluginWithState; /** * Entity Plugin handles all operations related to an entity and generate entity specified events */ @@ -49,3 +45,8 @@ export interface ContentModelCorePlugins extends StandaloneEditorCorePlugins { */ readonly lifecycle: PluginWithState; } + +/** + * An interface for Content Model editor core plugins. + */ +export interface ContentModelCorePlugins extends StandaloneEditorCorePlugins, UnportedCorePlugins {} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index 9be82c2b66f..34e8bb630e1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -4,16 +4,10 @@ import type { EditorPlugin, ExperimentalFeatures, IEditor, - Rect, Snapshot, - TrustedHTMLHandler, UndoSnapshotsService, } from 'roosterjs-editor-types'; -import type { - StandaloneEditorOptions, - IStandaloneEditor, - StandaloneCoreApiMap, -} from 'roosterjs-content-model-types'; +import type { StandaloneEditorOptions, IStandaloneEditor } from 'roosterjs-content-model-types'; /** * An interface of editor with Content Model support. @@ -44,12 +38,6 @@ export interface ContentModelEditorOptions extends StandaloneEditorOptions { */ initialContent?: string; - /** - * A function map to override default core API implementation - * Default value is null - */ - coreApiOverride?: Partial; - /** * A plugin map to override default core Plugin implementation * Default value is null @@ -77,13 +65,6 @@ export interface ContentModelEditorOptions extends StandaloneEditorOptions { */ experimentalFeatures?: ExperimentalFeatures[]; - /** - * Customized trusted type handler used for sanitizing HTML string before assign to DOM tree - * This is required when trusted-type Content-Security-Policy (CSP) is enabled. - * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types - */ - trustedHTMLHandler?: TrustedHTMLHandler; - /** * Current zoom scale, @default value is 1 * When editor is put under a zoomed container, need to pass the zoom scale number using this property @@ -91,16 +72,6 @@ export interface ContentModelEditorOptions extends StandaloneEditorOptions { */ zoomScale?: number; - /** - * Retrieves the visible viewport of the Editor. The default viewport is the Rect of the scrollContainer. - */ - getVisibleViewport?: () => Rect | null; - - /** - * Color of the border of a selectedImage. Default color: '#DB626C' - */ - imageSelectionBorderColor?: string; - /** * A callback to be invoked when any exception is thrown during disposing editor * @param plugin The plugin that causes exception diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts index 1f597bdf9f5..7d6ab21182b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts @@ -10,7 +10,9 @@ import * as LifecyclePlugin from '../../lib/corePlugins/LifecyclePlugin'; import * as NormalizeTablePlugin from '../../lib/corePlugins/NormalizeTablePlugin'; import * as UndoPlugin from '../../lib/corePlugins/UndoPlugin'; import { coreApiMap } from '../../lib/coreApi/coreApiMap'; -import { createEditorCore, defaultTrustHtmlHandler } from '../../lib/editor/createEditorCore'; +import { createEditorCore } from '../../lib/editor/createEditorCore'; +import { defaultTrustHtmlHandler } from 'roosterjs-content-model-core/lib/editor/createStandaloneEditorCore'; +import { standaloneCoreApiMap } from 'roosterjs-content-model-core/lib/editor/standaloneCoreApiMap'; const mockedDomEventState = 'DOMEVENTSTATE' as any; const mockedEditState = 'EDITSTATE' as any; @@ -87,8 +89,8 @@ describe('createEditorCore', () => { const core = createEditorCore(contentDiv, {}); expect(core).toEqual({ contentDiv, - api: coreApiMap, - originalApi: coreApiMap, + api: { ...coreApiMap, ...standaloneCoreApiMap }, + originalApi: { ...coreApiMap, ...standaloneCoreApiMap }, plugins: [ mockedCachePlugin, mockedFormatPlugin, @@ -112,7 +114,6 @@ describe('createEditorCore', () => { trustedHTMLHandler: defaultTrustHtmlHandler, zoomScale: 1, sizeTransformer: jasmine.anything(), - getVisibleViewport: jasmine.anything(), imageSelectionBorderColor: undefined, darkColorHandler: jasmine.anything(), disposeErrorHandler: undefined, @@ -141,8 +142,8 @@ describe('createEditorCore', () => { expect(core).toEqual({ contentDiv, - api: coreApiMap, - originalApi: coreApiMap, + api: { ...coreApiMap, ...standaloneCoreApiMap }, + originalApi: { ...coreApiMap, ...standaloneCoreApiMap }, plugins: [ mockedCachePlugin, mockedFormatPlugin, @@ -166,7 +167,6 @@ describe('createEditorCore', () => { trustedHTMLHandler: defaultTrustHtmlHandler, zoomScale: 1, sizeTransformer: jasmine.anything(), - getVisibleViewport: jasmine.anything(), imageSelectionBorderColor: undefined, darkColorHandler: jasmine.anything(), disposeErrorHandler: undefined, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts index 025c0d7882e..69579d81fb6 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts @@ -14,13 +14,15 @@ export function initEditor(id: string): IContentModelEditor { let options: ContentModelEditorOptions = { plugins: [new ContentModelPastePlugin()], - getVisibleViewport: () => { - return { - top: 100, - bottom: 200, - left: 100, - right: 200, - }; + coreApiOverride: { + getVisibleViewport: () => { + return { + top: 100, + bottom: 200, + left: 100, + right: 200, + }; + }, }, }; diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts index 3b8dcbd6d2a..d18116e8707 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts @@ -24,7 +24,10 @@ import type { TrustedHTMLHandler, } from 'roosterjs-editor-types'; import type { ContentModelDocument } from '../group/ContentModelDocument'; -import type { ContentModelPluginState } from '../pluginState/ContentModelPluginState'; +import type { + StandaloneEditorCorePluginState, + UnportedCorePluginState, +} from '../pluginState/StandaloneEditorPluginState'; import type { DOMSelection } from '../selection/DOMSelection'; import type { DomToModelOption } from '../context/DomToModelOption'; import type { DomToModelSettings } from '../context/DomToModelSettings'; @@ -179,6 +182,12 @@ export type AddUndoSnapshot = ( additionalData?: ContentChangedData ) => void; +/** + * Retrieves the rect of the visible viewport of the editor. + * @param core The StandaloneEditorCore object + */ +export type GetVisibleViewport = (core: StandaloneEditorCore) => Rect | null; + /** * Change the editor selection to the given range * @param core The StandaloneEditorCore object @@ -375,6 +384,12 @@ export interface PortedCoreApiMap { * @param isOn True to switch On, False to switch Off */ switchShadowEdit: SwitchShadowEdit; + + /** + * Retrieves the rect of the visible viewport of the editor. + * @param core The StandaloneEditorCore object + */ + getVisibleViewport: GetVisibleViewport; } /** @@ -550,7 +565,8 @@ export interface StandaloneCoreApiMap extends PortedCoreApiMap, UnportedCoreApiM * Represents the core data structure of a Content Model editor */ export interface StandaloneEditorCore - extends ContentModelPluginState, + extends StandaloneEditorCorePluginState, + UnportedCorePluginState, StandaloneEditorDefaultSettings { /** * The content DIV element of this editor @@ -581,17 +597,12 @@ export interface StandaloneEditorCore * Dark model handler for the editor, used for variable-based solution. * If keep it null, editor will still use original dataset-based dark mode solution. */ - darkColorHandler: DarkColorHandler; - - /** - * Retrieves the Visible Viewport of the editor. - */ - getVisibleViewport: () => Rect | null; + readonly darkColorHandler: DarkColorHandler; /** * Color of the border of a selectedImage. Default color: '#DB626C' */ - imageSelectionBorderColor?: string; + readonly imageSelectionBorderColor?: string; /** * A handler to convert HTML string to a trust HTML string. diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts index fe3d8beac6f..b82b5056f09 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts @@ -1,4 +1,5 @@ -import type { DefaultFormat, EditorPlugin } from 'roosterjs-editor-types'; +import type { StandaloneCoreApiMap } from './StandaloneEditorCore'; +import type { DefaultFormat, EditorPlugin, TrustedHTMLHandler } from 'roosterjs-editor-types'; import type { DomToModelOption } from '../context/DomToModelOption'; import type { ModelToDomOption } from '../context/ModelToDomOption'; @@ -47,4 +48,28 @@ export interface StandaloneEditorOptions { * By default, the scroll container will be the same with editor content DIV */ scrollContainer?: HTMLElement; + + /** + * Base dark mode color. We will use this color to calculate the dark mode color from a given light mode color + * @default #333333 + */ + baseDarkColor?: string; + + /** + * Customized trusted type handler used for sanitizing HTML string before assign to DOM tree + * This is required when trusted-type Content-Security-Policy (CSP) is enabled. + * See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types + */ + trustedHTMLHandler?: TrustedHTMLHandler; + + /** + * A function map to override default core API implementation + * Default value is null + */ + coreApiOverride?: Partial; + + /** + * Color of the border of a selectedImage. Default color: '#DB626C' + */ + imageSelectionBorderColor?: string; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index c3b562ef901..0c9d0ce1754 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -226,11 +226,15 @@ export { GetStyleBasedFormatState, RestoreUndoSnapshot, EnsureTypeInContainer, + GetVisibleViewport, } from './editor/StandaloneEditorCore'; export { StandaloneEditorCorePlugins } from './editor/StandaloneEditorCorePlugins'; export { ContentModelCachePluginState } from './pluginState/ContentModelCachePluginState'; -export { ContentModelPluginState } from './pluginState/ContentModelPluginState'; +export { + StandaloneEditorCorePluginState, + UnportedCorePluginState, +} from './pluginState/StandaloneEditorPluginState'; export { ContentModelFormatPluginState, PendingFormat, diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts similarity index 79% rename from packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts rename to packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts index 5d725106a7f..fdf29ed64fd 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/ContentModelPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts @@ -10,10 +10,10 @@ import type { ContentModelFormatPluginState } from './ContentModelFormatPluginSt import type { DOMEventPluginState } from './DOMEventPluginState'; /** - * Temporary core plugin state for Content Model editor + * Temporary core plugin state for Content Model editor (ported part) * TODO: Create Content Model plugin state from all core plugins once we have standalone Content Model Editor */ -export interface ContentModelPluginState { +export interface StandaloneEditorCorePluginState { /** * Plugin state for ContentModelCachePlugin */ @@ -33,8 +33,13 @@ export interface ContentModelPluginState { * Plugin state for DOMEventPlugin */ domEvent: DOMEventPluginState; +} - // Plugins copied from legacy editor +/** + * Temporary core plugin state for Content Model editor (unported part) + * TODO: Port these plugins + */ +export interface UnportedCorePluginState { lifecycle: LifecyclePluginState; entity: EntityPluginState; undo: UndoPluginState; From 2699cf9f7bc2b2e7446fd32a44c63dbd84ac2f2c Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 22 Nov 2023 09:23:56 -0800 Subject: [PATCH 054/111] Standalone Editor: Port LifecyclePlugin (#2219) * Standalone Editor: CreateStandaloneEditorCore * Standalone Editor: Port LifecyclePlugin * fix build * fix test * improve * fix test * fix comment --- .../controls/ContentModelEditorMainPane.tsx | 16 +- .../common/retrieveModelFormatState.ts | 2 +- .../lib/publicApi/segment/toggleBold.ts | 10 +- .../test/publicApi/link/insertLinkTest.ts | 1 - .../lib/coreApi/switchShadowEdit.ts | 13 +- .../corePlugin/ContentModelFormatPlugin.ts | 12 +- .../lib/corePlugin/LifecyclePlugin.ts | 209 ++++++++++++++++++ .../createStandaloneEditorCorePlugins.ts | 2 + .../lib/editor/createStandaloneEditorCore.ts | 8 +- .../roosterjs-content-model-core/lib/index.ts | 2 + .../publicApi/model/createModelFromHtml.ts | 22 ++ .../lib/publicApi/model/isBold.ts | 9 + .../test/coreApi/switchShadowEditTest.ts | 4 +- .../ContentModelFormatPluginTest.ts | 35 +-- .../test/corePlugin/LifecyclePluginTest.ts | 188 ++++++++++++++++ .../lib/formatHandlers/utils/color.ts | 13 +- .../roosterjs-content-model-dom/lib/index.ts | 2 +- .../lib/coreApi/ensureTypeInContainer.ts | 10 - .../lib/coreApi/getContent.ts | 13 +- .../lib/coreApi/getSelectionRange.ts | 12 +- .../lib/coreApi/getSelectionRangeEx.ts | 51 +---- .../lib/coreApi/selectRange.ts | 4 +- .../lib/coreApi/setContent.ts | 2 +- .../lib/corePlugins/LifecyclePlugin.ts | 200 ----------------- .../lib/corePlugins/createCorePlugins.ts | 17 +- .../lib/editor/ContentModelEditor.ts | 41 ++-- .../lib/editor/createEditorCore.ts | 38 ++-- .../publicTypes/ContentModelCorePlugins.ts | 6 - .../lib/publicTypes/ContentModelEditorCore.ts | 18 +- .../lib/publicTypes/IContentModelEditor.ts | 24 -- .../test/editor/ContentModelEditorTest.ts | 68 +++--- .../test/editor/createEditorCoreTest.ts | 6 +- .../test/paste/e2e/cmPasteFromExcelTest.ts | 10 +- .../test/paste/e2e/cmPasteFromWordTest.ts | 10 +- .../lib/editor/StandaloneEditorCorePlugins.ts | 6 + .../lib/editor/StandaloneEditorOptions.ts | 21 +- .../lib/index.ts | 1 + .../lib/pluginState/LifecyclePluginState.ts | 28 +++ .../StandaloneEditorPluginState.ts | 8 +- .../lib/createContentModelEditor.ts | 4 +- .../roosterjs-content-model/package.json | 3 +- 41 files changed, 669 insertions(+), 480 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/publicApi/model/isBold.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index c7bc65464d3..0bda5eb0590 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -17,9 +17,9 @@ import TitleBar from './titleBar/TitleBar'; import { arrayPush } from 'roosterjs-editor-dom'; import { ContentModelEditPlugin } from 'roosterjs-content-model-plugins'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; +import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { createEmojiPlugin, createPasteOptionPlugin, RibbonPlugin } from 'roosterjs-react'; import { EditorPlugin } from 'roosterjs-editor-types'; -import { getDarkColor } from 'roosterjs-color-utils'; import { PartialTheme } from '@fluentui/react/lib/Theme'; import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; import { @@ -210,6 +210,17 @@ class ContentModelEditorMainPane extends MainPaneBase height: `calc(${100 / this.state.scale}%)`, width: `calc(${100 / this.state.scale}%)`, }; + const format = this.state.initState.defaultFormat; + const defaultFormat: ContentModelSegmentFormat = { + fontWeight: format.bold ? 'bold' : undefined, + italic: format.italic || undefined, + underline: format.underline || undefined, + fontFamily: format.fontFamily || undefined, + fontSize: format.fontSize || undefined, + textColor: format.textColors?.lightModeColor || format.textColor || undefined, + backgroundColor: + format.backgroundColors?.lightModeColor || format.backgroundColor || undefined, + }; this.updateContentPlugin.forceUpdate(); @@ -220,9 +231,8 @@ class ContentModelEditorMainPane extends MainPaneBase = 600) - ); -} diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts index a8b122ddde8..ea64c16d70a 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts @@ -343,7 +343,6 @@ describe('insertLink', () => { const a = div.querySelector('a'); expect(a!.outerHTML).toBe('http://test.com'); - expect(onPluginEvent).toHaveBeenCalledTimes(4); expect(onPluginEvent).toHaveBeenCalledWith({ eventType: PluginEventType.ContentChanged, source: ChangeSource.CreateLink, diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts index 0e393ec8e1f..cdc8324d67e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts @@ -1,7 +1,7 @@ import { iterateSelections } from '../publicApi/selection/iterateSelections'; +import { moveChildNodes } from 'roosterjs-content-model-dom'; import { PluginEventType } from 'roosterjs-editor-types'; import type { SwitchShadowEdit } from 'roosterjs-content-model-types'; -import type { SelectionPath } from 'roosterjs-editor-types'; /** * @internal @@ -20,17 +20,16 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { // Fake object, not used in Content Model Editor, just to satisfy original editor code // TODO: we can remove them once we have standalone Content Model Editor const fragment = core.contentDiv.ownerDocument.createDocumentFragment(); - const selectionPath: SelectionPath = { - start: [], - end: [], - }; + const clonedRoot = core.contentDiv.cloneNode(true /*deep*/); + + moveChildNodes(fragment, clonedRoot); core.api.triggerEvent( core, { eventType: PluginEventType.EnteredShadowEdit, fragment, - selectionPath, + selectionPath: null, }, false /*broadcast*/ ); @@ -41,11 +40,9 @@ export const switchShadowEdit: SwitchShadowEdit = (editorCore, isOn): void => { core.cache.cachedModel = model; } - core.lifecycle.shadowEditSelectionPath = selectionPath; core.lifecycle.shadowEditFragment = fragment; } else { core.lifecycle.shadowEditFragment = null; - core.lifecycle.shadowEditSelectionPath = null; core.api.triggerEvent( core, diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts index 7630f9fe210..ae05ee4b1e4 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts @@ -38,18 +38,8 @@ class ContentModelFormatPlugin implements PluginWithState { + private editor: (IStandaloneEditor & IEditor) | null = null; + private state: LifecyclePluginState; + private initialModel: ContentModelDocument; + private initializer: (() => void) | null = null; + private disposer: (() => void) | null = null; + private adjustColor: () => void; + + /** + * Construct a new instance of LifecyclePlugin + * @param options The editor options + * @param contentDiv The editor content DIV + */ + constructor(options: StandaloneEditorOptions, contentDiv: HTMLDivElement) { + this.initialModel = + options.initialModel ?? this.createInitModel(options.defaultSegmentFormat); + + // Make the container editable and set its selection styles + if (contentDiv.getAttribute(ContentEditableAttributeName) === null) { + this.initializer = () => { + contentDiv.contentEditable = 'true'; + contentDiv.style.userSelect = 'text'; + }; + this.disposer = () => { + contentDiv.style.userSelect = ''; + contentDiv.removeAttribute(ContentEditableAttributeName); + }; + } + this.adjustColor = options.doNotAdjustEditorColor + ? () => {} + : () => { + this.adjustContainerColor(contentDiv); + }; + + this.state = { + isDarkMode: !!options.inDarkMode, + onExternalContentTransform: null, + shadowEditFragment: null, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Lifecycle'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor as IEditor & IStandaloneEditor; + + this.editor.setContentModel( + this.initialModel, + { ignoreSelection: true }, + this.editor.isDarkMode() ? this.onInitialNodeCreated : undefined + ); + + // Initial model is only used once. After that we can just clean it up to make sure we don't cache anything useless + // including the cached DOM element inside the model. + this.initialModel = createContentModelDocument(); + + // Set content DIV to be editable + this.initializer?.(); + + // Set editor background color for dark mode + this.adjustColor(); + + // Let other plugins know that we are ready + this.editor.triggerPluginEvent(PluginEventType.EditorReady, {}, true /*broadcast*/); + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor?.triggerPluginEvent(PluginEventType.BeforeDispose, {}, true /*broadcast*/); + + if (this.disposer) { + this.disposer(); + this.disposer = null; + this.initializer = null; + } + + this.editor = null; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + if ( + event.eventType == PluginEventType.ContentChanged && + (event.source == ChangeSource.SwitchToDarkMode || + event.source == ChangeSource.SwitchToLightMode) + ) { + this.state.isDarkMode = event.source == ChangeSource.SwitchToDarkMode; + this.adjustColor(); + } + } + + private adjustContainerColor(contentDiv: HTMLElement) { + if (this.editor) { + const { isDarkMode } = this.state; + const darkColorHandler = this.editor.getDarkColorHandler(); + + setColor( + contentDiv, + DefaultTextColor, + false /*isBackground*/, + darkColorHandler, + isDarkMode + ); + setColor( + contentDiv, + DefaultBackColor, + true /*isBackground*/, + darkColorHandler, + isDarkMode + ); + } + } + + private createInitModel(format?: ContentModelSegmentFormat) { + const model = createContentModelDocument(format); + const paragraph = createParagraph(false /*isImplicit*/, undefined /*blockFormat*/, format); + + paragraph.segments.push(createSelectionMarker(format), createBr(format)); + model.blocks.push(paragraph); + + return model; + } + + private onInitialNodeCreated: OnNodeCreated = (model, node) => { + if (isEntity(model) && this.editor) { + this.editor.transformToDarkColor(node, ColorTransformDirection.LightToDark); + } + }; +} + +function isEntity( + modelElement: + | ContentModelBlock + | ContentModelBlockGroup + | ContentModelSegment + | ContentModelDecorator + | ContentModelTableRow +): modelElement is ContentModelEntity { + return ( + (modelElement as ContentModelSegment).segmentType == 'Entity' || + (modelElement as ContentModelBlock).blockType == 'Entity' + ); +} + +/** + * @internal + * Create a new instance of LifecyclePlugin. + * @param option The editor option + * @param contentDiv The editor content DIV element + */ +export function createLifecyclePlugin( + option: StandaloneEditorOptions, + contentDiv: HTMLDivElement +): PluginWithState { + return new LifecyclePlugin(option, contentDiv); +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts index 5e7e38c683c..67125e54a4f 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts @@ -2,6 +2,7 @@ import { createContentModelCachePlugin } from './ContentModelCachePlugin'; import { createContentModelCopyPastePlugin } from './ContentModelCopyPastePlugin'; import { createContentModelFormatPlugin } from './ContentModelFormatPlugin'; import { createDOMEventPlugin } from './DOMEventPlugin'; +import { createLifecyclePlugin } from './LifecyclePlugin'; import type { StandaloneEditorCorePlugins, StandaloneEditorOptions, @@ -21,5 +22,6 @@ export function createStandaloneEditorCorePlugins( format: createContentModelFormatPlugin(options), copyPaste: createContentModelCopyPastePlugin(options), domEvent: createDOMEventPlugin(options, contentDiv), + lifecycle: createLifecyclePlugin(options, contentDiv), }; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts index d23409d641a..317dd22d73f 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts @@ -2,6 +2,7 @@ import { createStandaloneEditorCorePlugins } from '../corePlugin/createStandalon import { createStandaloneEditorDefaultSettings } from './createStandaloneEditorDefaultSettings'; import { DarkColorHandlerImpl } from './DarkColorHandlerImpl'; import { standaloneCoreApiMap } from './standaloneCoreApiMap'; +import type { EditorPlugin } from 'roosterjs-editor-types'; import type { EditorEnvironment, StandaloneEditorCore, @@ -20,7 +21,8 @@ export function createStandaloneEditorCore( contentDiv: HTMLDivElement, options: StandaloneEditorOptions, unportedCoreApiMap: UnportedCoreApiMap, - unportedCorePluginState: UnportedCorePluginState + unportedCorePluginState: UnportedCorePluginState, + tempPlugins: EditorPlugin[] ): StandaloneEditorCore { const corePlugins = createStandaloneEditorCorePlugins(options, contentDiv); @@ -33,7 +35,8 @@ export function createStandaloneEditorCore( corePlugins.format, corePlugins.copyPaste, corePlugins.domEvent, - // TODO: Add additional plugins here + ...tempPlugins, + corePlugins.lifecycle, ], environment: createEditorEnvironment(), darkColorHandler: new DarkColorHandlerImpl(contentDiv, options.baseDarkColor), @@ -72,5 +75,6 @@ function getPluginState(corePlugins: StandaloneEditorCorePlugins) { copyPaste: corePlugins.copyPaste.getState(), cache: corePlugins.cache.getState(), format: corePlugins.format.getState(), + lifecycle: corePlugins.lifecycle.getState(), }; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/index.ts b/packages-content-model/roosterjs-content-model-core/lib/index.ts index ec683666bc8..73d35cdb658 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/index.ts @@ -6,6 +6,8 @@ export { getClosestAncestorBlockGroupIndex, TypeOfBlockGroup, } from './publicApi/model/getClosestAncestorBlockGroupIndex'; +export { isBold } from './publicApi/model/isBold'; +export { createModelFromHtml } from './publicApi/model/createModelFromHtml'; export { iterateSelections, diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts new file mode 100644 index 00000000000..0ec4ef88545 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts @@ -0,0 +1,22 @@ +import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; +import type { ContentModelDocument, DomToModelOption } from 'roosterjs-content-model-types'; +import type { TrustedHTMLHandler } from 'roosterjs-editor-types'; + +/** + * Create Content Model from HTML string + * @param html The source HTML string + * @param options Options for DOM to Content Model conversion + * @param trustedHTMLHandler A string handler to convert string to trusted string + * @returns A Content Model Document object that contains the Content Model from the give HTML, or undefined if failed to parse the source HTML + */ +export function createModelFromHtml( + html: string, + options?: DomToModelOption, + trustedHTMLHandler?: TrustedHTMLHandler +): ContentModelDocument | undefined { + const doc = new DOMParser().parseFromString(trustedHTMLHandler?.(html) ?? html, 'text/html'); + + return doc?.body + ? domToContentModel(doc.body, createDomToModelContext(undefined /*editorContext*/, options)) + : undefined; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/isBold.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/isBold.ts new file mode 100644 index 00000000000..82c1d524b5e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/isBold.ts @@ -0,0 +1,9 @@ +/** + * Check if the given bold style represents a bold style + * @param boldStyle The style to check + */ +export function isBold(boldStyle?: string): boolean { + return ( + !!boldStyle && (boldStyle == 'bold' || boldStyle == 'bolder' || parseInt(boldStyle) >= 600) + ); +} diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/switchShadowEditTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/switchShadowEditTest.ts index effd9f25284..19fc938f8e6 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/switchShadowEditTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/switchShadowEditTest.ts @@ -46,7 +46,7 @@ describe('switchShadowEdit', () => { { eventType: PluginEventType.EnteredShadowEdit, fragment: document.createDocumentFragment(), - selectionPath: { start: [], end: [] }, + selectionPath: null, }, false ); @@ -67,7 +67,7 @@ describe('switchShadowEdit', () => { { eventType: PluginEventType.EnteredShadowEdit, fragment: document.createDocumentFragment(), - selectionPath: { start: [], end: [] }, + selectionPath: null, }, false ); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts index a9eaa784f5b..e71818fb02f 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/ContentModelFormatPluginTest.ts @@ -289,7 +289,7 @@ describe('ContentModelFormatPlugin for default format', () => { it('Collapsed range, text input, under editor directly', () => { const plugin = createContentModelFormatPlugin({ - defaultFormat: { + defaultSegmentFormat: { fontFamily: 'Arial', }, }); @@ -339,19 +339,13 @@ describe('ContentModelFormatPlugin for default format', () => { expect(context).toEqual({ newPendingFormat: { fontFamily: 'Arial', - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, }, }); }); it('Expanded range, text input, under editor directly', () => { const plugin = createContentModelFormatPlugin({ - defaultFormat: { + defaultSegmentFormat: { fontFamily: 'Arial', }, }); @@ -404,7 +398,7 @@ describe('ContentModelFormatPlugin for default format', () => { it('Collapsed range, IME input, under editor directly', () => { const plugin = createContentModelFormatPlugin({ - defaultFormat: { + defaultSegmentFormat: { fontFamily: 'Arial', }, }); @@ -453,19 +447,13 @@ describe('ContentModelFormatPlugin for default format', () => { expect(context).toEqual({ newPendingFormat: { fontFamily: 'Arial', - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, }, }); }); it('Collapsed range, other input, under editor directly', () => { const plugin = createContentModelFormatPlugin({ - defaultFormat: { + defaultSegmentFormat: { fontFamily: 'Arial', }, }); @@ -516,7 +504,7 @@ describe('ContentModelFormatPlugin for default format', () => { it('Collapsed range, normal input, not under editor directly, no style', () => { const plugin = createContentModelFormatPlugin({ - defaultFormat: { + defaultSegmentFormat: { fontFamily: 'Arial', }, }); @@ -567,19 +555,13 @@ describe('ContentModelFormatPlugin for default format', () => { expect(context).toEqual({ newPendingFormat: { fontFamily: 'Arial', - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, }, }); }); it('Collapsed range, text input, under editor directly, has pending format', () => { const plugin = createContentModelFormatPlugin({ - defaultFormat: { + defaultSegmentFormat: { fontFamily: 'Arial', }, }); @@ -635,11 +617,6 @@ describe('ContentModelFormatPlugin for default format', () => { newPendingFormat: { fontFamily: 'Arial', fontSize: '10pt', - fontWeight: undefined, - italic: undefined, - underline: undefined, - textColor: undefined, - backgroundColor: undefined, }, }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts new file mode 100644 index 00000000000..b061a78ab19 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts @@ -0,0 +1,188 @@ +import { createLifecyclePlugin } from '../../lib/corePlugin/LifecyclePlugin'; +import { DarkColorHandler, IEditor, PluginEventType } from 'roosterjs-editor-types'; + +describe('LifecyclePlugin', () => { + it('init', () => { + const div = document.createElement('div'); + const plugin = createLifecyclePlugin({}, div); + const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + const state = plugin.getState(); + const setContentModelSpy = jasmine.createSpy('setContentModel'); + + plugin.initialize(({ + triggerPluginEvent, + setContent: (content: string) => (div.innerHTML = content), + getFocusedPosition: () => null, + getDarkColorHandler: () => null, + isDarkMode: () => false, + setContentModel: setContentModelSpy, + })); + + expect(state).toEqual({ + isDarkMode: false, + onExternalContentTransform: null, + shadowEditFragment: null, + }); + + expect(div.isContentEditable).toBeTrue(); + expect(div.style.userSelect).toBe('text'); + expect(div.innerHTML).toBe(''); + expect(triggerPluginEvent).toHaveBeenCalledTimes(1); + expect(triggerPluginEvent.calls.argsFor(0)[0]).toBe(PluginEventType.EditorReady); + expect(setContentModelSpy).toHaveBeenCalledTimes(1); + expect(setContentModelSpy).toHaveBeenCalledWith( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + }, + ], + }, + { ignoreSelection: true }, + undefined + ); + + plugin.dispose(); + expect(div.isContentEditable).toBeFalse(); + }); + + it('init with options', () => { + const mockedModel = 'MODEL' as any; + const div = document.createElement('div'); + const plugin = createLifecyclePlugin( + { + defaultSegmentFormat: { + fontFamily: 'arial', + }, + initialModel: mockedModel, + }, + div + ); + const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + const state = plugin.getState(); + const setContentModelSpy = jasmine.createSpy('setContentModel'); + + plugin.initialize(({ + triggerPluginEvent, + setContent: (content: string) => (div.innerHTML = content), + getFocusedPosition: () => null, + getDarkColorHandler: () => null, + isDarkMode: () => false, + setContentModel: setContentModelSpy, + })); + + expect(state).toEqual({ + isDarkMode: false, + onExternalContentTransform: null, + shadowEditFragment: null, + }); + + expect(setContentModelSpy).toHaveBeenCalledTimes(1); + expect(setContentModelSpy).toHaveBeenCalledWith( + mockedModel, + { ignoreSelection: true }, + undefined + ); + + expect(div.isContentEditable).toBeTrue(); + expect(div.style.userSelect).toBe('text'); + expect(triggerPluginEvent).toHaveBeenCalledTimes(1); + expect(triggerPluginEvent.calls.argsFor(0)[0]).toBe(PluginEventType.EditorReady); + + plugin.dispose(); + expect(div.isContentEditable).toBeFalse(); + }); + + it('init with DIV which already has contenteditable attribute', () => { + const div = document.createElement('div'); + div.contentEditable = 'true'; + const plugin = createLifecyclePlugin({}, div); + const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + const setContentModelSpy = jasmine.createSpy('setContentModel'); + + plugin.initialize(({ + triggerPluginEvent, + setContent: (content: string) => (div.innerHTML = content), + getFocusedPosition: () => null, + getDarkColorHandler: () => null, + isDarkMode: () => false, + setContentModel: setContentModelSpy, + })); + + expect(div.isContentEditable).toBeTrue(); + expect(div.style.userSelect).toBe(''); + expect(triggerPluginEvent).toHaveBeenCalledTimes(1); + expect(triggerPluginEvent.calls.argsFor(0)[0]).toBe(PluginEventType.EditorReady); + + expect(setContentModelSpy).toHaveBeenCalledTimes(1); + expect(setContentModelSpy).toHaveBeenCalledWith( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + }, + ], + }, + { ignoreSelection: true }, + undefined + ); + + plugin.dispose(); + expect(div.isContentEditable).toBeTrue(); + }); + + it('init with DIV which already has contenteditable attribute and set to false', () => { + const div = document.createElement('div'); + div.contentEditable = 'false'; + const plugin = createLifecyclePlugin({}, div); + const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + const setContentModelSpy = jasmine.createSpy('setContentModel'); + + plugin.initialize(({ + triggerPluginEvent, + setContent: (content: string) => (div.innerHTML = content), + getFocusedPosition: () => null, + getDarkColorHandler: () => null, + isDarkMode: () => false, + setContentModel: setContentModelSpy, + })); + + expect(setContentModelSpy).toHaveBeenCalledTimes(1); + expect(setContentModelSpy).toHaveBeenCalledWith( + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + }, + ], + }, + { ignoreSelection: true }, + undefined + ); + expect(div.isContentEditable).toBeFalse(); + expect(div.style.userSelect).toBe(''); + expect(triggerPluginEvent).toHaveBeenCalledTimes(1); + expect(triggerPluginEvent.calls.argsFor(0)[0]).toBe(PluginEventType.EditorReady); + + plugin.dispose(); + expect(div.isContentEditable).toBeFalse(); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts index 5a24444305f..185190e8289 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/color.ts @@ -30,7 +30,11 @@ export const DeprecatedColors: string[] = [ ]; /** - * @internal + * Get color from given HTML element + * @param element The element to get color from + * @param isBackground True to get background color, false to get text color + * @param darkColorHandler The dark color handler object to help manager dark mode color + * @param isDarkMode Whether element is in dark mode now */ export function getColor( element: HTMLElement, @@ -55,7 +59,12 @@ export function getColor( } /** - * @internal + * Set color to given HTML element + * @param element The element to set color to + * @param lightModeColor The color to set, always pass in color in light mode + * @param isBackground True to set background color, false to set text color + * @param darkColorHandler The dark color handler object to help manager dark mode color + * @param isDarkMode Whether element is in dark mode now */ export function setColor( element: HTMLElement, diff --git a/packages-content-model/roosterjs-content-model-dom/lib/index.ts b/packages-content-model/roosterjs-content-model-dom/lib/index.ts index a2264ed0050..95fa3b1024b 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -55,7 +55,7 @@ export { setParagraphNotImplicit } from './modelApi/block/setParagraphNotImplici export { parseValueWithUnit } from './formatHandlers/utils/parseValueWithUnit'; export { BorderKeys } from './formatHandlers/common/borderFormatHandler'; -export { DeprecatedColors } from './formatHandlers/utils/color'; +export { DeprecatedColors, getColor, setColor } from './formatHandlers/utils/color'; export { createDomToModelContext, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts index 40552de4afd..3980ae9ce54 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts @@ -1,6 +1,5 @@ import { ContentPosition, KnownCreateElementDataIndex, PositionType } from 'roosterjs-editor-types'; import { - applyFormat, createElement, createRange, findClosestElementAncestor, @@ -60,15 +59,6 @@ export const ensureTypeInContainer: EnsureTypeInContainer = (core, position, key position = new Position(formatNode, PositionType.Begin); } - if (formatNode && core.lifecycle.defaultFormat) { - applyFormat( - formatNode, - core.lifecycle.defaultFormat, - core.lifecycle.isDarkMode, - core.darkColorHandler - ); - } - // If this is triggered by a keyboard event, let's select the new position if (keyboardEvent) { core.api.selectRange(core, createRange(new Position(position))); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts index 346b0cf4664..622bbdbbc7b 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts @@ -33,13 +33,12 @@ export const getContent: GetContent = (core, mode): string => { clonedRoot.normalize(); const originalRange = core.api.getSelectionRange(core, true /*tryGetFromCache*/); - const path = !includeSelectionMarker - ? null - : core.lifecycle.shadowEditFragment - ? core.lifecycle.shadowEditSelectionPath - : originalRange - ? getSelectionPath(core.contentDiv, originalRange) - : null; + const path = + !includeSelectionMarker || core.lifecycle.shadowEditFragment + ? null + : originalRange + ? getSelectionPath(core.contentDiv, originalRange) + : null; const range = path && createRange(clonedRoot, path.start, path.end); core.api.transformColor( diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts index ee0ad25eaf6..89b91f22b74 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts @@ -1,4 +1,4 @@ -import { contains, createRange } from 'roosterjs-editor-dom'; +import { contains } from 'roosterjs-editor-dom'; import type { GetSelectionRange } from 'roosterjs-content-model-types'; /** @@ -12,15 +12,7 @@ export const getSelectionRange: GetSelectionRange = (core, tryGetFromCache: bool let result: Range | null = null; if (core.lifecycle.shadowEditFragment) { - result = - core.lifecycle.shadowEditSelectionPath && - createRange( - core.contentDiv, - core.lifecycle.shadowEditSelectionPath.start, - core.lifecycle.shadowEditSelectionPath.end - ); - - return result; + return null; } else { if (!tryGetFromCache || core.api.hasFocus(core)) { const selection = core.contentDiv.ownerDocument.defaultView?.getSelection(); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts index dae2e1ca849..687f2494f22 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts @@ -1,4 +1,4 @@ -import { contains, createRange, findClosestElementAncestor } from 'roosterjs-editor-dom'; +import { contains } from 'roosterjs-editor-dom'; import { SelectionRangeTypes } from 'roosterjs-editor-types'; import type { GetSelectionRangeEx } from 'roosterjs-content-model-types'; import type { SelectionRangeEx } from 'roosterjs-editor-types'; @@ -12,54 +12,7 @@ import type { SelectionRangeEx } from 'roosterjs-editor-types'; export const getSelectionRangeEx: GetSelectionRangeEx = core => { const result: SelectionRangeEx | null = null; if (core.lifecycle.shadowEditFragment) { - const { - shadowEditTableSelectionPath, - shadowEditSelectionPath, - shadowEditImageSelectionPath, - } = core.lifecycle; - - if ((shadowEditTableSelectionPath?.length || 0) > 0) { - const ranges = core.lifecycle.shadowEditTableSelectionPath!.map(path => - createRange(core.contentDiv, path.start, path.end) - ); - - return { - type: SelectionRangeTypes.TableSelection, - ranges, - areAllCollapsed: checkAllCollapsed(ranges), - table: findClosestElementAncestor( - ranges[0].startContainer, - core.contentDiv, - 'table' - ) as HTMLTableElement, - coordinates: undefined, - }; - } else if ((shadowEditImageSelectionPath?.length || 0) > 0) { - const ranges = core.lifecycle.shadowEditImageSelectionPath!.map(path => - createRange(core.contentDiv, path.start, path.end) - ); - return { - type: SelectionRangeTypes.ImageSelection, - ranges, - areAllCollapsed: checkAllCollapsed(ranges), - image: findClosestElementAncestor( - ranges[0].startContainer, - core.contentDiv, - 'img' - ) as HTMLImageElement, - imageId: undefined, - }; - } else { - const shadowRange = - shadowEditSelectionPath && - createRange( - core.contentDiv, - shadowEditSelectionPath.start, - shadowEditSelectionPath.end - ); - - return createNormalSelectionEx(shadowRange ? [shadowRange] : []); - } + return createNormalSelectionEx([]); } else { if (core.api.hasFocus(core)) { if (core.domEvent.tableSelectionRange) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts index adb720c958f..810f5b81dbc 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts @@ -1,4 +1,4 @@ -import { contains, addRangeToSelection } from 'roosterjs-editor-dom'; +import { addRangeToSelection, contains } from 'roosterjs-editor-dom'; import type { SelectRange } from 'roosterjs-content-model-types'; /** @@ -11,7 +11,7 @@ import type { SelectRange } from 'roosterjs-content-model-types'; * This parameter is always treat as true in Edge to avoid some weird runtime exception. */ export const selectRange: SelectRange = (core, range, skipSameRange) => { - if (!core.lifecycle.shadowEditSelectionPath && contains(core.contentDiv, range)) { + if (!core.lifecycle.shadowEditFragment && contains(core.contentDiv, range)) { addRangeToSelection(range, skipSameRange); if (!core.api.hasFocus(core)) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts index 571eb1c2ef7..49db9d90a6a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts @@ -78,7 +78,7 @@ export const setContent: SetContent = (core, content, triggerContentChangedEvent }; function selectContentMetadata(core: StandaloneEditorCore, metadata: ContentMetadata | undefined) { - if (!core.lifecycle.shadowEditSelectionPath && metadata) { + if (!core.lifecycle.shadowEditFragment && metadata) { core.domEvent.tableSelectionRange = null; core.domEvent.imageSelectionRange = null; core.domEvent.selectionRange = null; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts deleted file mode 100644 index b7af12db247..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/LifecyclePlugin.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { ChangeSource, PluginEventType } from 'roosterjs-editor-types'; -import { getObjectKeys, setColor } from 'roosterjs-editor-dom'; -import type { - IEditor, - LifecyclePluginState, - PluginWithState, - PluginEvent, -} from 'roosterjs-editor-types'; -import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; - -const CONTENT_EDITABLE_ATTRIBUTE_NAME = 'contenteditable'; - -const DARK_MODE_DEFAULT_FORMAT = { - backgroundColors: { - darkModeColor: 'rgb(51,51,51)', - lightModeColor: 'rgb(255,255,255)', - }, - textColors: { - darkModeColor: 'rgb(255,255,255)', - lightModeColor: 'rgb(0,0,0)', - }, -}; - -/** - * Lifecycle plugin handles editor initialization and disposing - */ -class LifecyclePlugin implements PluginWithState { - private editor: IEditor | null = null; - private state: LifecyclePluginState; - private initialContent: string; - private initializer: (() => void) | null = null; - private disposer: (() => void) | null = null; - private adjustColor: () => void; - - /** - * Construct a new instance of LifecyclePlugin - * @param options The editor options - * @param contentDiv The editor content DIV - */ - constructor(options: ContentModelEditorOptions, contentDiv: HTMLDivElement) { - this.initialContent = options.initialContent || contentDiv.innerHTML || ''; - - // Make the container editable and set its selection styles - if (contentDiv.getAttribute(CONTENT_EDITABLE_ATTRIBUTE_NAME) === null) { - this.initializer = () => { - contentDiv.contentEditable = 'true'; - contentDiv.style.userSelect = 'text'; - }; - this.disposer = () => { - contentDiv.style.userSelect = ''; - contentDiv.removeAttribute(CONTENT_EDITABLE_ATTRIBUTE_NAME); - }; - } - this.adjustColor = options.doNotAdjustEditorColor - ? () => {} - : () => { - const { textColors, backgroundColors } = DARK_MODE_DEFAULT_FORMAT; - const { isDarkMode } = this.state; - const darkColorHandler = this.editor?.getDarkColorHandler(); - setColor( - contentDiv, - textColors, - false /*isBackground*/, - isDarkMode, - false /*shouldAdaptFontColor*/, - darkColorHandler - ); - setColor( - contentDiv, - backgroundColors, - true /*isBackground*/, - isDarkMode, - false /*shouldAdaptFontColor*/, - darkColorHandler - ); - }; - - const getDarkColor = options.getDarkColor ?? ((color: string) => color); - const defaultFormat = options.defaultFormat ? { ...options.defaultFormat } : null; - - if (defaultFormat) { - if (defaultFormat.textColor && !defaultFormat.textColors) { - defaultFormat.textColors = { - lightModeColor: defaultFormat.textColor, - darkModeColor: getDarkColor(defaultFormat.textColor), - }; - delete defaultFormat.textColor; - } - - if (defaultFormat.backgroundColor && !defaultFormat.backgroundColors) { - defaultFormat.backgroundColors = { - lightModeColor: defaultFormat.backgroundColor, - darkModeColor: getDarkColor(defaultFormat.backgroundColor), - }; - delete defaultFormat.backgroundColor; - } - } - - this.state = { - customData: {}, - defaultFormat, - isDarkMode: !!options.inDarkMode, - getDarkColor, - onExternalContentTransform: null, - experimentalFeatures: options.experimentalFeatures || [], - shadowEditFragment: null, - shadowEditEntities: null, - shadowEditSelectionPath: null, - shadowEditTableSelectionPath: null, - shadowEditImageSelectionPath: null, - }; - } - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'Lifecycle'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - - // Ensure initial content and its format - this.editor.setContent(this.initialContent, false /*triggerContentChangedEvent*/); - - // Set content DIV to be editable - this.initializer?.(); - - // Set editor background color for dark mode - this.adjustColor(); - - // Let other plugins know that we are ready - this.editor.triggerPluginEvent(PluginEventType.EditorReady, {}, true /*broadcast*/); - } - - /** - * Dispose this plugin - */ - dispose() { - this.editor?.triggerPluginEvent(PluginEventType.BeforeDispose, {}, true /*broadcast*/); - - getObjectKeys(this.state.customData).forEach(key => { - const data = this.state.customData[key]; - - if (data && data.disposer) { - data.disposer(data.value); - } - - delete this.state.customData[key]; - }); - - if (this.disposer) { - this.disposer(); - this.disposer = null; - this.initializer = null; - } - - this.editor = null; - } - - /** - * Get plugin state object - */ - getState() { - return this.state; - } - - /** - * Handle events triggered from editor - * @param event PluginEvent object - */ - onPluginEvent(event: PluginEvent) { - if ( - event.eventType == PluginEventType.ContentChanged && - (event.source == ChangeSource.SwitchToDarkMode || - event.source == ChangeSource.SwitchToLightMode) - ) { - this.state.isDarkMode = event.source == ChangeSource.SwitchToDarkMode; - this.adjustColor(); - } - } -} - -/** - * @internal - * Create a new instance of LifecyclePlugin. - * @param option The editor option - * @param contentDiv The editor content DIV element - */ -export function createLifecyclePlugin( - option: ContentModelEditorOptions, - contentDiv: HTMLDivElement -): PluginWithState { - return new LifecyclePlugin(option, contentDiv); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts index 216b3d23fdd..3adb8e549af 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts @@ -1,42 +1,28 @@ import { createEditPlugin } from './EditPlugin'; import { createEntityPlugin } from './EntityPlugin'; import { createImageSelection } from './ImageSelection'; -import { createLifecyclePlugin } from './LifecyclePlugin'; import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; import { createUndoPlugin } from './UndoPlugin'; import type { UnportedCorePlugins } from '../publicTypes/ContentModelCorePlugins'; import type { UnportedCorePluginState } from 'roosterjs-content-model-types'; import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; -/** - * @internal - */ -export interface CreateCorePluginResponse extends UnportedCorePlugins { - _placeholder: null; -} - /** * @internal * Create Core Plugins - * @param contentDiv Content DIV of editor * @param options Editor options */ -export function createCorePlugins( - contentDiv: HTMLDivElement, - options: ContentModelEditorOptions -): CreateCorePluginResponse { +export function createCorePlugins(options: ContentModelEditorOptions): UnportedCorePlugins { const map = options.corePluginOverride || {}; // The order matters, some plugin needs to be put before/after others to make sure event // can be handled in right order return { edit: map.edit || createEditPlugin(), - _placeholder: null, undo: map.undo || createUndoPlugin(options), entity: map.entity || createEntityPlugin(), imageSelection: map.imageSelection || createImageSelection(), normalizeTable: map.normalizeTable || createNormalizeTablePlugin(), - lifecycle: map.lifecycle || createLifecyclePlugin(options, contentDiv), }; } @@ -48,7 +34,6 @@ export function createCorePlugins( export function getPluginState(corePlugins: UnportedCorePlugins): UnportedCorePluginState { return { edit: corePlugins.edit.getState(), - lifecycle: corePlugins.lifecycle.getState(), undo: corePlugins.undo.getState(), entity: corePlugins.entity.getState(), }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index e9bf1d56ff0..457264905e4 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -1,13 +1,13 @@ import { createEditorCore } from './createEditorCore'; +import { getObjectKeys } from 'roosterjs-content-model-dom'; import { getPendableFormatState } from './utils/getPendableFormatState'; -import { paste } from 'roosterjs-content-model-core'; +import { isBold, paste } from 'roosterjs-content-model-core'; import { ChangeSource, ColorTransformDirection, ContentPosition, GetContentMode, PluginEventType, - PositionType, QueryScope, RegionType, } from 'roosterjs-editor-types'; @@ -29,6 +29,7 @@ import type { PluginEvent, PluginEventData, PluginEventFromType, + PositionType, Rect, Region, SelectionPath, @@ -98,16 +99,6 @@ export class ContentModelEditor implements IContentModelEditor { constructor(contentDiv: HTMLDivElement, options: ContentModelEditorOptions = {}) { this.core = createEditorCore(contentDiv, options); this.core.plugins.forEach(plugin => plugin.initialize(this)); - this.ensureTypeInContainer( - new Position(this.core.contentDiv, PositionType.Begin).normalize() - ); - - if (options.cacheModel) { - // Create an initial content model to cache - // TODO: Once we have standalone editor and get rid of `ensureTypeInContainer` function, we can set init content - // using content model and cache the model directly - this.createContentModel(); - } } /** @@ -207,6 +198,16 @@ export class ContentModelEditor implements IContentModelEditor { } } + getObjectKeys(core.customData).forEach(key => { + const data = core.customData[key]; + + if (data && data.disposer) { + data.disposer(data.value); + } + + delete core.customData[key]; + }); + core.darkColorHandler.reset(); this.core = null; @@ -715,7 +716,7 @@ export class ContentModelEditor implements IContentModelEditor { */ getCustomData(key: string, getter?: () => T, disposer?: (value: T) => void): T { const core = this.getCore(); - return (core.lifecycle.customData[key] = core.lifecycle.customData[key] || { + return (core.customData[key] = core.customData[key] || { value: getter ? getter() : undefined, disposer, }).value as T; @@ -734,7 +735,17 @@ export class ContentModelEditor implements IContentModelEditor { * @returns Default format object of this editor */ getDefaultFormat(): DefaultFormat { - return this.getCore().lifecycle.defaultFormat ?? {}; + const format = this.getCore().format.defaultFormat; + + return { + bold: isBold(format.fontWeight), + italic: format.italic, + underline: format.underline, + fontFamily: format.fontFamily, + fontSize: format.fontSize, + textColor: format.textColor, + backgroundColor: format.backgroundColor, + }; } /** @@ -1015,7 +1026,7 @@ export class ContentModelEditor implements IContentModelEditor { * @param feature The feature to check */ isFeatureEnabled(feature: ExperimentalFeatures | CompatibleExperimentalFeatures): boolean { - return this.getCore().lifecycle.experimentalFeatures.indexOf(feature) >= 0; + return this.getCore().experimentalFeatures.indexOf(feature) >= 0; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts index d8fa0d0b6b4..bb500a2c2cc 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts @@ -1,9 +1,9 @@ import { coreApiMap } from '../coreApi/coreApiMap'; import { createCorePlugins, getPluginState } from '../corePlugins/createCorePlugins'; -import { createStandaloneEditorCore } from 'roosterjs-content-model-core'; -import { getObjectKeys } from 'roosterjs-content-model-dom'; +import { createModelFromHtml, createStandaloneEditorCore } from 'roosterjs-content-model-core'; import type { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; +import type { EditorPlugin } from 'roosterjs-editor-types'; /** * @internal @@ -15,16 +15,34 @@ export function createEditorCore( contentDiv: HTMLDivElement, options: ContentModelEditorOptions ): ContentModelEditorCore { - const corePlugins = createCorePlugins(contentDiv, options); + const corePlugins = createCorePlugins(options); const pluginState = getPluginState(corePlugins); + const additionalPlugins: EditorPlugin[] = [ + corePlugins.edit, + ...(options.plugins ?? []), + corePlugins.undo, + corePlugins.entity, + corePlugins.imageSelection, + corePlugins.normalizeTable, + ].filter(x => !!x); const zoomScale: number = (options.zoomScale ?? -1) > 0 ? options.zoomScale! : 1; + const initContent = options.initialContent ?? contentDiv.innerHTML; + + if (initContent && !options.initialModel) { + options.initialModel = createModelFromHtml( + initContent, + options.defaultDomToModelOptions, + options.trustedHTMLHandler + ); + } const standaloneEditorCore = createStandaloneEditorCore( contentDiv, options, coreApiMap, - pluginState + pluginState, + additionalPlugins ); const core: ContentModelEditorCore = { @@ -33,17 +51,9 @@ export function createEditorCore( zoomScale: zoomScale, sizeTransformer: (size: number) => size / zoomScale, disposeErrorHandler: options.disposeErrorHandler, + customData: {}, + experimentalFeatures: options.experimentalFeatures ?? [], }; - getObjectKeys(corePlugins).forEach(name => { - if (name == '_placeholder') { - if (options.plugins) { - core.plugins.push(...options.plugins.filter(x => !!x)); - } - } else if (corePlugins[name]) { - core.plugins.push(corePlugins[name]); - } - }); - return core; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts index 4c8cd1948a4..1bc63f1bd74 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts @@ -3,7 +3,6 @@ import type { EditPluginState, EditorPlugin, EntityPluginState, - LifecyclePluginState, PluginWithState, UndoPluginState, } from 'roosterjs-editor-types'; @@ -39,11 +38,6 @@ export interface UnportedCorePlugins { * NormalizeTable plugin makes sure each table in editor has TBODY/THEAD/TFOOT tag around TR tags */ readonly normalizeTable: EditorPlugin; - - /** - * Lifecycle plugin handles editor initialization and disposing - */ - readonly lifecycle: PluginWithState; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts index b6407bea00d..11f1707122f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts @@ -1,4 +1,10 @@ -import type { EditorPlugin, SizeTransformer } from 'roosterjs-editor-types'; +import type { CompatibleExperimentalFeatures } from 'roosterjs-editor-types/lib/compatibleTypes'; +import type { + CustomData, + EditorPlugin, + ExperimentalFeatures, + SizeTransformer, +} from 'roosterjs-editor-types'; import type { StandaloneCoreApiMap, StandaloneEditorCore } from 'roosterjs-content-model-types'; /** @@ -33,4 +39,14 @@ export interface ContentModelEditorCore extends StandaloneEditorCore { * @param error The error object we got */ disposeErrorHandler?: (plugin: EditorPlugin, error: Error) => void; + + /** + * Custom data of this editor + */ + customData: Record; + + /** + * Enabled experimental features + */ + experimentalFeatures: (ExperimentalFeatures | CompatibleExperimentalFeatures)[]; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index 34e8bb630e1..e248d13b966 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -1,6 +1,5 @@ import type { ContentModelCorePlugins } from './ContentModelCorePlugins'; import type { - DefaultFormat, EditorPlugin, ExperimentalFeatures, IEditor, @@ -19,13 +18,6 @@ export interface IContentModelEditor extends IEditor, IStandaloneEditor {} * Options for Content Model editor */ export interface ContentModelEditorOptions extends StandaloneEditorOptions { - /** - * Default format of editor content. This will be applied to empty content. - * If there is already content inside editor, format of existing content will not be changed. - * Default value is the computed style of editor content DIV - */ - defaultFormat?: DefaultFormat; - /** * Undo snapshot service based on content metadata. Use this parameter to customize the undo snapshot service. * When this property is set, value of undoSnapshotService will be ignored. @@ -44,22 +36,6 @@ export interface ContentModelEditorOptions extends StandaloneEditorOptions { */ corePluginOverride?: Partial; - /** - * If the editor is currently in dark mode - */ - inDarkMode?: boolean; - - /** - * A util function to transform light mode color to dark mode color - * Default value is to return the original light color - */ - getDarkColor?: (lightColor: string) => string; - - /** - * Whether to skip the adjust editor process when for light/dark mode - */ - doNotAdjustEditorColor?: boolean; - /** * Specify the enabled experimental features */ diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts index d6bec9c55c0..f615351421e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -25,9 +25,14 @@ describe('ContentModelEditor', () => { spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue(mockedConfig); const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - - spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); + const editor = new ContentModelEditor(div, { + coreApiOverride: { + createEditorContext: jasmine + .createSpy('createEditorContext') + .and.returnValue(editorContext), + setContentModel: jasmine.createSpy('setContentModel'), + }, + }); const model = editor.createContentModel(); @@ -56,9 +61,14 @@ describe('ContentModelEditor', () => { spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue(mockedConfig); const div = document.createElement('div'); - const editor = new ContentModelEditor(div); - - spyOn((editor as any).core.api, 'createEditorContext').and.returnValue(editorContext); + const editor = new ContentModelEditor(div, { + coreApiOverride: { + createEditorContext: jasmine + .createSpy('createEditorContext') + .and.returnValue(editorContext), + setContentModel: jasmine.createSpy('setContentModel'), + }, + }); const model = editor.createContentModel(); @@ -97,7 +107,7 @@ describe('ContentModelEditor', () => { const selection = editor.setContentModel(mockedModel); - expect(contentModelToDom.contentModelToDom).toHaveBeenCalledTimes(1); + expect(contentModelToDom.contentModelToDom).toHaveBeenCalledTimes(2); expect(contentModelToDom.contentModelToDom).toHaveBeenCalledWith( document, div, @@ -134,7 +144,7 @@ describe('ContentModelEditor', () => { const selection = editor.setContentModel(mockedModel); - expect(contentModelToDom.contentModelToDom).toHaveBeenCalledTimes(1); + expect(contentModelToDom.contentModelToDom).toHaveBeenCalledTimes(2); expect(contentModelToDom.contentModelToDom).toHaveBeenCalledWith( document, div, @@ -175,16 +185,24 @@ describe('ContentModelEditor', () => { expect(model).toEqual({ blockGroupType: 'Document', - blocks: [], - format: { - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, - }, + blocks: [ + { + blockType: 'Paragraph', + format: {}, + segments: [ + { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + cachedElement: jasmine.anything(), + }, + ], }); }); @@ -219,20 +237,14 @@ describe('ContentModelEditor', () => { it('default format', () => { const div = document.createElement('div'); const editor = new ContentModelEditor(div, { - defaultFormat: { - bold: true, + defaultSegmentFormat: { + fontWeight: 'bold', italic: true, underline: true, fontFamily: 'Arial', fontSize: '10pt', - textColors: { - lightModeColor: 'black', - darkModeColor: 'white', - }, - backgroundColors: { - lightModeColor: 'white', - darkModeColor: 'black', - }, + textColor: 'black', + backgroundColor: 'white', }, }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts index 7d6ab21182b..0d38370a6e9 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts @@ -6,7 +6,7 @@ import * as DOMEventPlugin from 'roosterjs-content-model-core/lib/corePlugin/DOM import * as EditPlugin from '../../lib/corePlugins/EditPlugin'; import * as EntityPlugin from '../../lib/corePlugins/EntityPlugin'; import * as ImageSelection from '../../lib/corePlugins/ImageSelection'; -import * as LifecyclePlugin from '../../lib/corePlugins/LifecyclePlugin'; +import * as LifecyclePlugin from 'roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin'; import * as NormalizeTablePlugin from '../../lib/corePlugins/NormalizeTablePlugin'; import * as UndoPlugin from '../../lib/corePlugins/UndoPlugin'; import { coreApiMap } from '../../lib/coreApi/coreApiMap'; @@ -123,6 +123,8 @@ describe('createEditorCore', () => { isAndroid: false, isSafari: false, }, + customData: {}, + experimentalFeatures: [], }); }); @@ -176,6 +178,8 @@ describe('createEditorCore', () => { isAndroid: false, isSafari: false, }, + customData: {}, + experimentalFeatures: [], }); }); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts index dfd1cc6ad7b..a2a0064cb7d 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromExcelTest.ts @@ -82,15 +82,7 @@ describe(ID, () => { format: {}, }, ], - format: { - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, - }, + format: {}, }); expect(processPastedContentFromExcel.processPastedContentFromExcel).not.toHaveBeenCalled(); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts index ba61bd45854..d0e5ecd4909 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/e2e/cmPasteFromWordTest.ts @@ -96,15 +96,7 @@ describe(ID, () => { decorator: { tagName: 'p', format: {} }, }, ], - format: { - fontWeight: undefined, - italic: undefined, - underline: undefined, - fontFamily: undefined, - fontSize: undefined, - textColor: undefined, - backgroundColor: undefined, - }, + format: {}, }); }); diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts index 72199d2578e..ca099dd503f 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts @@ -1,3 +1,4 @@ +import type { LifecyclePluginState } from '../pluginState/LifecyclePluginState'; import type { DOMEventPluginState } from '../pluginState/DOMEventPluginState'; import type { ContentModelCachePluginState } from '../pluginState/ContentModelCachePluginState'; import type { ContentModelFormatPluginState } from '../pluginState/ContentModelFormatPluginState'; @@ -26,4 +27,9 @@ export interface StandaloneEditorCorePlugins { * DomEvent plugin helps handle additional DOM events such as IME composition, cut, drop. */ readonly domEvent: PluginWithState; + + /** + * Lifecycle plugin handles editor initialization and disposing + */ + readonly lifecycle: PluginWithState; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts index b82b5056f09..6aeb08701ef 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts @@ -1,7 +1,9 @@ +import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; import type { StandaloneCoreApiMap } from './StandaloneEditorCore'; -import type { DefaultFormat, EditorPlugin, TrustedHTMLHandler } from 'roosterjs-editor-types'; +import type { EditorPlugin, TrustedHTMLHandler } from 'roosterjs-editor-types'; import type { DomToModelOption } from '../context/DomToModelOption'; import type { ModelToDomOption } from '../context/ModelToDomOption'; +import type { ContentModelDocument } from '../group/ContentModelDocument'; /** * Options for Content Model editor @@ -35,7 +37,7 @@ export interface StandaloneEditorOptions { * If there is already content inside editor, format of existing content will not be changed. * Default value is the computed style of editor content DIV */ - defaultFormat?: DefaultFormat; + defaultSegmentFormat?: ContentModelSegmentFormat; /** * Allowed custom content type when paste besides text/plain, text/html and images @@ -72,4 +74,19 @@ export interface StandaloneEditorOptions { * Color of the border of a selectedImage. Default color: '#DB626C' */ imageSelectionBorderColor?: string; + + /** + * Initial Content Model + */ + initialModel?: ContentModelDocument; + + /** + * Whether to skip the adjust editor process when for light/dark mode + */ + doNotAdjustEditorColor?: boolean; + + /** + * If the editor is currently in dark mode + */ + inDarkMode?: boolean; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index 0c9d0ce1754..6156a186799 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -240,6 +240,7 @@ export { PendingFormat, } from './pluginState/ContentModelFormatPluginState'; export { DOMEventPluginState } from './pluginState/DOMEventPluginState'; +export { LifecyclePluginState } from './pluginState/LifecyclePluginState'; export { EditorEnvironment } from './parameter/EditorEnvironment'; export { diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts new file mode 100644 index 00000000000..7deb442085f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts @@ -0,0 +1,28 @@ +import type { DarkColorHandler } from 'roosterjs-editor-types'; + +/** + * The state object for LifecyclePlugin + */ +export interface LifecyclePluginState { + /** + * Whether editor is in dark mode + */ + isDarkMode: boolean; + + /** + * Cached document fragment for original content + */ + shadowEditFragment: DocumentFragment | null; + + /** + * External content transform function to help do color transform for existing content + */ + onExternalContentTransform: + | (( + element: HTMLElement, + fromDarkMode: boolean, + toDarkMode: boolean, + darkColorHandler: DarkColorHandler + ) => void) + | null; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts index fdf29ed64fd..f205b35a1e7 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts @@ -2,12 +2,12 @@ import type { CopyPastePluginState, EditPluginState, EntityPluginState, - LifecyclePluginState, UndoPluginState, } from 'roosterjs-editor-types'; import type { ContentModelCachePluginState } from './ContentModelCachePluginState'; import type { ContentModelFormatPluginState } from './ContentModelFormatPluginState'; import type { DOMEventPluginState } from './DOMEventPluginState'; +import type { LifecyclePluginState } from './LifecyclePluginState'; /** * Temporary core plugin state for Content Model editor (ported part) @@ -33,6 +33,11 @@ export interface StandaloneEditorCorePluginState { * Plugin state for DOMEventPlugin */ domEvent: DOMEventPluginState; + + /** + * Plugin state for LifecyclePlugin + */ + lifecycle: LifecyclePluginState; } /** @@ -40,7 +45,6 @@ export interface StandaloneEditorCorePluginState { * TODO: Port these plugins */ export interface UnportedCorePluginState { - lifecycle: LifecyclePluginState; entity: EntityPluginState; undo: UndoPluginState; edit: EditPluginState; diff --git a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts index 821d628f9e6..d8ebc205f48 100644 --- a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts @@ -1,6 +1,5 @@ import { ContentModelEditor } from 'roosterjs-content-model-editor'; import { ContentModelEditPlugin, ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; -import { getDarkColor } from 'roosterjs-color-utils'; import type { EditorPlugin } from 'roosterjs-editor-types'; import type { ContentModelEditorOptions, @@ -26,8 +25,7 @@ export function createContentModelEditor( const options: ContentModelEditorOptions = { plugins: plugins, initialContent: initialContent, - getDarkColor: getDarkColor, - defaultFormat: { + defaultSegmentFormat: { fontFamily: 'Calibri,Arial,Helvetica,sans-serif', fontSize: '11pt', textColor: '#000000', diff --git a/packages-content-model/roosterjs-content-model/package.json b/packages-content-model/roosterjs-content-model/package.json index 698c3df6db1..76bfe86013d 100644 --- a/packages-content-model/roosterjs-content-model/package.json +++ b/packages-content-model/roosterjs-content-model/package.json @@ -9,8 +9,7 @@ "roosterjs-content-model-core": "", "roosterjs-content-model-api": "", "roosterjs-content-model-editor": "", - "roosterjs-content-model-plugins": "", - "roosterjs-color-utils": "" + "roosterjs-content-model-plugins": "" }, "version": "0.0.0", "main": "./lib/index.ts" From 6264256e6cf5eb97ee5686bf31221ee045aa0ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 22 Nov 2023 18:48:43 -0300 Subject: [PATCH 055/111] select on click --- .../lib/corePlugins/ImageSelection.ts | 3 +- .../test/corePlugins/imageSelectionTest.ts | 58 ++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts b/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts index 0041b247fc9..153602d4b31 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/ImageSelection.ts @@ -43,7 +43,8 @@ export default class ImageSelection implements EditorPlugin { if ( safeInstanceOf(target, 'HTMLImageElement') && target.isContentEditable && - event.rawEvent.button != mouseMiddleButton + event.rawEvent.button != mouseMiddleButton && + event.isClicking ) { this.editor.select(target); } diff --git a/packages/roosterjs-editor-core/test/corePlugins/imageSelectionTest.ts b/packages/roosterjs-editor-core/test/corePlugins/imageSelectionTest.ts index 60331218a62..d134c723571 100644 --- a/packages/roosterjs-editor-core/test/corePlugins/imageSelectionTest.ts +++ b/packages/roosterjs-editor-core/test/corePlugins/imageSelectionTest.ts @@ -7,6 +7,8 @@ import { ImageSelectionRange, PluginEvent, PluginEventType, + PluginMouseUpEvent, + PluginMouseDownEvent, } from 'roosterjs-editor-types'; export * from 'roosterjs-editor-dom/test/DomTestHelper'; @@ -58,8 +60,8 @@ describe('ImageSelectionPlugin |', () => { editor.setContent(``); const target = document.getElementById(imageId); editorIsFeatureEnabled.and.returnValue(true); - simulateMouseEvent('mousedown', target!, 0); - simulateMouseEvent('mouseup', target!, 0); + imageSelection.onPluginEvent(mouseDown(target!, 0)); + imageSelection.onPluginEvent(mouseup(target!, 0, true)); editor.focus(); const selection = editor.getSelectionRangeEx(); @@ -67,6 +69,19 @@ describe('ImageSelectionPlugin |', () => { expect(selection.areAllCollapsed).toBe(false); }); + it('should not be triggered in mouse up left click', () => { + editor.setContent(``); + const target = document.getElementById(imageId); + editorIsFeatureEnabled.and.returnValue(true); + imageSelection.onPluginEvent(mouseDown(target!, 0)); + imageSelection.onPluginEvent(mouseup(target!, 0, false)); + editor.focus(); + + const selection = editor.getSelectionRangeEx(); + expect(selection.type).toBe(SelectionRangeTypes.Normal); + expect(selection.areAllCollapsed).toBe(true); + }); + it('should handle a ESCAPE KEY in a image', () => { editor.setContent(``); const target = document.getElementById(imageId); @@ -204,6 +219,45 @@ describe('ImageSelectionPlugin |', () => { }; }; + const mouseup = ( + target: HTMLElement, + keyNumber: number, + isClicking: boolean + ): PluginMouseUpEvent => { + const rect = target.getBoundingClientRect(); + return { + eventType: PluginEventType.MouseUp, + rawEvent: { + target: target, + view: window, + bubbles: true, + cancelable: true, + clientX: rect.left, + clientY: rect.top, + shiftKey: false, + button: keyNumber, + }, + isClicking, + }; + }; + + const mouseDown = (target: HTMLElement, keyNumber: number): PluginMouseDownEvent => { + const rect = target.getBoundingClientRect(); + return { + eventType: PluginEventType.MouseDown, + rawEvent: { + target: target, + view: window, + bubbles: true, + cancelable: true, + clientX: rect.left, + clientY: rect.top, + shiftKey: false, + button: keyNumber, + }, + }; + }; + function simulateMouseEvent(mouseEvent: string, target: HTMLElement, keyNumber: number) { const rect = target.getBoundingClientRect(); var event = new MouseEvent(mouseEvent, { From 006fcae997ab97fc0c8f1baf0903afa67319ae46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 22 Nov 2023 18:54:38 -0300 Subject: [PATCH 056/111] test --- .../test/corePlugins/imageSelectionTest.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/packages/roosterjs-editor-core/test/corePlugins/imageSelectionTest.ts b/packages/roosterjs-editor-core/test/corePlugins/imageSelectionTest.ts index d134c723571..c5ac7e4e940 100644 --- a/packages/roosterjs-editor-core/test/corePlugins/imageSelectionTest.ts +++ b/packages/roosterjs-editor-core/test/corePlugins/imageSelectionTest.ts @@ -257,18 +257,4 @@ describe('ImageSelectionPlugin |', () => { }, }; }; - - function simulateMouseEvent(mouseEvent: string, target: HTMLElement, keyNumber: number) { - const rect = target.getBoundingClientRect(); - var event = new MouseEvent(mouseEvent, { - view: window, - bubbles: true, - cancelable: true, - clientX: rect.left, - clientY: rect.top, - shiftKey: false, - button: keyNumber, - }); - target.dispatchEvent(event); - } }); From 1e5c3bddcfd455afcd195b92c646741ca272e227 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 22 Nov 2023 20:29:16 -0800 Subject: [PATCH 057/111] Standalone Editor: Support keyboard input (init step) (#2221) --- .../lib/edit/ContentModelEditPlugin.ts | 6 + .../lib/edit/keyboardDelete.ts | 35 +- .../lib/edit/keyboardInput.ts | 48 +++ .../test/edit/ContentModelEditPluginTest.ts | 37 +- .../test/edit/keyboardDeleteTest.ts | 6 +- .../test/edit/keyboardInputTest.ts | 327 ++++++++++++++++++ 6 files changed, 414 insertions(+), 45 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts create mode 100644 packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts index 02d347a71b0..0581a9d4b39 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/ContentModelEditPlugin.ts @@ -1,4 +1,5 @@ import { keyboardDelete } from './keyboardDelete'; +import { keyboardInput } from './keyboardInput'; import { PluginEventType } from 'roosterjs-editor-types'; import type { IContentModelEditor } from 'roosterjs-content-model-editor'; import type { @@ -72,6 +73,11 @@ export class ContentModelEditPlugin implements EditorPlugin { // No need to clear cache here since if we rely on browser's behavior, there will be Input event and its handler will reconcile cache keyboardDelete(editor, rawEvent); break; + + case 'Enter': + default: + keyboardInput(editor, rawEvent); + break; } } } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts index 8c8a279edd3..7ba0ab3649c 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardDelete.ts @@ -15,21 +15,18 @@ import { forwardDeleteCollapsedSelection, } from './deleteSteps/deleteCollapsedSelection'; import type { IContentModelEditor } from 'roosterjs-content-model-editor'; -import type { DeleteSelectionStep } from 'roosterjs-content-model-types'; +import type { DOMSelection, DeleteSelectionStep } from 'roosterjs-content-model-types'; /** * @internal * Do keyboard event handling for DELETE/BACKSPACE key * @param editor The Content Model Editor * @param rawEvent DOM keyboard event - * @returns True if the event is handled with this function, otherwise false */ -export function keyboardDelete(editor: IContentModelEditor, rawEvent: KeyboardEvent): boolean { +export function keyboardDelete(editor: IContentModelEditor, rawEvent: KeyboardEvent) { const selection = editor.getDOMSelection(); - const range = selection?.type == 'range' ? selection.range : null; - let isDeleted = false; - if (shouldDeleteWithContentModel(range, rawEvent)) { + if (shouldDeleteWithContentModel(selection, rawEvent)) { editor.formatContentModel( (model, context) => { const result = deleteSelection( @@ -38,8 +35,6 @@ export function keyboardDelete(editor: IContentModelEditor, rawEvent: KeyboardEv context ).deleteResult; - isDeleted = result != 'notDeleted'; - return handleKeyboardEventResult(editor, model, rawEvent, result, context); }, { @@ -52,8 +47,6 @@ export function keyboardDelete(editor: IContentModelEditor, rawEvent: KeyboardEv return true; } - - return isDeleted; } function getDeleteSteps(rawEvent: KeyboardEvent, isMac: boolean): (DeleteSelectionStep | null)[] { @@ -71,13 +64,21 @@ function getDeleteSteps(rawEvent: KeyboardEvent, isMac: boolean): (DeleteSelecti return [deleteAllSegmentBeforeStep, deleteWordSelection, deleteCollapsedSelection]; } -function shouldDeleteWithContentModel(range: Range | null, rawEvent: KeyboardEvent) { - return !( - range?.collapsed && - isNodeOfType(range.startContainer, 'TEXT_NODE') && - !isModifierKey(rawEvent) && - (canDeleteBefore(rawEvent, range) || canDeleteAfter(rawEvent, range)) - ); +function shouldDeleteWithContentModel(selection: DOMSelection | null, rawEvent: KeyboardEvent) { + if (!selection) { + return false; // Nothing to delete + } else if (selection.type != 'range' || !selection.range.collapsed) { + return true; // Selection is not collapsed, need to delete all selections + } else { + const range = selection.range; + + // When selection is collapsed and is in middle of text node, no need to use Content Model to delete + return !( + isNodeOfType(range.startContainer, 'TEXT_NODE') && + !isModifierKey(rawEvent) && + (canDeleteBefore(rawEvent, range) || canDeleteAfter(rawEvent, range)) + ); + } } function canDeleteBefore(rawEvent: KeyboardEvent, range: Range) { diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts new file mode 100644 index 00000000000..7f56900266b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -0,0 +1,48 @@ +import { deleteSelection, isModifierKey } from 'roosterjs-content-model-core'; +import type { IContentModelEditor } from 'roosterjs-content-model-editor'; +import type { DOMSelection } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function keyboardInput(editor: IContentModelEditor, rawEvent: KeyboardEvent) { + const selection = editor.getDOMSelection(); + + if (shouldInputWithContentModel(selection, rawEvent)) { + editor.addUndoSnapshot(); + + editor.formatContentModel( + (model, context) => { + const result = deleteSelection(model, [], context).deleteResult; + + // We have deleted selection then we will let browser to handle the input. + // With this combined operation, we don't wan to mass up the cached model so clear it + context.clearModelCache = true; + + // Skip undo snapshot here and add undo snapshot before the operation so that we don't add another undo snapshot in middle of this replace operation + context.skipUndoSnapshot = true; + + // Do not preventDefault since we still want browser to handle the final input for now + return result == 'range'; + }, + { + rawEvent, + } + ); + + return true; + } +} + +function shouldInputWithContentModel(selection: DOMSelection | null, rawEvent: KeyboardEvent) { + if (!selection) { + return false; // Nothing to delete + } else if ( + !isModifierKey(rawEvent) && + (rawEvent.key == 'Enter' || rawEvent.key == 'Space' || rawEvent.key.length == 1) + ) { + return selection.type != 'range' || !selection.range.collapsed; // TODO: Also handle Enter key even selection is collapsed + } else { + return false; + } +} diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts index e793fbd99e4..2522eed5b24 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/ContentModelEditPluginTest.ts @@ -1,4 +1,5 @@ import * as keyboardDelete from '../../lib/edit/keyboardDelete'; +import * as keyboardInput from '../../lib/edit/keyboardInput'; import { ContentModelEditPlugin } from '../../lib/edit/ContentModelEditPlugin'; import { EntityOperation, PluginEventType } from 'roosterjs-editor-types'; import { IContentModelEditor } from 'roosterjs-content-model-editor'; @@ -17,9 +18,11 @@ describe('ContentModelEditPlugin', () => { describe('onPluginEvent', () => { let keyboardDeleteSpy: jasmine.Spy; + let keyboardInputSpy: jasmine.Spy; beforeEach(() => { - keyboardDeleteSpy = spyOn(keyboardDelete, 'keyboardDelete').and.returnValue(true); + keyboardDeleteSpy = spyOn(keyboardDelete, 'keyboardDelete'); + keyboardInputSpy = spyOn(keyboardInput, 'keyboardInput'); }); it('Backspace', () => { @@ -34,6 +37,7 @@ describe('ContentModelEditPlugin', () => { }); expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardInputSpy).not.toHaveBeenCalled(); }); it('Delete', () => { @@ -48,11 +52,15 @@ describe('ContentModelEditPlugin', () => { }); expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, rawEvent); + expect(keyboardInputSpy).not.toHaveBeenCalled(); }); it('Other key', () => { const plugin = new ContentModelEditPlugin(); - const rawEvent = { which: 41 } as any; + const rawEvent = { which: 41, key: 'A' } as any; + const addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + + editor.addUndoSnapshot = addUndoSnapshotSpy; plugin.initialize(editor); @@ -62,6 +70,7 @@ describe('ContentModelEditPlugin', () => { }); expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardInputSpy).toHaveBeenCalledWith(editor, rawEvent); }); it('Default prevented', () => { @@ -75,6 +84,7 @@ describe('ContentModelEditPlugin', () => { }); expect(keyboardDeleteSpy).not.toHaveBeenCalled(); + expect(keyboardInputSpy).not.toHaveBeenCalled(); }); it('Trigger entity event first', () => { @@ -110,28 +120,7 @@ describe('ContentModelEditPlugin', () => { expect(keyboardDeleteSpy).toHaveBeenCalledWith(editor, { key: 'Delete', } as any); - }); - - it('SelectionChanged event should clear cached model', () => { - const plugin = new ContentModelEditPlugin(); - - plugin.initialize(editor); - plugin.onPluginEvent({ - eventType: PluginEventType.SelectionChanged, - selectionRangeEx: null!, - }); - }); - - it('keyboardDelete returns false', () => { - const plugin = new ContentModelEditPlugin(); - - keyboardDeleteSpy.and.returnValue(false); - - plugin.initialize(editor); - plugin.onPluginEvent({ - eventType: PluginEventType.SelectionChanged, - selectionRangeEx: null!, - }); + expect(keyboardInputSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts index 902d3502c8b..8f5e7317224 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardDeleteTest.ts @@ -442,9 +442,8 @@ describe('keyboardDelete', () => { getDOMSelection: () => range, } as any; - const result = keyboardDelete(editor, rawEvent); + keyboardDelete(editor, rawEvent); - expect(result).toBeFalse(); expect(formatWithContentModelSpy).not.toHaveBeenCalled(); }); @@ -464,9 +463,8 @@ describe('keyboardDelete', () => { getDOMSelection: () => range, } as any; - const result = keyboardDelete(editor, rawEvent); + keyboardDelete(editor, rawEvent); - expect(result).toBeFalse(); expect(formatWithContentModelSpy).not.toHaveBeenCalled(); }); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts new file mode 100644 index 00000000000..4842b861705 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts @@ -0,0 +1,327 @@ +import * as deleteSelection from 'roosterjs-content-model-core/lib/publicApi/selection/deleteSelection'; +import { IContentModelEditor } from 'roosterjs-content-model-editor'; +import { keyboardInput } from '../../lib/edit/keyboardInput'; +import { + ContentModelDocument, + ContentModelFormatter, + FormatWithContentModelContext, +} from 'roosterjs-content-model-types'; + +describe('keyboardInput', () => { + let editor: IContentModelEditor; + let addUndoSnapshotSpy: jasmine.Spy; + let formatContentModelSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let deleteSelectionSpy: jasmine.Spy; + let mockedModel: ContentModelDocument; + let mockedContext: FormatWithContentModelContext; + let formatResult: boolean | undefined; + + beforeEach(() => { + mockedModel = 'MODEL' as any; + mockedContext = { + deletedEntities: [], + newEntities: [], + newImages: [], + }; + + formatResult = undefined; + addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: ContentModelFormatter) => { + formatResult = callback(mockedModel, mockedContext); + }); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + deleteSelectionSpy = spyOn(deleteSelection, 'deleteSelection'); + + editor = { + getDOMSelection: getDOMSelectionSpy, + addUndoSnapshot: addUndoSnapshotSpy, + formatContentModel: formatContentModelSpy, + } as any; + }); + + it('Letter input, collapsed selection, no modifier key', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: true, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'notDeleted', + }); + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(deleteSelectionSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeUndefined(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Letter input, expanded selection, no modifier key, deleteSelection returns not deleted', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: false, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'notDeleted', + }); + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(formatResult).toBeFalse(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + }); + }); + + it('Letter input, expanded selection, no modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: false, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + }); + }); + + it('Letter input, table selection, no modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + }); + }); + + it('Letter input, image selection, no modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'image', + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + }); + }); + + it('Letter input, no selection, no modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue(null); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(deleteSelectionSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeUndefined(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Letter input, expanded selection, has modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: false, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'A', + ctrlKey: true, + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(deleteSelectionSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeUndefined(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Space input, table selection, no modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'Space', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + }); + }); + + it('Backspace input, table selection, no modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'Backspace', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).not.toHaveBeenCalled(); + expect(formatContentModelSpy).not.toHaveBeenCalled(); + expect(deleteSelectionSpy).not.toHaveBeenCalled(); + expect(formatResult).toBeUndefined(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + }); + }); + + it('Enter input, table selection, no modifier key, deleteSelection returns range', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + }); + + const rawEvent = { + key: 'Enter', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + }); + }); +}); From f74149b698acf96cb2e215077449bdb861647213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 24 Nov 2023 15:00:59 -0300 Subject: [PATCH 058/111] apply table format fix --- .../table/setTableCellBackgroundColor.ts | 66 ++++++++-- .../publicApi/table/applyTableFormatTest.ts | 121 ++++++++++++++++++ 2 files changed, 174 insertions(+), 13 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts index 816ac4e5505..2e55c348206 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts @@ -8,6 +8,7 @@ const DARK_COLORS_LIGHTNESS = 20; const BRIGHT_COLORS_LIGHTNESS = 80; const White = '#ffffff'; const Black = '#000000'; +const DEFAULT_COLORS = ['rgb(0,0,0', '#000000', '#ffffff', 'rgb(255, 255, 255)']; /** * Set shade color of table cell @@ -15,7 +16,9 @@ const Black = '#000000'; * @param color The color to set * @param isColorOverride @optional When pass true, it means this shade color is not part of table format, so it can be preserved when apply table format * @param applyToSegments @optional When pass true, we will also apply text color from table cell to its child blocks and segments + * */ + export function setTableCellBackgroundColor( cell: ContentModelTableCell, color: string | null | undefined, @@ -43,28 +46,65 @@ export function setTableCellBackgroundColor( delete cell.format.textColor; } - if (applyToSegments && cell.format.textColor) { - cell.blocks.forEach(block => { - if (block.blockType == 'Paragraph') { + if (applyToSegments) { + setAdaptiveCellColor(cell); + } + } else { + delete cell.format.backgroundColor; + delete cell.format.textColor; + if (applyToSegments) { + removeAdaptiveCellColor(cell); + } + } + + delete cell.cachedElement; +} + +function removeAdaptiveCellColor(cell: ContentModelTableCell) { + cell.blocks.forEach(block => { + if (block.blockType == 'Paragraph') { + console.log(block.segmentFormat?.textColor, 'segmentFormat?.textColor'); + if ( + block.segmentFormat?.textColor && + DEFAULT_COLORS.indexOf(block.segmentFormat?.textColor) > 0 + ) { + delete block.segmentFormat.textColor; + } + block.segments.forEach(segment => { + console.log(segment.format.textColor, 'segment?.textColor'); + if ( + segment.format.textColor && + DEFAULT_COLORS.indexOf(segment.format.textColor) > 0 + ) { + delete segment.format.textColor; + } + }); + } + }); +} + +function setAdaptiveCellColor(cell: ContentModelTableCell) { + if (cell.format.textColor) { + cell.blocks.forEach(block => { + if (block.blockType == 'Paragraph') { + if (!block.segmentFormat?.textColor) { block.segmentFormat = { ...block.segmentFormat, textColor: cell.format.textColor, }; - block.segments.forEach(segment => { + } + + block.segments.forEach(segment => { + if (!segment.format?.textColor) { segment.format = { ...segment.format, textColor: cell.format.textColor, }; - }); - } - }); - } - } else { - delete cell.format.backgroundColor; - delete cell.format.textColor; + } + }); + } + }); } - - delete cell.cachedElement; } function calculateLightness(color: string) { diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/table/applyTableFormatTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/table/applyTableFormatTest.ts index 1e4bfe552c6..08ba9666e0a 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/table/applyTableFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/table/applyTableFormatTest.ts @@ -486,6 +486,7 @@ describe('applyTableFormat', () => { expect(table.rows[0].cells[0].format.backgroundColor).toBe('blue'); expect(table.rows[0].cells[0].dataset.editingInfo).toBe('{"bgColorOverride":true}'); }); + it('Has borderOverride', () => { const table = createTable(1, 1); table.rows[0].cells[0].format.borderLeft = '1px solid red'; @@ -510,4 +511,124 @@ describe('applyTableFormat', () => { expect(table.rows[0].cells[0].format.borderTop).toBe('1px solid green'); expect(table.rows[0].cells[0].dataset.editingInfo).toBe('{"borderOverride":true}'); }); + + it('Adaptive text color', () => { + const table = createTable(1, 1); + + const format: TableMetadataFormat = { + topBorderColor: '#000000', + bottomBorderColor: '#000000', + verticalBorderColor: '#000000', + hasHeaderRow: false, + hasFirstColumn: false, + hasBandedRows: false, + hasBandedColumns: false, + bgColorEven: null, + bgColorOdd: '#00000020', + headerRowColor: '#000000', + tableBorderFormat: TableBorderFormat.Default, + verticalAlign: null, + }; + + // Try to apply default format black + applyTableFormat(table, format); + + //apply HeaderRowColor + applyTableFormat(table, { ...format, hasHeaderRow: true }); + + //expect HeaderRowColor text color to be applied + table.rows[0].cells[0].blocks.forEach(block => { + if (block.blockType == 'Paragraph') { + expect(block.segmentFormat?.textColor).toBe('#ffffff'); + block.segments.forEach(segment => { + expect(segment.format?.textColor).toBe('#ffffff'); + }); + } + }); + }); + + it(' Should not set adaptive text color', () => { + const table = createTable(1, 1); + table.rows[0].cells[0].blocks.forEach(block => { + if (block.blockType == 'Paragraph') { + block.segmentFormat = { + textColor: '#ABABAB', + }; + block.segments.forEach(segment => { + segment.format = { + textColor: '#ABABAB', + }; + }); + } + }); + + const format: TableMetadataFormat = { + topBorderColor: '#000000', + bottomBorderColor: '#000000', + verticalBorderColor: '#000000', + hasHeaderRow: false, + hasFirstColumn: false, + hasBandedRows: false, + hasBandedColumns: false, + bgColorEven: null, + bgColorOdd: '#00000020', + headerRowColor: '#000000', + tableBorderFormat: TableBorderFormat.Default, + verticalAlign: null, + }; + + // Try to apply default format black + applyTableFormat(table, format); + + //apply HeaderRowColor + applyTableFormat(table, { ...format, hasHeaderRow: true }); + + //expect HeaderRowColor text color to be applied + table.rows[0].cells[0].blocks.forEach(block => { + if (block.blockType == 'Paragraph') { + expect(block.segmentFormat?.textColor).toBe('#ABABAB'); + block.segments.forEach(segment => { + expect(segment.format?.textColor).toBe('#ABABAB'); + }); + } + }); + }); + + it('Remove adaptive text color', () => { + const table = createTable(1, 1); + + const format: TableMetadataFormat = { + topBorderColor: '#000000', + bottomBorderColor: '#000000', + verticalBorderColor: '#000000', + hasHeaderRow: false, + hasFirstColumn: false, + hasBandedRows: false, + hasBandedColumns: false, + bgColorEven: null, + bgColorOdd: '#00000020', + headerRowColor: '#000000', + tableBorderFormat: TableBorderFormat.Default, + verticalAlign: null, + }; + + // Try to apply default format black + applyTableFormat(table, format); + + //apply HeaderRowColor + applyTableFormat(table, { ...format, hasHeaderRow: true }); + + //Toggle HeaderRowColor + applyTableFormat(table, { ...format, hasHeaderRow: false }); + + //expect HeaderRowColor text color to be applied + table.rows[0].cells[0].blocks.forEach(block => { + if (block.blockType == 'Paragraph') { + expect(block.segmentFormat?.textColor).toBe(undefined); + block.segments.forEach(segment => { + expect(segment.format?.textColor).toBe(undefined); + }); + } + }); + }); }); From 7dce8ed94dd5b2d72a2e75ebf1247d7539684a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 24 Nov 2023 15:09:39 -0300 Subject: [PATCH 059/111] remove console.log --- .../lib/publicApi/table/setTableCellBackgroundColor.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts index 2e55c348206..003afa567ff 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts @@ -63,7 +63,6 @@ export function setTableCellBackgroundColor( function removeAdaptiveCellColor(cell: ContentModelTableCell) { cell.blocks.forEach(block => { if (block.blockType == 'Paragraph') { - console.log(block.segmentFormat?.textColor, 'segmentFormat?.textColor'); if ( block.segmentFormat?.textColor && DEFAULT_COLORS.indexOf(block.segmentFormat?.textColor) > 0 @@ -71,7 +70,6 @@ function removeAdaptiveCellColor(cell: ContentModelTableCell) { delete block.segmentFormat.textColor; } block.segments.forEach(segment => { - console.log(segment.format.textColor, 'segment?.textColor'); if ( segment.format.textColor && DEFAULT_COLORS.indexOf(segment.format.textColor) > 0 From 34519c3134201827ade93a9705cf50199a1e586b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 27 Nov 2023 17:12:25 -0300 Subject: [PATCH 060/111] fix comments --- .../lib/publicApi/table/setTableCellBackgroundColor.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts index 003afa567ff..94d6b0dfcc1 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts @@ -8,7 +8,7 @@ const DARK_COLORS_LIGHTNESS = 20; const BRIGHT_COLORS_LIGHTNESS = 80; const White = '#ffffff'; const Black = '#000000'; -const DEFAULT_COLORS = ['rgb(0,0,0', '#000000', '#ffffff', 'rgb(255, 255, 255)']; +const ADAPTED_TEXT_COLORS = ['rgb(0,0,0)', '#000000', '#ffffff', 'rgb(255, 255, 255)']; /** * Set shade color of table cell @@ -65,14 +65,14 @@ function removeAdaptiveCellColor(cell: ContentModelTableCell) { if (block.blockType == 'Paragraph') { if ( block.segmentFormat?.textColor && - DEFAULT_COLORS.indexOf(block.segmentFormat?.textColor) > 0 + ADAPTED_TEXT_COLORS.indexOf(block.segmentFormat?.textColor) >= 0 ) { delete block.segmentFormat.textColor; } block.segments.forEach(segment => { if ( segment.format.textColor && - DEFAULT_COLORS.indexOf(segment.format.textColor) > 0 + ADAPTED_TEXT_COLORS.indexOf(segment.format.textColor) >= 0 ) { delete segment.format.textColor; } From 007bfb7858d112159788a0ddc730e25dd5b60ba3 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 28 Nov 2023 09:39:57 -0800 Subject: [PATCH 061/111] Standalone Editor: Port EntityPlugin (#2223) * Standalone Editor: CreateStandaloneEditorCore * Standalone Editor: Port LifecyclePlugin * fix build * fix test * improve * fix test * Standalone Editor: Support keyboard input (init step) * Standalone Editor: Port EntityPlugin * improve * Add test * improve --- .../controls/ContentModelEditorMainPane.tsx | 5 +- .../test/publicApi/link/insertLinkTest.ts | 1 + .../lib/coreApi/formatContentModel.ts | 80 +-- .../lib/corePlugin/EntityPlugin.ts | 279 ++++++++ .../createStandaloneEditorCorePlugins.ts | 2 + .../lib/corePlugin/utils/findAllEntities.ts | 44 ++ .../lib/editor/createStandaloneEditorCore.ts | 5 +- .../lib/publicApi/model/paste.ts | 3 + .../test/coreApi/formatContentModelTest.ts | 77 +-- .../test/corePlugin/EntityPluginTest.ts | 650 ++++++++++++++++++ .../corePlugin/utils/findAllEntitiesTest.ts | 214 ++++++ .../lib/domUtils/entityUtils.ts | 66 +- .../roosterjs-content-model-dom/lib/index.ts | 8 + .../test/domUtils/entityUtilTest.ts | 135 ++++ .../lib/corePlugins/EntityPlugin.ts | 397 ----------- .../lib/corePlugins/createCorePlugins.ts | 3 - .../lib/editor/createEditorCore.ts | 1 - .../publicTypes/ContentModelCorePlugins.ts | 7 - .../test/editor/ContentModelEditorTest.ts | 3 + .../test/editor/createEditorCoreTest.ts | 6 +- .../entityDelimiter/EntityDelimiterPlugin.ts} | 167 +++-- .../lib/index.ts | 1 + .../lib/editor/StandaloneEditorCorePlugins.ts | 6 + .../lib/enum/EntityOperation.ts | 7 +- .../event/ContentModelContentChangedEvent.ts | 31 +- .../lib/index.ts | 2 + .../lib/pluginState/EntityPluginState.ts | 29 + .../StandaloneEditorPluginState.ts | 8 +- .../lib/createContentModelEditor.ts | 12 +- 29 files changed, 1643 insertions(+), 606 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/findAllEntitiesTest.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityPlugin.ts rename packages-content-model/{roosterjs-content-model-editor/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts => roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts} (69%) create mode 100644 packages-content-model/roosterjs-content-model-types/lib/pluginState/EntityPluginState.ts diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 0bda5eb0590..1a277e010f0 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -15,7 +15,7 @@ import SidePane from './sidePane/SidePane'; import SnapshotPlugin from './sidePane/snapshot/SnapshotPlugin'; import TitleBar from './titleBar/TitleBar'; import { arrayPush } from 'roosterjs-editor-dom'; -import { ContentModelEditPlugin } from 'roosterjs-content-model-plugins'; +import { ContentModelEditPlugin, EntityDelimiterPlugin } from 'roosterjs-content-model-plugins'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { createEmojiPlugin, createPasteOptionPlugin, RibbonPlugin } from 'roosterjs-react'; @@ -98,6 +98,7 @@ class ContentModelEditorMainPane extends MainPaneBase private contentModelRibbonPlugin: RibbonPlugin; private pasteOptionPlugin: EditorPlugin; private emojiPlugin: EditorPlugin; + private entityDelimiterPlugin: EntityDelimiterPlugin; private toggleablePlugins: EditorPlugin[] | null = null; private formatPainterPlugin: ContentModelFormatPainterPlugin; private sampleEntityPlugin: SampleEntityPlugin; @@ -115,6 +116,7 @@ class ContentModelEditorMainPane extends MainPaneBase this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); this.pasteOptionPlugin = createPasteOptionPlugin(); this.emojiPlugin = createEmojiPlugin(); + this.entityDelimiterPlugin = new EntityDelimiterPlugin(); this.formatPainterPlugin = new ContentModelFormatPainterPlugin(); this.sampleEntityPlugin = new SampleEntityPlugin(); this.state = { @@ -177,6 +179,7 @@ class ContentModelEditorMainPane extends MainPaneBase this.contentModelEditPlugin, this.pasteOptionPlugin, this.emojiPlugin, + this.entityDelimiterPlugin, this.formatPainterPlugin, this.sampleEntityPlugin, ]; diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts index ea64c16d70a..b3c4f48538f 100644 --- a/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/publicApi/link/insertLinkTest.ts @@ -352,6 +352,7 @@ describe('insertLink', () => { }, contentModel: jasmine.anything(), selection: jasmine.anything(), + changedEntities: [], }); document.body.removeChild(div); diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts index 08bd752028d..a81ae4f7be0 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts @@ -1,10 +1,9 @@ import { ChangeSource } from '../constants/ChangeSource'; -import { ColorTransformDirection, EntityOperation, PluginEventType } from 'roosterjs-editor-types'; -import type { Entity } from 'roosterjs-editor-types'; +import { PluginEventType } from 'roosterjs-editor-types'; import type { + ChangedEntity, ContentModelContentChangedEvent, DOMSelection, - EntityRemovalOperation, FormatContentModel, FormatWithContentModelContext, StandaloneEditorCore, @@ -35,8 +34,6 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) if (formatter(model, context)) { const writeBack = () => { - handleNewEntities(core, context); - handleDeletedEntities(core, context); handleImages(core, context); selection = @@ -69,6 +66,21 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) additionalData: { formatApiName: apiName, }, + changedEntities: context.newEntities + .map( + (entity): ChangedEntity => ({ + entity, + operation: 'newEntity', + rawEvent, + }) + ) + .concat( + context.deletedEntities.map(entry => ({ + entity: entry.entity, + operation: entry.operation, + rawEvent, + })) + ), }; core.api.triggerEvent(core, eventData, true /*broadcast*/); } else { @@ -81,64 +93,6 @@ export const formatContentModel: FormatContentModel = (core, formatter, options) } }; -function handleNewEntities(core: StandaloneEditorCore, context: FormatWithContentModelContext) { - // TODO: Ideally we can trigger NewEntity event here. But to be compatible with original editor code, we don't do it here for now. - // Once Content Model Editor can be standalone, we can change this behavior to move triggering NewEntity event code - // from EntityPlugin to here - - if (core.lifecycle.isDarkMode) { - context.newEntities.forEach(entity => { - core.api.transformColor( - core, - entity.wrapper, - true /*includeSelf*/, - null /*callback*/, - ColorTransformDirection.LightToDark - ); - }); - } -} - -// This is only used for compatibility with old editor -// TODO: Remove this map once we have standalone editor -const EntityOperationMap: Record = { - overwrite: EntityOperation.Overwrite, - removeFromEnd: EntityOperation.RemoveFromEnd, - removeFromStart: EntityOperation.RemoveFromStart, -}; - -function handleDeletedEntities(core: StandaloneEditorCore, context: FormatWithContentModelContext) { - context.deletedEntities.forEach( - ({ - entity: { - wrapper, - entityFormat: { id, entityType, isReadonly }, - }, - operation, - }) => { - if (id && entityType) { - // TODO: Revisit this entity parameter for standalone editor, we may just directly pass ContentModelEntity object instead - const entity: Entity = { - id, - type: entityType, - isReadonly: !!isReadonly, - wrapper, - }; - core.api.triggerEvent( - core, - { - eventType: PluginEventType.EntityOperation, - entity, - operation: EntityOperationMap[operation], - rawEvent: context.rawEvent, - }, - false /*broadcast*/ - ); - } - } - ); -} - function handleImages(core: StandaloneEditorCore, context: FormatWithContentModelContext) { if (context.newImages.length > 0) { const viewport = core.api.getVisibleViewport(core); diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts new file mode 100644 index 00000000000..2dc12f09383 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts @@ -0,0 +1,279 @@ +import { findAllEntities } from './utils/findAllEntities'; +import { + createEntity, + generateEntityClassNames, + getAllEntityWrappers, + getObjectKeys, + isEntityElement, + parseEntityClassName, +} from 'roosterjs-content-model-dom'; +import { + ColorTransformDirection, + EntityOperation as LegacyEntityOperation, + PluginEventType, +} from 'roosterjs-editor-types'; +import type { + ChangedEntity, + ContentModelContentChangedEvent, + ContentModelEntityFormat, + EntityOperation, + EntityPluginState, + IStandaloneEditor, +} from 'roosterjs-content-model-types'; +import type { + ContentChangedEvent, + IEditor, + PluginEvent, + PluginMouseUpEvent, + PluginWithState, +} from 'roosterjs-editor-types'; + +const ENTITY_ID_REGEX = /_(\d{1,8})$/; + +// This is only used for compatibility with old editor +// TODO: Remove this map once we have standalone editor +const EntityOperationMap: Record = { + newEntity: LegacyEntityOperation.NewEntity, + overwrite: LegacyEntityOperation.Overwrite, + removeFromEnd: LegacyEntityOperation.RemoveFromEnd, + removeFromStart: LegacyEntityOperation.RemoveFromStart, + replaceTemporaryContent: LegacyEntityOperation.ReplaceTemporaryContent, + updateEntityState: LegacyEntityOperation.UpdateEntityState, + click: LegacyEntityOperation.Click, +}; + +/** + * Entity Plugin helps handle all operations related to an entity and generate entity specified events + */ +class EntityPlugin implements PluginWithState { + private editor: (IEditor & IStandaloneEditor) | null = null; + private state: EntityPluginState; + + /** + * Construct a new instance of EntityPlugin + */ + constructor() { + this.state = { + entityMap: {}, + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Entity'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize(editor: IEditor) { + this.editor = editor as IStandaloneEditor & IEditor; + } + + /** + * Dispose this plugin + */ + dispose() { + this.editor = null; + this.state.entityMap = {}; + } + + /** + * Get plugin state object + */ + getState() { + return this.state; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + if (this.editor) { + switch (event.eventType) { + case PluginEventType.MouseUp: + this.handleMouseUpEvent(this.editor, event); + break; + case PluginEventType.ContentChanged: + this.handleContentChangedEvent(this.editor, event); + break; + + case PluginEventType.EditorReady: + this.handleContentChangedEvent(this.editor); + break; + case PluginEventType.ExtractContentWithDom: + this.handleExtractContentWithDomEvent(this.editor, event.clonedRoot); + break; + } + } + } + + private handleMouseUpEvent(editor: IEditor & IStandaloneEditor, event: PluginMouseUpEvent) { + const { rawEvent, isClicking } = event; + let node: Node | null = rawEvent.target as Node; + + if (isClicking && this.editor) { + while (node && this.editor.contains(node)) { + if (isEntityElement(node)) { + this.triggerEvent(editor, node as HTMLElement, 'click', rawEvent); + break; + } else { + node = node.parentNode; + } + } + } + } + + private handleContentChangedEvent( + editor: IStandaloneEditor & IEditor, + event?: ContentChangedEvent + ) { + const modifiedEntities: ChangedEntity[] = + (event as ContentModelContentChangedEvent)?.changedEntities ?? + this.getChangedEntities(editor); + + modifiedEntities.forEach(entry => { + const { entity, operation, rawEvent } = entry; + const { + entityFormat: { id, entityType, isFakeEntity }, + wrapper, + } = entity; + + if (entityType && !isFakeEntity) { + if (operation == 'newEntity') { + entity.entityFormat.id = this.ensureUniqueId(entityType, id ?? '', wrapper); + wrapper.className = generateEntityClassNames(entity.entityFormat); + + const eventResult = this.triggerEvent(editor, wrapper, operation, rawEvent); + + this.state.entityMap[entity.entityFormat.id] = { + element: wrapper, + canPersist: eventResult?.shouldPersist, + }; + + if (editor.isDarkMode()) { + editor.transformToDarkColor(wrapper, ColorTransformDirection.LightToDark); + } + } else if (id) { + const mapEntry = this.state.entityMap[id]; + + if (mapEntry) { + mapEntry.isDeleted = true; + } + + this.triggerEvent(editor, wrapper, operation, rawEvent); + } + } + }); + } + + private getChangedEntities(editor: IStandaloneEditor): ChangedEntity[] { + const result: ChangedEntity[] = []; + + findAllEntities(editor.createContentModel(), result); + + getObjectKeys(this.state.entityMap).forEach(id => { + const entry = this.state.entityMap[id]; + + if (!entry.isDeleted) { + const index = result.findIndex( + x => + x.operation == 'newEntity' && + x.entity.entityFormat.id == id && + x.entity.wrapper == entry.element + ); + + if (index >= 0) { + // Found matched entity in editor, so there is no change to this entity, + // we can safely remove it from the new entity array + result.splice(index, 1); + } else { + // Entity is not in editor, which means it is deleted, use a temporary entity here to represent this entity + const tempEntity = createEntity(entry.element); + let isEntity = false; + + entry.element.classList.forEach(name => { + isEntity = parseEntityClassName(name, tempEntity.entityFormat) || isEntity; + }); + + if (isEntity) { + result.push({ + entity: tempEntity, + operation: 'overwrite', + }); + } + } + } + }); + + return result; + } + + private handleExtractContentWithDomEvent( + editor: IEditor & IStandaloneEditor, + root: HTMLElement + ) { + getAllEntityWrappers(root).forEach(element => { + element.removeAttribute('contentEditable'); + + this.triggerEvent(editor, element, 'replaceTemporaryContent'); + }); + } + + private triggerEvent( + editor: IEditor & IStandaloneEditor, + wrapper: HTMLElement, + operation: EntityOperation, + rawEvent?: Event + ) { + const format: ContentModelEntityFormat = {}; + wrapper.classList.forEach(name => { + parseEntityClassName(name, format); + }); + + return format.id && format.entityType && !format.isFakeEntity + ? editor.triggerPluginEvent(PluginEventType.EntityOperation, { + operation: EntityOperationMap[operation], + rawEvent, + entity: { + id: format.id, + type: format.entityType, + isReadonly: !!format.isReadonly, + wrapper, + }, + }) + : null; + } + + private ensureUniqueId(type: string, id: string, wrapper: HTMLElement): string { + const match = ENTITY_ID_REGEX.exec(id); + const baseId = (match ? id.substr(0, id.length - match[0].length) : id) || type; + + // Make sure entity id is unique + let newId = ''; + + for (let num = (match && parseInt(match[1])) || 0; ; num++) { + newId = num > 0 ? `${baseId}_${num}` : baseId; + + const item = this.state.entityMap[newId]; + + if (!item || item.element == wrapper) { + break; + } + } + + return newId; + } +} + +/** + * @internal + * Create a new instance of EntityPlugin. + */ +export function createEntityPlugin(): PluginWithState { + return new EntityPlugin(); +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts index 67125e54a4f..48e3caffef1 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts @@ -2,6 +2,7 @@ import { createContentModelCachePlugin } from './ContentModelCachePlugin'; import { createContentModelCopyPastePlugin } from './ContentModelCopyPastePlugin'; import { createContentModelFormatPlugin } from './ContentModelFormatPlugin'; import { createDOMEventPlugin } from './DOMEventPlugin'; +import { createEntityPlugin } from './EntityPlugin'; import { createLifecyclePlugin } from './LifecyclePlugin'; import type { StandaloneEditorCorePlugins, @@ -23,5 +24,6 @@ export function createStandaloneEditorCorePlugins( copyPaste: createContentModelCopyPastePlugin(options), domEvent: createDOMEventPlugin(options, contentDiv), lifecycle: createLifecyclePlugin(options, contentDiv), + entity: createEntityPlugin(), }; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities.ts new file mode 100644 index 00000000000..0f0b466eb92 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities.ts @@ -0,0 +1,44 @@ +import type { ChangedEntity, ContentModelBlockGroup } from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function findAllEntities(group: ContentModelBlockGroup, entities: ChangedEntity[]) { + group.blocks.forEach(block => { + switch (block.blockType) { + case 'BlockGroup': + findAllEntities(block, entities); + break; + + case 'Entity': + entities.push({ + entity: block, + operation: 'newEntity', + }); + break; + + case 'Paragraph': + block.segments.forEach(segment => { + switch (segment.segmentType) { + case 'Entity': + entities.push({ + entity: segment, + operation: 'newEntity', + }); + break; + + case 'General': + findAllEntities(segment, entities); + break; + } + }); + break; + + case 'Table': + block.rows.forEach(row => + row.cells.forEach(cell => findAllEntities(cell, entities)) + ); + break; + } + }); +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts index 317dd22d73f..c221789659a 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts @@ -6,6 +6,7 @@ import type { EditorPlugin } from 'roosterjs-editor-types'; import type { EditorEnvironment, StandaloneEditorCore, + StandaloneEditorCorePluginState, StandaloneEditorCorePlugins, StandaloneEditorOptions, UnportedCoreApiMap, @@ -35,6 +36,7 @@ export function createStandaloneEditorCore( corePlugins.format, corePlugins.copyPaste, corePlugins.domEvent, + corePlugins.entity, ...tempPlugins, corePlugins.lifecycle, ], @@ -69,12 +71,13 @@ export function defaultTrustHtmlHandler(html: string) { return html; } -function getPluginState(corePlugins: StandaloneEditorCorePlugins) { +function getPluginState(corePlugins: StandaloneEditorCorePlugins): StandaloneEditorCorePluginState { return { domEvent: corePlugins.domEvent.getState(), copyPaste: corePlugins.copyPaste.getState(), cache: corePlugins.cache.getState(), format: corePlugins.format.getState(), lifecycle: corePlugins.lifecycle.getState(), + entity: corePlugins.entity.getState(), }; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts index 3a9804013c4..b3faba1a10d 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts @@ -14,6 +14,7 @@ import type { } from 'roosterjs-content-model-types'; import type { ClipboardData, IEditor } from 'roosterjs-editor-types'; import { + AllowedEntityClasses, applySegmentFormatToElement, createDomToModelContext, domToContentModel, @@ -163,6 +164,8 @@ function createBeforePasteEventData( ): ContentModelBeforePasteEventData { const options = createDefaultHtmlSanitizerOptions(); + options.additionalAllowedCssClasses.push(...AllowedEntityClasses); + // Remove "caret-color" style generated by Safari to make sure caret shows in right color after paste options.cssStyleCallbacks['caret-color'] = () => false; diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts index fab0ddf50ec..cd2832176d4 100644 --- a/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts @@ -1,12 +1,7 @@ import { ChangeSource } from '../../lib/constants/ChangeSource'; import { createImage } from 'roosterjs-content-model-dom'; +import { EditorCore, PluginEventType } from 'roosterjs-editor-types'; import { formatContentModel } from '../../lib/coreApi/formatContentModel'; -import { - ColorTransformDirection, - EditorCore, - EntityOperation, - PluginEventType, -} from 'roosterjs-editor-types'; import { ContentModelDocument, ContentModelSegmentFormat, @@ -108,6 +103,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); @@ -144,6 +140,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); @@ -177,6 +174,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); @@ -218,6 +216,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); @@ -251,6 +250,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); @@ -291,27 +291,7 @@ describe('formatContentModel', () => { expect(setContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(core, mockedModel, undefined, undefined); - expect(triggerEvent).toHaveBeenCalledTimes(3); - expect(triggerEvent).toHaveBeenCalledWith( - core, - { - eventType: PluginEventType.EntityOperation, - entity: { id: 'E1', type: 'E', isReadonly: true, wrapper: entity1.wrapper }, - operation: EntityOperation.RemoveFromStart, - rawEvent: rawEvent, - }, - false - ); - expect(triggerEvent).toHaveBeenCalledWith( - core, - { - eventType: PluginEventType.EntityOperation, - entity: { id: 'E2', type: 'E', isReadonly: true, wrapper: entity2.wrapper }, - operation: EntityOperation.RemoveFromEnd, - rawEvent: rawEvent, - }, - false - ); + expect(triggerEvent).toHaveBeenCalledTimes(1); expect(triggerEvent).toHaveBeenCalledWith( core, { @@ -323,6 +303,18 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [ + { + entity: entity1, + operation: 'removeFromStart', + rawEvent: 'RawEvent', + }, + { + entity: entity2, + operation: 'removeFromEnd', + rawEvent: 'RawEvent', + }, + ], }, true ); @@ -374,24 +366,22 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [ + { + entity: entity1, + operation: 'newEntity', + rawEvent: 'RawEvent', + }, + { + entity: entity2, + operation: 'newEntity', + rawEvent: 'RawEvent', + }, + ], }, true ); - expect(transformToDarkColorSpy).toHaveBeenCalledTimes(2); - expect(transformToDarkColorSpy).toHaveBeenCalledWith( - core, - wrapper1, - true, - null, - ColorTransformDirection.LightToDark - ); - expect(transformToDarkColorSpy).toHaveBeenCalledWith( - core, - wrapper2, - true, - null, - ColorTransformDirection.LightToDark - ); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); }); it('With selectionOverride', () => { @@ -418,6 +408,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); @@ -459,6 +450,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); @@ -491,6 +483,7 @@ describe('formatContentModel', () => { additionalData: { formatApiName: apiName, }, + changedEntities: [], }, true ); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts new file mode 100644 index 00000000000..9bdf710ffe1 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts @@ -0,0 +1,650 @@ +import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; +import { createContentModelDocument, createEntity } from '../../../roosterjs-content-model-dom/lib'; +import { createEntityPlugin } from '../../lib/corePlugin/EntityPlugin'; +import { + ColorTransformDirection, + EntityOperation, + EntityPluginState, + IEditor, + PluginEventType, + PluginWithState, +} from 'roosterjs-editor-types'; + +describe('EntityPlugin', () => { + let editor: IEditor; + let plugin: PluginWithState; + let createContentModelSpy: jasmine.Spy; + let triggerPluginEventSpy: jasmine.Spy; + let isDarkModeSpy: jasmine.Spy; + let containsSpy: jasmine.Spy; + let transformToDarkColorSpy: jasmine.Spy; + + beforeEach(() => { + createContentModelSpy = jasmine.createSpy('createContentModel'); + triggerPluginEventSpy = jasmine.createSpy('triggerPluginEvent'); + isDarkModeSpy = jasmine.createSpy('isDarkMode'); + containsSpy = jasmine.createSpy('contains'); + transformToDarkColorSpy = jasmine.createSpy('transformToDarkColor'); + + editor = { + createContentModel: createContentModelSpy, + triggerPluginEvent: triggerPluginEventSpy, + isDarkMode: isDarkModeSpy, + contains: containsSpy, + transformToDarkColor: transformToDarkColorSpy, + } as any; + plugin = createEntityPlugin(); + plugin.initialize(editor); + }); + + it('ctor', () => { + const state = plugin.getState(); + + expect(state).toEqual({ + entityMap: {}, + }); + }); + + describe('EditorReady event', () => { + it('empty doc', () => { + createContentModelSpy.and.returnValue(createContentModelDocument()); + + plugin.onPluginEvent({ + eventType: PluginEventType.EditorReady, + }); + + const state = plugin.getState(); + expect(state).toEqual({ + entityMap: {}, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + + it('Doc with entity', () => { + const wrapper = document.createElement('div'); + const entity = createEntity(wrapper, true, undefined, 'Entity1'); + const doc = createContentModelDocument(); + + doc.blocks.push(entity); + + createContentModelSpy.and.returnValue(doc); + + plugin.onPluginEvent({ + eventType: PluginEventType.EditorReady, + }); + + const state = plugin.getState(); + expect(state).toEqual({ + entityMap: { + Entity1: { + element: wrapper, + canPersist: undefined, + }, + }, + }); + expect(wrapper.outerHTML).toBe( + '
                                                          ' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: undefined, + entity: { + id: 'Entity1', + type: 'Entity1', + isReadonly: true, + wrapper: wrapper, + }, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + + it('Doc with entity, can persist', () => { + const wrapper = document.createElement('div'); + const entity = createEntity(wrapper, true, undefined, 'Entity1'); + const doc = createContentModelDocument(); + + doc.blocks.push(entity); + + createContentModelSpy.and.returnValue(doc); + triggerPluginEventSpy.and.returnValue({ + shouldPersist: true, + }); + + plugin.onPluginEvent({ + eventType: PluginEventType.EditorReady, + }); + + const state = plugin.getState(); + expect(state).toEqual({ + entityMap: { + Entity1: { + element: wrapper, + canPersist: true, + }, + }, + }); + expect(wrapper.outerHTML).toBe( + '
                                                          ' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: undefined, + entity: { + id: 'Entity1', + type: 'Entity1', + isReadonly: true, + wrapper: wrapper, + }, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + }); + + describe('ContentChanged event', () => { + it('No changedEntity param', () => { + const wrapper = document.createElement('div'); + const entity = createEntity(wrapper, true, undefined, 'Entity1'); + const doc = createContentModelDocument(); + + doc.blocks.push(entity); + + createContentModelSpy.and.returnValue(doc); + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + } as any); + + const state = plugin.getState(); + expect(state).toEqual({ + entityMap: { + Entity1: { + element: wrapper, + canPersist: undefined, + }, + }, + }); + expect(wrapper.outerHTML).toBe( + '
                                                          ' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: undefined, + entity: { + id: 'Entity1', + type: 'Entity1', + isReadonly: true, + wrapper: wrapper, + }, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + + it('New entity in dark mode', () => { + const wrapper = document.createElement('div'); + const entity = createEntity(wrapper, true, undefined, 'Entity1'); + const doc = createContentModelDocument(); + + doc.blocks.push(entity); + + createContentModelSpy.and.returnValue(doc); + isDarkModeSpy.and.returnValue(true); + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + } as any); + + const state = plugin.getState(); + expect(state).toEqual({ + entityMap: { + Entity1: { + element: wrapper, + canPersist: undefined, + }, + }, + }); + expect(wrapper.outerHTML).toBe( + '
                                                          ' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: undefined, + entity: { + id: 'Entity1', + type: 'Entity1', + isReadonly: true, + wrapper: wrapper, + }, + }); + expect(transformToDarkColorSpy).toHaveBeenCalledTimes(1); + expect(transformToDarkColorSpy).toHaveBeenCalledWith( + wrapper, + ColorTransformDirection.LightToDark + ); + }); + + it('No changedEntity param, has deleted entity', () => { + const wrapper = document.createElement('div'); + const entity = createEntity(wrapper, true, undefined, 'Entity1'); + const doc = createContentModelDocument(); + + doc.blocks.push(entity); + + createContentModelSpy.and.returnValue(doc); + const state = plugin.getState(); + + const wrapper2 = document.createElement('div'); + wrapper2.className = '_Entity _EType_T2 _EId_T2 _EReadonly_1'; + + state.entityMap['T2'] = { + element: wrapper2, + }; + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + } as any); + + expect(state).toEqual({ + entityMap: { + Entity1: { + element: wrapper, + canPersist: undefined, + }, + T2: { + element: wrapper2, + isDeleted: true, + }, + }, + }); + expect(wrapper.outerHTML).toBe( + '
                                                          ' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(2); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: undefined, + entity: { + id: 'Entity1', + type: 'Entity1', + isReadonly: true, + wrapper: wrapper, + }, + }); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.Overwrite, + rawEvent: undefined, + entity: { + id: 'T2', + type: 'T2', + isReadonly: true, + wrapper: wrapper2, + }, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + + it('Do not trigger event for already deleted entity', () => { + const doc = createContentModelDocument(); + + createContentModelSpy.and.returnValue(doc); + const state = plugin.getState(); + + const wrapper2 = document.createElement('div'); + wrapper2.className = '_Entity _EType_T2 _EId_T2 _EReadonly_1'; + + state.entityMap['T2'] = { + element: wrapper2, + isDeleted: true, + }; + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + } as any); + + expect(state).toEqual({ + entityMap: { + T2: { + element: wrapper2, + isDeleted: true, + }, + }, + }); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(0); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + + it('Add back a deleted entity', () => { + const wrapper = document.createElement('div'); + const entity = createEntity(wrapper, true, undefined, 'Entity1'); + const doc = createContentModelDocument(); + + doc.blocks.push(entity); + + createContentModelSpy.and.returnValue(doc); + const state = plugin.getState(); + + state.entityMap['Entity1'] = { + element: wrapper, + isDeleted: true, + }; + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + } as any); + + expect(state).toEqual({ + entityMap: { + Entity1: { + element: wrapper, + canPersist: undefined, + }, + }, + }); + expect(wrapper.outerHTML).toBe( + '
                                                          ' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: undefined, + entity: { + id: 'Entity1', + type: 'Entity1', + isReadonly: true, + wrapper: wrapper, + }, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + + it('Has changedEntities parameter', () => { + const wrapper1 = document.createElement('div'); + const wrapper2 = document.createElement('div'); + + wrapper1.className = '_Entity _EType_E1 _EId_E1 _EReadonly_1'; + wrapper2.className = '_Entity _EType_E2 _EId_E2 _EReadonly_1'; + + const entity1 = createEntity(wrapper1, true, undefined, 'E1', 'E1'); + const entity2 = createEntity(wrapper2, true, undefined, 'E2', 'E2'); + const mockedEvent = 'EVENT' as any; + const state = plugin.getState(); + + state.entityMap['E1'] = { + element: wrapper1, + }; + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + changedEntities: [ + { + entity: entity1, + operation: 'removeFromStart', + rawEvent: mockedEvent, + }, + { + entity: entity2, + operation: 'newEntity', + rawEvent: mockedEvent, + }, + ], + } as any); + + expect(state).toEqual({ + entityMap: { + E1: { + element: wrapper1, + isDeleted: true, + }, + E2: { + element: wrapper2, + canPersist: undefined, + }, + }, + }); + expect(wrapper1.outerHTML).toBe( + '
                                                          ' + ); + expect(wrapper2.outerHTML).toBe( + '
                                                          ' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(2); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: mockedEvent, + entity: { + id: 'E2', + type: 'E2', + isReadonly: true, + wrapper: wrapper2, + }, + }); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.RemoveFromStart, + rawEvent: mockedEvent, + entity: { + id: 'E1', + type: 'E1', + isReadonly: true, + wrapper: wrapper1, + }, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + + it('Handle conflict id', () => { + const wrapper1 = document.createElement('div'); + const wrapper2 = document.createElement('div'); + + wrapper1.className = '_Entity _EType_E1 _EId_E1 _EReadonly_1'; + wrapper2.className = '_Entity _EType_E2 _EId_E1 _EReadonly_1'; + + const entity2 = createEntity(wrapper2, true, undefined, 'E2', 'E1'); + const mockedEvent = 'EVENT' as any; + const state = plugin.getState(); + + state.entityMap['E1'] = { + element: wrapper1, + }; + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + changedEntities: [ + { + entity: entity2, + operation: 'newEntity', + rawEvent: mockedEvent, + }, + ], + } as any); + + expect(state).toEqual({ + entityMap: { + E1: { + element: wrapper1, + }, + E1_1: { + element: wrapper2, + canPersist: undefined, + }, + }, + }); + expect(wrapper1.outerHTML).toBe( + '
                                                          ' + ); + expect(wrapper2.outerHTML).toBe( + '
                                                          ' + ); + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.NewEntity, + rawEvent: mockedEvent, + entity: { + id: 'E1_1', + type: 'E2', + isReadonly: true, + wrapper: wrapper2, + }, + }); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + }); + }); + + describe('MouseUp event', () => { + it('Not on entity', () => { + const mockedNode = { + parentNode: null as any, + } as any; + const mockedEvent = { + target: mockedNode, + } as any; + + containsSpy.and.returnValue(true); + + plugin.onPluginEvent({ + eventType: PluginEventType.MouseUp, + rawEvent: mockedEvent, + isClicking: true, + } as any); + + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(0); + }); + + it('Click on entity', () => { + const mockedNode = { + parentNode: null as any, + classList: ['_ENtity', '_EType_A', '_EId_A'], + } as any; + const mockedEvent = { + target: mockedNode, + } as any; + + containsSpy.and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(true); + + plugin.onPluginEvent({ + eventType: PluginEventType.MouseUp, + rawEvent: mockedEvent, + isClicking: true, + } as any); + + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.Click, + rawEvent: mockedEvent, + entity: { + id: 'A', + type: 'A', + isReadonly: false, + wrapper: mockedNode, + }, + }); + }); + + it('Click on child of entity', () => { + const mockedNode1 = { + parentNode: null as any, + classList: ['_ENtity', '_EType_A', '_EId_A'], + } as any; + + const mockedNode2 = { + parentNode: mockedNode1, + } as any; + const mockedEvent = { + target: mockedNode2, + } as any; + + containsSpy.and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.callFake(node => node == mockedNode1); + + plugin.onPluginEvent({ + eventType: PluginEventType.MouseUp, + rawEvent: mockedEvent, + isClicking: true, + } as any); + + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.Click, + rawEvent: mockedEvent, + entity: { + id: 'A', + type: 'A', + isReadonly: false, + wrapper: mockedNode1, + }, + }); + }); + + it('Not clicking', () => { + const mockedNode = { + parentNode: null as any, + classList: ['_ENtity', '_EType_A', '_EId_A'], + } as any; + const mockedEvent = { + target: mockedNode, + } as any; + + containsSpy.and.returnValue(true); + spyOn(entityUtils, 'isEntityElement').and.returnValue(true); + + plugin.onPluginEvent({ + eventType: PluginEventType.MouseUp, + rawEvent: mockedEvent, + isClicking: false, + } as any); + + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(0); + }); + }); + + describe('ExtractContentWithDom event', () => { + it('no entity', () => { + spyOn(entityUtils, 'getAllEntityWrappers').and.returnValue([]); + + plugin.onPluginEvent({ + eventType: PluginEventType.ExtractContentWithDom, + } as any); + + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(0); + }); + + it('Has entity', () => { + const wrapper1 = document.createElement('div'); + const wrapper2 = document.createElement('div'); + + wrapper1.className = '_Entity _EType_E1 _EId_E1 _EReadonly_1'; + wrapper2.className = '_Entity _EType_E2 _EId_E2 _EReadonly_1'; + + spyOn(entityUtils, 'getAllEntityWrappers').and.returnValue([wrapper1, wrapper2]); + + plugin.onPluginEvent({ + eventType: PluginEventType.ExtractContentWithDom, + } as any); + + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(2); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.ReplaceTemporaryContent, + rawEvent: undefined, + entity: { + id: 'E1', + type: 'E1', + isReadonly: true, + wrapper: wrapper1, + }, + }); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.ReplaceTemporaryContent, + rawEvent: undefined, + entity: { + id: 'E2', + type: 'E2', + isReadonly: true, + wrapper: wrapper2, + }, + }); + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/findAllEntitiesTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/findAllEntitiesTest.ts new file mode 100644 index 00000000000..2106383876f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/utils/findAllEntitiesTest.ts @@ -0,0 +1,214 @@ +import { ChangedEntity } from 'roosterjs-content-model-types'; +import { findAllEntities } from '../../../lib/corePlugin/utils/findAllEntities'; +import { + createContentModelDocument, + createEntity, + createFormatContainer, + createGeneralBlock, + createGeneralSegment, + createListItem, + createListLevel, + createParagraph, + createTable, + createTableCell, +} from 'roosterjs-content-model-dom'; + +describe('findAllEntities', () => { + it('Empty model', () => { + const model = createContentModelDocument(); + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([]); + }); + + it('Root level block entity', () => { + const model = createContentModelDocument(); + const wrapper = document.createElement('div'); + const entity = createEntity(wrapper); + + model.blocks.push(entity); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity, + operation: 'newEntity', + }, + ]); + }); + + it('Inline entity under root level paragraph', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const wrapper = document.createElement('span'); + const entity = createEntity(wrapper); + + para.segments.push(entity); + model.blocks.push(para); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity, + operation: 'newEntity', + }, + ]); + }); + + it('Mixed entities', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const wrapper1 = document.createElement('div'); + const wrapper2 = document.createElement('span'); + const entity1 = createEntity(wrapper1); + const entity2 = createEntity(wrapper2); + + para.segments.push(entity2); + model.blocks.push(para, entity1); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity: entity2, + operation: 'newEntity', + }, + { + entity: entity1, + operation: 'newEntity', + }, + ]); + }); + + it('Inline entity under general model', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const wrapper = document.createElement('span'); + const entity = createEntity(wrapper); + const generalElement = document.createElement('div'); + const generalBlock = createGeneralBlock(generalElement); + + para.segments.push(entity); + generalBlock.blocks.push(para); + model.blocks.push(generalBlock); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity, + operation: 'newEntity', + }, + ]); + }); + + it('Inline entity under general segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const wrapper = document.createElement('span'); + const entity = createEntity(wrapper); + const generalElement = document.createElement('span'); + const generalSegment = createGeneralSegment(generalElement); + + generalSegment.blocks.push(entity); + para.segments.push(generalSegment); + model.blocks.push(para); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity, + operation: 'newEntity', + }, + ]); + }); + + it('Inline entity under list item', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const wrapper = document.createElement('span'); + const entity = createEntity(wrapper); + + const listItem = createListItem([createListLevel('OL')]); + + para.segments.push(entity); + listItem.blocks.push(para); + model.blocks.push(listItem); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity, + operation: 'newEntity', + }, + ]); + }); + + it('Inline entity under format container', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const wrapper = document.createElement('span'); + const entity = createEntity(wrapper); + + const container = createFormatContainer('div'); + + para.segments.push(entity); + container.blocks.push(para); + model.blocks.push(container); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity, + operation: 'newEntity', + }, + ]); + }); + + it('Inline entity under table', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + const wrapper = document.createElement('span'); + const entity = createEntity(wrapper); + + const table = createTable(1); + const cell = createTableCell(); + + para.segments.push(entity); + cell.blocks.push(para); + table.rows[0].cells.push(cell); + model.blocks.push(table); + + const result: ChangedEntity[] = []; + + findAllEntities(model, result); + + expect(result).toEqual([ + { + entity, + operation: 'newEntity', + }, + ]); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts index 778dcdbf768..10eb0c81c09 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domUtils/entityUtils.ts @@ -1,3 +1,4 @@ +import toArray from './toArray'; import { isElementOfType } from './isElementOfType'; import { isNodeOfType } from './isNodeOfType'; import type { ContentModelEntityFormat } from 'roosterjs-content-model-types'; @@ -11,14 +12,25 @@ const DELIMITER_BEFORE = 'entityDelimiterBefore'; const DELIMITER_AFTER = 'entityDelimiterAfter'; /** - * @internal + * Check if the given DOM Node is an entity wrapper element */ export function isEntityElement(node: Node): boolean { return isNodeOfType(node, 'ELEMENT_NODE') && node.classList.contains(ENTITY_INFO_NAME); } /** - * @internal + * Get all entity wrapper elements under the given root element + * @param root The root element to query from + * @returns An array of entity wrapper elements + */ +export function getAllEntityWrappers(root: HTMLElement): HTMLElement[] { + return toArray(root.querySelectorAll('.' + ENTITY_INFO_NAME)) as HTMLElement[]; +} + +/** + * Parse entity class names from entity wrapper element + * @param className Class names of entity + * @param format The output entity format object */ export function parseEntityClassName( className: string, @@ -36,7 +48,9 @@ export function parseEntityClassName( } /** - * @internal + * Generate Entity class names for an entity wrapper + * @param format The source entity format object + * @returns A combined CSS class name string for entity wrapper */ export function generateEntityClassNames(format: ContentModelEntityFormat): string { return format.isFakeEntity @@ -59,15 +73,38 @@ export function isEntityDelimiter(element: HTMLElement): boolean { } /** - * @internal * Adds delimiters to the element provided. If the delimiters already exists, will not be added * @param element the node to add the delimiters */ export function addDelimiters(doc: Document, element: HTMLElement): HTMLElement[] { - return [ - insertDelimiter(doc, element, true /*isAfter*/), - insertDelimiter(doc, element, false /*isAfter*/), - ]; + let [delimiterAfter, delimiterBefore] = getDelimiters(element); + + if (!delimiterAfter) { + delimiterAfter = insertDelimiter(doc, element, true /*isAfter*/); + } + + if (!delimiterBefore) { + delimiterBefore = insertDelimiter(doc, element, false /*isAfter*/); + } + + return [delimiterAfter, delimiterBefore]; +} + +function getDelimiters(entityWrapper: HTMLElement): (HTMLElement | undefined)[] { + const result: (HTMLElement | undefined)[] = []; + const { nextElementSibling, previousElementSibling } = entityWrapper; + result.push( + isDelimiter(nextElementSibling, DELIMITER_AFTER), + isDelimiter(previousElementSibling, DELIMITER_BEFORE) + ); + + return result; +} + +function isDelimiter(el: Element | null, className: string): HTMLElement | undefined { + return el?.classList.contains(className) && el.textContent == ZERO_WIDTH_SPACE + ? (el as HTMLElement) + : undefined; } function insertDelimiter(doc: Document, element: Element, isAfter: boolean) { @@ -79,3 +116,16 @@ function insertDelimiter(doc: Document, element: Element, isAfter: boolean) { return span; } + +/** + * Allowed CSS selector for entity, used by HtmlSanitizer. + * TODO: Revisit paste logic and check if we can remove HtmlSanitizer + */ +export const AllowedEntityClasses: ReadonlyArray = [ + '^' + ENTITY_INFO_NAME + '$', + '^' + ENTITY_ID_PREFIX, + '^' + ENTITY_TYPE_PREFIX, + '^' + ENTITY_READONLY_PREFIX, + '^' + DELIMITER_BEFORE + '$', + '^' + DELIMITER_AFTER + '$', +]; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/index.ts b/packages-content-model/roosterjs-content-model-dom/lib/index.ts index 95fa3b1024b..27cb62585d7 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -20,6 +20,14 @@ export { getObjectKeys } from './domUtils/getObjectKeys'; export { default as toArray } from './domUtils/toArray'; export { moveChildNodes, wrapAllChildNodes } from './domUtils/moveChildNodes'; export { wrap } from './domUtils/wrap'; +export { + AllowedEntityClasses, + isEntityElement, + getAllEntityWrappers, + parseEntityClassName, + generateEntityClassNames, + addDelimiters, +} from './domUtils/entityUtils'; export { createBr } from './modelApi/creators/createBr'; export { createListItem } from './modelApi/creators/createListItem'; diff --git a/packages-content-model/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts index 7cdcac33cd7..74d896e4da0 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domUtils/entityUtilTest.ts @@ -1,6 +1,9 @@ import { ContentModelEntityFormat } from 'roosterjs-content-model-types'; import { + addDelimiters, generateEntityClassNames, + getAllEntityWrappers, + isEntityDelimiter, isEntityElement, parseEntityClassName, } from '../../lib/domUtils/entityUtils'; @@ -151,3 +154,135 @@ describe('generateEntityClassNames', () => { expect(className).toBe(''); }); }); + +describe('getAllEntityWrappers', () => { + it('No entity', () => { + const div = document.createElement('div'); + div.innerHTML = '
                                                          test
                                                          '; + + const result = getAllEntityWrappers(div); + + expect(result).toEqual([]); + }); + + it('Has entities', () => { + const div = document.createElement('div'); + const child1 = document.createElement('span'); + const child2 = document.createElement('span'); + const child3 = document.createElement('span'); + const child4 = document.createElement('span'); + + child1.className = 'c1'; + child2.className = '_Entity _EType_A'; + child3.className = 'c3'; + child4.className = '_Entity _EType_B'; + + div.appendChild(child1); + div.appendChild(child2); + div.appendChild(child3); + div.appendChild(child4); + + const result = getAllEntityWrappers(div); + + expect(result).toEqual([child2, child4]); + }); +}); + +describe('isEntityDelimiter', () => { + it('Not a delimiter - empty span', () => { + const span = document.createElement('span'); + + const result = isEntityDelimiter(span); + + expect(result).toBeFalse(); + }); + + it('Not a delimiter - wrong content', () => { + const span = document.createElement('span'); + + span.className = 'entityDelimiterBefore'; + span.textContent = 'aa'; + + const result = isEntityDelimiter(span); + + expect(result).toBeFalse(); + }); + + it('Not a delimiter - wrong class name', () => { + const span = document.createElement('span'); + + span.className = 'test'; + span.textContent = '\u200B'; + + const result = isEntityDelimiter(span); + + expect(result).toBeFalse(); + }); + + it('Not a delimiter - wrong tag name', () => { + const span = document.createElement('div'); + + span.className = 'entityDelimiterBefore'; + span.textContent = '\u200B'; + + const result = isEntityDelimiter(span); + + expect(result).toBeFalse(); + }); + + it('delimiter before', () => { + const span = document.createElement('span'); + + span.className = 'entityDelimiterBefore'; + span.textContent = '\u200B'; + + const result = isEntityDelimiter(span); + + expect(result).toBeTrue(); + }); + + it('delimiter after', () => { + const span = document.createElement('span'); + + span.className = 'entityDelimiterAfter'; + span.textContent = '\u200B'; + + const result = isEntityDelimiter(span); + + expect(result).toBeTrue(); + }); +}); + +describe('addDelimiters', () => { + it('no delimiter', () => { + const parent = document.createElement('div'); + const entity = document.createElement('span'); + + parent.appendChild(entity); + + const result = addDelimiters(document, entity); + + expect(parent.innerHTML).toBe( + '\u200B\u200B' + ); + expect(result[0]).toBe(parent.lastChild as any); + expect(result[1]).toBe(parent.firstChild as any); + }); + + it('already has delimiter', () => { + const parent = document.createElement('div'); + + parent.innerHTML = + '\u200B\u200B'; + + const entity = parent.querySelector('._Entity') as HTMLElement; + + const result = addDelimiters(document, entity); + + expect(parent.innerHTML).toBe( + '\u200B\u200B' + ); + expect(result[0]).toBe(parent.lastChild as any); + expect(result[1]).toBe(parent.firstChild as any); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityPlugin.ts deleted file mode 100644 index e87553dff97..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EntityPlugin.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { - inlineEntityOnPluginEvent, - normalizeDelimitersInEditor, -} from './utils/inlineEntityOnPluginEvent'; -import { - Browser, - commitEntity, - getEntityFromElement, - getEntitySelector, - isCharacterValue, - toArray, - arrayPush, - createElement, - addRangeToSelection, - createRange, - isBlockElement, - getObjectKeys, -} from 'roosterjs-editor-dom'; -import type { - ContentChangedEvent, - Entity, - EntityOperationEvent, - EntityPluginState, - KnownEntityItem, - HtmlSanitizerOptions, - IEditor, - PluginEvent, - PluginMouseUpEvent, - PluginWithState, -} from 'roosterjs-editor-types'; -import { - ChangeSource, - ContentPosition, - EntityClasses, - EntityOperation, - Keys, - PluginEventType, - QueryScope, -} from 'roosterjs-editor-types'; -import type { CompatibleEntityOperation } from 'roosterjs-editor-types/lib/compatibleTypes'; - -const ENTITY_ID_REGEX = /_(\d{1,8})$/; - -const ENTITY_CSS_REGEX = '^' + EntityClasses.ENTITY_INFO_NAME + '$'; -const ENTITY_ID_CSS_REGEX = '^' + EntityClasses.ENTITY_ID_PREFIX; -const ENTITY_TYPE_CSS_REGEX = '^' + EntityClasses.ENTITY_TYPE_PREFIX; -const ENTITY_READONLY_CSS_REGEX = '^' + EntityClasses.ENTITY_READONLY_PREFIX; -const ALLOWED_CSS_CLASSES = [ - ENTITY_CSS_REGEX, - ENTITY_ID_CSS_REGEX, - ENTITY_TYPE_CSS_REGEX, - ENTITY_READONLY_CSS_REGEX, -]; -const REMOVE_ENTITY_OPERATIONS: (EntityOperation | CompatibleEntityOperation)[] = [ - EntityOperation.Overwrite, - EntityOperation.PartialOverwrite, - EntityOperation.RemoveFromStart, - EntityOperation.RemoveFromEnd, -]; - -/** - * Entity Plugin helps handle all operations related to an entity and generate entity specified events - */ -class EntityPlugin implements PluginWithState { - private editor: IEditor | null = null; - private state: EntityPluginState; - - /** - * Construct a new instance of EntityPlugin - */ - constructor() { - this.state = { - entityMap: {}, - }; - } - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'Entity'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - } - - /** - * Dispose this plugin - */ - dispose() { - this.editor = null; - this.state.entityMap = {}; - } - - /** - * Get plugin state object - */ - getState() { - return this.state; - } - - /** - * Handle events triggered from editor - * @param event PluginEvent object - */ - onPluginEvent(event: PluginEvent) { - switch (event.eventType) { - case PluginEventType.MouseUp: - this.handleMouseUpEvent(event); - break; - case PluginEventType.KeyDown: - this.handleKeyDownEvent(event.rawEvent); - break; - case PluginEventType.BeforeCutCopy: - if (event.isCut) { - this.handleCutEvent(event.rawEvent); - } - break; - case PluginEventType.BeforePaste: - this.handleBeforePasteEvent(event.sanitizingOption); - break; - case PluginEventType.ContentChanged: - this.handleContentChangedEvent(event); - break; - case PluginEventType.EditorReady: - this.handleContentChangedEvent(); - break; - case PluginEventType.ExtractContentWithDom: - this.handleExtractContentWithDomEvent(event.clonedRoot); - break; - case PluginEventType.ContextMenu: - this.handleContextMenuEvent(event.rawEvent); - break; - case PluginEventType.EntityOperation: - this.handleEntityOperationEvent(event); - break; - } - - if (this.editor) { - inlineEntityOnPluginEvent(event, this.editor); - } - } - - private handleContextMenuEvent(event: UIEvent) { - const node = event.target as Node; - const entityElement = node && this.editor?.getElementAtCursor(getEntitySelector(), node); - - if (entityElement) { - event.preventDefault(); - this.triggerEvent(entityElement, EntityOperation.ContextMenu, event); - } - } - - private handleCutEvent = (event: ClipboardEvent) => { - const range = this.editor?.getSelectionRange(); - if (range && !range.collapsed) { - this.checkRemoveEntityForRange(event); - } - }; - - private handleMouseUpEvent(event: PluginMouseUpEvent) { - const { rawEvent, isClicking } = event; - const node = rawEvent.target as Node; - let entityElement: HTMLElement | null; - - if ( - this.editor && - isClicking && - node && - !!(entityElement = this.editor.getElementAtCursor(getEntitySelector(), node)) - ) { - this.triggerEvent(entityElement, EntityOperation.Click, rawEvent); - - workaroundSelectionIssueForIE(this.editor); - } - } - - private handleKeyDownEvent(event: KeyboardEvent) { - if ( - isCharacterValue(event) || - event.which == Keys.BACKSPACE || - event.which == Keys.DELETE || - event.which == Keys.ENTER - ) { - const range = this.editor?.getSelectionRange(); - if (range && !range.collapsed) { - this.checkRemoveEntityForRange(event); - } - } - } - - private handleBeforePasteEvent(sanitizingOption: HtmlSanitizerOptions) { - const range = this.editor?.getSelectionRange(); - - if (range && !range.collapsed) { - this.checkRemoveEntityForRange(null! /*rawEvent*/); - } - - if (sanitizingOption.additionalAllowedCssClasses) { - arrayPush(sanitizingOption.additionalAllowedCssClasses, ALLOWED_CSS_CLASSES); - } - } - - private handleContentChangedEvent(event?: ContentChangedEvent) { - let shouldNormalizeDelimiters: boolean = false; - // 1. find removed entities - getObjectKeys(this.state.entityMap).forEach(id => { - const item = this.state.entityMap[id]; - const element = item.element; - - if (this.editor && !item.isDeleted && !this.editor.contains(element)) { - item.isDeleted = true; - - this.triggerEvent(element, EntityOperation.Overwrite); - - if ( - !shouldNormalizeDelimiters && - !element.isContentEditable && - !isBlockElement(element) - ) { - shouldNormalizeDelimiters = true; - } - } - }); - - // 2. collect all new entities - const newEntities = - event?.source == ChangeSource.InsertEntity && event.data - ? [event.data as Entity] - : this.getExistingEntities().filter(entity => { - const item = this.state.entityMap[entity.id]; - - return !item || item.element != entity.wrapper || item.isDeleted; - }); - - // 3. Add new entities to known entity list, and hydrate - newEntities.forEach(entity => { - const { wrapper, type, id, isReadonly } = entity; - - entity.id = this.ensureUniqueId(type, id, wrapper); - commitEntity(wrapper, type, isReadonly, entity.id); // Use entity.id here because it is newly updated - this.handleNewEntity(entity); - }); - - if (shouldNormalizeDelimiters && this.editor) { - normalizeDelimitersInEditor(this.editor); - } - } - - private handleEntityOperationEvent(event: EntityOperationEvent) { - if (this.editor && REMOVE_ENTITY_OPERATIONS.indexOf(event.operation) >= 0) { - const item = this.state.entityMap[event.entity.id]; - - if (item) { - item.isDeleted = true; - } - } - } - - private handleExtractContentWithDomEvent(root: HTMLElement) { - toArray(root.querySelectorAll(getEntitySelector())).forEach(element => { - element.removeAttribute('contentEditable'); - - this.triggerEvent(element as HTMLElement, EntityOperation.ReplaceTemporaryContent); - }); - } - - private checkRemoveEntityForRange(event: Event) { - const editableEntityElements: HTMLElement[] = []; - const selector = getEntitySelector(); - this.editor?.queryElements(selector, QueryScope.OnSelection, element => { - if (element.isContentEditable) { - editableEntityElements.push(element); - } else { - this.triggerEvent(element, EntityOperation.Overwrite, event); - } - }); - - // For editable entities, we need to check if it is fully or partially covered by current selection, - // and trigger different events; - if (this.editor && editableEntityElements.length > 0) { - const inSelectionEntityElements = this.editor.queryElements( - selector, - QueryScope.InSelection - ); - editableEntityElements.forEach(element => { - const isFullyCovered = inSelectionEntityElements.indexOf(element) >= 0; - this.triggerEvent( - element, - isFullyCovered ? EntityOperation.Overwrite : EntityOperation.PartialOverwrite, - event - ); - }); - } - } - - private triggerEvent(element: HTMLElement, operation: EntityOperation, rawEvent?: Event) { - const entity = element && getEntityFromElement(element); - - return entity - ? this.editor?.triggerPluginEvent(PluginEventType.EntityOperation, { - operation, - rawEvent, - entity, - }) - : null; - } - - private handleNewEntity(entity: Entity) { - const { wrapper } = entity; - const event = this.triggerEvent(wrapper, EntityOperation.NewEntity); - - const newItem: KnownEntityItem = { - element: entity.wrapper, - }; - - if (event?.shouldPersist) { - newItem.canPersist = true; - } - - this.state.entityMap[entity.id] = newItem; - } - - private getExistingEntities(): Entity[] { - return ( - this.editor - ?.queryElements(getEntitySelector()) - .map(getEntityFromElement) - .filter((x): x is Entity => !!x) ?? [] - ); - } - - private ensureUniqueId(type: string, id: string, wrapper: HTMLElement) { - const match = ENTITY_ID_REGEX.exec(id); - const baseId = (match ? id.substr(0, id.length - match[0].length) : id) || type; - - // Make sure entity id is unique - let newId = ''; - - for (let num = (match && parseInt(match[1])) || 0; ; num++) { - newId = num > 0 ? `${baseId}_${num}` : baseId; - - const item = this.state.entityMap[newId]; - - if (!item || item.element == wrapper) { - break; - } - } - - return newId; - } -} - -/** - * IE will show a resize border around the readonly content within content editable DIV - * This is a workaround to remove it by temporarily move focus out of editor - */ -const workaroundSelectionIssueForIE = Browser.isIE - ? (editor: IEditor) => { - editor.runAsync(editor => { - const workaroundButton = editor.getCustomData('ENTITY_IE_FOCUS_BUTTON', () => { - const button = createElement( - { - tag: 'button', - style: 'overflow:hidden;position:fixed;width:0;height:0;top:-1000px', - }, - editor.getDocument() - ) as HTMLElement; - button.onblur = () => { - button.style.display = 'none'; - }; - - editor.insertNode(button, { - position: ContentPosition.Outside, - }); - - return button; - }); - - workaroundButton.style.display = ''; - addRangeToSelection(createRange(workaroundButton, 0)); - }); - } - : () => {}; - -/** - * @internal - * Create a new instance of EntityPlugin. - */ -export function createEntityPlugin(): PluginWithState { - return new EntityPlugin(); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts index 3adb8e549af..35ce4bce8c1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts @@ -1,5 +1,4 @@ import { createEditPlugin } from './EditPlugin'; -import { createEntityPlugin } from './EntityPlugin'; import { createImageSelection } from './ImageSelection'; import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; import { createUndoPlugin } from './UndoPlugin'; @@ -20,7 +19,6 @@ export function createCorePlugins(options: ContentModelEditorOptions): UnportedC return { edit: map.edit || createEditPlugin(), undo: map.undo || createUndoPlugin(options), - entity: map.entity || createEntityPlugin(), imageSelection: map.imageSelection || createImageSelection(), normalizeTable: map.normalizeTable || createNormalizeTablePlugin(), }; @@ -35,6 +33,5 @@ export function getPluginState(corePlugins: UnportedCorePlugins): UnportedCorePl return { edit: corePlugins.edit.getState(), undo: corePlugins.undo.getState(), - entity: corePlugins.entity.getState(), }; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts index bb500a2c2cc..0eb0db09f75 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts @@ -21,7 +21,6 @@ export function createEditorCore( corePlugins.edit, ...(options.plugins ?? []), corePlugins.undo, - corePlugins.entity, corePlugins.imageSelection, corePlugins.normalizeTable, ].filter(x => !!x); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts index 1bc63f1bd74..a2a7cadb93c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts @@ -2,7 +2,6 @@ import type { StandaloneEditorCorePlugins } from 'roosterjs-content-model-types' import type { EditPluginState, EditorPlugin, - EntityPluginState, PluginWithState, UndoPluginState, } from 'roosterjs-editor-types'; @@ -22,12 +21,6 @@ export interface UnportedCorePlugins { */ readonly undo: PluginWithState; - /** - * Entity Plugin handles all operations related to an entity and generate entity specified events - */ - - readonly entity: PluginWithState; - /** * Image selection Plugin detects image selection and help highlight the image */ diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts index f615351421e..f9f2d178473 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/ContentModelEditorTest.ts @@ -2,6 +2,7 @@ import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/c import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; +import * as findAllEntities from 'roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities'; import { ContentModelDocument, EditorContext } from 'roosterjs-content-model-types'; import { ContentModelEditor } from '../../lib/editor/ContentModelEditor'; import { ContentModelEditorCore } from '../../lib/publicTypes/ContentModelEditorCore'; @@ -23,6 +24,7 @@ describe('ContentModelEditor', () => { mockedContext ); spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue(mockedConfig); + spyOn(findAllEntities, 'findAllEntities'); const div = document.createElement('div'); const editor = new ContentModelEditor(div, { @@ -59,6 +61,7 @@ describe('ContentModelEditor', () => { mockedContext ); spyOn(createDomToModelContext, 'createDomToModelConfig').and.returnValue(mockedConfig); + spyOn(findAllEntities, 'findAllEntities'); const div = document.createElement('div'); const editor = new ContentModelEditor(div, { diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts index 0d38370a6e9..ab259fc40d4 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts @@ -4,7 +4,7 @@ import * as ContentModelFormatPlugin from 'roosterjs-content-model-core/lib/core import * as createStandaloneEditorDefaultSettings from 'roosterjs-content-model-core/lib/editor/createStandaloneEditorDefaultSettings'; import * as DOMEventPlugin from 'roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin'; import * as EditPlugin from '../../lib/corePlugins/EditPlugin'; -import * as EntityPlugin from '../../lib/corePlugins/EntityPlugin'; +import * as EntityPlugin from 'roosterjs-content-model-core/lib/corePlugin/EntityPlugin'; import * as ImageSelection from '../../lib/corePlugins/ImageSelection'; import * as LifecyclePlugin from 'roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin'; import * as NormalizeTablePlugin from '../../lib/corePlugins/NormalizeTablePlugin'; @@ -96,9 +96,9 @@ describe('createEditorCore', () => { mockedFormatPlugin, mockedCopyPastePlugin, mockedDOMEventPlugin, + mockedEntityPlugin, mockedEditPlugin, mockedUndoPlugin, - mockedEntityPlugin, mockedImageSelection, mockedNormalizeTablePlugin, mockedLifecyclePlugin, @@ -151,9 +151,9 @@ describe('createEditorCore', () => { mockedFormatPlugin, mockedCopyPastePlugin, mockedDOMEventPlugin, + mockedEntityPlugin, mockedEditPlugin, mockedUndoPlugin, - mockedEntityPlugin, mockedImageSelection, mockedNormalizeTablePlugin, mockedLifecyclePlugin, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts b/packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts similarity index 69% rename from packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts rename to packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts index e077b6cc5ce..600228855fc 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/inlineEntityOnPluginEvent.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts @@ -1,20 +1,11 @@ +import { isCharacterValue } from 'roosterjs-content-model-core'; import { addDelimiters, - arrayPush, - createRange, - getDelimiterFromElement, - getEntityFromElement, - getEntitySelector, isBlockElement, - isCharacterValue, - matchesSelector, - Position, - safeInstanceOf, - splitTextNode, -} from 'roosterjs-editor-dom'; -import type { Entity, IEditor, PluginEvent, PluginKeyDownEvent } from 'roosterjs-editor-types'; + isEntityElement, + isNodeOfType, +} from 'roosterjs-content-model-dom'; import { - ChangeSource, DelimiterClasses, Keys, NodeType, @@ -22,6 +13,22 @@ import { PositionType, SelectionRangeTypes, } from 'roosterjs-editor-types'; +import { + Position, + createRange, + getDelimiterFromElement, + getEntityFromElement, + getEntitySelector, + matchesSelector, + splitTextNode, +} from 'roosterjs-editor-dom'; +import type { + EditorPlugin, + IEditor, + PluginEvent, + PluginKeyDownEvent, +} from 'roosterjs-editor-types'; +import type { IContentModelEditor } from 'roosterjs-content-model-editor'; const DELIMITER_SELECTOR = '.' + DelimiterClasses.DELIMITER_AFTER + ',.' + DelimiterClasses.DELIMITER_BEFORE; @@ -29,45 +36,73 @@ const ZERO_WIDTH_SPACE = '\u200B'; const INLINE_ENTITY_SELECTOR = 'span' + getEntitySelector(); /** - * @internal + * Entity delimiter plugin helps maintain delimiter elements around an entity so that user can put focus before/after an entity */ -export function inlineEntityOnPluginEvent(event: PluginEvent, editor: IEditor) { - switch (event.eventType) { - case PluginEventType.ContentChanged: - if (event.source === ChangeSource.SetContent) { - normalizeDelimitersInEditor(editor); - } - break; - case PluginEventType.EditorReady: - normalizeDelimitersInEditor(editor); - break; - - case PluginEventType.BeforePaste: - const { fragment, sanitizingOption } = event; - addDelimitersIfNeeded(fragment.querySelectorAll(INLINE_ENTITY_SELECTOR)); - - if (sanitizingOption.additionalAllowedCssClasses) { - arrayPush(sanitizingOption.additionalAllowedCssClasses, [ - DelimiterClasses.DELIMITER_AFTER, - DelimiterClasses.DELIMITER_BEFORE, - ]); - } - break; - - case PluginEventType.ExtractContentWithDom: - case PluginEventType.BeforeCutCopy: - event.clonedRoot.querySelectorAll(DELIMITER_SELECTOR).forEach(node => { - if (getDelimiterFromElement(node)) { - removeNode(node); - } else { - removeDelimiterAttr(node); - } - }); - break; +export class EntityDelimiterPlugin implements EditorPlugin { + private editor: IContentModelEditor | null = null; + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'EntityDelimiter'; + } + + /** + * The first method that editor will call to a plugin when editor is initializing. + * It will pass in the editor instance, plugin should take this chance to save the + * editor reference so that it can call to any editor method or format API later. + * @param editor The editor object + */ + initialize(editor: IEditor) { + this.editor = editor as IContentModelEditor; + } - case PluginEventType.KeyDown: - handleKeyDownEvent(editor, event); - break; + /** + * The last method that editor will call to a plugin before it is disposed. + * Plugin can take this chance to clear the reference to editor. After this method is + * called, plugin should not call to any editor method since it will result in error. + */ + dispose() { + this.editor = null; + } + + /** + * Core method for a plugin. Once an event happens in editor, editor will call this + * method of each plugin to handle the event as long as the event is not handled + * exclusively by another plugin. + * @param event The event to handle: + */ + onPluginEvent(event: PluginEvent) { + if (this.editor) { + switch (event.eventType) { + case PluginEventType.ContentChanged: + case PluginEventType.EditorReady: + normalizeDelimitersInEditor(this.editor); + break; + + case PluginEventType.BeforePaste: + const { fragment } = event; + addDelimitersIfNeeded(fragment.querySelectorAll(INLINE_ENTITY_SELECTOR)); + + break; + + case PluginEventType.ExtractContentWithDom: + case PluginEventType.BeforeCutCopy: + event.clonedRoot.querySelectorAll(DELIMITER_SELECTOR).forEach(node => { + if (getDelimiterFromElement(node)) { + removeNode(node); + } else { + removeDelimiterAttr(node); + } + }); + break; + + case PluginEventType.KeyDown: + handleKeyDownEvent(this.editor, event); + break; + } + } } } @@ -113,38 +148,22 @@ export function normalizeDelimitersInEditor(editor: IEditor) { function addDelimitersIfNeeded(nodes: Element[] | NodeListOf) { nodes.forEach(node => { if (isEntityElement(node)) { - addDelimiters(node); + addDelimiters(node.ownerDocument, node as HTMLElement); } }); } -function isEntityElement(node: Node | null): node is HTMLElement { - return !!( - node && - safeInstanceOf(node, 'HTMLElement') && - isReadOnly(getEntityFromElement(node)) - ); -} - function removeNode(el: Node | undefined | null) { el?.parentElement?.removeChild(el); } -function isReadOnly(entity: Entity | null) { - return ( - entity?.isReadonly && - !isBlockElement(entity.wrapper) && - safeInstanceOf(entity.wrapper, 'HTMLElement') - ); -} - function removeInvalidDelimiters(nodes: Element[] | NodeListOf) { nodes.forEach(node => { if (getDelimiterFromElement(node)) { const sibling = node.classList.contains(DelimiterClasses.DELIMITER_BEFORE) ? node.nextElementSibling : node.previousElementSibling; - if (!(safeInstanceOf(sibling, 'HTMLElement') && getEntityFromElement(sibling))) { + if (!(isNodeOfType(sibling, 'ELEMENT_NODE') && getEntityFromElement(sibling))) { removeNode(node); } } else { @@ -185,15 +204,16 @@ function handleCollapsedEnter(editor: IEditor, delimiter: HTMLElement) { return; } const blockToCheck = isAfter ? block.nextSibling : block.previousSibling; - if (blockToCheck && safeInstanceOf(blockToCheck, 'HTMLElement')) { + if (blockToCheck && isNodeOfType(blockToCheck, 'ELEMENT_NODE')) { const delimiters = blockToCheck.querySelectorAll(DELIMITER_SELECTOR); // Check if the last or first delimiter still contain the delimiter class and remove it. const delimiterToCheck = delimiters.item(isAfter ? 0 : delimiters.length - 1); removeDelimiterAttr(delimiterToCheck); } - if (isEntityElement(entity)) { - const { nextElementSibling, previousElementSibling } = entity; + if (entity && isEntityElement(entity)) { + const entityElement = entity as HTMLElement; + const { nextElementSibling, previousElementSibling } = entityElement; [nextElementSibling, previousElementSibling].forEach(el => { // Check if after Enter the ZWS got removed but we still have a element with the class // Remove the attributes of the element if it is invalid now. @@ -201,8 +221,9 @@ function handleCollapsedEnter(editor: IEditor, delimiter: HTMLElement) { removeDelimiterAttr(el, false /* checkEntity */); } }); + // Add delimiters to the entity if needed because on Enter we can sometimes lose the ZWS of the element. - addDelimiters(entity); + addDelimiters(entityElement.ownerDocument, entityElement); } }); } @@ -222,7 +243,7 @@ function getBlock(editor: IEditor, element: Node | undefined) { let block = editor.getBlockElementAtNode(element)?.getStartNode(); - while (block && !isBlockElement(block)) { + while (block && (!isNodeOfType(block, 'ELEMENT_NODE') || !isBlockElement(block))) { block = editor.contains(block.parentElement) ? block.parentElement! : undefined; } diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts index 373fd4da9eb..d479dec6ddf 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/index.ts @@ -1,2 +1,3 @@ export { ContentModelPastePlugin } from './paste/ContentModelPastePlugin'; export { ContentModelEditPlugin } from './edit/ContentModelEditPlugin'; +export { EntityDelimiterPlugin } from './entityDelimiter/EntityDelimiterPlugin'; diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts index ca099dd503f..1574528f2a8 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts @@ -1,3 +1,4 @@ +import type { EntityPluginState } from '../pluginState/EntityPluginState'; import type { LifecyclePluginState } from '../pluginState/LifecyclePluginState'; import type { DOMEventPluginState } from '../pluginState/DOMEventPluginState'; import type { ContentModelCachePluginState } from '../pluginState/ContentModelCachePluginState'; @@ -28,6 +29,11 @@ export interface StandaloneEditorCorePlugins { */ readonly domEvent: PluginWithState; + /** + * Entity Plugin handles all operations related to an entity and generate entity specified events + */ + readonly entity: PluginWithState; + /** * Lifecycle plugin handles editor initialization and disposing */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/enum/EntityOperation.ts b/packages-content-model/roosterjs-content-model-types/lib/enum/EntityOperation.ts index d6a768f4dfc..9c9c9a30989 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/enum/EntityOperation.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/enum/EntityOperation.ts @@ -23,7 +23,12 @@ export type EntityLifecycleOperation = * Notify plugins that a new entity state need to be updated to an entity. * This is normally happened when user undo/redo the content with an entity snapshot added by a plugin that handles entity */ - | 'UpdateEntityState'; + | 'updateEntityState' + + /** + * Notify plugins that user is clicking target to an entity + */ + | 'click'; /** * Define entity removal related operations diff --git a/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts index 7c425792fcc..5ab4eec9b1f 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts @@ -1,3 +1,5 @@ +import type { ContentModelEntity } from '../entity/ContentModelEntity'; +import type { EntityRemovalOperation } from '../enum/EntityOperation'; import type { ContentModelDocument } from '../group/ContentModelDocument'; import type { DOMSelection } from '../selection/DOMSelection'; import type { @@ -6,6 +8,26 @@ import type { ContentChangedEventData, } from 'roosterjs-editor-types'; +/** + * Represents an entity that has been changed during a content change process + */ +export interface ChangedEntity { + /** + * The changed entity + */ + entity: ContentModelEntity; + + /** + * Operation that causes the change + */ + operation: EntityRemovalOperation | 'newEntity'; + + /** + * @optional Raw DOM event that causes the chagne + */ + rawEvent?: Event; +} + /** * Data of ContentModelContentChangedEvent */ @@ -13,12 +35,17 @@ export interface ContentModelContentChangedEventData extends ContentChangedEvent /** * The content model that is applied which causes this content changed event */ - contentModel?: ContentModelDocument; + readonly contentModel?: ContentModelDocument; /** * Selection range applied to the document */ - selection?: DOMSelection; + readonly selection?: DOMSelection; + + /** + * Entities got changed (added or removed) during the content change process + */ + readonly changedEntities?: ChangedEntity[]; } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index 6156a186799..c834231c92d 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -241,6 +241,7 @@ export { } from './pluginState/ContentModelFormatPluginState'; export { DOMEventPluginState } from './pluginState/DOMEventPluginState'; export { LifecyclePluginState } from './pluginState/LifecyclePluginState'; +export { EntityPluginState, KnownEntityItem } from './pluginState/EntityPluginState'; export { EditorEnvironment } from './parameter/EditorEnvironment'; export { @@ -271,4 +272,5 @@ export { ContentModelContentChangedEvent, CompatibleContentModelContentChangedEvent, ContentModelContentChangedEventData, + ChangedEntity, } from './event/ContentModelContentChangedEvent'; diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/EntityPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/EntityPluginState.ts new file mode 100644 index 00000000000..e5759c2077e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/EntityPluginState.ts @@ -0,0 +1,29 @@ +/** + * Represents all info of a known entity, including its DOM element, whether it is deleted and if it can be persisted + */ +export interface KnownEntityItem { + /** + * The HTML element of entity wrapper + */ + element: HTMLElement; + + /** + * Whether this entity is deleted. + */ + isDeleted?: boolean; + + /** + * Whether we want to persist this entity element during undo/redo + */ + canPersist?: boolean; +} + +/** + * The state object for EntityPlugin + */ +export interface EntityPluginState { + /** + * Entities cached for undo snapshot + */ + entityMap: Record; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts index f205b35a1e7..af2b30fd4dd 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts @@ -1,12 +1,12 @@ import type { CopyPastePluginState, EditPluginState, - EntityPluginState, UndoPluginState, } from 'roosterjs-editor-types'; import type { ContentModelCachePluginState } from './ContentModelCachePluginState'; import type { ContentModelFormatPluginState } from './ContentModelFormatPluginState'; import type { DOMEventPluginState } from './DOMEventPluginState'; +import type { EntityPluginState } from './EntityPluginState'; import type { LifecyclePluginState } from './LifecyclePluginState'; /** @@ -38,6 +38,11 @@ export interface StandaloneEditorCorePluginState { * Plugin state for LifecyclePlugin */ lifecycle: LifecyclePluginState; + + /** + * Plugin state for EntityPlugin + */ + entity: EntityPluginState; } /** @@ -45,7 +50,6 @@ export interface StandaloneEditorCorePluginState { * TODO: Port these plugins */ export interface UnportedCorePluginState { - entity: EntityPluginState; undo: UndoPluginState; edit: EditPluginState; } diff --git a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts index d8ebc205f48..676a8b26397 100644 --- a/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model/lib/createContentModelEditor.ts @@ -1,5 +1,9 @@ import { ContentModelEditor } from 'roosterjs-content-model-editor'; -import { ContentModelEditPlugin, ContentModelPastePlugin } from 'roosterjs-content-model-plugins'; +import { + ContentModelEditPlugin, + ContentModelPastePlugin, + EntityDelimiterPlugin, +} from 'roosterjs-content-model-plugins'; import type { EditorPlugin } from 'roosterjs-editor-types'; import type { ContentModelEditorOptions, @@ -20,7 +24,11 @@ export function createContentModelEditor( initialContent?: string ): IContentModelEditor { const plugins = additionalPlugins ? [...additionalPlugins] : []; - plugins.push(new ContentModelPastePlugin(), new ContentModelEditPlugin()); + plugins.push( + new ContentModelPastePlugin(), + new ContentModelEditPlugin(), + new EntityDelimiterPlugin() + ); const options: ContentModelEditorOptions = { plugins: plugins, From 453c6e12b9df7f275dccd6e6cb37d4a6e636ca42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 28 Nov 2023 15:54:00 -0300 Subject: [PATCH 062/111] test --- .../table/setTableCellBackgroundColor.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts index 94d6b0dfcc1..d7e285def22 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts @@ -8,7 +8,6 @@ const DARK_COLORS_LIGHTNESS = 20; const BRIGHT_COLORS_LIGHTNESS = 80; const White = '#ffffff'; const Black = '#000000'; -const ADAPTED_TEXT_COLORS = ['rgb(0,0,0)', '#000000', '#ffffff', 'rgb(255, 255, 255)']; /** * Set shade color of table cell @@ -65,14 +64,14 @@ function removeAdaptiveCellColor(cell: ContentModelTableCell) { if (block.blockType == 'Paragraph') { if ( block.segmentFormat?.textColor && - ADAPTED_TEXT_COLORS.indexOf(block.segmentFormat?.textColor) >= 0 + shouldRemoveColor(block.segmentFormat?.textColor, cell.format.backgroundColor || '') ) { delete block.segmentFormat.textColor; } block.segments.forEach(segment => { if ( segment.format.textColor && - ADAPTED_TEXT_COLORS.indexOf(segment.format.textColor) >= 0 + shouldRemoveColor(segment.format.textColor, cell.format.backgroundColor || '') ) { delete segment.format.textColor; } @@ -91,7 +90,6 @@ function setAdaptiveCellColor(cell: ContentModelTableCell) { textColor: cell.format.textColor, }; } - block.segments.forEach(segment => { if (!segment.format?.textColor) { segment.format = { @@ -105,6 +103,24 @@ function setAdaptiveCellColor(cell: ContentModelTableCell) { } } +/** + * If the cell background color is white or black, and the text color is white or black, we should remove the text color + * @param textColor the segment or block text color + * @param cellBackgroundColor the cell background color + * @returns + */ +function shouldRemoveColor(textColor: string, cellBackgroundColor: string) { + if ( + ([White, 'rgb(255,255,255)'].indexOf(textColor) > -1 && + [White, 'rgb(255,255,255)', ''].indexOf(cellBackgroundColor) > -1) || + ([Black, 'rgb(0,0,0)'].indexOf(textColor) > -1 && + [Black, 'rgb(0,0,0)', ''].indexOf(cellBackgroundColor) > -1) + ) { + return true; + } + return false; +} + function calculateLightness(color: string) { const colorValues = parseColor(color); From 13afd41c00861d1198e2908b23bf7f5fa0525fda Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 28 Nov 2023 15:23:25 -0800 Subject: [PATCH 063/111] Content Model: Keep image port if exist (#2226) --- .../domToModel/processors/imageProcessor.ts | 5 +++- .../processors/imageProcessorTest.ts | 28 +++++++++++++++++++ .../paste/processPastedContentFromWacTest.ts | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/imageProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/imageProcessor.ts index df62e8361af..bf37ae26adc 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/imageProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/imageProcessor.ts @@ -12,11 +12,14 @@ export const imageProcessor: ElementProcessor = (group, elemen stackFormat(context, { segment: 'shallowClone' }, () => { const imageFormat: ContentModelImageFormat = context.segmentFormat; + // Use getAttribute('src') instead of retrieving src directly, in case the src has port and may be stripped by browser + const src = element.getAttribute('src') ?? ''; + parseFormat(element, context.formatParsers.segment, imageFormat, context); parseFormat(element, context.formatParsers.image, imageFormat, context); parseFormat(element, context.formatParsers.block, context.blockFormat, context); - const image = createImage(element.src, imageFormat); + const image = createImage(src, imageFormat); const alt = element.alt; const title = element.title; diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts index cf591255318..4fc31b81bbb 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/imageProcessorTest.ts @@ -71,6 +71,34 @@ describe('imageProcessor', () => { }); }); + it('Image with src and port', () => { + const doc = createContentModelDocument(); + const img = document.createElement('img'); + + img.src = 'http://test.com:80/testSrc'; + + imageProcessor(doc, img, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Image', + format: {}, + src: 'http://test.com:80/testSrc', + dataset: {}, + }, + ], + }, + ], + }); + }); + it('Image with regular selection', () => { const doc = createContentModelDocument(); const img = document.createElement('img'); diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts index e505f48d5a0..5144dde363e 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/processPastedContentFromWacTest.ts @@ -1355,7 +1355,7 @@ describe('wordOnlineHandler', () => { segments: [ { segmentType: 'Image', - src: 'http://www.microsoft.com/', + src: 'http://www.microsoft.com', format: { letterSpacing: 'normal', fontFamily: From 6c07965bc75e898d5e261468acde02eee152523f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 29 Nov 2023 10:34:59 -0300 Subject: [PATCH 064/111] add color spectrum check --- .../lib/publicApi/table/setTableCellBackgroundColor.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts index d7e285def22..adf90b10804 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts @@ -104,17 +104,18 @@ function setAdaptiveCellColor(cell: ContentModelTableCell) { } /** - * If the cell background color is white or black, and the text color is white or black, we should remove the text color + * If the cell background color is too dark or too bright, and the text color is white or black, we should remove the text color * @param textColor the segment or block text color * @param cellBackgroundColor the cell background color * @returns */ function shouldRemoveColor(textColor: string, cellBackgroundColor: string) { + const lightness = calculateLightness(cellBackgroundColor); if ( ([White, 'rgb(255,255,255)'].indexOf(textColor) > -1 && - [White, 'rgb(255,255,255)', ''].indexOf(cellBackgroundColor) > -1) || + (lightness > BRIGHT_COLORS_LIGHTNESS || cellBackgroundColor == '')) || ([Black, 'rgb(0,0,0)'].indexOf(textColor) > -1 && - [Black, 'rgb(0,0,0)', ''].indexOf(cellBackgroundColor) > -1) + (lightness < DARK_COLORS_LIGHTNESS || cellBackgroundColor == '')) ) { return true; } From 676ad2d828d0ffc4aab3619212bc4deb5c494433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 29 Nov 2023 11:35:45 -0300 Subject: [PATCH 065/111] auto format test --- .../lib/plugins/AutoFormat/AutoFormat.ts | 13 +++++++++---- .../test/AutoFormat/autoFormatTest.ts | 11 +++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/roosterjs-editor-plugins/lib/plugins/AutoFormat/AutoFormat.ts b/packages/roosterjs-editor-plugins/lib/plugins/AutoFormat/AutoFormat.ts index a71090d70dc..c1396475f3d 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/AutoFormat/AutoFormat.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/AutoFormat/AutoFormat.ts @@ -59,17 +59,19 @@ export default class AutoFormat implements EditorPlugin { if ( this.lastKeyTyped === '-' && !specialCharacters.test(keyTyped) && - keyTyped !== ' ' && keyTyped !== '-' ) { const searcher = this.editor.getContentSearcherOfCursor(event); const textBeforeCursor = searcher?.getSubStringBefore(3); const dashes = searcher?.getSubStringBefore(2); const isPrecededByADash = textBeforeCursor?.[0] === '-'; - const isPrecededByASpace = textBeforeCursor?.[0] === ' '; + const isSpaced = + (textBeforeCursor == ' --' && keyTyped !== ' ') || + (textBeforeCursor !== ' --' && keyTyped === ' '); + if ( isPrecededByADash || - isPrecededByASpace || + isSpaced || (typeof textBeforeCursor === 'string' && specialCharacters.test(textBeforeCursor[0])) || dashes !== '--' @@ -78,7 +80,10 @@ export default class AutoFormat implements EditorPlugin { } const textRange = searcher?.getRangeFromText(dashes, true /* exactMatch */); - const nodeHyphen = document.createTextNode('—'); + const nodeHyphen = + textBeforeCursor === ' --' && keyTyped === ' ' + ? document.createTextNode('–') + : document.createTextNode('—'); this.editor.addUndoSnapshot( () => { if (textRange) { diff --git a/packages/roosterjs-editor-plugins/test/AutoFormat/autoFormatTest.ts b/packages/roosterjs-editor-plugins/test/AutoFormat/autoFormatTest.ts index 6218ffc8e33..88c4064114f 100644 --- a/packages/roosterjs-editor-plugins/test/AutoFormat/autoFormatTest.ts +++ b/packages/roosterjs-editor-plugins/test/AutoFormat/autoFormatTest.ts @@ -34,6 +34,9 @@ describe('AutoHyphen |', () => { plugin.onPluginEvent(keyDown(keysTyped[1])); plugin.onPluginEvent(keyDown(keysTyped[2])); plugin.onPluginEvent(keyDown(keysTyped[3])); + plugin.onPluginEvent(keyDown(keysTyped[4])); + plugin.onPluginEvent(keyDown(keysTyped[5])); + plugin.onPluginEvent(keyDown(keysTyped[6])); expect(editor.getContent()).toBe(expectedResult); } @@ -45,6 +48,14 @@ describe('AutoHyphen |', () => { ); }); + it('Should format with space ', () => { + runTestShouldHandleAutoHyphen( + '
                                                          t--
                                                          ', + ['t', ' ', '-', '-', ' ', 'b'], + '
                                                          t—
                                                          ' + ); + }); + it('Should not format| - ', () => { runTestShouldHandleAutoHyphen( '
                                                          t—-
                                                          ', From 263e6eb4dd4eaf91acbb8af4cb27ea4353149d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Wed, 29 Nov 2023 13:16:02 -0300 Subject: [PATCH 066/111] remove empty line --- .../lib/publicApi/table/setTableCellBackgroundColor.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts index adf90b10804..3fb0593f789 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/table/setTableCellBackgroundColor.ts @@ -15,9 +15,7 @@ const Black = '#000000'; * @param color The color to set * @param isColorOverride @optional When pass true, it means this shade color is not part of table format, so it can be preserved when apply table format * @param applyToSegments @optional When pass true, we will also apply text color from table cell to its child blocks and segments - * */ - export function setTableCellBackgroundColor( cell: ContentModelTableCell, color: string | null | undefined, From d5271d52758391e20ad8a8da55710d0fae9431dd Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 1 Dec 2023 10:45:47 -0800 Subject: [PATCH 067/111] Allow each package has its own version when publish (#2233) * Allow each package has its own version when publish * remove unnecessary change --- tools/buildTools/normalize.js | 8 ++++---- versions.json | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tools/buildTools/normalize.js b/tools/buildTools/normalize.js index 740489870a3..8236c64979c 100644 --- a/tools/buildTools/normalize.js +++ b/tools/buildTools/normalize.js @@ -19,7 +19,7 @@ function normalize() { allPackages.forEach(packageName => { const versionKey = findPackageRoot(packageName); - const version = versions[versionKey]; + const version = versions.overrides?.[packageName] ?? versions[versionKey]; const packageJson = readPackageJson(packageName, true /*readFromSourceFolder*/); Object.keys(packageJson.dependencies).forEach(dep => { @@ -38,12 +38,12 @@ function normalize() { } }); - if (packageJson.version && packageJson.version != '0.0.0') { - knownCustomizedPackages[packageName] = packageJson.version; - } else { + if (!packageJson.version || packageJson.version == '0.0.0') { packageJson.version = version; } + knownCustomizedPackages[packageName] = packageJson.version; + packageJson.typings = './lib/index.d.ts'; packageJson.main = './lib/index.js'; packageJson.module = './lib-mjs/index.js'; diff --git a/versions.json b/versions.json index 62eb04c9ee4..b11ce22809a 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,6 @@ { "packages": "0.0.0", "packages-ui": "0.0.0", - "packages-content-model": "0.0.0" + "packages-content-model": "0.0.0", + "overrides": {} } From 5520f04fa5357ac1a910694485157ea2a26bd119 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 1 Dec 2023 11:06:30 -0800 Subject: [PATCH 068/111] Content Model: Fix #2230 (#2231) --- .../lib/edit/keyboardInput.ts | 13 ++++-- .../test/edit/keyboardInputTest.ts | 43 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 7f56900266b..3680f3c67d5 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -13,7 +13,7 @@ export function keyboardInput(editor: IContentModelEditor, rawEvent: KeyboardEve editor.formatContentModel( (model, context) => { - const result = deleteSelection(model, [], context).deleteResult; + const result = deleteSelection(model, [], context); // We have deleted selection then we will let browser to handle the input. // With this combined operation, we don't wan to mass up the cached model so clear it @@ -22,8 +22,15 @@ export function keyboardInput(editor: IContentModelEditor, rawEvent: KeyboardEve // Skip undo snapshot here and add undo snapshot before the operation so that we don't add another undo snapshot in middle of this replace operation context.skipUndoSnapshot = true; - // Do not preventDefault since we still want browser to handle the final input for now - return result == 'range'; + if (result.deleteResult == 'range') { + // We have deleted something, next input should inherit the segment format from deleted content, so set pending format here + context.newPendingFormat = result.insertPoint?.marker.format; + + // Do not preventDefault since we still want browser to handle the final input for now + return true; + } else { + return false; + } }, { rawEvent, diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts index 4842b861705..93fb2ff88be 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts @@ -130,6 +130,7 @@ describe('keyboardInput', () => { newImages: [], clearModelCache: true, skipUndoSnapshot: true, + newPendingFormat: undefined, }); }); @@ -158,6 +159,7 @@ describe('keyboardInput', () => { newImages: [], clearModelCache: true, skipUndoSnapshot: true, + newPendingFormat: undefined, }); }); @@ -186,6 +188,7 @@ describe('keyboardInput', () => { newImages: [], clearModelCache: true, skipUndoSnapshot: true, + newPendingFormat: undefined, }); }); @@ -268,6 +271,7 @@ describe('keyboardInput', () => { newImages: [], clearModelCache: true, skipUndoSnapshot: true, + newPendingFormat: undefined, }); }); @@ -322,6 +326,45 @@ describe('keyboardInput', () => { newImages: [], clearModelCache: true, skipUndoSnapshot: true, + newPendingFormat: undefined, + }); + }); + + it('Letter input, expanded selection, no modifier key, deleteSelection returns range, has segment format', () => { + const mockedFormat = 'FORMAT' as any; + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: false, + }, + }); + deleteSelectionSpy.and.returnValue({ + deleteResult: 'range', + insertPoint: { + marker: { + format: mockedFormat, + }, + }, + }); + + const rawEvent = { + key: 'A', + } as any; + + keyboardInput(editor, rawEvent); + + expect(getDOMSelectionSpy).toHaveBeenCalled(); + expect(addUndoSnapshotSpy).toHaveBeenCalled(); + expect(formatContentModelSpy).toHaveBeenCalled(); + expect(deleteSelectionSpy).toHaveBeenCalledWith(mockedModel, [], mockedContext); + expect(formatResult).toBeTrue(); + expect(mockedContext).toEqual({ + deletedEntities: [], + newEntities: [], + newImages: [], + clearModelCache: true, + skipUndoSnapshot: true, + newPendingFormat: mockedFormat, }); }); }); From e01419602770f51a37c187398913d1cffd28d6bb Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 1 Dec 2023 11:51:39 -0800 Subject: [PATCH 069/111] Content Model: keep default format when paste into empty editor (#2232) --- .../publicApi/model/createModelFromHtml.ts | 19 ++++++++++++++++--- .../lib/publicApi/model/paste.ts | 6 +++++- .../lib/editor/createEditorCore.ts | 3 ++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts index 0ec4ef88545..e289cb0fb52 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/createModelFromHtml.ts @@ -1,5 +1,9 @@ import { createDomToModelContext, domToContentModel } from 'roosterjs-content-model-dom'; -import type { ContentModelDocument, DomToModelOption } from 'roosterjs-content-model-types'; +import type { + ContentModelDocument, + ContentModelSegmentFormat, + DomToModelOption, +} from 'roosterjs-content-model-types'; import type { TrustedHTMLHandler } from 'roosterjs-editor-types'; /** @@ -12,11 +16,20 @@ import type { TrustedHTMLHandler } from 'roosterjs-editor-types'; export function createModelFromHtml( html: string, options?: DomToModelOption, - trustedHTMLHandler?: TrustedHTMLHandler + trustedHTMLHandler?: TrustedHTMLHandler, + defaultSegmentFormat?: ContentModelSegmentFormat ): ContentModelDocument | undefined { const doc = new DOMParser().parseFromString(trustedHTMLHandler?.(html) ?? html, 'text/html'); return doc?.body - ? domToContentModel(doc.body, createDomToModelContext(undefined /*editorContext*/, options)) + ? domToContentModel( + doc.body, + createDomToModelContext( + { + defaultFormat: defaultSegmentFormat, + }, + options + ) + ) : undefined; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts index b3faba1a10d..c191ee6721d 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/model/paste.ts @@ -107,7 +107,11 @@ export function paste( } if (originalFormat) { - context.newPendingFormat = { ...EmptySegmentFormat, ...originalFormat }; // Use empty format as initial value to clear any other format inherits from pasted content + context.newPendingFormat = { + ...EmptySegmentFormat, + ...model.format, + ...originalFormat, + }; // Use empty format as initial value to clear any other format inherits from pasted content } return true; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts index 0eb0db09f75..b1392b88c88 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts @@ -32,7 +32,8 @@ export function createEditorCore( options.initialModel = createModelFromHtml( initContent, options.defaultDomToModelOptions, - options.trustedHTMLHandler + options.trustedHTMLHandler, + options.defaultSegmentFormat ); } From 89a61d0ad080a0456d17ff0a4d4c60a42f0973c9 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 1 Dec 2023 11:56:47 -0800 Subject: [PATCH 070/111] Standalone Editor: Add a SelectionPlugin (#2228) --- .../lib/coreApi/setContentModel.ts | 2 +- .../lib/corePlugin/DOMEventPlugin.ts | 67 +-------- .../lib/corePlugin/SelectionPlugin.ts | 127 ++++++++++++++++++ .../createStandaloneEditorCorePlugins.ts | 2 + .../lib/editor/createStandaloneEditorCore.ts | 3 +- .../test/corePlugin/DomEventPluginTest.ts | 89 ------------ .../lib/coreApi/focus.ts | 6 +- .../lib/coreApi/getSelectionRange.ts | 2 +- .../lib/coreApi/getSelectionRangeEx.ts | 14 +- .../lib/coreApi/select.ts | 26 ++-- .../lib/coreApi/selectImage.ts | 2 +- .../lib/coreApi/selectRange.ts | 2 +- .../lib/coreApi/setContent.ts | 14 +- .../test/editor/createEditorCoreTest.ts | 12 +- .../lib/editor/StandaloneEditorCore.ts | 5 - .../lib/editor/StandaloneEditorCorePlugins.ts | 6 + .../lib/index.ts | 1 + .../lib/pluginState/DOMEventPluginState.ts | 26 +--- .../lib/pluginState/SelectionPluginState.ts | 36 +++++ .../StandaloneEditorPluginState.ts | 6 + 20 files changed, 229 insertions(+), 219 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts index f0cb94159b6..d7f4eed9161 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts @@ -33,7 +33,7 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea if (!option?.ignoreSelection) { core.api.setDOMSelection(core, selection); } else if (selection.type == 'range') { - core.domEvent.selectionRange = selection.range; + core.selection.selectionRange = selection.range; } } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts index 3561e1526d9..8d639bec281 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts @@ -38,11 +38,8 @@ class DOMEventPlugin implements PluginWithState { this.state = { isInIME: false, scrollContainer: options.scrollContainer || contentDiv, - selectionRange: null, contextMenuProviders: options.plugins?.filter>(isContextMenuProvider) || [], - tableSelectionRange: null, - imageSelectionRange: null, mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, @@ -90,27 +87,13 @@ class DOMEventPlugin implements PluginWithState { dragstart: this.onDragStart, drop: this.onDrop, - // 5. Focus management - focus: this.onFocus, - - // 6. Input event + // 5. Input event input: this.getEventHandler(PluginEventType.Input), }; - const env = this.editor.getEnvironment(); - - // 7. onBlur handlers - if (env.isSafari) { - document.addEventListener('mousedown', this.onMouseDownDocument, true /*useCapture*/); - document.addEventListener('keydown', this.onKeyDownDocument); - document.defaultView?.addEventListener('blur', this.cacheSelection); - } else { - eventHandlers.blur = this.cacheSelection; - } - this.disposer = editor.addDomEventHandler(>eventHandlers); - // 8. Scroll event + // 7. Scroll event this.state.scrollContainer.addEventListener('scroll', this.onScroll); document.defaultView?.addEventListener('scroll', this.onScroll); document.defaultView?.addEventListener('resize', this.onScroll); @@ -123,15 +106,6 @@ class DOMEventPlugin implements PluginWithState { this.removeMouseUpEventListener(); const document = this.editor?.getDocument(); - if (document) { - document.removeEventListener( - 'mousedown', - this.onMouseDownDocument, - true /*useCapture*/ - ); - document.removeEventListener('keydown', this.onKeyDownDocument); - document.defaultView?.removeEventListener('blur', this.cacheSelection); - } document?.defaultView?.removeEventListener('resize', this.onScroll); document?.defaultView?.removeEventListener('scroll', this.onScroll); @@ -162,43 +136,6 @@ class DOMEventPlugin implements PluginWithState { }); }; - private onFocus = () => { - if (!this.state.skipReselectOnFocus) { - const { table, coordinates } = this.state.tableSelectionRange || {}; - const { image } = this.state.imageSelectionRange || {}; - - if (table && coordinates) { - this.editor?.select(table, coordinates); - } else if (image) { - this.editor?.select(image); - } else if (this.state.selectionRange) { - this.editor?.select(this.state.selectionRange); - } - } - - this.state.selectionRange = null; - }; - private onKeyDownDocument = (event: KeyboardEvent) => { - if (event.which == Keys.TAB && !event.defaultPrevented) { - this.cacheSelection(); - } - }; - - private onMouseDownDocument = (event: MouseEvent) => { - if ( - this.editor && - !this.state.selectionRange && - !this.editor.contains(event.target as Node) - ) { - this.cacheSelection(); - } - }; - - private cacheSelection = () => { - if (!this.state.selectionRange && this.editor) { - this.state.selectionRange = this.editor.getSelectionRange(false /*tryGetFromCache*/); - } - }; private onScroll = (e: Event) => { this.editor?.triggerPluginEvent(PluginEventType.Scroll, { rawEvent: e, diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts new file mode 100644 index 00000000000..c2093e1c984 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts @@ -0,0 +1,127 @@ +import type { IEditor, PluginWithState } from 'roosterjs-editor-types'; +import type { + IStandaloneEditor, + SelectionPluginState, + StandaloneEditorOptions, +} from 'roosterjs-content-model-types'; + +class SelectionPlugin implements PluginWithState { + private editor: (IStandaloneEditor & IEditor) | null = null; + private state: SelectionPluginState; + private disposer: (() => void) | null = null; + + constructor(options: StandaloneEditorOptions) { + this.state = { + selectionRange: null, + tableSelectionRange: null, + imageSelectionRange: null, + selectionStyleNode: null, + imageSelectionBorderColor: options.imageSelectionBorderColor, // TODO: Move to Selection core plugin + }; + } + + getName() { + return 'Selection'; + } + + initialize(editor: IEditor) { + this.editor = editor as IEditor & IStandaloneEditor; + + const doc = this.editor.getDocument(); + const styleNode = doc.createElement('style'); + + doc.head.appendChild(styleNode); + this.state.selectionStyleNode = styleNode; + + const env = this.editor.getEnvironment(); + const document = this.editor.getDocument(); + + if (env.isSafari) { + document.addEventListener('mousedown', this.onMouseDownDocument, true /*useCapture*/); + document.addEventListener('keydown', this.onKeyDownDocument); + document.defaultView?.addEventListener('blur', this.onBlur); + this.disposer = this.editor.addDomEventHandler('focus', this.onFocus); + } else { + this.disposer = this.editor.addDomEventHandler({ + focus: this.onFocus, + blur: this.onBlur, + }); + } + } + + dispose() { + if (this.state.selectionStyleNode) { + this.state.selectionStyleNode.parentNode?.removeChild(this.state.selectionStyleNode); + this.state.selectionStyleNode = null; + } + + if (this.disposer) { + this.disposer(); + this.disposer = null; + } + + if (this.editor) { + const document = this.editor.getDocument(); + + document.removeEventListener( + 'mousedown', + this.onMouseDownDocument, + true /*useCapture*/ + ); + document.removeEventListener('keydown', this.onKeyDownDocument); + document.defaultView?.removeEventListener('blur', this.onBlur); + + this.editor = null; + } + } + + getState(): SelectionPluginState { + return this.state; + } + + private onFocus = () => { + if (!this.state.skipReselectOnFocus && this.editor) { + const { table, coordinates } = this.state.tableSelectionRange || {}; + const { image } = this.state.imageSelectionRange || {}; + + if (table && coordinates) { + this.editor.select(table, coordinates); + } else if (image) { + this.editor.select(image); + } else if (this.state.selectionRange) { + this.editor.select(this.state.selectionRange); + } + } + + this.state.selectionRange = null; + }; + + private onBlur = () => { + if (!this.state.selectionRange && this.editor) { + this.state.selectionRange = this.editor.getSelectionRange(false /*tryGetFromCache*/); + } + }; + + private onKeyDownDocument = (event: KeyboardEvent) => { + if (event.key == 'Tab' && !event.defaultPrevented) { + this.onBlur(); + } + }; + + private onMouseDownDocument = (event: MouseEvent) => { + if (this.editor && !this.editor.contains(event.target as Node)) { + this.onBlur(); + } + }; +} + +/** + * @internal + * Create a new instance of SelectionPlugin. + * @param option The editor option + */ +export function createSelectionPlugin( + options: StandaloneEditorOptions +): PluginWithState { + return new SelectionPlugin(options); +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts index 48e3caffef1..de2080bc6c3 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts @@ -4,6 +4,7 @@ import { createContentModelFormatPlugin } from './ContentModelFormatPlugin'; import { createDOMEventPlugin } from './DOMEventPlugin'; import { createEntityPlugin } from './EntityPlugin'; import { createLifecyclePlugin } from './LifecyclePlugin'; +import { createSelectionPlugin } from './SelectionPlugin'; import type { StandaloneEditorCorePlugins, StandaloneEditorOptions, @@ -25,5 +26,6 @@ export function createStandaloneEditorCorePlugins( domEvent: createDOMEventPlugin(options, contentDiv), lifecycle: createLifecyclePlugin(options, contentDiv), entity: createEntityPlugin(), + selection: createSelectionPlugin(options), }; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts index c221789659a..aa92da5fad5 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts @@ -36,13 +36,13 @@ export function createStandaloneEditorCore( corePlugins.format, corePlugins.copyPaste, corePlugins.domEvent, + corePlugins.selection, corePlugins.entity, ...tempPlugins, corePlugins.lifecycle, ], environment: createEditorEnvironment(), darkColorHandler: new DarkColorHandlerImpl(contentDiv, options.baseDarkColor), - imageSelectionBorderColor: options.imageSelectionBorderColor, // TODO: Move to Selection core plugin trustedHTMLHandler: options.trustedHTMLHandler || defaultTrustHtmlHandler, ...createStandaloneEditorDefaultSettings(options), ...getPluginState(corePlugins), @@ -79,5 +79,6 @@ function getPluginState(corePlugins: StandaloneEditorCorePlugins): StandaloneEdi format: corePlugins.format.getState(), lifecycle: corePlugins.lifecycle.getState(), entity: corePlugins.entity.getState(), + selection: corePlugins.selection.getState(), }; } diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts index b9105edad93..fa608f331c1 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts @@ -33,10 +33,7 @@ describe('DOMEventPlugin', () => { expect(state).toEqual({ isInIME: false, scrollContainer: div, - selectionRange: null, contextMenuProviders: [], - tableSelectionRange: null, - imageSelectionRange: null, mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, @@ -85,10 +82,7 @@ describe('DOMEventPlugin', () => { expect(state).toEqual({ isInIME: false, scrollContainer: divScrollContainer, - selectionRange: null, contextMenuProviders: [], - tableSelectionRange: null, - imageSelectionRange: null, mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, @@ -142,7 +136,6 @@ describe('DOMEventPlugin verify event handlers while disallow keyboard event pro expect(eventMap.compositionend).toBeDefined(); expect(eventMap.dragstart).toBeDefined(); expect(eventMap.drop).toBeDefined(); - expect(eventMap.focus).toBeDefined(); expect(eventMap.mouseup).not.toBeDefined(); }); @@ -243,10 +236,7 @@ describe('DOMEventPlugin handle mouse down and mouse up event', () => { expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - selectionRange: null, contextMenuProviders: [], - tableSelectionRange: null, - imageSelectionRange: null, mouseDownX: 100, mouseDownY: 200, mouseUpEventListerAdded: true, @@ -265,10 +255,7 @@ describe('DOMEventPlugin handle mouse down and mouse up event', () => { expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - selectionRange: null, contextMenuProviders: [], - tableSelectionRange: null, - imageSelectionRange: null, mouseDownX: 100, mouseDownY: 200, mouseUpEventListerAdded: true, @@ -285,10 +272,7 @@ describe('DOMEventPlugin handle mouse down and mouse up event', () => { expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - selectionRange: null, contextMenuProviders: [], - tableSelectionRange: null, - imageSelectionRange: null, mouseDownX: 100, mouseDownY: 200, mouseUpEventListerAdded: false, @@ -311,10 +295,7 @@ describe('DOMEventPlugin handle mouse down and mouse up event', () => { expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - selectionRange: null, contextMenuProviders: [], - tableSelectionRange: null, - imageSelectionRange: null, mouseDownX: 100, mouseDownY: 200, mouseUpEventListerAdded: true, @@ -331,10 +312,7 @@ describe('DOMEventPlugin handle mouse down and mouse up event', () => { expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - selectionRange: null, contextMenuProviders: [], - tableSelectionRange: null, - imageSelectionRange: null, mouseDownX: 100, mouseDownY: 200, mouseUpEventListerAdded: false, @@ -394,10 +372,7 @@ describe('DOMEventPlugin handle other event', () => { expect(plugin.getState()).toEqual({ isInIME: true, scrollContainer: scrollContainer, - selectionRange: null, contextMenuProviders: [], - tableSelectionRange: null, - imageSelectionRange: null, mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, @@ -410,10 +385,7 @@ describe('DOMEventPlugin handle other event', () => { expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - selectionRange: null, contextMenuProviders: [], - tableSelectionRange: null, - imageSelectionRange: null, mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, @@ -436,10 +408,7 @@ describe('DOMEventPlugin handle other event', () => { expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - selectionRange: null, contextMenuProviders: [], - tableSelectionRange: null, - imageSelectionRange: null, mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, @@ -462,10 +431,7 @@ describe('DOMEventPlugin handle other event', () => { expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - selectionRange: null, contextMenuProviders: [], - tableSelectionRange: null, - imageSelectionRange: null, mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, @@ -484,10 +450,7 @@ describe('DOMEventPlugin handle other event', () => { expect(plugin.getState()).toEqual({ isInIME: false, scrollContainer: scrollContainer, - selectionRange: null, contextMenuProviders: [], - tableSelectionRange: null, - imageSelectionRange: null, mouseDownX: null, mouseDownY: null, mouseUpEventListerAdded: false, @@ -495,58 +458,6 @@ describe('DOMEventPlugin handle other event', () => { expect(addUndoSnapshotSpy).toHaveBeenCalledWith(jasmine.anything(), ChangeSource.Drop); }); - it('Trigger onFocus event', () => { - const selectSpy = jasmine.createSpy('select'); - editor.select = selectSpy; - - const state = plugin.getState(); - const mockedRange = 'RANGE' as any; - - state.skipReselectOnFocus = false; - state.selectionRange = mockedRange; - - eventMap.focus(); - expect(plugin.getState()).toEqual({ - isInIME: false, - scrollContainer: scrollContainer, - selectionRange: null, - contextMenuProviders: [], - tableSelectionRange: null, - imageSelectionRange: null, - mouseDownX: null, - mouseDownY: null, - mouseUpEventListerAdded: false, - skipReselectOnFocus: false, - }); - expect(selectSpy).toHaveBeenCalledWith(mockedRange); - }); - - it('Trigger onFocus event, skip reselect', () => { - const selectSpy = jasmine.createSpy('select'); - editor.select = selectSpy; - - const state = plugin.getState(); - const mockedRange = 'RANGE' as any; - - state.skipReselectOnFocus = true; - state.selectionRange = mockedRange; - - eventMap.focus(); - expect(plugin.getState()).toEqual({ - isInIME: false, - scrollContainer: scrollContainer, - selectionRange: null, - contextMenuProviders: [], - tableSelectionRange: null, - imageSelectionRange: null, - mouseDownX: null, - mouseDownY: null, - mouseUpEventListerAdded: false, - skipReselectOnFocus: true, - }); - expect(selectSpy).not.toHaveBeenCalled(); - }); - it('Trigger contextmenu event, skip reselect', () => { editor.getContentSearcherOfCursor = () => null!; const state = plugin.getState(); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts index 7291611b19a..2df2921d98a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts @@ -21,8 +21,8 @@ export const focus: Focus = core => { // to very begin to of editor since we don't really have last saved selection (created on blur which does not fire in this case). // It should be better than the case you cannot type if ( - !core.domEvent.selectionRange || - !core.api.selectRange(core, core.domEvent.selectionRange, true /*skipSameRange*/) + !core.selection.selectionRange || + !core.api.selectRange(core, core.selection.selectionRange, true /*skipSameRange*/) ) { const node = getFirstLeafNode(core.contentDiv) || core.contentDiv; core.api.selectRange( @@ -34,7 +34,7 @@ export const focus: Focus = core => { } // remember to clear cached selection range - core.domEvent.selectionRange = null; + core.selection.selectionRange = null; // This is more a fallback to ensure editor gets focus if it didn't manage to move focus to editor if (!core.api.hasFocus(core)) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts index 89b91f22b74..b357d56860c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts @@ -25,7 +25,7 @@ export const getSelectionRange: GetSelectionRange = (core, tryGetFromCache: bool } if (!result && tryGetFromCache) { - result = core.domEvent.selectionRange; + result = core.selection.selectionRange; } return result; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts index 687f2494f22..3d1f59e1861 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts @@ -15,12 +15,12 @@ export const getSelectionRangeEx: GetSelectionRangeEx = core => { return createNormalSelectionEx([]); } else { if (core.api.hasFocus(core)) { - if (core.domEvent.tableSelectionRange) { - return core.domEvent.tableSelectionRange; + if (core.selection.tableSelectionRange) { + return core.selection.tableSelectionRange; } - if (core.domEvent.imageSelectionRange) { - return core.domEvent.imageSelectionRange; + if (core.selection.imageSelectionRange) { + return core.selection.imageSelectionRange; } const selection = core.contentDiv.ownerDocument.defaultView?.getSelection(); @@ -33,10 +33,10 @@ export const getSelectionRangeEx: GetSelectionRangeEx = core => { } return ( - core.domEvent.tableSelectionRange ?? - core.domEvent.imageSelectionRange ?? + core.selection.tableSelectionRange ?? + core.selection.imageSelectionRange ?? createNormalSelectionEx( - core.domEvent.selectionRange ? [core.domEvent.selectionRange] : [] + core.selection.selectionRange ? [core.selection.selectionRange] : [] ) ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts index f6aad98ee84..b47997a30b3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts @@ -23,20 +23,20 @@ export const select: Select = (core, arg1, arg2, arg3, arg4) => { const rangeEx = buildRangeEx(core, arg1, arg2, arg3, arg4); if (rangeEx) { - const skipReselectOnFocus = core.domEvent.skipReselectOnFocus; + const skipReselectOnFocus = core.selection.skipReselectOnFocus; // We are applying a new selection, so we don't need to apply cached selection in DOMEventPlugin. // Set skipReselectOnFocus to skip this behavior - core.domEvent.skipReselectOnFocus = true; + core.selection.skipReselectOnFocus = true; try { applyRangeEx(core, rangeEx); } finally { - core.domEvent.skipReselectOnFocus = skipReselectOnFocus; + core.selection.skipReselectOnFocus = skipReselectOnFocus; } } else { - core.domEvent.tableSelectionRange = core.api.selectTable(core, null); - core.domEvent.imageSelectionRange = core.api.selectImage(core, null); + core.selection.tableSelectionRange = core.api.selectTable(core, null); + core.selection.imageSelectionRange = core.api.selectImage(core, null); } return !!rangeEx; @@ -100,25 +100,25 @@ function applyRangeEx(core: StandaloneEditorCore, rangeEx: SelectionRangeEx | nu switch (rangeEx?.type) { case SelectionRangeTypes.TableSelection: if (contains(core.contentDiv, rangeEx.table)) { - core.domEvent.imageSelectionRange = core.api.selectImage(core, null); - core.domEvent.tableSelectionRange = core.api.selectTable( + core.selection.imageSelectionRange = core.api.selectImage(core, null); + core.selection.tableSelectionRange = core.api.selectTable( core, rangeEx.table, rangeEx.coordinates ); - rangeEx = core.domEvent.tableSelectionRange; + rangeEx = core.selection.tableSelectionRange; } break; case SelectionRangeTypes.ImageSelection: if (contains(core.contentDiv, rangeEx.image)) { - core.domEvent.tableSelectionRange = core.api.selectTable(core, null); - core.domEvent.imageSelectionRange = core.api.selectImage(core, rangeEx.image); - rangeEx = core.domEvent.imageSelectionRange; + core.selection.tableSelectionRange = core.api.selectTable(core, null); + core.selection.imageSelectionRange = core.api.selectImage(core, rangeEx.image); + rangeEx = core.selection.imageSelectionRange; } break; case SelectionRangeTypes.Normal: - core.domEvent.tableSelectionRange = core.api.selectTable(core, null); - core.domEvent.imageSelectionRange = core.api.selectImage(core, null); + core.selection.tableSelectionRange = core.api.selectTable(core, null); + core.selection.imageSelectionRange = core.api.selectImage(core, null); if (contains(core.contentDiv, rangeEx.ranges[0])) { core.api.selectRange(core, rangeEx.ranges[0]); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts index 058e3c35bf6..cefe659926a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts @@ -55,7 +55,7 @@ const select = (core: StandaloneEditorCore, image: HTMLImageElement) => { const buildBorderCSS = (core: StandaloneEditorCore, imageId: string): string => { const divId = core.contentDiv.id; - const color = core.imageSelectionBorderColor || DEFAULT_SELECTION_BORDER_COLOR; + const color = core.selection.imageSelectionBorderColor || DEFAULT_SELECTION_BORDER_COLOR; return `#${divId} #${imageId} {outline-style: auto!important;outline-color: ${color}!important;caret-color: transparent!important;}`; }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts index 810f5b81dbc..63b74bf8976 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts @@ -15,7 +15,7 @@ export const selectRange: SelectRange = (core, range, skipSameRange) => { addRangeToSelection(range, skipSameRange); if (!core.api.hasFocus(core)) { - core.domEvent.selectionRange = range; + core.selection.selectionRange = range; } return true; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts index 49db9d90a6a..e535eae9f8b 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts @@ -79,9 +79,9 @@ export const setContent: SetContent = (core, content, triggerContentChangedEvent function selectContentMetadata(core: StandaloneEditorCore, metadata: ContentMetadata | undefined) { if (!core.lifecycle.shadowEditFragment && metadata) { - core.domEvent.tableSelectionRange = null; - core.domEvent.imageSelectionRange = null; - core.domEvent.selectionRange = null; + core.selection.tableSelectionRange = null; + core.selection.imageSelectionRange = null; + core.selection.selectionRange = null; switch (metadata.type) { case SelectionRangeTypes.Normal: @@ -98,7 +98,11 @@ function selectContentMetadata(core: StandaloneEditorCore, metadata: ContentMeta )[0] as HTMLTableElement; if (table) { - core.domEvent.tableSelectionRange = core.api.selectTable(core, table, metadata); + core.selection.tableSelectionRange = core.api.selectTable( + core, + table, + metadata + ); } break; case SelectionRangeTypes.ImageSelection: @@ -108,7 +112,7 @@ function selectContentMetadata(core: StandaloneEditorCore, metadata: ContentMeta )[0] as HTMLImageElement; if (image) { - core.domEvent.imageSelectionRange = core.api.selectImage(core, image); + core.selection.imageSelectionRange = core.api.selectImage(core, image); } break; } diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts index ab259fc40d4..d932279742b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts @@ -8,6 +8,7 @@ import * as EntityPlugin from 'roosterjs-content-model-core/lib/corePlugin/Entit import * as ImageSelection from '../../lib/corePlugins/ImageSelection'; import * as LifecyclePlugin from 'roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin'; import * as NormalizeTablePlugin from '../../lib/corePlugins/NormalizeTablePlugin'; +import * as SelectionPlugin from 'roosterjs-content-model-core/lib/corePlugin/SelectionPlugin'; import * as UndoPlugin from '../../lib/corePlugins/UndoPlugin'; import { coreApiMap } from '../../lib/coreApi/coreApiMap'; import { createEditorCore } from '../../lib/editor/createEditorCore'; @@ -22,6 +23,7 @@ const mockedEntityState = 'ENTITYSTATE' as any; const mockedCopyPasteState = 'COPYPASTESTATE' as any; const mockedCacheState = 'CACHESTATE' as any; const mockedFormatState = 'FORMATSTATE' as any; +const mockedSelectionState = 'SELECTION' as any; const mockedFormatPlugin = { getState: () => mockedFormatState, @@ -44,6 +46,9 @@ const mockedDOMEventPlugin = { const mockedEntityPlugin = { getState: () => mockedEntityState, } as any; +const mockedSelectionPlugin = { + getState: () => mockedSelectionState, +} as any; const mockedImageSelection = 'ImageSelection' as any; const mockedNormalizeTablePlugin = 'NormalizeTablePlugin' as any; const mockedLifecyclePlugin = { @@ -73,6 +78,7 @@ describe('createEditorCore', () => { spyOn(EditPlugin, 'createEditPlugin').and.returnValue(mockedEditPlugin); spyOn(UndoPlugin, 'createUndoPlugin').and.returnValue(mockedUndoPlugin); spyOn(DOMEventPlugin, 'createDOMEventPlugin').and.returnValue(mockedDOMEventPlugin); + spyOn(SelectionPlugin, 'createSelectionPlugin').and.returnValue(mockedSelectionPlugin); spyOn(EntityPlugin, 'createEntityPlugin').and.returnValue(mockedEntityPlugin); spyOn(ImageSelection, 'createImageSelection').and.returnValue(mockedImageSelection); spyOn(NormalizeTablePlugin, 'createNormalizeTablePlugin').and.returnValue( @@ -96,6 +102,7 @@ describe('createEditorCore', () => { mockedFormatPlugin, mockedCopyPastePlugin, mockedDOMEventPlugin, + mockedSelectionPlugin, mockedEntityPlugin, mockedEditPlugin, mockedUndoPlugin, @@ -111,10 +118,10 @@ describe('createEditorCore', () => { copyPaste: mockedCopyPasteState, cache: mockedCacheState, format: mockedFormatState, + selection: mockedSelectionState, trustedHTMLHandler: defaultTrustHtmlHandler, zoomScale: 1, sizeTransformer: jasmine.anything(), - imageSelectionBorderColor: undefined, darkColorHandler: jasmine.anything(), disposeErrorHandler: undefined, ...mockedDefaultSettings, @@ -151,6 +158,7 @@ describe('createEditorCore', () => { mockedFormatPlugin, mockedCopyPastePlugin, mockedDOMEventPlugin, + mockedSelectionPlugin, mockedEntityPlugin, mockedEditPlugin, mockedUndoPlugin, @@ -166,10 +174,10 @@ describe('createEditorCore', () => { copyPaste: mockedCopyPasteState, cache: mockedCacheState, format: mockedFormatState, + selection: mockedSelectionState, trustedHTMLHandler: defaultTrustHtmlHandler, zoomScale: 1, sizeTransformer: jasmine.anything(), - imageSelectionBorderColor: undefined, darkColorHandler: jasmine.anything(), disposeErrorHandler: undefined, ...mockedDefaultSettings, diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts index d18116e8707..5e92099fe94 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts @@ -599,11 +599,6 @@ export interface StandaloneEditorCore */ readonly darkColorHandler: DarkColorHandler; - /** - * Color of the border of a selectedImage. Default color: '#DB626C' - */ - readonly imageSelectionBorderColor?: string; - /** * A handler to convert HTML string to a trust HTML string. * By default it will just return the original HTML string directly. diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts index 1574528f2a8..064a00aab53 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts @@ -1,3 +1,4 @@ +import type { SelectionPluginState } from '../pluginState/SelectionPluginState'; import type { EntityPluginState } from '../pluginState/EntityPluginState'; import type { LifecyclePluginState } from '../pluginState/LifecyclePluginState'; import type { DOMEventPluginState } from '../pluginState/DOMEventPluginState'; @@ -29,6 +30,11 @@ export interface StandaloneEditorCorePlugins { */ readonly domEvent: PluginWithState; + /** + * Selection plugin handles selection, including range selection, table selection, and image selection + */ + readonly selection: PluginWithState; + /** * Entity Plugin handles all operations related to an entity and generate entity specified events */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index c834231c92d..066317a05b4 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -242,6 +242,7 @@ export { export { DOMEventPluginState } from './pluginState/DOMEventPluginState'; export { LifecyclePluginState } from './pluginState/LifecyclePluginState'; export { EntityPluginState, KnownEntityItem } from './pluginState/EntityPluginState'; +export { SelectionPluginState } from './pluginState/SelectionPluginState'; export { EditorEnvironment } from './parameter/EditorEnvironment'; export { diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/DOMEventPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/DOMEventPluginState.ts index 38c5c6d7dd8..1c578e0edc5 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/DOMEventPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/DOMEventPluginState.ts @@ -1,8 +1,4 @@ -import type { - ContextMenuProvider, - ImageSelectionRange, - TableSelectionRange, -} from 'roosterjs-editor-types'; +import type { ContextMenuProvider } from 'roosterjs-editor-types'; /** * The state object for DOMEventPlugin @@ -18,31 +14,11 @@ export interface DOMEventPluginState { */ scrollContainer: HTMLElement; - /** - * Cached selection range - */ - selectionRange: Range | null; - - /** - * Table selection range - */ - tableSelectionRange: TableSelectionRange | null; - /** * Context menu providers, that can provide context menu items */ contextMenuProviders: ContextMenuProvider[]; - /** - * Image selection range - */ - imageSelectionRange: ImageSelectionRange | null; - - /** - * When set to true, onFocus event will not trigger reselect cached range - */ - skipReselectOnFocus?: boolean; - /** * Whether mouse up event handler is added */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts new file mode 100644 index 00000000000..b58bf24eb37 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts @@ -0,0 +1,36 @@ +import type { ImageSelectionRange, TableSelectionRange } from 'roosterjs-editor-types'; + +/** + * The state object for SelectionPlugin + */ +export interface SelectionPluginState { + /** + * Cached selection range + */ + selectionRange: Range | null; + + /** + * Table selection range + */ + tableSelectionRange: TableSelectionRange | null; + + /** + * Image selection range + */ + imageSelectionRange: ImageSelectionRange | null; + + /** + * A style node in current document to help implement image and table selection + */ + selectionStyleNode: HTMLStyleElement | null; + + /** + * When set to true, onFocus event will not trigger reselect cached range + */ + skipReselectOnFocus?: boolean; + + /** + * Color of the border of a selectedImage. Default color: '#DB626C' + */ + imageSelectionBorderColor?: string; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts index af2b30fd4dd..5198de965ff 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts @@ -1,3 +1,4 @@ +import type { SelectionPluginState } from './SelectionPluginState'; import type { CopyPastePluginState, EditPluginState, @@ -43,6 +44,11 @@ export interface StandaloneEditorCorePluginState { * Plugin state for EntityPlugin */ entity: EntityPluginState; + + /** + * Plugin state for SelectionPlugin + */ + selection: SelectionPluginState; } /** From 2bee9809401b8c457ad3099f2e924a85b91518f7 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 4 Dec 2023 13:34:42 -0800 Subject: [PATCH 071/111] Content Model: Go back to getDarkColor (#2239) * Content Model: go back to getDarkColor * fix test --- .../controls/ContentModelEditorMainPane.tsx | 2 ++ .../lib/editor/DarkColorHandlerImpl.ts | 36 ++----------------- .../lib/editor/createStandaloneEditorCore.ts | 10 +++++- .../roosterjs-content-model-core/package.json | 1 - .../test/editor/DarkColorHandlerImplTest.ts | 33 ++++++++--------- .../lib/editor/StandaloneEditorOptions.ts | 6 ++-- 6 files changed, 30 insertions(+), 58 deletions(-) diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 1a277e010f0..84751b237c0 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -20,6 +20,7 @@ import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentMo import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { createEmojiPlugin, createPasteOptionPlugin, RibbonPlugin } from 'roosterjs-react'; import { EditorPlugin } from 'roosterjs-editor-types'; +import { getDarkColor } from 'roosterjs-color-utils'; import { PartialTheme } from '@fluentui/react/lib/Theme'; import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; import { @@ -236,6 +237,7 @@ class ContentModelEditorMainPane extends MainPaneBase plugins={allPlugins} defaultSegmentFormat={defaultFormat} inDarkMode={this.state.isDarkMode} + getDarkColor={getDarkColor} experimentalFeatures={this.state.initState.experimentalFeatures} undoMetadataSnapshotService={this.snapshotPlugin.getSnapshotService()} trustedHTMLHandler={trustedHTMLHandler} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts index cda3a6bb61d..0f9687fcc6e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts @@ -1,4 +1,3 @@ -import * as Color from 'color'; import { getObjectKeys, parseColor, setColor } from 'roosterjs-editor-dom'; import type { ColorKeyAndValue, @@ -6,7 +5,6 @@ import type { ModeIndependentColor, } from 'roosterjs-editor-types'; -const DefaultLightness = 21.25; // Lightness for #333333 const VARIABLE_REGEX = /^\s*var\(\s*(\-\-[a-zA-Z0-9\-_]+)\s*(?:,\s*(.*))?\)\s*$/; const VARIABLE_PREFIX = 'var('; const COLOR_VAR_PREFIX = 'darkColor'; @@ -30,11 +28,8 @@ const ColorAttributeName: { [key in ColorAttributeEnum]: string }[] = [ */ export class DarkColorHandlerImpl implements DarkColorHandler { private knownColors: Record> = {}; - readonly baseLightness: number; - constructor(private contentDiv: HTMLElement, baseDarkColor?: string) { - this.baseLightness = getLightness(baseDarkColor); - } + constructor(private contentDiv: HTMLElement, private getDarkColor: (color: string) => string) {} /** * Get a copy of known colors @@ -66,7 +61,7 @@ export class DarkColorHandlerImpl implements DarkColorHandler { colorKey || `--${COLOR_VAR_PREFIX}_${lightModeColor.replace(/[^\d\w]/g, '_')}`; if (!this.knownColors[colorKey]) { - darkModeColor = darkModeColor || getDarkColor(lightModeColor, this.baseLightness); + darkModeColor = darkModeColor || this.getDarkColor(lightModeColor); this.knownColors[colorKey] = { lightModeColor, darkModeColor }; this.contentDiv.style.setProperty(colorKey, darkModeColor); @@ -176,30 +171,3 @@ export class DarkColorHandlerImpl implements DarkColorHandler { }); } } - -function getDarkColor(color: string, baseLightness: number): string { - try { - const computedColor = Color(color || undefined); - const colorLab = computedColor.lab().array(); - const newLValue = (100 - colorLab[0]) * ((100 - baseLightness) / 100) + baseLightness; - color = Color.lab(newLValue, colorLab[1], colorLab[2]) - .rgb() - .alpha(computedColor.alpha()) - .toString(); - } catch {} - - return color; -} - -function getLightness(color?: string): number { - let result = DefaultLightness; - - if (color) { - try { - const computedColor = Color(color || undefined); - result = computedColor.lab().array()[0]; - } catch {} - } - - return result; -} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts index aa92da5fad5..c561fa1b6a1 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts @@ -42,7 +42,10 @@ export function createStandaloneEditorCore( corePlugins.lifecycle, ], environment: createEditorEnvironment(), - darkColorHandler: new DarkColorHandlerImpl(contentDiv, options.baseDarkColor), + darkColorHandler: new DarkColorHandlerImpl( + contentDiv, + options.getDarkColor ?? getDarkColorFallback + ), trustedHTMLHandler: options.trustedHTMLHandler || defaultTrustHtmlHandler, ...createStandaloneEditorDefaultSettings(options), ...getPluginState(corePlugins), @@ -82,3 +85,8 @@ function getPluginState(corePlugins: StandaloneEditorCorePlugins): StandaloneEdi selection: corePlugins.selection.getState(), }; } + +// A fallback function, always return original color +function getDarkColorFallback(color: string) { + return color; +} diff --git a/packages-content-model/roosterjs-content-model-core/package.json b/packages-content-model/roosterjs-content-model-core/package.json index 40d2aab4f68..c037037dea2 100644 --- a/packages-content-model/roosterjs-content-model-core/package.json +++ b/packages-content-model/roosterjs-content-model-core/package.json @@ -3,7 +3,6 @@ "description": "Content Model for roosterjs (Under development)", "dependencies": { "tslib": "^2.3.1", - "color": "^3.0.0", "roosterjs-editor-types": "", "roosterjs-editor-dom": "", "roosterjs-content-model-dom": "", diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts index 0372c39c164..61ca7c9cc99 100644 --- a/packages-content-model/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts @@ -1,26 +1,21 @@ import { ColorKeyAndValue } from 'roosterjs-editor-types'; import { DarkColorHandlerImpl } from '../../lib/editor/DarkColorHandlerImpl'; +function getDarkColor(color: string) { + return 'Dark_' + color; +} + describe('DarkColorHandlerImpl.ctor', () => { it('No additional param', () => { const div = document.createElement('div'); - const handler = new DarkColorHandlerImpl(div); - - expect(handler).toBeDefined(); - expect(handler.baseLightness).toBe(21.25); - }); - - it('With customized base color', () => { - const div = document.createElement('div'); - const handler = new DarkColorHandlerImpl(div, '#555555'); + const handler = new DarkColorHandlerImpl(div, getDarkColor); expect(handler).toBeDefined(); - expect(Math.round(handler.baseLightness)).toBe(36); }); it('Calculate color using customized base color', () => { const div = document.createElement('div'); - const handler = new DarkColorHandlerImpl(div, '#555555'); + const handler = new DarkColorHandlerImpl(div, getDarkColor); const darkColor = handler.registerColor('red', true); const parsedColor = handler.parseColorValue(darkColor); @@ -29,7 +24,7 @@ describe('DarkColorHandlerImpl.ctor', () => { expect(parsedColor).toEqual({ key: '--darkColor_red', lightModeColor: 'red', - darkModeColor: 'rgb(255, 72, 40)', + darkModeColor: 'Dark_red', }); }); }); @@ -40,7 +35,7 @@ describe('DarkColorHandlerImpl.parseColorValue', () => { beforeEach(() => { div = document.createElement('div'); - handler = new DarkColorHandlerImpl(div); + handler = new DarkColorHandlerImpl(div, getDarkColor); }); function runTest(input: string, expectedOutput: ColorKeyAndValue) { @@ -143,7 +138,7 @@ describe('DarkColorHandlerImpl.registerColor', () => { setProperty, }, } as any) as HTMLElement; - handler = new DarkColorHandlerImpl(div); + handler = new DarkColorHandlerImpl(div, getDarkColor); }); function runTest( @@ -186,10 +181,10 @@ describe('DarkColorHandlerImpl.registerColor', () => { { '--darkColor_red': { lightModeColor: 'red', - darkModeColor: 'rgb(255, 39, 17)', + darkModeColor: 'Dark_red', }, }, - [['--darkColor_red', 'rgb(255, 39, 17)']] + [['--darkColor_red', 'Dark_red']] ); }); @@ -222,10 +217,10 @@ describe('DarkColorHandlerImpl.registerColor', () => { { '--aa': { lightModeColor: 'red', - darkModeColor: 'rgb(255, 39, 17)', + darkModeColor: 'Dark_red', }, }, - [['--aa', 'rgb(255, 39, 17)']] + [['--aa', 'Dark_red']] ); }); @@ -395,7 +390,7 @@ describe('DarkColorHandlerImpl.transformElementColor', () => { beforeEach(() => { contentDiv = document.createElement('div'); - handler = new DarkColorHandlerImpl(contentDiv); + handler = new DarkColorHandlerImpl(contentDiv, getDarkColor); parseColorSpy = spyOn(handler, 'parseColorValue').and.callThrough(); registerColorSpy = spyOn(handler, 'registerColor').and.callThrough(); diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts index 6aeb08701ef..43b52d4b30e 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts @@ -52,10 +52,10 @@ export interface StandaloneEditorOptions { scrollContainer?: HTMLElement; /** - * Base dark mode color. We will use this color to calculate the dark mode color from a given light mode color - * @default #333333 + * A util function to transform light mode color to dark mode color + * Default value is to return the original light color */ - baseDarkColor?: string; + getDarkColor?: (lightColor: string) => string; /** * Customized trusted type handler used for sanitizing HTML string before assign to DOM tree From a7bdb437ee980ab01c62ae27a3ae66722d235f45 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 4 Dec 2023 14:06:48 -0800 Subject: [PATCH 072/111] Content Model: Fix overwrite table cell bug (#2240) --- .../lib/domToModel/utils/addSelectionMarker.ts | 8 ++++++-- .../roosterjs-content-model-dom/lib/index.ts | 1 - .../lib/modelApi/common/addSegment.ts | 6 ++++-- .../lib/modelApi/common/ensureParagraph.ts | 7 +++++-- .../domToModel/processors/childProcessorTest.ts | 1 + .../domToModel/utils/addSelectionMarkerTest.ts | 1 + .../lib/edit/keyboardInput.ts | 3 +++ .../test/edit/keyboardInputTest.ts | 14 ++++++++++++++ 8 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts index 86234b6faab..6e173de9f32 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/utils/addSelectionMarker.ts @@ -7,9 +7,13 @@ import type { ContentModelBlockGroup, DomToModelContext } from 'roosterjs-conten * @internal */ export function addSelectionMarker(group: ContentModelBlockGroup, context: DomToModelContext) { - const marker = createSelectionMarker(context.segmentFormat); + const segmentFormat = { + ...context.defaultFormat, + ...context.segmentFormat, + }; + const marker = createSelectionMarker(segmentFormat); addDecorators(marker, context); - addSegment(group, marker, context.blockFormat); + addSegment(group, marker, context.blockFormat, segmentFormat); } diff --git a/packages-content-model/roosterjs-content-model-dom/lib/index.ts b/packages-content-model/roosterjs-content-model-dom/lib/index.ts index 27cb62585d7..1df3dc46f67 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/index.ts @@ -49,7 +49,6 @@ export { createListLevel } from './modelApi/creators/createListLevel'; export { addBlock } from './modelApi/common/addBlock'; export { addCode } from './modelApi/common/addDecorators'; export { addLink } from './modelApi/common/addDecorators'; -export { ensureParagraph } from './modelApi/common/ensureParagraph'; export { normalizeContentModel } from './modelApi/common/normalizeContentModel'; export { isGeneralSegment } from './modelApi/common/isGeneralSegment'; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts index 35389ed6e25..4680693861c 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/addSegment.ts @@ -4,6 +4,7 @@ import type { ContentModelBlockGroup, ContentModelParagraph, ContentModelSegment, + ContentModelSegmentFormat, } from 'roosterjs-content-model-types'; /** @@ -16,9 +17,10 @@ import type { export function addSegment( group: ContentModelBlockGroup, newSegment: ContentModelSegment, - blockFormat?: ContentModelBlockFormat + blockFormat?: ContentModelBlockFormat, + segmentFormat?: ContentModelSegmentFormat ): ContentModelParagraph { - const paragraph = ensureParagraph(group, blockFormat); + const paragraph = ensureParagraph(group, blockFormat, segmentFormat); const lastSegment = paragraph.segments[paragraph.segments.length - 1]; if (newSegment.segmentType == 'SelectionMarker') { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/ensureParagraph.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/ensureParagraph.ts index 9e22bda96c2..e638f1920ce 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/ensureParagraph.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/common/ensureParagraph.ts @@ -4,23 +4,26 @@ import type { ContentModelBlockFormat, ContentModelBlockGroup, ContentModelParagraph, + ContentModelSegmentFormat, } from 'roosterjs-content-model-types'; /** + * @internal * Ensure there is a Paragraph that can insert segments in a Content Model Block Group * @param group The parent block group of the target paragraph * @param blockFormat Format of the paragraph. This is only used if we need to create a new paragraph */ export function ensureParagraph( group: ContentModelBlockGroup, - blockFormat?: ContentModelBlockFormat + blockFormat?: ContentModelBlockFormat, + segmentFormat?: ContentModelSegmentFormat ): ContentModelParagraph { const lastBlock = group.blocks[group.blocks.length - 1]; if (lastBlock?.blockType == 'Paragraph') { return lastBlock; } else { - const paragraph = createParagraph(true, blockFormat); + const paragraph = createParagraph(true, blockFormat, segmentFormat); addBlock(group, paragraph); return paragraph; diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts index 4be880b0ffa..623dc10a878 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/childProcessorTest.ts @@ -325,6 +325,7 @@ describe('childProcessor', () => { { segmentType: 'SelectionMarker', format: { a: 'b' } as any, isSelected: true }, ], isImplicit: true, + segmentFormat: { a: 'b' } as any, format: {}, }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/addSelectionMarkerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/addSelectionMarkerTest.ts index b1cb0ec6ca5..c84bfa3d12d 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/addSelectionMarkerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/utils/addSelectionMarkerTest.ts @@ -52,6 +52,7 @@ describe('addSelectionMarker', () => { format: { fontWeight: 'bold' }, }, ], + segmentFormat: { fontWeight: 'bold' }, }, ], }); diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts index 3680f3c67d5..794b2df12ed 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/edit/keyboardInput.ts @@ -1,4 +1,5 @@ import { deleteSelection, isModifierKey } from 'roosterjs-content-model-core'; +import { normalizeContentModel } from 'roosterjs-content-model-dom'; import type { IContentModelEditor } from 'roosterjs-content-model-editor'; import type { DOMSelection } from 'roosterjs-content-model-types'; @@ -26,6 +27,8 @@ export function keyboardInput(editor: IContentModelEditor, rawEvent: KeyboardEve // We have deleted something, next input should inherit the segment format from deleted content, so set pending format here context.newPendingFormat = result.insertPoint?.marker.format; + normalizeContentModel(model); + // Do not preventDefault since we still want browser to handle the final input for now return true; } else { diff --git a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts index 93fb2ff88be..ec0864a6c55 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/edit/keyboardInputTest.ts @@ -1,4 +1,5 @@ import * as deleteSelection from 'roosterjs-content-model-core/lib/publicApi/selection/deleteSelection'; +import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { IContentModelEditor } from 'roosterjs-content-model-editor'; import { keyboardInput } from '../../lib/edit/keyboardInput'; import { @@ -14,6 +15,7 @@ describe('keyboardInput', () => { let getDOMSelectionSpy: jasmine.Spy; let deleteSelectionSpy: jasmine.Spy; let mockedModel: ContentModelDocument; + let normalizeContentModelSpy: jasmine.Spy; let mockedContext: FormatWithContentModelContext; let formatResult: boolean | undefined; @@ -34,6 +36,7 @@ describe('keyboardInput', () => { }); getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); deleteSelectionSpy = spyOn(deleteSelection, 'deleteSelection'); + normalizeContentModelSpy = spyOn(normalizeContentModel, 'normalizeContentModel'); editor = { getDOMSelection: getDOMSelectionSpy, @@ -69,6 +72,7 @@ describe('keyboardInput', () => { newEntities: [], newImages: [], }); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); }); it('Letter input, expanded selection, no modifier key, deleteSelection returns not deleted', () => { @@ -100,6 +104,7 @@ describe('keyboardInput', () => { clearModelCache: true, skipUndoSnapshot: true, }); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); }); it('Letter input, expanded selection, no modifier key, deleteSelection returns range', () => { @@ -132,6 +137,7 @@ describe('keyboardInput', () => { skipUndoSnapshot: true, newPendingFormat: undefined, }); + expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); }); it('Letter input, table selection, no modifier key, deleteSelection returns range', () => { @@ -161,6 +167,7 @@ describe('keyboardInput', () => { skipUndoSnapshot: true, newPendingFormat: undefined, }); + expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); }); it('Letter input, image selection, no modifier key, deleteSelection returns range', () => { @@ -190,6 +197,7 @@ describe('keyboardInput', () => { skipUndoSnapshot: true, newPendingFormat: undefined, }); + expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); }); it('Letter input, no selection, no modifier key, deleteSelection returns range', () => { @@ -214,6 +222,7 @@ describe('keyboardInput', () => { newEntities: [], newImages: [], }); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); }); it('Letter input, expanded selection, has modifier key, deleteSelection returns range', () => { @@ -244,6 +253,7 @@ describe('keyboardInput', () => { newEntities: [], newImages: [], }); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); }); it('Space input, table selection, no modifier key, deleteSelection returns range', () => { @@ -273,6 +283,7 @@ describe('keyboardInput', () => { skipUndoSnapshot: true, newPendingFormat: undefined, }); + expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); }); it('Backspace input, table selection, no modifier key, deleteSelection returns range', () => { @@ -299,6 +310,7 @@ describe('keyboardInput', () => { newEntities: [], newImages: [], }); + expect(normalizeContentModelSpy).not.toHaveBeenCalled(); }); it('Enter input, table selection, no modifier key, deleteSelection returns range', () => { @@ -328,6 +340,7 @@ describe('keyboardInput', () => { skipUndoSnapshot: true, newPendingFormat: undefined, }); + expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); }); it('Letter input, expanded selection, no modifier key, deleteSelection returns range, has segment format', () => { @@ -366,5 +379,6 @@ describe('keyboardInput', () => { skipUndoSnapshot: true, newPendingFormat: mockedFormat, }); + expect(normalizeContentModelSpy).toHaveBeenCalledWith(mockedModel); }); }); From 6e5f30c87ad9e08da75485238f76b2937ed6dfe0 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Tue, 5 Dec 2023 16:22:18 -0800 Subject: [PATCH 073/111] ContentModel: Set readonly for new entity (#2243) --- .../lib/corePlugin/EntityPlugin.ts | 4 ++++ .../test/corePlugin/EntityPluginTest.ts | 16 ++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts index 2dc12f09383..0ad7d0e4109 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts @@ -148,6 +148,10 @@ class EntityPlugin implements PluginWithState { entity.entityFormat.id = this.ensureUniqueId(entityType, id ?? '', wrapper); wrapper.className = generateEntityClassNames(entity.entityFormat); + if (entity.entityFormat.isReadonly) { + wrapper.contentEditable = 'false'; + } + const eventResult = this.triggerEvent(editor, wrapper, operation, rawEvent); this.state.entityMap[entity.entityFormat.id] = { diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts index 9bdf710ffe1..f303a9a430e 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts @@ -83,7 +83,7 @@ describe('EntityPlugin', () => { }, }); expect(wrapper.outerHTML).toBe( - '
                                                          ' + '
                                                          ' ); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { @@ -125,7 +125,7 @@ describe('EntityPlugin', () => { }, }); expect(wrapper.outerHTML).toBe( - '
                                                          ' + '
                                                          ' ); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { @@ -166,7 +166,7 @@ describe('EntityPlugin', () => { }, }); expect(wrapper.outerHTML).toBe( - '
                                                          ' + '
                                                          ' ); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { @@ -206,7 +206,7 @@ describe('EntityPlugin', () => { }, }); expect(wrapper.outerHTML).toBe( - '
                                                          ' + '
                                                          ' ); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { @@ -260,7 +260,7 @@ describe('EntityPlugin', () => { }, }); expect(wrapper.outerHTML).toBe( - '
                                                          ' + '
                                                          ' ); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(2); expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { @@ -344,7 +344,7 @@ describe('EntityPlugin', () => { }, }); expect(wrapper.outerHTML).toBe( - '
                                                          ' + '
                                                          ' ); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { @@ -408,7 +408,7 @@ describe('EntityPlugin', () => { '
                                                          ' ); expect(wrapper2.outerHTML).toBe( - '
                                                          ' + '
                                                          ' ); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(2); expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { @@ -475,7 +475,7 @@ describe('EntityPlugin', () => { '
                                                          ' ); expect(wrapper2.outerHTML).toBe( - '
                                                          ' + '
                                                          ' ); expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { From 9f4e4f0b0fb534b8e373cec9fd75db2f55ee1442 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Wed, 6 Dec 2023 11:22:15 -0800 Subject: [PATCH 074/111] Standalone Editor: Selection API step 2: Port selection API (#2229) * Standalone Editor: CreateStandaloneEditorCore * Standalone Editor: Port LifecyclePlugin * fix build * fix test * improve * fix test * Standalone Editor: Support keyboard input (init step) * Standalone Editor: Port EntityPlugin * improve * Add test * improve * port selection api * improve * improve * fix build * fix build * fix build * improve * Improve * improve * improve * fix test * improve * add test * remove unused code * improve --- .../lib/coreApi/focus.ts | 21 + .../lib/coreApi/getDOMSelection.ts | 37 +- .../lib/coreApi/hasFocus.ts | 5 +- .../lib/coreApi/setContentModel.ts | 8 +- .../lib/coreApi/setDOMSelection.ts | 250 +++++- .../lib/corePlugin/SelectionPlugin.ts | 26 +- .../lib/editor/standaloneCoreApiMap.ts | 4 + .../roosterjs-content-model-core/lib/index.ts | 1 + .../lib/publicApi/domUtils/tableCellUtils.ts | 62 ++ .../test/coreApi/focusTest.ts | 120 +++ .../test/coreApi/getDOMSelectionTest.ts | 87 ++ .../test/coreApi/hasFocusTest.ts | 46 + .../test/coreApi/setDOMSelectionTest.ts | 803 ++++++++++++++++++ .../test/corePlugin/SelectionPluginTest.ts | 169 ++++ .../publicApi/domUtils/tableCellUtilsTest.ts | 260 ++++++ .../lib/coreApi/addUndoSnapshot.ts | 60 +- .../lib/coreApi/coreApiMap.ts | 16 - .../lib/coreApi/ensureTypeInContainer.ts | 5 +- .../lib/coreApi/focus.ts | 44 - .../lib/coreApi/getContent.ts | 6 +- .../lib/coreApi/getSelectionRange.ts | 33 - .../lib/coreApi/getSelectionRangeEx.ts | 55 -- .../lib/coreApi/insertNode.ts | 8 +- .../lib/coreApi/select.ts | 178 ---- .../lib/coreApi/selectImage.ts | 66 -- .../lib/coreApi/selectRange.ts | 25 - .../lib/coreApi/selectTable.ts | 265 ------ .../lib/coreApi/setContent.ts | 53 +- .../lib/coreApi/utils/addUniqueId.ts | 31 - .../corePlugins/EventTypeTranslatePlugin.ts | 51 ++ .../lib/corePlugins/createCorePlugins.ts | 2 + .../corePlugins/utils/forEachSelectedCell.ts | 22 - .../utils/removeCellsOutsideSelection.ts | 37 - .../lib/editor/ContentModelEditor.ts | 22 +- .../lib/editor/createEditorCore.ts | 1 + .../lib/editor/utils/buildRangeEx.ts | 110 +++ .../editor/utils/getPendableFormatState.ts | 3 +- .../lib/editor/utils/selectionConverter.ts | 168 ++++ .../publicTypes/ContentModelCorePlugins.ts | 5 + .../EventTypeTranslatePluginTest.ts | 56 ++ .../test/editor/createEditorCoreTest.ts | 7 + .../editor/utils/selectionConverterTest.ts | 384 +++++++++ .../lib/editor/IStandaloneEditor.ts | 2 +- .../lib/editor/StandaloneEditorCore.ts | 172 +--- .../ContentModelSelectionChangedEvent.ts | 30 + .../lib/index.ts | 11 +- .../lib/pluginState/SelectionPluginState.ts | 14 +- 47 files changed, 2703 insertions(+), 1138 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/coreApi/focus.ts rename packages-content-model/{roosterjs-content-model-editor => roosterjs-content-model-core}/lib/coreApi/hasFocus.ts (66%) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/coreApi/focusTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/coreApi/hasFocusTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectTable.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/utils/addUniqueId.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EventTypeTranslatePlugin.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/forEachSelectedCell.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/removeCellsOutsideSelection.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/editor/utils/buildRangeEx.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/test/corePlugins/EventTypeTranslatePluginTest.ts create mode 100644 packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/event/ContentModelSelectionChangedEvent.ts diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/focus.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/focus.ts new file mode 100644 index 00000000000..b5b5c8e69f5 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/focus.ts @@ -0,0 +1,21 @@ +import type { Focus } from 'roosterjs-content-model-types'; + +/** + * @internal + * Focus to editor. If there is a cached selection range, use it as current selection + * @param core The StandaloneEditorCore object + */ +export const focus: Focus = core => { + if (!core.lifecycle.shadowEditFragment) { + const { api, selection } = core; + + if (!api.hasFocus(core) && selection.selection?.type == 'range') { + api.setDOMSelection(core, selection.selection, true /*skipSelectionChangedEvent*/); + } + + // fallback, in case editor still have no focus + if (!core.api.hasFocus(core)) { + core.contentDiv.focus(); + } + } +}; diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts index 559a7f98d8e..a693df4c426 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts @@ -1,4 +1,3 @@ -import { SelectionRangeTypes } from 'roosterjs-editor-types'; import type { DOMSelection, GetDOMSelection, @@ -9,33 +8,19 @@ import type { * @internal */ export const getDOMSelection: GetDOMSelection = core => { - return core.cache.cachedSelection ?? getNewSelection(core); + return core.lifecycle.shadowEditFragment + ? null + : core.selection.selection ?? getNewSelection(core); }; function getNewSelection(core: StandaloneEditorCore): DOMSelection | null { - // TODO: Get rid of getSelectionRangeEx when we have standalone editor - const rangeEx = core.api.getSelectionRangeEx(core); + const selection = core.contentDiv.ownerDocument.defaultView?.getSelection(); + const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; - if (rangeEx.type == SelectionRangeTypes.Normal && rangeEx.ranges[0]) { - return { - type: 'range', - range: rangeEx.ranges[0], - }; - } else if (rangeEx.type == SelectionRangeTypes.TableSelection && rangeEx.coordinates) { - return { - type: 'table', - table: rangeEx.table, - firstColumn: rangeEx.coordinates.firstCell.x, - lastColumn: rangeEx.coordinates.lastCell.x, - firstRow: rangeEx.coordinates.firstCell.y, - lastRow: rangeEx.coordinates.lastCell.y, - }; - } else if (rangeEx.type == SelectionRangeTypes.ImageSelection) { - return { - type: 'image', - image: rangeEx.image, - }; - } else { - return null; - } + return range && core.contentDiv.contains(range.commonAncestorContainer) + ? { + type: 'range', + range: range, + } + : null; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/hasFocus.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/hasFocus.ts similarity index 66% rename from packages-content-model/roosterjs-content-model-editor/lib/coreApi/hasFocus.ts rename to packages-content-model/roosterjs-content-model-core/lib/coreApi/hasFocus.ts index c5a67d878cc..5026e5f17eb 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/hasFocus.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/hasFocus.ts @@ -1,4 +1,3 @@ -import { contains } from 'roosterjs-editor-dom'; import type { HasFocus } from 'roosterjs-content-model-types'; /** @@ -9,7 +8,5 @@ import type { HasFocus } from 'roosterjs-content-model-types'; */ export const hasFocus: HasFocus = core => { const activeElement = core.contentDiv.ownerDocument.activeElement; - return !!( - activeElement && contains(core.contentDiv, activeElement, true /*treatSameNodeAsContain*/) - ); + return !!(activeElement && core.contentDiv.contains(activeElement)); }; diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts index d7f4eed9161..858abcfd098 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setContentModel.ts @@ -29,12 +29,8 @@ export const setContentModel: SetContentModel = (core, model, option, onNodeCrea if (!core.lifecycle.shadowEditFragment) { core.cache.cachedSelection = selection || undefined; - if (selection) { - if (!option?.ignoreSelection) { - core.api.setDOMSelection(core, selection); - } else if (selection.type == 'range') { - core.selection.selectionRange = selection.range; - } + if (!option?.ignoreSelection && selection) { + core.api.setDOMSelection(core, selection); } core.cache.cachedModel = model; diff --git a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts index be46307b163..50c413b9ae4 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts @@ -1,42 +1,218 @@ -import { SelectionRangeTypes } from 'roosterjs-editor-types'; -import type { SelectionRangeEx } from 'roosterjs-editor-types'; -import type { SetDOMSelection } from 'roosterjs-content-model-types'; +import { addRangeToSelection } from '../corePlugin/utils/addRangeToSelection'; +import { isNodeOfType, toArray } from 'roosterjs-content-model-dom'; +import { parseTableCells } from '../publicApi/domUtils/tableCellUtils'; +import { PluginEventType } from 'roosterjs-editor-types'; +import type { + ContentModelSelectionChangedEvent, + SetDOMSelection, + TableSelection, +} from 'roosterjs-content-model-types'; + +const IMAGE_ID = 'image'; +const TABLE_ID = 'table'; +const CONTENT_DIV_ID = 'contentDiv'; +const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; +const TABLE_CSS_RULE = '{background-color: rgb(198,198,198) !important; caret-color: transparent}'; +const MAX_RULE_SELECTOR_LENGTH = 9000; /** * @internal */ -export const setDOMSelection: SetDOMSelection = (core, selection) => { - // TODO: Get rid of SelectionRangeEx in standalone editor - const rangeEx: SelectionRangeEx = - selection.type == 'range' - ? { - type: SelectionRangeTypes.Normal, - ranges: [selection.range], - areAllCollapsed: selection.range.collapsed, - } - : selection.type == 'image' - ? { - type: SelectionRangeTypes.ImageSelection, - ranges: [], - areAllCollapsed: false, - image: selection.image, - } - : { - type: SelectionRangeTypes.TableSelection, - ranges: [], - areAllCollapsed: false, - table: selection.table, - coordinates: { - firstCell: { - x: selection.firstColumn, - y: selection.firstRow, - }, - lastCell: { - x: selection.lastColumn, - y: selection.lastRow, - }, - }, - }; - - core.api.select(core, rangeEx); +export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionChangedEvent) => { + // We are applying a new selection, so we don't need to apply cached selection in DOMEventPlugin. + // Set skipReselectOnFocus to skip this behavior + const skipReselectOnFocus = core.selection.skipReselectOnFocus; + + const doc = core.contentDiv.ownerDocument; + const sheet = core.selection.selectionStyleNode?.sheet; + + core.selection.skipReselectOnFocus = true; + + try { + let selectionRules: string[] | undefined; + const rootSelector = '#' + addUniqueId(core.contentDiv, CONTENT_DIV_ID); + + switch (selection?.type) { + case 'image': + const image = selection.image; + + selectionRules = buildImageCSS( + rootSelector + ' #' + addUniqueId(image, IMAGE_ID), + core.selection.imageSelectionBorderColor + ); + core.selection.selection = selection; + + setRangeSelection(doc, image); + break; + case 'table': + const { table, firstColumn, firstRow } = selection; + + selectionRules = buildTableCss( + rootSelector + ' #' + addUniqueId(table, TABLE_ID), + selection + ); + core.selection.selection = selection; + + setRangeSelection(doc, table.rows[firstRow]?.cells[firstColumn]); + break; + case 'range': + addRangeToSelection(doc, selection.range); + + core.selection.selection = core.api.hasFocus(core) ? null : selection; + break; + + default: + core.selection.selection = null; + break; + } + + if (sheet) { + for (let i = sheet.cssRules.length - 1; i >= 0; i--) { + sheet.deleteRule(i); + } + + if (selectionRules) { + for (let i = 0; i < selectionRules.length; i++) { + sheet.insertRule(selectionRules[i]); + } + } + } + } finally { + core.selection.skipReselectOnFocus = skipReselectOnFocus; + } + + if (!skipSelectionChangedEvent) { + const eventData: ContentModelSelectionChangedEvent = { + eventType: PluginEventType.SelectionChanged, + newSelection: selection, + selectionRangeEx: null, + }; + + core.api.triggerEvent(core, eventData, true /*broadcast*/); + } }; + +function buildImageCSS(rootSelector: string, borderColor?: string): string[] { + const color = borderColor || DEFAULT_SELECTION_BORDER_COLOR; + + return [ + `${rootSelector} {outline-style:auto!important;outline-color:${color}!important;caret-color:transparent;}`, + ]; +} + +function buildTableCss(rootSelector: string, selection: TableSelection): string[] { + const { firstColumn, firstRow, lastColumn, lastRow } = selection; + const cells = parseTableCells(selection.table); + const isAllTableSelected = + firstRow == 0 && + firstColumn == 0 && + lastRow == cells.length - 1 && + lastColumn == (cells[lastRow]?.length ?? 0) - 1; + const selectors = isAllTableSelected + ? [rootSelector, `${rootSelector} *`] + : handleTableSelected(rootSelector, selection, cells); + + const cssRules: string[] = []; + let currentRules: string = ''; + + for (let i = 0; i < selectors.length; i++) { + currentRules += (currentRules.length > 0 ? ',' : '') + selectors[i] || ''; + + if ( + currentRules.length + (selectors[0]?.length || 0) > MAX_RULE_SELECTOR_LENGTH || + i == selectors.length - 1 + ) { + cssRules.push(currentRules + ' ' + TABLE_CSS_RULE); + currentRules = ''; + } + } + + return cssRules; +} + +function handleTableSelected( + rootSelector: string, + selection: TableSelection, + cells: (HTMLTableCellElement | null)[][] +) { + const { firstRow, firstColumn, lastRow, lastColumn, table } = selection; + const selectors: string[] = []; + + // Get whether table has thead, tbody or tfoot, then Set the start and end of each of the table children, + // so we can build the selector according the element between the table and the row. + let cont = 0; + const indexes = toArray(table.childNodes) + .filter( + (node): node is HTMLTableSectionElement => + ['THEAD', 'TBODY', 'TFOOT'].indexOf( + isNodeOfType(node, 'ELEMENT_NODE') ? node.tagName : '' + ) > -1 + ) + .map(node => { + const result = { + el: node.tagName, + start: cont, + end: node.childNodes.length + cont, + }; + + cont = result.end; + return result; + }); + + cells.forEach((row, rowIndex) => { + let tdCount = 0; + + //Get current TBODY/THEAD/TFOOT + const midElement = indexes.filter(ind => ind.start <= rowIndex && ind.end > rowIndex)[0]; + const middleElSelector = midElement ? '>' + midElement.el + '>' : '>'; + const currentRow = + midElement && rowIndex + 1 >= midElement.start + ? rowIndex + 1 - midElement.start + : rowIndex + 1; + + for (let cellIndex = 0; cellIndex < row.length; cellIndex++) { + const cell = row[cellIndex]; + + if (cell) { + tdCount++; + + if ( + rowIndex >= firstRow && + rowIndex <= lastRow && + cellIndex >= firstColumn && + cellIndex <= lastColumn + ) { + const selector = `${rootSelector}${middleElSelector} tr:nth-child(${currentRow})>${cell.tagName}:nth-child(${tdCount})`; + + selectors.push(selector, selector + ' *'); + } + } + } + }); + + return selectors; +} + +function setRangeSelection(doc: Document, element: HTMLElement | undefined) { + if (element) { + const range = doc.createRange(); + + range.selectNode(element); + range.collapse(); + + addRangeToSelection(doc, range); + } +} + +function addUniqueId(element: HTMLElement, idPrefix: string): string { + idPrefix = element.id || idPrefix; + + const doc = element.ownerDocument; + let i = 0; + + while (!element.id || doc.querySelectorAll('#' + element.id).length > 1) { + element.id = idPrefix + '_' + i++; + } + + return element.id; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts index c2093e1c984..6eccc72c235 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts @@ -12,9 +12,7 @@ class SelectionPlugin implements PluginWithState { constructor(options: StandaloneEditorOptions) { this.state = { - selectionRange: null, - tableSelectionRange: null, - imageSelectionRange: null, + selection: null, selectionStyleNode: null, imageSelectionBorderColor: options.imageSelectionBorderColor, // TODO: Move to Selection core plugin }; @@ -80,25 +78,19 @@ class SelectionPlugin implements PluginWithState { } private onFocus = () => { - if (!this.state.skipReselectOnFocus && this.editor) { - const { table, coordinates } = this.state.tableSelectionRange || {}; - const { image } = this.state.imageSelectionRange || {}; - - if (table && coordinates) { - this.editor.select(table, coordinates); - } else if (image) { - this.editor.select(image); - } else if (this.state.selectionRange) { - this.editor.select(this.state.selectionRange); - } + if (!this.state.skipReselectOnFocus && this.state.selection) { + this.editor?.setDOMSelection(this.state.selection); } - this.state.selectionRange = null; + if (this.state.selection?.type == 'range') { + // Editor is focused, now we can get live selection. So no need to keep a selection if the selection type is range. + this.state.selection = null; + } }; private onBlur = () => { - if (!this.state.selectionRange && this.editor) { - this.state.selectionRange = this.editor.getSelectionRange(false /*tryGetFromCache*/); + if (!this.state.selection && this.editor) { + this.state.selection = this.editor.getDOMSelection(); } }; diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts index 5092a757f71..1482f4aed0e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/standaloneCoreApiMap.ts @@ -1,8 +1,10 @@ import { createContentModel } from '../coreApi/createContentModel'; import { createEditorContext } from '../coreApi/createEditorContext'; +import { focus } from '../coreApi/focus'; import { formatContentModel } from '../coreApi/formatContentModel'; import { getDOMSelection } from '../coreApi/getDOMSelection'; import { getVisibleViewport } from '../coreApi/getVisibleViewport'; +import { hasFocus } from '../coreApi/hasFocus'; import { setContentModel } from '../coreApi/setContentModel'; import { setDOMSelection } from '../coreApi/setDOMSelection'; import { switchShadowEdit } from '../coreApi/switchShadowEdit'; @@ -21,4 +23,6 @@ export const standaloneCoreApiMap: PortedCoreApiMap = { setDOMSelection: setDOMSelection, switchShadowEdit: switchShadowEdit, getVisibleViewport: getVisibleViewport, + focus: focus, + hasFocus: hasFocus, }; diff --git a/packages-content-model/roosterjs-content-model-core/lib/index.ts b/packages-content-model/roosterjs-content-model-core/lib/index.ts index 73d35cdb658..89efa36a4d5 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/index.ts @@ -36,6 +36,7 @@ export { setTableCellBackgroundColor } from './publicApi/table/setTableCellBackg export { isCharacterValue, isModifierKey } from './publicApi/domUtils/eventUtils'; export { combineBorderValue, extractBorderValues } from './publicApi/domUtils/borderValues'; export { isPunctuation, isSpace, normalizeText } from './publicApi/domUtils/stringUtil'; +export { parseTableCells, createTableRanges } from './publicApi/domUtils/tableCellUtils'; export { updateImageMetadata } from './metadata/updateImageMetadata'; export { updateTableCellMetadata } from './metadata/updateTableCellMetadata'; diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts new file mode 100644 index 00000000000..b6b727e0e5f --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts @@ -0,0 +1,62 @@ +import { toArray } from 'roosterjs-content-model-dom'; +import type { TableSelection } from 'roosterjs-content-model-types'; + +/** + * Parse a table into a two dimensions array of TD elements. For those merged cells, the value will be null. + * @param table Input HTML Table element + * @returns Array of TD elements + */ +export function parseTableCells(table: HTMLTableElement): (HTMLTableCellElement | null)[][] { + const trs = toArray(table.rows); + const cells: (HTMLTableCellElement | null)[][] = trs.map(row => []); + + trs.forEach((tr, rowIndex) => { + for (let sourceCol = 0, targetCol = 0; sourceCol < tr.cells.length; sourceCol++) { + // Skip the cells which already initialized + for (; cells[rowIndex][targetCol] !== undefined; targetCol++) {} + + const td = tr.cells[sourceCol]; + + for (let colSpan = 0; colSpan < td.colSpan; colSpan++, targetCol++) { + for (let rowSpan = 0; rowSpan < td.rowSpan; rowSpan++) { + if (cells[rowIndex + rowSpan]) { + cells[rowIndex + rowSpan][targetCol] = + colSpan == 0 && rowSpan == 0 ? td : null; + } + } + } + } + + for (let col = 0; col < cells[rowIndex].length; col++) { + cells[rowIndex][col] = cells[rowIndex][col] || null; + } + }); + + return cells; +} + +/** + * Create ranges from a table selection + * @param selection The source table selection + * @returns An array of DOM ranges of selected table cells + */ +export function createTableRanges(selection: TableSelection): Range[] { + const result: Range[] = []; + const { table, firstColumn, firstRow, lastColumn, lastRow } = selection; + const cells = parseTableCells(table); + + for (let row = firstRow; row <= lastRow; row++) { + for (let col = firstColumn; col <= lastColumn; col++) { + const td = cells[row]?.[col]; + + if (td) { + const range = table.ownerDocument.createRange(); + + range.selectNode(td); + result.push(range); + } + } + } + + return result; +} diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/focusTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/focusTest.ts new file mode 100644 index 00000000000..af96e16dd14 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/focusTest.ts @@ -0,0 +1,120 @@ +import { focus } from '../../lib/coreApi/focus'; +import { StandaloneEditorCore } from 'roosterjs-content-model-types'; + +describe('focus', () => { + let div: HTMLDivElement; + let core: StandaloneEditorCore; + let hasFocusSpy: jasmine.Spy; + let setDOMSelectionSpy: jasmine.Spy; + let nativeFocusSpy: jasmine.Spy; + let mockedSelection1 = { + type: 'range', + } as any; + let mockedSelection2 = { + type: 'table', + } as any; + + beforeEach(() => { + hasFocusSpy = jasmine.createSpy('hasFocus'); + setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + nativeFocusSpy = jasmine.createSpy('focus'); + + div = { + focus: nativeFocusSpy, + } as any; + + core = { + lifecycle: {}, + api: { + hasFocus: hasFocusSpy, + setDOMSelection: setDOMSelectionSpy, + }, + selection: {}, + contentDiv: div, + } as any; + }); + + it('focus - has selection, do not need fallback', () => { + let hasFocus = false; + hasFocusSpy.and.callFake(() => { + const result = hasFocus; + hasFocus = true; + + return result; + }); + + core.selection.selection = mockedSelection1; + + focus(core); + + expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedSelection1, true); + expect(nativeFocusSpy).not.toHaveBeenCalled(); + }); + + it('focus - has selection, need fallback', () => { + let hasFocus = false; + hasFocusSpy.and.callFake(() => { + const result = hasFocus; + hasFocus = false; + + return result; + }); + + core.selection.selection = mockedSelection1; + + focus(core); + + expect(setDOMSelectionSpy).toHaveBeenCalledWith(core, mockedSelection1, true); + expect(nativeFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('focus - has selection with other type', () => { + let hasFocus = false; + hasFocusSpy.and.callFake(() => { + const result = hasFocus; + hasFocus = false; + + return result; + }); + + core.selection.selection = mockedSelection2; + + focus(core); + + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + expect(nativeFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('focus - no selection', () => { + let hasFocus = false; + hasFocusSpy.and.callFake(() => { + const result = hasFocus; + hasFocus = false; + + return result; + }); + + focus(core); + + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + expect(nativeFocusSpy).toHaveBeenCalledTimes(1); + }); + + it('focus - in shadow edit', () => { + let hasFocus = false; + + core.lifecycle.shadowEditFragment = {} as any; + + hasFocusSpy.and.callFake(() => { + const result = hasFocus; + hasFocus = false; + + return result; + }); + + focus(core); + + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + expect(nativeFocusSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts new file mode 100644 index 00000000000..d689ff28b48 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts @@ -0,0 +1,87 @@ +import { getDOMSelection } from '../../lib/coreApi/getDOMSelection'; +import { StandaloneEditorCore } from 'roosterjs-content-model-types'; + +describe('getDOMSelection', () => { + let core: StandaloneEditorCore; + let getSelectionSpy: jasmine.Spy; + let containsSpy: jasmine.Spy; + + beforeEach(() => { + getSelectionSpy = jasmine.createSpy('getSelection'); + containsSpy = jasmine.createSpy('contains'); + + core = { + lifecycle: {}, + selection: {}, + contentDiv: { + ownerDocument: { + defaultView: { + getSelection: getSelectionSpy, + }, + }, + contains: containsSpy, + }, + } as any; + }); + + it('no cached selection, no range selection', () => { + const mockedSelection = { + rangeCount: 0, + }; + + getSelectionSpy.and.returnValue(mockedSelection); + + const result = getDOMSelection(core); + + expect(result).toBeNull(); + }); + + it('no cached selection, range selection is out of editor', () => { + const mockedElement = 'ELEMENT' as any; + const mockedSelection = { + rangeCount: 1, + getRangeAt: () => ({ + commonAncestorContainer: mockedElement, + }), + }; + + getSelectionSpy.and.returnValue(mockedSelection); + containsSpy.and.returnValue(false); + + const result = getDOMSelection(core); + + expect(result).toBeNull(); + }); + + it('no cached selection, range selection is in editor', () => { + const mockedElement = 'ELEMENT' as any; + const mockedRange = { + commonAncestorContainer: mockedElement, + } as any; + const mockedSelection = { + rangeCount: 1, + getRangeAt: () => mockedRange, + }; + + getSelectionSpy.and.returnValue(mockedSelection); + containsSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toEqual({ + type: 'range', + range: mockedRange, + }); + }); + + it('has cached selection', () => { + const mockedSelection = 'SELECTION' as any; + core.selection.selection = mockedSelection; + + containsSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toBe(mockedSelection); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/hasFocusTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/hasFocusTest.ts new file mode 100644 index 00000000000..3eff77e4d38 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/hasFocusTest.ts @@ -0,0 +1,46 @@ +import { hasFocus } from '../../lib/coreApi/hasFocus'; +import { StandaloneEditorCore } from 'roosterjs-content-model-types'; + +describe('hasFocus', () => { + let core: StandaloneEditorCore; + let containsSpy: jasmine.Spy; + let mockedElement = 'ELEMENT' as any; + + beforeEach(() => { + containsSpy = jasmine.createSpy('contains'); + core = { + contentDiv: { + ownerDocument: {}, + contains: containsSpy, + }, + } as any; + }); + + afterEach(() => { + core = null; + }); + + it('Has active element inside editor', () => { + (core.contentDiv.ownerDocument as any).activeElement = mockedElement; + containsSpy.and.returnValue(true); + + let result = hasFocus(core); + + expect(result).toBe(true); + }); + + it('Has active element outside editor', () => { + (core.contentDiv.ownerDocument as any).activeElement = mockedElement; + containsSpy.and.returnValue(false); + + let result = hasFocus(core); + + expect(result).toBe(false); + }); + + it('No active element', () => { + let result = hasFocus(core); + + expect(result).toBe(false); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts new file mode 100644 index 00000000000..358a777722e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts @@ -0,0 +1,803 @@ +import * as addRangeToSelection from '../../lib/corePlugin/utils/addRangeToSelection'; +import { createElement } from 'roosterjs-editor-dom'; +import { CreateElementData, PluginEventType } from 'roosterjs-editor-types'; +import { DOMSelection, StandaloneEditorCore } from 'roosterjs-content-model-types'; +import { setDOMSelection } from '../../lib/coreApi/setDOMSelection'; + +describe('setDOMSelection', () => { + let core: StandaloneEditorCore; + let querySelectorAllSpy: jasmine.Spy; + let hasFocusSpy: jasmine.Spy; + let triggerEventSpy: jasmine.Spy; + let addRangeToSelectionSpy: jasmine.Spy; + let createRangeSpy: jasmine.Spy; + let deleteRuleSpy: jasmine.Spy; + let insertRuleSpy: jasmine.Spy; + let doc: Document; + let contentDiv: HTMLDivElement; + let mockedRange = 'RANGE' as any; + let mockedStyleNode: HTMLStyleElement; + + beforeEach(() => { + querySelectorAllSpy = jasmine.createSpy('querySelectorAll'); + hasFocusSpy = jasmine.createSpy('hasFocus'); + triggerEventSpy = jasmine.createSpy('triggerEvent'); + addRangeToSelectionSpy = spyOn(addRangeToSelection, 'addRangeToSelection').and.callFake( + () => { + expect(core.selection.skipReselectOnFocus).toBeTrue(); + } + ); + createRangeSpy = jasmine.createSpy('createRange'); + deleteRuleSpy = jasmine.createSpy('deleteRule'); + insertRuleSpy = jasmine.createSpy('insertRule'); + + doc = { + querySelectorAll: querySelectorAllSpy, + createRange: createRangeSpy, + } as any; + contentDiv = { + ownerDocument: doc, + } as any; + mockedStyleNode = { + sheet: { + cssRules: [], + deleteRule: deleteRuleSpy, + insertRule: insertRuleSpy, + }, + } as any; + + core = { + selection: { + selectionStyleNode: mockedStyleNode, + }, + contentDiv, + api: { + hasFocus: hasFocusSpy, + triggerEvent: triggerEventSpy, + }, + } as any; + }); + + describe('Null selection', () => { + beforeEach(() => { + querySelectorAllSpy.and.returnValue([]); + }); + + function runTest(originalSelection: DOMSelection | null) { + core.selection.selection = originalSelection; + (core.selection.selectionStyleNode!.sheet!.cssRules as any) = ['Rule1', 'Rule2']; + + setDOMSelection(core, null); + + expect(core.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: null, + selectionStyleNode: mockedStyleNode, + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null, + newSelection: null, + }, + true + ); + expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); + expect(contentDiv.id).toBe('contentDiv_0'); + expect(deleteRuleSpy).toHaveBeenCalledTimes(2); + expect(deleteRuleSpy).toHaveBeenCalledWith(1); + expect(deleteRuleSpy).toHaveBeenCalledWith(0); + expect(insertRuleSpy).not.toHaveBeenCalled(); + } + + it('From null selection', () => { + runTest(null); + }); + + it('From range selection', () => { + runTest({ + type: 'range', + range: {} as any, + }); + }); + + it('From image selection', () => { + runTest({ + type: 'image', + image: {} as any, + }); + }); + + it('From table selection', () => { + runTest({ + type: 'table', + table: {} as any, + firstColumn: 0, + firstRow: 0, + lastColumn: 1, + lastRow: 1, + }); + }); + }); + + describe('Range selection', () => { + it('range selection, editor id is unique, editor has focus, trigger event', () => { + const mockedSelection = { + type: 'range', + range: mockedRange, + } as any; + + (core.selection.selectionStyleNode!.sheet!.cssRules as any) = ['Rule1', 'Rule2']; + + querySelectorAllSpy.and.returnValue([]); + hasFocusSpy.and.returnValue(true); + + setDOMSelection(core, mockedSelection); + + expect(core.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: null, + selectionStyleNode: mockedStyleNode, + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null, + newSelection: mockedSelection, + }, + true + ); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(contentDiv.id).toBe('contentDiv_0'); + expect(deleteRuleSpy).toHaveBeenCalledTimes(2); + expect(deleteRuleSpy).toHaveBeenCalledWith(1); + expect(deleteRuleSpy).toHaveBeenCalledWith(0); + expect(insertRuleSpy).not.toHaveBeenCalled(); + }); + + it('range selection, with existing css rule', () => { + const mockedSelection = { + type: 'range', + range: mockedRange, + } as any; + + querySelectorAllSpy.and.returnValue([]); + hasFocusSpy.and.returnValue(true); + + setDOMSelection(core, mockedSelection); + + expect(core.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: null, + selectionStyleNode: mockedStyleNode, + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null, + newSelection: mockedSelection, + }, + true + ); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(contentDiv.id).toBe('contentDiv_0'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).not.toHaveBeenCalled(); + }); + + it('range selection, editor id is unique, editor has focus, do not trigger event', () => { + const mockedSelection = { + type: 'range', + range: mockedRange, + } as any; + + querySelectorAllSpy.and.returnValue([]); + hasFocusSpy.and.returnValue(true); + + setDOMSelection(core, mockedSelection, true); + + expect(core.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: null, + selectionStyleNode: mockedStyleNode, + } as any); + expect(triggerEventSpy).not.toHaveBeenCalled(); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(contentDiv.id).toBe('contentDiv_0'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).not.toHaveBeenCalled(); + }); + + it('range selection, editor id is unique, editor does not have focus', () => { + const mockedSelection = { + type: 'range', + range: mockedRange, + } as any; + + querySelectorAllSpy.and.returnValue([]); + hasFocusSpy.and.returnValue(false); + + setDOMSelection(core, mockedSelection); + + expect(core.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: mockedSelection, + selectionStyleNode: mockedStyleNode, + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null, + newSelection: mockedSelection, + }, + true + ); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(contentDiv.id).toBe('contentDiv_0'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).not.toHaveBeenCalled(); + }); + + it('range selection, editor has unique id', () => { + const mockedSelection = { + type: 'range', + range: mockedRange, + } as any; + contentDiv.id = 'testId'; + + querySelectorAllSpy.and.returnValue([]); + hasFocusSpy.and.returnValue(false); + + setDOMSelection(core, mockedSelection); + + expect(core.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: mockedSelection, + selectionStyleNode: mockedStyleNode, + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null, + newSelection: mockedSelection, + }, + true + ); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(contentDiv.id).toBe('testId'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).not.toHaveBeenCalled(); + }); + + it('range selection, editor has duplicated id', () => { + const mockedSelection = { + type: 'range', + range: mockedRange, + } as any; + contentDiv.id = 'testId'; + + querySelectorAllSpy.and.callFake(selector => { + return selector == '#testId' ? ['', ''] : ['']; + }); + hasFocusSpy.and.returnValue(false); + + setDOMSelection(core, mockedSelection); + + expect(core.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: mockedSelection, + selectionStyleNode: mockedStyleNode, + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null, + newSelection: mockedSelection, + }, + true + ); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(contentDiv.id).toBe('testId_0'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).not.toHaveBeenCalled(); + }); + + it('range selection, editor has duplicated id - 2', () => { + const mockedSelection = { + type: 'range', + range: mockedRange, + } as any; + contentDiv.id = 'testId'; + + querySelectorAllSpy.and.callFake(selector => { + return selector == '#testId' || selector == '#testId_0' ? ['', ''] : ['']; + }); + hasFocusSpy.and.returnValue(false); + + setDOMSelection(core, mockedSelection); + + expect(core.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: mockedSelection, + selectionStyleNode: mockedStyleNode, + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null, + newSelection: mockedSelection, + }, + true + ); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(contentDiv.id).toBe('testId_1'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Image selection', () => { + let mockedImage: HTMLImageElement; + + beforeEach(() => { + mockedImage = { + ownerDocument: doc, + } as any; + }); + + it('image selection', () => { + const mockedSelection = { + type: 'image', + image: mockedImage, + } as any; + const selectNodeSpy = jasmine.createSpy('selectNode'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + selectNode: selectNodeSpy, + collapse: collapseSpy, + }; + + createRangeSpy.and.returnValue(mockedRange); + + querySelectorAllSpy.and.returnValue([]); + hasFocusSpy.and.returnValue(false); + + setDOMSelection(core, mockedSelection); + + expect(core.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: mockedSelection, + selectionStyleNode: mockedStyleNode, + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null, + newSelection: mockedSelection, + }, + true + ); + expect(selectNodeSpy).toHaveBeenCalledWith(mockedImage); + expect(collapseSpy).toHaveBeenCalledWith(); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(contentDiv.id).toBe('contentDiv_0'); + expect(mockedImage.id).toBe('image_0'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).toHaveBeenCalledWith( + '#contentDiv_0 #image_0 {outline-style:auto!important;outline-color:#DB626C!important;caret-color:transparent;}' + ); + }); + + it('image selection with duplicated id', () => { + const mockedSelection = { + type: 'image', + image: mockedImage, + } as any; + const selectNodeSpy = jasmine.createSpy('selectNode'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + selectNode: selectNodeSpy, + collapse: collapseSpy, + }; + + mockedImage.id = 'image_0'; + createRangeSpy.and.returnValue(mockedRange); + + querySelectorAllSpy.and.callFake(selector => { + return selector == '#image_0' ? ['', ''] : ['']; + }); + hasFocusSpy.and.returnValue(false); + + setDOMSelection(core, mockedSelection); + + expect(core.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: mockedSelection, + selectionStyleNode: mockedStyleNode, + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null, + newSelection: mockedSelection, + }, + true + ); + expect(selectNodeSpy).toHaveBeenCalledWith(mockedImage); + expect(collapseSpy).toHaveBeenCalledWith(); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(contentDiv.id).toBe('contentDiv_0'); + expect(mockedImage.id).toBe('image_0_0'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).toHaveBeenCalledWith( + '#contentDiv_0 #image_0_0 {outline-style:auto!important;outline-color:#DB626C!important;caret-color:transparent;}' + ); + }); + + it('image selection with customized selection border color', () => { + const mockedSelection = { + type: 'image', + image: mockedImage, + } as any; + const selectNodeSpy = jasmine.createSpy('selectNode'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + selectNode: selectNodeSpy, + collapse: collapseSpy, + }; + + core.selection.imageSelectionBorderColor = 'red'; + + createRangeSpy.and.returnValue(mockedRange); + + querySelectorAllSpy.and.returnValue([]); + hasFocusSpy.and.returnValue(false); + + setDOMSelection(core, mockedSelection); + + expect(core.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: mockedSelection, + selectionStyleNode: mockedStyleNode, + imageSelectionBorderColor: 'red', + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null, + newSelection: mockedSelection, + }, + true + ); + expect(selectNodeSpy).toHaveBeenCalledWith(mockedImage); + expect(collapseSpy).toHaveBeenCalledWith(); + expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange); + expect(contentDiv.id).toBe('contentDiv_0'); + expect(mockedImage.id).toBe('image_0'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).toHaveBeenCalledWith( + '#contentDiv_0 #image_0 {outline-style:auto!important;outline-color:red!important;caret-color:transparent;}' + ); + }); + }); + + describe('Table selection', () => { + let mockedTable: HTMLTableElement; + + beforeEach(() => { + mockedTable = { + ownerDocument: doc, + rows: [], + childNodes: [], + } as any; + }); + + it('empty table', () => { + const mockedSelection = { + type: 'table', + table: mockedTable, + } as any; + const selectNodeSpy = jasmine.createSpy('selectNode'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + selectNode: selectNodeSpy, + collapse: collapseSpy, + }; + + createRangeSpy.and.returnValue(mockedRange); + + querySelectorAllSpy.and.returnValue([]); + hasFocusSpy.and.returnValue(false); + + setDOMSelection(core, mockedSelection); + + expect(core.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: mockedSelection, + selectionStyleNode: mockedStyleNode, + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null, + newSelection: mockedSelection, + }, + true + ); + expect(selectNodeSpy).not.toHaveBeenCalled(); + expect(collapseSpy).not.toHaveBeenCalled(); + expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); + expect(contentDiv.id).toBe('contentDiv_0'); + expect(mockedTable.id).toBe('table_0'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).not.toHaveBeenCalled(); + }); + + function runTest( + mockedTable: HTMLTableElement, + firstColumn: number, + firstRow: number, + lastColumn: number, + lastRow: number, + result: string + ) { + const mockedSelection = { + type: 'table', + table: mockedTable, + firstColumn, + firstRow, + lastColumn, + lastRow, + } as any; + const selectNodeSpy = jasmine.createSpy('selectNode'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + selectNode: selectNodeSpy, + collapse: collapseSpy, + }; + + createRangeSpy.and.returnValue(mockedRange); + + querySelectorAllSpy.and.returnValue([]); + hasFocusSpy.and.returnValue(false); + + setDOMSelection(core, mockedSelection); + + expect(core.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: mockedSelection, + selectionStyleNode: mockedStyleNode, + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null, + newSelection: mockedSelection, + }, + true + ); + expect(contentDiv.id).toBe('contentDiv_0'); + expect(mockedTable.id).toBe('table_0'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).toHaveBeenCalledWith(result); + } + + it('Select Table Cells TR under Table Tag', () => { + runTest( + buildTable(true), + 1, + 0, + 1, + 1, + '#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + ); + }); + + it('Select Table Cells TBODY', () => { + runTest( + buildTable(false), + 0, + 0, + 0, + 1, + '#contentDiv_0 #table_0> tr:nth-child(1)>TD:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(1)>TD:nth-child(1) *,#contentDiv_0 #table_0> tr:nth-child(2)>TD:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(2)>TD:nth-child(1) * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + ); + }); + + it('Select TH and TR in the same row', () => { + runTest( + createElement( + { + tag: 'table', + children: [ + { + tag: 'TR', + children: [ + { + tag: 'TH', + children: ['test'], + }, + { + tag: 'TD', + children: ['test'], + }, + ], + }, + { + tag: 'TR', + children: [ + { + tag: 'TH', + children: ['test'], + }, + { + tag: 'TD', + children: ['test'], + }, + ], + }, + ], + }, + document + ) as HTMLTableElement, + 0, + 0, + 0, + 1, + '#contentDiv_0 #table_0> tr:nth-child(1)>TH:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(1)>TH:nth-child(1) *,#contentDiv_0 #table_0> tr:nth-child(2)>TH:nth-child(1),#contentDiv_0 #table_0> tr:nth-child(2)>TH:nth-child(1) * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + ); + }); + + it('Select Table Cells THEAD, TBODY', () => { + runTest( + buildTable(true /* tbody */, true /* thead */), + 1, + 1, + 2, + 2, + '#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + ); + }); + + it('Select Table Cells TBODY, TFOOT', () => { + runTest( + buildTable(true /* tbody */, false /* thead */, true /* tfoot */), + 1, + 1, + 2, + 2, + '#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + ); + }); + + it('Select Table Cells THEAD, TBODY, TFOOT', () => { + runTest( + buildTable(true /* tbody */, true /* thead */, true /* tfoot */), + 1, + 1, + 1, + 4, + '#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *,#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + ); + }); + + it('Select Table Cells THEAD, TFOOT', () => { + runTest( + buildTable(false /* tbody */, true /* thead */, true /* tfoot */), + 1, + 1, + 1, + 2, + '#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2),#contentDiv_0 #table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *,#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2),#contentDiv_0 #table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + ); + }); + + it('Select All', () => { + runTest( + buildTable(true /* tbody */, false, false), + 0, + 0, + 1, + 1, + '#contentDiv_0 #table_0,#contentDiv_0 #table_0 * {background-color: rgb(198,198,198) !important; caret-color: transparent}' + ); + }); + }); +}); + +function buildTable(tbody: boolean, thead: boolean = false, tfoot: boolean = false) { + const getElement = (tag: string): CreateElementData => { + return { + tag, + children: [ + { + tag: 'TR', + children: [ + { + tag: 'TD', + children: ['test'], + }, + { + tag: 'TD', + children: ['test'], + }, + ], + }, + { + tag: 'TR', + children: [ + { + tag: 'TD', + children: ['test'], + }, + { + tag: 'TD', + children: ['test'], + }, + ], + }, + ], + }; + }; + + const children: (string | CreateElementData)[] = []; + if (thead) { + children.push(getElement('thead')); + } + if (tbody) { + children.push(getElement('tbody')); + } + if (tfoot) { + children.push(getElement('tfoot')); + } + if (children.length === 0) { + children.push( + { + tag: 'TR', + children: [ + { + tag: 'TD', + children: ['test'], + }, + { + tag: 'TD', + children: ['test'], + }, + ], + }, + { + tag: 'TR', + children: [ + { + tag: 'TD', + children: ['test'], + }, + { + tag: 'TD', + children: ['test'], + }, + ], + } + ); + } + + return createElement( + { + tag: 'table', + children, + }, + document + ) as HTMLTableElement; +} diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts new file mode 100644 index 00000000000..c44a3119c25 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts @@ -0,0 +1,169 @@ +import { createSelectionPlugin } from '../../lib/corePlugin/SelectionPlugin'; +import { IEditor, PluginWithState } from 'roosterjs-editor-types'; +import { IStandaloneEditor, SelectionPluginState } from 'roosterjs-content-model-types'; + +const MockedStyleNode = 'STYLENODE' as any; + +describe('SelectionPlugin', () => { + it('init and dispose', () => { + const plugin = createSelectionPlugin({}); + const disposer = jasmine.createSpy('disposer'); + const createElementSpy = jasmine + .createSpy('createElement') + .and.returnValue(MockedStyleNode); + const appendChildSpy = jasmine.createSpy('appendChild'); + const addDomEventHandler = jasmine + .createSpy('addDomEventHandler') + .and.returnValue(disposer); + const removeEventListenerSpy = jasmine.createSpy('removeEventListener'); + const getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ + createElement: createElementSpy, + head: { + appendChild: appendChildSpy, + }, + removeEventListener: removeEventListenerSpy, + }); + const state = plugin.getState(); + const editor = ({ + getDocument: getDocumentSpy, + addDomEventHandler, + getEnvironment: () => ({}), + } as any) as IStandaloneEditor & IEditor; + + plugin.initialize(editor); + + expect(state).toEqual({ + selection: null, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + }); + expect(addDomEventHandler).toHaveBeenCalled(); + expect(removeEventListenerSpy).not.toHaveBeenCalled(); + expect(disposer).not.toHaveBeenCalled(); + + plugin.dispose(); + + expect(removeEventListenerSpy).toHaveBeenCalled(); + expect(disposer).toHaveBeenCalled(); + }); + + it('init with different options', () => { + const plugin = createSelectionPlugin({ + imageSelectionBorderColor: 'red', + }); + const state = plugin.getState(); + + const addDomEventHandler = jasmine + .createSpy('addDomEventHandler') + .and.returnValue(jasmine.createSpy('disposer')); + const createElementSpy = jasmine + .createSpy('createElement') + .and.returnValue(MockedStyleNode); + const appendChildSpy = jasmine.createSpy('appendChild'); + const removeEventListenerSpy = jasmine.createSpy('removeEventListener'); + const getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ + createElement: createElementSpy, + head: { + appendChild: appendChildSpy, + }, + removeEventListener: removeEventListenerSpy, + }); + + plugin.initialize(({ + getDocument: getDocumentSpy, + addDomEventHandler, + getEnvironment: () => ({}), + })); + + expect(state).toEqual({ + selection: null, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: 'red', + }); + + expect(addDomEventHandler).toHaveBeenCalled(); + + plugin.dispose(); + }); +}); + +describe('DOMEventPlugin handle onFocus and onBlur event', () => { + let plugin: PluginWithState; + let triggerPluginEvent: jasmine.Spy; + let eventMap: Record; + let getElementAtCursorSpy: jasmine.Spy; + let createElementSpy: jasmine.Spy; + let getDocumentSpy: jasmine.Spy; + let appendChildSpy: jasmine.Spy; + let setDOMSelectionSpy: jasmine.Spy; + let removeEventListenerSpy: jasmine.Spy; + + let editor: IEditor; + + beforeEach(() => { + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); + getElementAtCursorSpy = jasmine.createSpy('getElementAtCursor'); + createElementSpy = jasmine.createSpy('createElement').and.returnValue(MockedStyleNode); + appendChildSpy = jasmine.createSpy('appendChild'); + removeEventListenerSpy = jasmine.createSpy('removeEventListener'); + getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ + createElement: createElementSpy, + head: { + appendChild: appendChildSpy, + }, + removeEventListener: removeEventListenerSpy, + }); + setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + + plugin = createSelectionPlugin({}); + + editor = ({ + getDocument: getDocumentSpy, + triggerPluginEvent, + getEnvironment: () => ({}), + addDomEventHandler: (map: Record) => { + eventMap = map; + return jasmine.createSpy('disposer'); + }, + getElementAtCursor: getElementAtCursorSpy, + setDOMSelection: setDOMSelectionSpy, + }); + plugin.initialize(editor); + }); + + afterEach(() => { + plugin.dispose(); + }); + + it('Trigger onFocus event', () => { + const state = plugin.getState(); + const mockedRange = 'RANGE' as any; + + state.skipReselectOnFocus = false; + state.selection = mockedRange; + + eventMap.focus(); + expect(plugin.getState()).toEqual({ + selection: mockedRange, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + skipReselectOnFocus: false, + }); + }); + + it('Trigger onFocus event, skip reselect', () => { + const state = plugin.getState(); + const mockedRange = 'RANGE' as any; + + state.skipReselectOnFocus = true; + state.selection = mockedRange; + + eventMap.focus(); + expect(plugin.getState()).toEqual({ + selection: mockedRange, + selectionStyleNode: MockedStyleNode, + imageSelectionBorderColor: undefined, + skipReselectOnFocus: true, + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts new file mode 100644 index 00000000000..ebf926eee41 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts @@ -0,0 +1,260 @@ +import { createTableRanges, parseTableCells } from '../../../lib/publicApi/domUtils/tableCellUtils'; +import { DOMSelection } from 'roosterjs-content-model-types'; + +describe('parseTableCells', () => { + function runTest(html: string, expectedResult: (string | null)[][]) { + const div = document.createElement('div'); + div.innerHTML = html; + + const table = div.firstChild as HTMLTableElement; + + const result = parseTableCells(table); + const idResult = result.map(row => row.map(td => (td ? td.id : null))); + + expect(idResult).toEqual(expectedResult); + } + + it('empty table', () => { + runTest('
                                                          ', []); + }); + + it('1*1 table', () => { + runTest('
                                                          ', [['td1']]); + }); + + it('2*2 table', () => { + runTest( + '
                                                          ', + [ + ['td1', 'td2'], + ['td3', 'td4'], + ] + ); + }); + + it('table with merged row', () => { + runTest( + '
                                                          ', + [ + ['td1', 'td2'], + [null, 'td4'], + ] + ); + }); + + it('table with merged col', () => { + runTest( + '
                                                          ', + [ + ['td1', null], + ['td3', 'td4'], + ] + ); + }); + + it('table with all merged cell', () => { + runTest('
                                                          ', [ + ['td1', null], + [null, null], + ]); + }); + + it('table with variant lengths columns', () => { + runTest( + '
                                                          ', + [ + ['td1', 'td2'], + ['td3', 'td4', 'td5'], + ] + ); + }); + + it('table with complex merged cells', () => { + runTest( + '
                                                          ', + [ + ['td1', null, 'td3'], + ['td4', 'td5', null], + [null, 'td8', null], + ] + ); + }); +}); + +describe('createTableRanges', () => { + function runTest( + html: string, + firstRow: number, + firstColumn: number, + lastRow: number, + lastColumn: number, + expectedIds: (string | null)[] + ) { + const div = document.createElement('div'); + div.innerHTML = html; + const table = div.firstChild as HTMLTableElement; + + const selection: DOMSelection = { + type: 'table', + table: table, + firstColumn, + firstRow, + lastColumn, + lastRow, + }; + + const result = createTableRanges(selection); + const idResult = result.map(range => { + const startContainer = range.startContainer.childNodes[range.startOffset]; + const endContainer = range.endContainer.childNodes[range.endOffset - 1]; + + return startContainer == endContainer ? (startContainer as HTMLElement).id : null; + }); + + expect(idResult).toEqual(expectedIds); + } + + it('empty table', () => { + runTest('
                                                          ', 0, 0, 0, 0, []); + }); + + it('1*1 table, no selection', () => { + runTest('
                                                          ', -1, -1, -1, -1, []); + }); + + it('1*1 table, has selection', () => { + runTest('
                                                          ', 0, 0, 0, 0, ['td1']); + }); + + it('2*2 table, has sub selection', () => { + runTest( + '
                                                          ', + 0, + 1, + 1, + 1, + ['td2', 'td4'] + ); + }); + + it('table with merged row - 1', () => { + runTest( + '
                                                          ', + 0, + 0, + 1, + 0, + ['td1'] + ); + }); + + it('table with merged row - 2', () => { + runTest( + '
                                                          ', + 0, + 1, + 1, + 1, + ['td2', 'td4'] + ); + }); + + it('table with merged col - 1', () => { + runTest( + '
                                                          ', + 0, + 0, + 0, + 1, + ['td1'] + ); + }); + + it('table with merged col - 2', () => { + runTest( + '
                                                          ', + 1, + 0, + 1, + 1, + ['td3', 'td4'] + ); + }); + + it('table with all merged cell - 1', () => { + runTest( + '
                                                          ', + 0, + 0, + 0, + 0, + ['td1'] + ); + }); + + it('table with all merged cell - 2', () => { + runTest( + '
                                                          ', + 1, + 1, + 1, + 1, + [] + ); + }); + + it('table with variant lengths columns', () => { + runTest( + '
                                                          ', + 0, + 1, + 1, + 2, + ['td2', 'td4', 'td5'] + ); + }); + + it('table with complex merged cells - 1', () => { + runTest( + '
                                                          ', + 0, + 0, + 0, + 2, + ['td1', 'td3'] + ); + }); + + it('table with complex merged cells - 2', () => { + runTest( + '
                                                          ', + 1, + 0, + 1, + 2, + ['td4', 'td5'] + ); + }); + + it('table with complex merged cells - 3', () => { + runTest( + '
                                                          ', + 1, + 1, + 2, + 2, + ['td5', 'td8'] + ); + }); + + it('table with complex merged cells - 4', () => { + runTest( + '
                                                          ', + 0, + 0, + 2, + 2, + ['td1', 'td3', 'td4', 'td5', 'td8'] + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts index ac38b4dea87..1fd5865cba6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts @@ -1,11 +1,7 @@ -import { getSelectionPath, Position } from 'roosterjs-editor-dom'; -import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import type { - EntityState, - ContentChangedEvent, - ContentMetadata, - SelectionRangeEx, -} from 'roosterjs-editor-types'; +import { convertDomSelectionToMetadata } from '../editor/utils/selectionConverter'; +import { PluginEventType } from 'roosterjs-editor-types'; +import { Position } from 'roosterjs-editor-dom'; +import type { EntityState, ContentChangedEvent } from 'roosterjs-editor-types'; import type { AddUndoSnapshot, StandaloneEditorCore } from 'roosterjs-content-model-types'; /** @@ -41,7 +37,8 @@ export const addUndoSnapshot: AddUndoSnapshot = ( try { if (callback) { - const range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); + const selection = core.api.getDOMSelection(core); + const range = selection?.type == 'range' ? selection.range : null; data = callback( range && Position.getStart(range).normalize(), range && Position.getEnd(range).normalize() @@ -69,11 +66,11 @@ export const addUndoSnapshot: AddUndoSnapshot = ( } if (canUndoByBackspace) { - const range = core.api.getSelectionRange(core, false /*tryGetFromCache*/); + const selection = core.api.getDOMSelection(core); - if (range) { + if (selection?.type == 'range') { core.undo.hasNewContent = false; - core.undo.autoCompletePosition = Position.getStart(range); + core.undo.autoCompletePosition = Position.getStart(selection.range); } } }; @@ -84,9 +81,12 @@ function addUndoSnapshotInternal( entityStates?: EntityState[] ) { if (!core.lifecycle.shadowEditFragment) { - const rangeEx = core.api.getSelectionRangeEx(core); - const isDarkMode = core.lifecycle.isDarkMode; - const metadata = createContentMetadata(core.contentDiv, rangeEx, isDarkMode) || null; + const selection = core.api.getDOMSelection(core); + const metadata = convertDomSelectionToMetadata(core.contentDiv, selection); + + if (metadata) { + metadata.isDarkMode = !!core.lifecycle.isDarkMode; + } core.undo.snapshotsService.addSnapshot( { @@ -100,33 +100,3 @@ function addUndoSnapshotInternal( core.undo.hasNewContent = false; } } - -function createContentMetadata( - root: HTMLElement, - rangeEx: SelectionRangeEx, - isDarkMode: boolean -): ContentMetadata | undefined { - switch (rangeEx?.type) { - case SelectionRangeTypes.TableSelection: - return { - type: SelectionRangeTypes.TableSelection, - tableId: rangeEx.table.id, - isDarkMode: !!isDarkMode, - ...rangeEx.coordinates!, - }; - case SelectionRangeTypes.ImageSelection: - return { - type: SelectionRangeTypes.ImageSelection, - imageId: rangeEx.image.id, - isDarkMode: !!isDarkMode, - }; - case SelectionRangeTypes.Normal: - return { - type: SelectionRangeTypes.Normal, - isDarkMode: !!isDarkMode, - start: [], - end: [], - ...(getSelectionPath(root, rangeEx.ranges[0]) || {}), - }; - } -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts index f0b17b15c2f..fe3217352bc 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/coreApiMap.ts @@ -1,18 +1,10 @@ import { addUndoSnapshot } from './addUndoSnapshot'; import { attachDomEvent } from './attachDomEvent'; import { ensureTypeInContainer } from './ensureTypeInContainer'; -import { focus } from './focus'; import { getContent } from './getContent'; -import { getSelectionRange } from './getSelectionRange'; -import { getSelectionRangeEx } from './getSelectionRangeEx'; import { getStyleBasedFormatState } from './getStyleBasedFormatState'; -import { hasFocus } from './hasFocus'; import { insertNode } from './insertNode'; import { restoreUndoSnapshot } from './restoreUndoSnapshot'; -import { select } from './select'; -import { selectImage } from './selectImage'; -import { selectRange } from './selectRange'; -import { selectTable } from './selectTable'; import { setContent } from './setContent'; import { transformColor } from './transformColor'; import { triggerEvent } from './triggerEvent'; @@ -25,19 +17,11 @@ export const coreApiMap: UnportedCoreApiMap = { attachDomEvent, addUndoSnapshot, ensureTypeInContainer, - focus, getContent, - getSelectionRange, - getSelectionRangeEx, getStyleBasedFormatState, - hasFocus, insertNode, restoreUndoSnapshot, - select, - selectRange, setContent, transformColor, triggerEvent, - selectTable, - selectImage, }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts index 3980ae9ce54..4f845aa26ea 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/ensureTypeInContainer.ts @@ -61,7 +61,10 @@ export const ensureTypeInContainer: EnsureTypeInContainer = (core, position, key // If this is triggered by a keyboard event, let's select the new position if (keyboardEvent) { - core.api.selectRange(core, createRange(new Position(position))); + core.api.setDOMSelection(core, { + type: 'range', + range: createRange(new Position(position)), + }); } }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts deleted file mode 100644 index 2df2921d98a..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/focus.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { createRange, getFirstLeafNode } from 'roosterjs-editor-dom'; -import { PositionType } from 'roosterjs-editor-types'; -import type { Focus } from 'roosterjs-content-model-types'; - -/** - * @internal - * Focus to editor. If there is a cached selection range, use it as current selection - * @param core The StandaloneEditorCore object - */ -export const focus: Focus = core => { - if (!core.lifecycle.shadowEditFragment) { - if ( - !core.api.hasFocus(core) || - !core.api.getSelectionRange(core, false /*tryGetFromCache*/) - ) { - // Focus (document.activeElement indicates) and selection are mostly in sync, but could be out of sync in some extreme cases. - // i.e. if you programmatically change window selection to point to a non-focusable DOM element (i.e. tabindex=-1 etc.). - // On Chrome/Firefox, it does not change document.activeElement. On Edge/IE, it change document.activeElement to be body - // Although on Chrome/Firefox, document.activeElement points to editor, you cannot really type which we don't want (no cursor). - // So here we always do a live selection pull on DOM and make it point in Editor. The pitfall is, the cursor could be reset - // to very begin to of editor since we don't really have last saved selection (created on blur which does not fire in this case). - // It should be better than the case you cannot type - if ( - !core.selection.selectionRange || - !core.api.selectRange(core, core.selection.selectionRange, true /*skipSameRange*/) - ) { - const node = getFirstLeafNode(core.contentDiv) || core.contentDiv; - core.api.selectRange( - core, - createRange(node, PositionType.Begin), - true /*skipSameRange*/ - ); - } - } - - // remember to clear cached selection range - core.selection.selectionRange = null; - - // This is more a fallback to ensure editor gets focus if it didn't manage to move focus to editor - if (!core.api.hasFocus(core)) { - core.contentDiv.focus(); - } - } -}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts index 622bbdbbc7b..1689718e3aa 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getContent.ts @@ -32,12 +32,12 @@ export const getContent: GetContent = (core, mode): string => { const clonedRoot = cloneNode(root); clonedRoot.normalize(); - const originalRange = core.api.getSelectionRange(core, true /*tryGetFromCache*/); + const originalRange = core.api.getDOMSelection(core); const path = !includeSelectionMarker || core.lifecycle.shadowEditFragment ? null - : originalRange - ? getSelectionPath(core.contentDiv, originalRange) + : originalRange?.type == 'range' + ? getSelectionPath(core.contentDiv, originalRange.range) : null; const range = path && createRange(clonedRoot, path.start, path.end); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts deleted file mode 100644 index b357d56860c..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRange.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { contains } from 'roosterjs-editor-dom'; -import type { GetSelectionRange } from 'roosterjs-content-model-types'; - -/** - * @internal - * Get current or cached selection range - * @param core The StandaloneEditorCore object - * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now - * @returns A Range object of the selection range - */ -export const getSelectionRange: GetSelectionRange = (core, tryGetFromCache: boolean) => { - let result: Range | null = null; - - if (core.lifecycle.shadowEditFragment) { - return null; - } else { - if (!tryGetFromCache || core.api.hasFocus(core)) { - const selection = core.contentDiv.ownerDocument.defaultView?.getSelection(); - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - if (contains(core.contentDiv, range)) { - result = range; - } - } - } - - if (!result && tryGetFromCache) { - result = core.selection.selectionRange; - } - - return result; - } -}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts deleted file mode 100644 index 3d1f59e1861..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/getSelectionRangeEx.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { contains } from 'roosterjs-editor-dom'; -import { SelectionRangeTypes } from 'roosterjs-editor-types'; -import type { GetSelectionRangeEx } from 'roosterjs-content-model-types'; -import type { SelectionRangeEx } from 'roosterjs-editor-types'; - -/** - * @internal - * Get current or cached selection range - * @param core The StandaloneEditorCore object - * @returns A Range object of the selection range - */ -export const getSelectionRangeEx: GetSelectionRangeEx = core => { - const result: SelectionRangeEx | null = null; - if (core.lifecycle.shadowEditFragment) { - return createNormalSelectionEx([]); - } else { - if (core.api.hasFocus(core)) { - if (core.selection.tableSelectionRange) { - return core.selection.tableSelectionRange; - } - - if (core.selection.imageSelectionRange) { - return core.selection.imageSelectionRange; - } - - const selection = core.contentDiv.ownerDocument.defaultView?.getSelection(); - if (!result && selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - if (contains(core.contentDiv, range)) { - return createNormalSelectionEx([range]); - } - } - } - - return ( - core.selection.tableSelectionRange ?? - core.selection.imageSelectionRange ?? - createNormalSelectionEx( - core.selection.selectionRange ? [core.selection.selectionRange] : [] - ) - ); - } -}; - -function createNormalSelectionEx(ranges: Range[]): SelectionRangeEx { - return { - type: SelectionRangeTypes.Normal, - ranges: ranges, - areAllCollapsed: checkAllCollapsed(ranges), - }; -} - -function checkAllCollapsed(ranges: Range[]): boolean { - return ranges.filter(range => range?.collapsed).length == ranges.length; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts index 18f9bf74963..60a76328d3b 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/insertNode.ts @@ -31,7 +31,8 @@ function getInitialRange( // Range inserts based on a provided range. // Both have the potential to use the current selection to restore cursor position // So in both cases we need to store the selection state. - let range = core.api.getSelectionRange(core, true /*tryGetFromCache*/); + const selection = core.api.getDOMSelection(core); + let range = selection?.type == 'range' ? selection.range : null; let rangeToRestore = null; if (option.position == ContentPosition.Range) { rangeToRestore = range; @@ -182,7 +183,10 @@ export const insertNode: InsertNode = ( } if (rangeToRestore) { - core.api.selectRange(core, rangeToRestore); + core.api.setDOMSelection(core, { + type: 'range', + range: rangeToRestore, + }); } break; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts deleted file mode 100644 index b47997a30b3..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/select.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { contains, createRange, safeInstanceOf } from 'roosterjs-editor-dom'; -import { PluginEventType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import type { Select, StandaloneEditorCore } from 'roosterjs-content-model-types'; -import type { - NodePosition, - PositionType, - SelectionPath, - SelectionRangeEx, - TableSelection, -} from 'roosterjs-editor-types'; - -/** - * @internal - * Select content according to the given information. - * There are a bunch of allowed combination of parameters. See IEditor.select for more details - * @param core The editor core object - * @param arg1 A DOM Range, or SelectionRangeEx, or NodePosition, or Node, or Selection Path - * @param arg2 (optional) A NodePosition, or an offset number, or a PositionType, or a TableSelection - * @param arg3 (optional) A Node - * @param arg4 (optional) An offset number, or a PositionType - */ -export const select: Select = (core, arg1, arg2, arg3, arg4) => { - const rangeEx = buildRangeEx(core, arg1, arg2, arg3, arg4); - - if (rangeEx) { - const skipReselectOnFocus = core.selection.skipReselectOnFocus; - - // We are applying a new selection, so we don't need to apply cached selection in DOMEventPlugin. - // Set skipReselectOnFocus to skip this behavior - core.selection.skipReselectOnFocus = true; - - try { - applyRangeEx(core, rangeEx); - } finally { - core.selection.skipReselectOnFocus = skipReselectOnFocus; - } - } else { - core.selection.tableSelectionRange = core.api.selectTable(core, null); - core.selection.imageSelectionRange = core.api.selectImage(core, null); - } - - return !!rangeEx; -}; - -function buildRangeEx( - core: StandaloneEditorCore, - arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, - arg2?: NodePosition | number | PositionType | TableSelection | null, - arg3?: Node, - arg4?: number | PositionType -) { - let rangeEx: SelectionRangeEx | null = null; - - if (isSelectionRangeEx(arg1)) { - rangeEx = arg1; - } else if (safeInstanceOf(arg1, 'HTMLTableElement') && isTableSelectionOrNull(arg2)) { - rangeEx = { - type: SelectionRangeTypes.TableSelection, - ranges: [], - areAllCollapsed: false, - table: arg1, - coordinates: arg2 ?? undefined, - }; - } else if (safeInstanceOf(arg1, 'HTMLImageElement') && typeof arg2 == 'undefined') { - rangeEx = { - type: SelectionRangeTypes.ImageSelection, - ranges: [], - areAllCollapsed: false, - image: arg1, - }; - } else { - const range = !arg1 - ? null - : safeInstanceOf(arg1, 'Range') - ? arg1 - : isSelectionPath(arg1) - ? createRange(core.contentDiv, arg1.start, arg1.end) - : isNodePosition(arg1) || safeInstanceOf(arg1, 'Node') - ? createRange( - arg1, - arg2, - arg3, - arg4 - ) - : null; - - rangeEx = range - ? { - type: SelectionRangeTypes.Normal, - ranges: [range], - areAllCollapsed: range.collapsed, - } - : null; - } - - return rangeEx; -} - -function applyRangeEx(core: StandaloneEditorCore, rangeEx: SelectionRangeEx | null) { - switch (rangeEx?.type) { - case SelectionRangeTypes.TableSelection: - if (contains(core.contentDiv, rangeEx.table)) { - core.selection.imageSelectionRange = core.api.selectImage(core, null); - core.selection.tableSelectionRange = core.api.selectTable( - core, - rangeEx.table, - rangeEx.coordinates - ); - rangeEx = core.selection.tableSelectionRange; - } - break; - case SelectionRangeTypes.ImageSelection: - if (contains(core.contentDiv, rangeEx.image)) { - core.selection.tableSelectionRange = core.api.selectTable(core, null); - core.selection.imageSelectionRange = core.api.selectImage(core, rangeEx.image); - rangeEx = core.selection.imageSelectionRange; - } - break; - case SelectionRangeTypes.Normal: - core.selection.tableSelectionRange = core.api.selectTable(core, null); - core.selection.imageSelectionRange = core.api.selectImage(core, null); - - if (contains(core.contentDiv, rangeEx.ranges[0])) { - core.api.selectRange(core, rangeEx.ranges[0]); - } else { - rangeEx = null; - } - break; - } - - core.api.triggerEvent( - core, - { - eventType: PluginEventType.SelectionChanged, - selectionRangeEx: rangeEx, - }, - true /** broadcast **/ - ); -} - -function isSelectionRangeEx(obj: any): obj is SelectionRangeEx { - const rangeEx = obj as SelectionRangeEx; - return ( - rangeEx && - typeof rangeEx == 'object' && - typeof rangeEx.type == 'number' && - Array.isArray(rangeEx.ranges) - ); -} - -function isTableSelectionOrNull(obj: any): obj is TableSelection | null { - const selection = obj as TableSelection | null; - - return ( - selection === null || - (selection && - typeof selection == 'object' && - typeof selection.firstCell == 'object' && - typeof selection.lastCell == 'object') - ); -} - -function isSelectionPath(obj: any): obj is SelectionPath { - const path = obj as SelectionPath; - - return path && typeof path == 'object' && Array.isArray(path.start) && Array.isArray(path.end); -} - -function isNodePosition(obj: any): obj is NodePosition { - const pos = obj as NodePosition; - - return ( - pos && - typeof pos == 'object' && - typeof pos.node == 'object' && - typeof pos.offset == 'number' - ); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts deleted file mode 100644 index cefe659926a..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectImage.ts +++ /dev/null @@ -1,66 +0,0 @@ -import addUniqueId from './utils/addUniqueId'; -import { PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { - createRange, - Position, - removeGlobalCssStyle, - removeImportantStyleRule, - setGlobalCssStyles, -} from 'roosterjs-editor-dom'; -import type { ImageSelectionRange } from 'roosterjs-editor-types'; -import type { SelectImage, StandaloneEditorCore } from 'roosterjs-content-model-types'; - -const IMAGE_ID = 'imageSelected'; -const CONTENT_DIV_ID = 'contentDiv_'; -const STYLE_ID = 'imageStyle'; -const DEFAULT_SELECTION_BORDER_COLOR = '#DB626C'; - -/** - * @internal - * Select a image and save data of the selected range - * @param image Image to select - * @returns Selected image information - */ -export const selectImage: SelectImage = (core, image: HTMLImageElement | null) => { - unselect(core); - - let selection: ImageSelectionRange | null = null; - - if (image) { - const range = createRange(image); - - addUniqueId(image, IMAGE_ID); - addUniqueId(core.contentDiv, CONTENT_DIV_ID); - - core.api.selectRange(core, createRange(new Position(image, PositionType.After))); - - select(core, image); - - selection = { - type: SelectionRangeTypes.ImageSelection, - ranges: [range], - image: image, - areAllCollapsed: range.collapsed, - }; - } - - return selection; -}; - -const select = (core: StandaloneEditorCore, image: HTMLImageElement) => { - removeImportantStyleRule(image, ['border', 'margin']); - const borderCSS = buildBorderCSS(core, image.id); - setGlobalCssStyles(core.contentDiv.ownerDocument, borderCSS, STYLE_ID + core.contentDiv.id); -}; - -const buildBorderCSS = (core: StandaloneEditorCore, imageId: string): string => { - const divId = core.contentDiv.id; - const color = core.selection.imageSelectionBorderColor || DEFAULT_SELECTION_BORDER_COLOR; - - return `#${divId} #${imageId} {outline-style: auto!important;outline-color: ${color}!important;caret-color: transparent!important;}`; -}; - -const unselect = (core: StandaloneEditorCore) => { - const doc = core.contentDiv.ownerDocument; - removeGlobalCssStyle(doc, STYLE_ID + core.contentDiv.id); -}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts deleted file mode 100644 index 63b74bf8976..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectRange.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { addRangeToSelection, contains } from 'roosterjs-editor-dom'; -import type { SelectRange } from 'roosterjs-content-model-types'; - -/** - * @internal - * Change the editor selection to the given range - * @param core The StandaloneEditorCore object - * @param range The range to select - * @param skipSameRange When set to true, do nothing if the given range is the same with current selection - * in editor, otherwise it will always remove current selection range and set to the given one. - * This parameter is always treat as true in Edge to avoid some weird runtime exception. - */ -export const selectRange: SelectRange = (core, range, skipSameRange) => { - if (!core.lifecycle.shadowEditFragment && contains(core.contentDiv, range)) { - addRangeToSelection(range, skipSameRange); - - if (!core.api.hasFocus(core)) { - core.selection.selectionRange = range; - } - - return true; - } else { - return false; - } -}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectTable.ts deleted file mode 100644 index f852786e8a2..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/selectTable.ts +++ /dev/null @@ -1,265 +0,0 @@ -import addUniqueId from './utils/addUniqueId'; -import { PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { - createRange, - getTagOfNode, - isWholeTableSelected, - Position, - removeGlobalCssStyle, - removeImportantStyleRule, - setGlobalCssStyles, - toArray, - VTable, -} from 'roosterjs-editor-dom'; -import type { TableSelection, Coordinates } from 'roosterjs-editor-types'; -import type { SelectTable, StandaloneEditorCore } from 'roosterjs-content-model-types'; - -const TABLE_ID = 'tableSelected'; -const CONTENT_DIV_ID = 'contentDiv_'; -const STYLE_ID = 'tableStyle'; -const SELECTED_CSS_RULE = - '{background-color: rgb(198,198,198) !important; caret-color: transparent}'; -const MAX_RULE_SELECTOR_LENGTH = 9000; - -/** - * @internal - * Select a table and save data of the selected range - * @param core The StandaloneEditorCore object - * @param table table to select - * @param coordinates first and last cell of the selection, if this parameter is null, instead of - * selecting, will unselect the table. - * @returns true if successful - */ -export const selectTable: SelectTable = (core, table, coordinates) => { - unselect(core); - - if (areValidCoordinates(coordinates) && table) { - addUniqueId(table, TABLE_ID); - addUniqueId(core.contentDiv, CONTENT_DIV_ID); - - const { ranges, isWholeTableSelected } = select(core, table, coordinates); - if (!isMergedCell(table, coordinates)) { - const cellToSelect = table.rows - .item(coordinates.firstCell.y) - ?.cells.item(coordinates.firstCell.x); - - if (cellToSelect) { - core.api.selectRange( - core, - createRange(new Position(cellToSelect, PositionType.Begin)) - ); - } - } - - return { - type: SelectionRangeTypes.TableSelection, - ranges, - table, - areAllCollapsed: ranges.filter(range => range?.collapsed).length == ranges.length, - coordinates, - isWholeTableSelected, - }; - } - - return null; -}; - -function buildCss( - table: HTMLTableElement, - coordinates: TableSelection, - contentDivSelector: string -): { cssRules: string[]; ranges: Range[]; isWholeTableSelected: boolean } { - const ranges: Range[] = []; - const selectors: string[] = []; - - const vTable = new VTable(table); - const isAllTableSelected = isWholeTableSelected(vTable, coordinates); - if (isAllTableSelected) { - handleAllTableSelected(contentDivSelector, vTable, selectors, ranges); - } else { - handleTableSelected(coordinates, vTable, contentDivSelector, selectors, ranges); - } - - const cssRules: string[] = []; - let currentRules: string = ''; - while (selectors.length > 0) { - currentRules += (currentRules.length > 0 ? ',' : '') + selectors.shift() || ''; - if ( - currentRules.length + (selectors[0]?.length || 0) > MAX_RULE_SELECTOR_LENGTH || - selectors.length == 0 - ) { - cssRules.push(currentRules + ' ' + SELECTED_CSS_RULE); - currentRules = ''; - } - } - - return { cssRules, ranges, isWholeTableSelected: isAllTableSelected }; -} - -function handleAllTableSelected( - contentDivSelector: string, - vTable: VTable, - selectors: string[], - ranges: Range[] -) { - const table = vTable.table; - const tableSelector = contentDivSelector + ' #' + table.id; - selectors.push(tableSelector, `${tableSelector} *`); - - const tableRange = new Range(); - tableRange.selectNode(table); - ranges.push(tableRange); -} - -function handleTableSelected( - coordinates: TableSelection, - vTable: VTable, - contentDivSelector: string, - selectors: string[], - ranges: Range[] -) { - const tr1 = coordinates.firstCell.y; - const td1 = coordinates.firstCell.x; - const tr2 = coordinates.lastCell.y; - const td2 = coordinates.lastCell.x; - const table = vTable.table; - - let firstSelected: HTMLTableCellElement | null = null; - let lastSelected: HTMLTableCellElement | null = null; - // Get whether table has thead, tbody or tfoot. - const tableChildren = toArray(table.childNodes).filter( - node => ['THEAD', 'TBODY', 'TFOOT'].indexOf(getTagOfNode(node)) > -1 - ); - // Set the start and end of each of the table children, so we can build the selector according the element between the table and the row. - let cont = 0; - const indexes = tableChildren.map(node => { - const result = { - el: getTagOfNode(node), - start: cont, - end: node.childNodes.length + cont, - }; - - cont = result.end; - return result; - }); - - vTable.cells?.forEach((row, rowIndex) => { - let tdCount = 0; - firstSelected = null; - lastSelected = null; - - //Get current TBODY/THEAD/TFOOT - const midElement = indexes.filter(ind => ind.start <= rowIndex && ind.end > rowIndex)[0]; - - const middleElSelector = midElement ? '>' + midElement.el + '>' : '>'; - const currentRow = - midElement && rowIndex + 1 >= midElement.start - ? rowIndex + 1 - midElement.start - : rowIndex + 1; - - for (let cellIndex = 0; cellIndex < row.length; cellIndex++) { - const cell = row[cellIndex].td; - if (cell) { - tdCount++; - if (rowIndex >= tr1 && rowIndex <= tr2 && cellIndex >= td1 && cellIndex <= td2) { - removeImportant(cell); - - const selector = generateCssFromCell( - contentDivSelector, - table.id, - middleElSelector, - currentRow, - getTagOfNode(cell), - tdCount - ); - const elementsSelector = selector + ' *'; - - selectors.push(selector, elementsSelector); - firstSelected = firstSelected || table.querySelector(selector); - lastSelected = table.querySelector(selector); - } - } - } - - if (firstSelected && lastSelected) { - const rowRange = new Range(); - rowRange.setStartBefore(firstSelected); - rowRange.setEndAfter(lastSelected); - ranges.push(rowRange); - } - }); -} - -function select( - core: StandaloneEditorCore, - table: HTMLTableElement, - coordinates: TableSelection -): { ranges: Range[]; isWholeTableSelected: boolean } { - const contentDivSelector = '#' + core.contentDiv.id; - const { cssRules, ranges, isWholeTableSelected } = buildCss( - table, - coordinates, - contentDivSelector - ); - cssRules.forEach(css => - setGlobalCssStyles(core.contentDiv.ownerDocument, css, STYLE_ID + core.contentDiv.id) - ); - - return { ranges, isWholeTableSelected }; -} - -const unselect = (core: StandaloneEditorCore) => { - const doc = core.contentDiv.ownerDocument; - removeGlobalCssStyle(doc, STYLE_ID + core.contentDiv.id); -}; - -function generateCssFromCell( - contentDivSelector: string, - tableId: string, - middleElSelector: string, - rowIndex: number, - cellTag: string, - index: number -): string { - return ( - contentDivSelector + - ' #' + - tableId + - middleElSelector + - ' tr:nth-child(' + - rowIndex + - ')>' + - cellTag + - ':nth-child(' + - index + - ')' - ); -} - -function removeImportant(cell: HTMLTableCellElement) { - if (cell) { - removeImportantStyleRule(cell, ['background-color', 'background']); - } -} - -function areValidCoordinates(input?: TableSelection): input is TableSelection { - if (input) { - const { firstCell, lastCell } = input || {}; - if (firstCell && lastCell) { - const handler = (coordinate: Coordinates) => - isValidCoordinate(coordinate.x) && isValidCoordinate(coordinate.y); - return handler(firstCell) && handler(lastCell); - } - } - - return false; -} - -function isValidCoordinate(input: number): boolean { - return (!!input || input == 0) && input > -1; -} - -function isMergedCell(table: HTMLTableElement, coordinates: TableSelection): boolean { - const { firstCell } = coordinates; - return !(table.rows.item(firstCell.y) && table.rows.item(firstCell.y)?.cells.item(firstCell.x)); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts index e535eae9f8b..c184a3bebe5 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/setContent.ts @@ -1,15 +1,6 @@ -import { - ChangeSource, - ColorTransformDirection, - PluginEventType, - SelectionRangeTypes, -} from 'roosterjs-editor-types'; -import { - createRange, - extractContentMetadata, - queryElements, - restoreContentWithEntityPlaceholder, -} from 'roosterjs-editor-dom'; +import { ChangeSource, ColorTransformDirection, PluginEventType } from 'roosterjs-editor-types'; +import { convertMetadataToDOMSelection } from '../editor/utils/selectionConverter'; +import { extractContentMetadata, restoreContentWithEntityPlaceholder } from 'roosterjs-editor-dom'; import type { ContentMetadata } from 'roosterjs-editor-types'; import type { SetContent, StandaloneEditorCore } from 'roosterjs-content-model-types'; @@ -79,42 +70,10 @@ export const setContent: SetContent = (core, content, triggerContentChangedEvent function selectContentMetadata(core: StandaloneEditorCore, metadata: ContentMetadata | undefined) { if (!core.lifecycle.shadowEditFragment && metadata) { - core.selection.tableSelectionRange = null; - core.selection.imageSelectionRange = null; - core.selection.selectionRange = null; + const selection = convertMetadataToDOMSelection(core.contentDiv, metadata); - switch (metadata.type) { - case SelectionRangeTypes.Normal: - core.api.selectTable(core, null); - core.api.selectImage(core, null); - - const range = createRange(core.contentDiv, metadata.start, metadata.end); - core.api.selectRange(core, range); - break; - case SelectionRangeTypes.TableSelection: - const table = queryElements( - core.contentDiv, - '#' + metadata.tableId - )[0] as HTMLTableElement; - - if (table) { - core.selection.tableSelectionRange = core.api.selectTable( - core, - table, - metadata - ); - } - break; - case SelectionRangeTypes.ImageSelection: - const image = queryElements( - core.contentDiv, - '#' + metadata.imageId - )[0] as HTMLImageElement; - - if (image) { - core.selection.imageSelectionRange = core.api.selectImage(core, image); - } - break; + if (selection) { + core.api.setDOMSelection(core, selection); } } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/utils/addUniqueId.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/utils/addUniqueId.ts deleted file mode 100644 index 9d3897bc5a3..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/utils/addUniqueId.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @internal - * Add an unique id to element and ensure that is unique - * @param el The HTMLElement that will receive the id - * @param idPrefix The prefix that will antecede the id (Ex: tableSelected01) - */ -export default function addUniqueId(el: HTMLElement, idPrefix: string) { - const doc = el.ownerDocument; - if (!el.id) { - applyId(el, idPrefix, doc); - } else { - const elements = doc.querySelectorAll(`#${el.id}`); - if (elements.length > 1) { - el.removeAttribute('id'); - applyId(el, idPrefix, doc); - } - } -} - -function applyId(el: HTMLElement, idPrefix: string, doc: Document) { - let cont = 0; - const getElement = () => doc.getElementById(idPrefix + cont); - //Ensure that there are no elements with the same ID - let element = getElement(); - while (element) { - cont++; - element = getElement(); - } - - el.id = idPrefix + cont; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EventTypeTranslatePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EventTypeTranslatePlugin.ts new file mode 100644 index 00000000000..8c467cc6234 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/EventTypeTranslatePlugin.ts @@ -0,0 +1,51 @@ +import { convertDomSelectionToRangeEx } from '../editor/utils/selectionConverter'; +import { PluginEventType } from 'roosterjs-editor-types'; +import type { ContentModelSelectionChangedEvent } from 'roosterjs-content-model-types'; +import type { EditorPlugin, PluginEvent, SelectionChangedEvent } from 'roosterjs-editor-types'; + +/** + * Translate Standalone editor event type to legacy event type + */ +class EventTypeTranslatePlugin implements EditorPlugin { + /** + * Get a friendly name of this plugin + */ + getName() { + return 'EventTypeTranslate'; + } + + /** + * Initialize this plugin. This should only be called from Editor + * @param editor Editor instance + */ + initialize() {} + + /** + * Dispose this plugin + */ + dispose() {} + + onPluginEvent(event: PluginEvent) { + switch (event.eventType) { + case PluginEventType.SelectionChanged: + if (!event.selectionRangeEx && isContentModelSelectionChangedEvent(event)) { + event.selectionRangeEx = convertDomSelectionToRangeEx(event.newSelection); + } + break; + } + } +} + +function isContentModelSelectionChangedEvent( + event: SelectionChangedEvent +): event is ContentModelSelectionChangedEvent { + return !!(event as ContentModelSelectionChangedEvent).newSelection; +} + +/** + * @internal + * Create a new instance of EventTypeTranslatePlugin. + */ +export function createEventTypeTranslatePlugin(): EditorPlugin { + return new EventTypeTranslatePlugin(); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts index 35ce4bce8c1..856041c5f9a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts @@ -1,4 +1,5 @@ import { createEditPlugin } from './EditPlugin'; +import { createEventTypeTranslatePlugin } from './EventTypeTranslatePlugin'; import { createImageSelection } from './ImageSelection'; import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; import { createUndoPlugin } from './UndoPlugin'; @@ -17,6 +18,7 @@ export function createCorePlugins(options: ContentModelEditorOptions): UnportedC // The order matters, some plugin needs to be put before/after others to make sure event // can be handled in right order return { + eventTranslate: map.eventTranslate || createEventTypeTranslatePlugin(), edit: map.edit || createEditPlugin(), undo: map.undo || createUndoPlugin(options), imageSelection: map.imageSelection || createImageSelection(), diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/forEachSelectedCell.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/forEachSelectedCell.ts deleted file mode 100644 index edd97016f3c..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/forEachSelectedCell.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { VCell } from 'roosterjs-editor-types'; -import type { VTable } from 'roosterjs-editor-dom'; - -/** - * @internal - * Executes an action to all the cells within the selection range. - * @param callback action to apply on each selected cell - * @returns the amount of cells modified - */ -export const forEachSelectedCell = (vTable: VTable, callback: (cell: VCell) => void): void => { - if (vTable.selection) { - const { lastCell, firstCell } = vTable.selection; - - for (let y = firstCell.y; y <= lastCell.y; y++) { - for (let x = firstCell.x; x <= lastCell.x; x++) { - if (vTable.cells && vTable.cells[y][x]?.td) { - callback(vTable.cells[y][x]); - } - } - } - } -}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/removeCellsOutsideSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/removeCellsOutsideSelection.ts deleted file mode 100644 index e2c75d2f55b..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/utils/removeCellsOutsideSelection.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { isWholeTableSelected } from 'roosterjs-editor-dom'; -import type { VTable } from 'roosterjs-editor-dom'; -import type { VCell } from 'roosterjs-editor-types'; - -/** - * @internal - * Remove the cells outside of the selection. - * @param vTable VTable to remove selection - */ -export const removeCellsOutsideSelection = (vTable: VTable) => { - if (vTable.selection) { - if (isWholeTableSelected(vTable, vTable.selection)) { - return; - } - - vTable.table.style.removeProperty('width'); - vTable.table.style.removeProperty('height'); - - const { firstCell, lastCell } = vTable.selection; - const resultCells: VCell[][] = []; - - const firstX = firstCell.x; - const firstY = firstCell.y; - const lastX = lastCell.x; - const lastY = lastCell.y; - - if (vTable.cells) { - vTable.cells.forEach((row, y) => { - row = row.filter((_, x) => y >= firstY && y <= lastY && x >= firstX && x <= lastX); - if (row.length > 0) { - resultCells.push(row); - } - }); - vTable.cells = resultCells; - } - } -}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 457264905e4..59a09375650 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -1,3 +1,4 @@ +import { buildRangeEx } from './utils/buildRangeEx'; import { createEditorCore } from './createEditorCore'; import { getObjectKeys } from 'roosterjs-content-model-dom'; import { getPendableFormatState } from './utils/getPendableFormatState'; @@ -39,6 +40,10 @@ import type { TableSelection, TrustedHTMLHandler, } from 'roosterjs-editor-types'; +import { + convertDomSelectionToRangeEx, + convertRangeExToDomSelection, +} from './utils/selectionConverter'; import type { CompatibleChangeSource, CompatibleColorTransformDirection, @@ -151,7 +156,7 @@ export class ContentModelEditor implements IContentModelEditor { * This is the replacement of IEditor.select. * @param selection The selection to set */ - setDOMSelection(selection: DOMSelection) { + setDOMSelection(selection: DOMSelection | null) { const core = this.getCore(); core.api.setDOMSelection(core, selection); @@ -447,8 +452,9 @@ export class ContentModelEditor implements IContentModelEditor { * @returns current selection range, or null if editor never got focus before */ getSelectionRange(tryGetFromCache: boolean = true): Range | null { - const core = this.getCore(); - return core.api.getSelectionRange(core, tryGetFromCache); + const selection = this.getDOMSelection(); + + return selection?.type == 'range' ? selection.range : null; } /** @@ -459,8 +465,9 @@ export class ContentModelEditor implements IContentModelEditor { * @returns current selection range, or null if editor never got focus before */ getSelectionRangeEx(): SelectionRangeEx { - const core = this.getCore(); - return core.api.getSelectionRangeEx(core); + const selection = this.getDOMSelection(); + + return convertDomSelectionToRangeEx(selection); } /** @@ -497,8 +504,11 @@ export class ContentModelEditor implements IContentModelEditor { arg4?: number | PositionType ): boolean { const core = this.getCore(); + const rangeEx = buildRangeEx(core, arg1, arg2, arg3, arg4); + const selection = convertRangeExToDomSelection(rangeEx); - return core.api.select(core, arg1, arg2, arg3, arg4); + this.setDOMSelection(selection); + return true; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts index b1392b88c88..0bb00a625e6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts @@ -18,6 +18,7 @@ export function createEditorCore( const corePlugins = createCorePlugins(options); const pluginState = getPluginState(corePlugins); const additionalPlugins: EditorPlugin[] = [ + corePlugins.eventTranslate, corePlugins.edit, ...(options.plugins ?? []), corePlugins.undo, diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/buildRangeEx.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/buildRangeEx.ts new file mode 100644 index 00000000000..9acba99f71a --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/buildRangeEx.ts @@ -0,0 +1,110 @@ +import { createRange, safeInstanceOf } from 'roosterjs-editor-dom'; +import { SelectionRangeTypes } from 'roosterjs-editor-types'; +import type { ContentModelEditorCore } from '../../publicTypes/ContentModelEditorCore'; +import type { + NodePosition, + PositionType, + SelectionPath, + SelectionRangeEx, + TableSelection, +} from 'roosterjs-editor-types'; + +/** + * @internal + */ +export function buildRangeEx( + core: ContentModelEditorCore, + arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, + arg2?: NodePosition | number | PositionType | TableSelection | null, + arg3?: Node, + arg4?: number | PositionType +): SelectionRangeEx { + let rangeEx: SelectionRangeEx | null = null; + + if (isSelectionRangeEx(arg1)) { + rangeEx = arg1; + } else if (safeInstanceOf(arg1, 'HTMLTableElement') && isTableSelectionOrNull(arg2)) { + rangeEx = { + type: SelectionRangeTypes.TableSelection, + ranges: [], + areAllCollapsed: false, + table: arg1, + coordinates: arg2 ?? undefined, + }; + } else if (safeInstanceOf(arg1, 'HTMLImageElement') && typeof arg2 == 'undefined') { + rangeEx = { + type: SelectionRangeTypes.ImageSelection, + ranges: [], + areAllCollapsed: false, + image: arg1, + }; + } else { + const range = !arg1 + ? null + : safeInstanceOf(arg1, 'Range') + ? arg1 + : isSelectionPath(arg1) + ? createRange(core.contentDiv, arg1.start, arg1.end) + : isNodePosition(arg1) || safeInstanceOf(arg1, 'Node') + ? createRange( + arg1, + arg2, + arg3, + arg4 + ) + : null; + + rangeEx = range + ? { + type: SelectionRangeTypes.Normal, + ranges: [range], + areAllCollapsed: range.collapsed, + } + : { + type: SelectionRangeTypes.Normal, + ranges: [], + areAllCollapsed: true, + }; + } + + return rangeEx; +} + +function isSelectionRangeEx(obj: any): obj is SelectionRangeEx { + const rangeEx = obj as SelectionRangeEx; + return ( + rangeEx && + typeof rangeEx == 'object' && + typeof rangeEx.type == 'number' && + Array.isArray(rangeEx.ranges) + ); +} + +function isTableSelectionOrNull(obj: any): obj is TableSelection | null { + const selection = obj as TableSelection | null; + + return ( + selection === null || + (selection && + typeof selection == 'object' && + typeof selection.firstCell == 'object' && + typeof selection.lastCell == 'object') + ); +} + +function isSelectionPath(obj: any): obj is SelectionPath { + const path = obj as SelectionPath; + + return path && typeof path == 'object' && Array.isArray(path.start) && Array.isArray(path.end); +} + +function isNodePosition(obj: any): obj is NodePosition { + const pos = obj as NodePosition; + + return ( + pos && + typeof pos == 'object' && + typeof pos.node == 'object' && + typeof pos.offset == 'number' + ); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts index 61071fdfd22..5a89fdcbc6c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/getPendableFormatState.ts @@ -11,7 +11,8 @@ import type { StandaloneEditorCore } from 'roosterjs-content-model-types'; * @returns The cached format state if it exists. If the cached position do not exist, search for pendable elements in the DOM tree and return the pendable format state. */ export function getPendableFormatState(core: StandaloneEditorCore): PendableFormatState { - const range = core.api.getSelectionRange(core, true /* tryGetFromCache*/); + const selection = core.api.getDOMSelection(core); + const range = selection?.type == 'range' ? selection.range : null; const currentPosition = range && Position.getStart(range).normalize(); return currentPosition ? queryCommandStateFromDOM(core, currentPosition) : {}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts new file mode 100644 index 00000000000..06773f66d86 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/selectionConverter.ts @@ -0,0 +1,168 @@ +import { createRange, getSelectionPath, queryElements } from 'roosterjs-editor-dom'; +import { createTableRanges } from 'roosterjs-content-model-core'; +import { SelectionRangeTypes } from 'roosterjs-editor-types'; +import type { DOMSelection } from 'roosterjs-content-model-types'; +import type { ContentMetadata, SelectionRangeEx } from 'roosterjs-editor-types'; + +// In theory, all functions below are not necessary. We keep these functions here only for compatibility with old IEditor interface + +/** + * @internal + */ +export function convertRangeExToDomSelection( + rangeEx: SelectionRangeEx | null +): DOMSelection | null { + switch (rangeEx?.type) { + case SelectionRangeTypes.ImageSelection: + return { + type: 'image', + image: rangeEx.image, + }; + + case SelectionRangeTypes.Normal: + return rangeEx.ranges.length > 0 + ? { + type: 'range', + range: rangeEx.ranges[0], + } + : null; + + case SelectionRangeTypes.TableSelection: + return rangeEx.coordinates + ? { + type: 'table', + table: rangeEx.table, + firstColumn: rangeEx.coordinates.firstCell.x, + firstRow: rangeEx.coordinates.firstCell.y, + lastColumn: rangeEx.coordinates.lastCell.x, + lastRow: rangeEx.coordinates.lastCell.y, + } + : null; + + default: + return null; + } +} + +/** + * @internal + */ +export function convertDomSelectionToRangeEx(selection: DOMSelection | null): SelectionRangeEx { + switch (selection?.type) { + case 'image': + return { + type: SelectionRangeTypes.ImageSelection, + image: selection.image, + areAllCollapsed: false, + ranges: [createRange(selection.image)], + }; + + case 'range': + return { + type: SelectionRangeTypes.Normal, + ranges: [selection.range], + areAllCollapsed: selection.range.collapsed, + }; + + case 'table': + return { + type: SelectionRangeTypes.TableSelection, + ranges: createTableRanges(selection), + areAllCollapsed: false, + table: selection.table, + coordinates: { + firstCell: { x: selection.firstColumn, y: selection.firstRow }, + lastCell: { x: selection.lastColumn, y: selection.lastRow }, + }, + }; + + default: + return { + type: SelectionRangeTypes.Normal, + ranges: [], + areAllCollapsed: true, + }; + } +} + +/** + * @internal + */ +export function convertDomSelectionToMetadata( + contentDiv: HTMLElement, + selection: DOMSelection | null +): ContentMetadata | null { + switch (selection?.type) { + case 'table': + return { + type: SelectionRangeTypes.TableSelection, + tableId: selection.table.id, + firstCell: { + x: selection.firstColumn, + y: selection.firstRow, + }, + lastCell: { + x: selection.lastColumn, + y: selection.lastRow, + }, + isDarkMode: false, + }; + case 'image': + return { + type: SelectionRangeTypes.ImageSelection, + imageId: selection.image.id, + isDarkMode: false, + }; + case 'range': + return { + type: SelectionRangeTypes.Normal, + isDarkMode: false, + start: [], + end: [], + ...(getSelectionPath(contentDiv, selection.range) || {}), + }; + default: + return null; + } +} + +/** + * @internal + */ +export function convertMetadataToDOMSelection( + contentDiv: HTMLElement, + metadata: ContentMetadata | undefined +): DOMSelection | null { + switch (metadata?.type) { + case SelectionRangeTypes.Normal: + return { + type: 'range', + range: createRange(contentDiv, metadata.start, metadata.end), + }; + case SelectionRangeTypes.TableSelection: + const table = queryElements(contentDiv, '#' + metadata.tableId)[0] as HTMLTableElement; + + return table + ? { + type: 'table', + table: table, + firstColumn: metadata.firstCell.x, + firstRow: metadata.firstCell.y, + lastColumn: metadata.lastCell.x, + lastRow: metadata.lastCell.y, + } + : null; + case SelectionRangeTypes.ImageSelection: + const image = queryElements(contentDiv, '#' + metadata.imageId)[0] as HTMLImageElement; + + return image + ? { + type: 'image', + image: image, + } + : null; + + default: + return null; + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts index a2a7cadb93c..6e861005bf7 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts @@ -11,6 +11,11 @@ import type { * TODO: Port these plugins */ export interface UnportedCorePlugins { + /** + * Translate Standalone editor event type to legacy event type + */ + readonly eventTranslate: EditorPlugin; + /** * Edit plugin handles ContentEditFeatures */ diff --git a/packages-content-model/roosterjs-content-model-editor/test/corePlugins/EventTypeTranslatePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/EventTypeTranslatePluginTest.ts new file mode 100644 index 00000000000..e916632a838 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/corePlugins/EventTypeTranslatePluginTest.ts @@ -0,0 +1,56 @@ +import * as selectionConvert from '../../lib/editor/utils/selectionConverter'; +import { createEventTypeTranslatePlugin } from '../../lib/corePlugins/EventTypeTranslatePlugin'; +import { PluginEventType } from 'roosterjs-editor-types'; + +describe('EventTypeTranslatePlugin', () => { + let convertDomSelectionToRangeExSpy: jasmine.Spy; + const mockedDOMSelection = 'DOMSELECTION' as any; + const mockedRangeEx = 'RANGEEX' as any; + + beforeEach(() => { + convertDomSelectionToRangeExSpy = spyOn( + selectionConvert, + 'convertDomSelectionToRangeEx' + ).and.returnValue(mockedRangeEx); + }); + + it('translate a SelectionChanged event without selection', () => { + const plugin = createEventTypeTranslatePlugin(); + + plugin.initialize({} as any); + + const event = { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null, + } as any; + + plugin.onPluginEvent(event); + + expect(event).toEqual({ + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null, + }); + expect(convertDomSelectionToRangeExSpy).not.toHaveBeenCalled(); + }); + + it('translate a SelectionChanged event', () => { + const plugin = createEventTypeTranslatePlugin(); + + plugin.initialize({} as any); + + const event = { + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: null, + newSelection: mockedDOMSelection, + } as any; + + plugin.onPluginEvent(event); + + expect(event).toEqual({ + eventType: PluginEventType.SelectionChanged, + selectionRangeEx: mockedRangeEx, + newSelection: mockedDOMSelection, + }); + expect(convertDomSelectionToRangeExSpy).toHaveBeenCalledWith(mockedDOMSelection); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts index d932279742b..78155c6c09d 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts @@ -5,6 +5,7 @@ import * as createStandaloneEditorDefaultSettings from 'roosterjs-content-model- import * as DOMEventPlugin from 'roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin'; import * as EditPlugin from '../../lib/corePlugins/EditPlugin'; import * as EntityPlugin from 'roosterjs-content-model-core/lib/corePlugin/EntityPlugin'; +import * as EventTranslate from '../../lib/corePlugins/EventTypeTranslatePlugin'; import * as ImageSelection from '../../lib/corePlugins/ImageSelection'; import * as LifecyclePlugin from 'roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin'; import * as NormalizeTablePlugin from '../../lib/corePlugins/NormalizeTablePlugin'; @@ -54,6 +55,7 @@ const mockedNormalizeTablePlugin = 'NormalizeTablePlugin' as any; const mockedLifecyclePlugin = { getState: () => mockedLifecycleState, } as any; +const mockedEventTranslatePlugin = 'EventTranslate' as any; const mockedDefaultSettings = { settings: 'SETTINGS', } as any; @@ -85,6 +87,9 @@ describe('createEditorCore', () => { mockedNormalizeTablePlugin ); spyOn(LifecyclePlugin, 'createLifecyclePlugin').and.returnValue(mockedLifecyclePlugin); + spyOn(EventTranslate, 'createEventTypeTranslatePlugin').and.returnValue( + mockedEventTranslatePlugin + ); spyOn( createStandaloneEditorDefaultSettings, 'createStandaloneEditorDefaultSettings' @@ -104,6 +109,7 @@ describe('createEditorCore', () => { mockedDOMEventPlugin, mockedSelectionPlugin, mockedEntityPlugin, + mockedEventTranslatePlugin, mockedEditPlugin, mockedUndoPlugin, mockedImageSelection, @@ -160,6 +166,7 @@ describe('createEditorCore', () => { mockedDOMEventPlugin, mockedSelectionPlugin, mockedEntityPlugin, + mockedEventTranslatePlugin, mockedEditPlugin, mockedUndoPlugin, mockedImageSelection, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts new file mode 100644 index 00000000000..9735df25736 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/selectionConverterTest.ts @@ -0,0 +1,384 @@ +import * as createRange from 'roosterjs-editor-dom/lib/selection/createRange'; +import * as getSelectionPath from 'roosterjs-editor-dom/lib/selection/getSelectionPath'; +import * as queryElements from 'roosterjs-editor-dom/lib/utils/queryElements'; +import * as tableCellUtils from 'roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils'; +import { ContentMetadata, SelectionRangeTypes } from 'roosterjs-editor-types'; +import { DOMSelection } from 'roosterjs-content-model-types'; +import { + convertDomSelectionToMetadata, + convertDomSelectionToRangeEx, + convertMetadataToDOMSelection, + convertRangeExToDomSelection, +} from '../../../lib/editor/utils/selectionConverter'; + +describe('convertRangeExToDomSelection', () => { + it('null selection', () => { + const result = convertRangeExToDomSelection(null); + + expect(result).toBeNull(); + }); + + it('range selection', () => { + const mockedRange = 'RANGE' as any; + const result = convertRangeExToDomSelection({ + type: SelectionRangeTypes.Normal, + ranges: [mockedRange], + areAllCollapsed: true, + }); + + expect(result).toEqual({ + type: 'range', + range: mockedRange, + }); + }); + + it('range selection without range', () => { + const result = convertRangeExToDomSelection({ + type: SelectionRangeTypes.Normal, + ranges: [], + areAllCollapsed: true, + }); + + expect(result).toBeNull(); + }); + + it('image selection', () => { + const mockedImage = 'Image' as any; + const result = convertRangeExToDomSelection({ + type: SelectionRangeTypes.ImageSelection, + ranges: [], + image: mockedImage, + areAllCollapsed: false, + }); + + expect(result).toEqual({ + type: 'image', + image: mockedImage, + }); + }); + + it('table selection', () => { + const mockedTable = 'Table' as any; + const result = convertRangeExToDomSelection({ + type: SelectionRangeTypes.TableSelection, + ranges: [], + table: mockedTable, + coordinates: { + firstCell: { + x: 0, + y: 1, + }, + lastCell: { x: 2, y: 3 }, + }, + areAllCollapsed: false, + }); + + expect(result).toEqual({ + type: 'table', + table: mockedTable, + firstColumn: 0, + firstRow: 1, + lastColumn: 2, + lastRow: 3, + }); + }); + + it('table selection without coordinates', () => { + const mockedTable = 'Table' as any; + const result = convertRangeExToDomSelection({ + type: SelectionRangeTypes.TableSelection, + ranges: [], + table: mockedTable, + coordinates: undefined, + areAllCollapsed: false, + }); + + expect(result).toBeNull(); + }); +}); + +describe('convertDomSelectionToRangeEx', () => { + let createRangeSpy: jasmine.Spy; + let tableCellUtilsSpy: jasmine.Spy; + + beforeEach(() => { + createRangeSpy = spyOn(createRange, 'default'); + tableCellUtilsSpy = spyOn(tableCellUtils, 'createTableRanges'); + }); + + it('null selection', () => { + const result = convertDomSelectionToRangeEx(null); + + expect(result).toEqual({ + type: SelectionRangeTypes.Normal, + ranges: [], + areAllCollapsed: true, + }); + expect(createRangeSpy).not.toHaveBeenCalled(); + }); + + it('range selection', () => { + const mockedRange = { + collapsed: false, + } as any; + const result = convertDomSelectionToRangeEx({ + type: 'range', + range: mockedRange, + }); + + expect(result).toEqual({ + type: SelectionRangeTypes.Normal, + ranges: [mockedRange], + areAllCollapsed: false, + }); + expect(createRangeSpy).not.toHaveBeenCalled(); + }); + + it('image selection', () => { + const mockedImage = 'IMAGE' as any; + const mockedRange = 'RANGE' as any; + + createRangeSpy.and.returnValue(mockedRange); + + const result = convertDomSelectionToRangeEx({ + type: 'image', + image: mockedImage, + }); + + expect(result).toEqual({ + type: SelectionRangeTypes.ImageSelection, + ranges: [mockedRange], + image: mockedImage, + areAllCollapsed: false, + }); + expect(createRangeSpy).toHaveBeenCalledWith(mockedImage); + }); + + it('table selection', () => { + const mockedTable = 'TABLE' as any; + const mockedRanges = 'RANGE' as any; + + tableCellUtilsSpy.and.returnValue(mockedRanges); + + const selection: DOMSelection = { + type: 'table', + table: mockedTable, + firstColumn: 1, + firstRow: 2, + lastColumn: 3, + lastRow: 4, + }; + + const result = convertDomSelectionToRangeEx(selection); + + expect(result).toEqual({ + type: SelectionRangeTypes.TableSelection, + ranges: mockedRanges, + table: mockedTable, + areAllCollapsed: false, + coordinates: { + firstCell: { x: 1, y: 2 }, + lastCell: { x: 3, y: 4 }, + }, + }); + expect(tableCellUtilsSpy).toHaveBeenCalledWith(selection); + }); +}); + +describe('convertDomSelectionToMetadata', () => { + let getSelectionPathSpy: jasmine.Spy; + + beforeEach(() => { + getSelectionPathSpy = spyOn(getSelectionPath, 'default'); + }); + + it('null selection', () => { + const mockedDiv = 'DIV' as any; + const result = convertDomSelectionToMetadata(mockedDiv, null); + + expect(result).toBeNull(); + expect(getSelectionPathSpy).not.toHaveBeenCalled(); + }); + + it('range selection', () => { + const mockedDiv = 'DIV' as any; + const mockedRange = 'RANGE' as any; + const mockedPathStart = 'START' as any; + const mockedPathEnd = 'END' as any; + + const selection: DOMSelection = { + type: 'range', + range: mockedRange, + }; + const mockedPath = { + start: mockedPathStart, + end: mockedPathEnd, + } as any; + + getSelectionPathSpy.and.returnValue(mockedPath); + const result = convertDomSelectionToMetadata(mockedDiv, selection); + + expect(result).toEqual({ + type: SelectionRangeTypes.Normal, + isDarkMode: false, + start: mockedPathStart, + end: mockedPathEnd, + }); + expect(getSelectionPathSpy).toHaveBeenCalledWith(mockedDiv, mockedRange); + }); + + it('image selection', () => { + const mockedDiv = 'DIV' as any; + const mockedImageId = 'IMAGEID'; + const mockedImage = { + id: mockedImageId, + } as any; + + const selection: DOMSelection = { + type: 'image', + image: mockedImage, + }; + + const result = convertDomSelectionToMetadata(mockedDiv, selection); + + expect(result).toEqual({ + type: SelectionRangeTypes.ImageSelection, + isDarkMode: false, + imageId: mockedImageId, + }); + expect(getSelectionPathSpy).not.toHaveBeenCalled(); + }); + + it('table selection', () => { + const mockedDiv = 'DIV' as any; + const mockedTableId = 'TABLEID'; + const mockedTable = { + id: mockedTableId, + } as any; + + const selection: DOMSelection = { + type: 'table', + table: mockedTable, + firstColumn: 1, + firstRow: 2, + lastColumn: 3, + lastRow: 4, + }; + + const result = convertDomSelectionToMetadata(mockedDiv, selection); + + expect(result).toEqual({ + type: SelectionRangeTypes.TableSelection, + isDarkMode: false, + tableId: mockedTableId, + firstCell: { + x: 1, + y: 2, + }, + lastCell: { + x: 3, + y: 4, + }, + }); + expect(getSelectionPathSpy).not.toHaveBeenCalled(); + }); +}); + +describe('convertMetadataToDOMSelection', () => { + let createRangeSpy = jasmine.createSpy('createRange'); + let queryElementsSpy = jasmine.createSpy('queryElements'); + + beforeEach(() => { + createRangeSpy = spyOn(createRange, 'default'); + queryElementsSpy = spyOn(queryElements, 'default'); + }); + + it('null selection', () => { + const mockedDiv = 'DIV' as any; + const result = convertMetadataToDOMSelection(mockedDiv, undefined); + + expect(result).toBeNull(); + expect(createRangeSpy).not.toHaveBeenCalled(); + expect(queryElementsSpy).not.toHaveBeenCalled(); + }); + + it('range selection', () => { + const mockedDiv = 'DIV' as any; + const mockedStartPath = 'START' as any; + const mockedEndPath = 'END' as any; + const mockedRange = 'RANGE' as any; + const metadata: ContentMetadata = { + type: SelectionRangeTypes.Normal, + isDarkMode: false, + start: mockedStartPath, + end: mockedEndPath, + }; + + createRangeSpy.and.returnValue(mockedRange); + + const result = convertMetadataToDOMSelection(mockedDiv, metadata); + + expect(result).toEqual({ + type: 'range', + range: mockedRange, + }); + expect(createRangeSpy).toHaveBeenCalledWith(mockedDiv, mockedStartPath, mockedEndPath); + expect(queryElementsSpy).not.toHaveBeenCalled(); + }); + + it('image selection', () => { + const mockedDiv = 'DIV' as any; + const mockedImage = 'IMAGE' as any; + const mockedImageId = 'IMAGEID'; + const metadata: ContentMetadata = { + type: SelectionRangeTypes.ImageSelection, + isDarkMode: false, + imageId: mockedImageId, + }; + + queryElementsSpy.and.returnValue([mockedImage]); + + const result = convertMetadataToDOMSelection(mockedDiv, metadata); + + expect(result).toEqual({ + type: 'image', + image: mockedImage, + }); + expect(createRangeSpy).not.toHaveBeenCalled(); + expect(queryElementsSpy).toHaveBeenCalledWith(mockedDiv, '#' + mockedImageId); + }); + + it('table selection', () => { + const mockedDiv = 'DIV' as any; + const mockedTable = 'TABLE' as any; + const mockedTableId = 'TABLEID'; + const metadata: ContentMetadata = { + type: SelectionRangeTypes.TableSelection, + isDarkMode: false, + tableId: mockedTableId, + firstCell: { + x: 1, + y: 2, + }, + lastCell: { + x: 3, + y: 4, + }, + }; + + queryElementsSpy.and.returnValue([mockedTable]); + + const result = convertMetadataToDOMSelection(mockedDiv, metadata); + + expect(result).toEqual({ + type: 'table', + table: mockedTable, + firstColumn: 1, + firstRow: 2, + lastColumn: 3, + lastRow: 4, + }); + expect(createRangeSpy).not.toHaveBeenCalled(); + expect(queryElementsSpy).toHaveBeenCalledWith(mockedDiv, '#' + mockedTableId); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts index c5911a87ac3..deb0d2b1268 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts @@ -62,7 +62,7 @@ export interface IStandaloneEditor { * This is the replacement of IEditor.select. * @param selection The selection to set */ - setDOMSelection(selection: DOMSelection): void; + setDOMSelection(selection: DOMSelection | null): void; /** * The general API to do format change with Content Model diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts index 5e92099fe94..ae390413a7c 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCore.ts @@ -10,17 +10,11 @@ import type { DarkColorHandler, EditorPlugin, GetContentMode, - ImageSelectionRange, InsertOption, NodePosition, PluginEvent, - PositionType, Rect, - SelectionPath, - SelectionRangeEx, StyleBasedFormatState, - TableSelection, - TableSelectionRange, TrustedHTMLHandler, } from 'roosterjs-editor-types'; import type { ContentModelDocument } from '../group/ContentModelDocument'; @@ -82,8 +76,13 @@ export type SetContentModel = ( * Set current DOM selection from editor. This is the replacement of core API select * @param core The StandaloneEditorCore object * @param selection The selection to set + * @param skipSelectionChangedEvent @param Pass true to skip triggering a SelectionChangedEvent */ -export type SetDOMSelection = (core: StandaloneEditorCore, selection: DOMSelection) => void; +export type SetDOMSelection = ( + core: StandaloneEditorCore, + selection: DOMSelection | null, + skipSelectionChangedEvent?: boolean +) => void; /** * The general API to do format change with Content Model @@ -107,24 +106,6 @@ export type FormatContentModel = ( */ export type SwitchShadowEdit = (core: StandaloneEditorCore, isOn: boolean) => void; -/** - * TODO: Remove this Core API and use setDOMSelection instead - * Select content according to the given information. - * There are a bunch of allowed combination of parameters. See IEditor.select for more details - * @param core The editor core object - * @param arg1 A DOM Range, or SelectionRangeEx, or NodePosition, or Node, or Selection Path - * @param arg2 (optional) A NodePosition, or an offset number, or a PositionType, or a TableSelection, or null - * @param arg3 (optional) A Node - * @param arg4 (optional) An offset number, or a PositionType - */ -export type Select = ( - core: StandaloneEditorCore, - arg1: Range | SelectionRangeEx | NodePosition | Node | SelectionPath | null, - arg2?: NodePosition | number | PositionType | TableSelection | null, - arg3?: Node, - arg4?: number | PositionType -) => boolean; - /** * Trigger a plugin event * @param core The StandaloneEditorCore object @@ -137,13 +118,6 @@ export type TriggerEvent = ( broadcast: boolean ) => void; -/** - * Get current selection range - * @param core The StandaloneEditorCore object - * @returns A Range object of the selection range - */ -export type GetSelectionRangeEx = (core: StandaloneEditorCore) => SelectionRangeEx; - /** * Edit and transform color of elements between light mode and dark mode * @param core The StandaloneEditorCore object @@ -188,45 +162,6 @@ export type AddUndoSnapshot = ( */ export type GetVisibleViewport = (core: StandaloneEditorCore) => Rect | null; -/** - * Change the editor selection to the given range - * @param core The StandaloneEditorCore object - * @param range The range to select - * @param skipSameRange When set to true, do nothing if the given range is the same with current selection - * in editor, otherwise it will always remove current selection range and set to the given one. - * This parameter is always treated as true in Edge to avoid some weird runtime exception. - */ -export type SelectRange = ( - core: StandaloneEditorCore, - range: Range, - skipSameRange?: boolean -) => boolean; - -/** - * Select a table and save data of the selected range - * @param core The StandaloneEditorCore object - * @param image image to select - * @returns true if successful - */ -export type SelectImage = ( - core: StandaloneEditorCore, - image: HTMLImageElement | null -) => ImageSelectionRange | null; - -/** - * Select a table and save data of the selected range - * @param core The StandaloneEditorCore object - * @param table table to select - * @param coordinates first and last cell of the selection, if this parameter is null, instead of - * selecting, will unselect the table. - * @returns true if successful - */ -export type SelectTable = ( - core: StandaloneEditorCore, - table: HTMLTableElement | null, - coordinates?: TableSelection -) => TableSelectionRange | null; - /** * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered * if triggerContentChangedEvent is set to true @@ -241,17 +176,6 @@ export type SetContent = ( metadata?: ContentMetadata ) => void; -/** - * Get current or cached selection range - * @param core The StandaloneEditorCore object - * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now - * @returns A Range object of the selection range - */ -export type GetSelectionRange = ( - core: StandaloneEditorCore, - tryGetFromCache: boolean -) => Range | null; - /** * Check if the editor has focus now * @param core The StandaloneEditorCore object @@ -364,6 +288,7 @@ export interface PortedCoreApiMap { * Set current DOM selection from editor. This is the replacement of core API select * @param core The StandaloneEditorCore object * @param selection The selection to set + * @param skipSelectionChangedEvent @param Pass true to skip triggering a SelectionChangedEvent */ setDOMSelection: SetDOMSelection; @@ -390,6 +315,19 @@ export interface PortedCoreApiMap { * @param core The StandaloneEditorCore object */ getVisibleViewport: GetVisibleViewport; + + /** + * Check if the editor has focus now + * @param core The StandaloneEditorCore object + * @returns True if the editor has focus, otherwise false + */ + hasFocus: HasFocus; + + /** + * Focus to editor. If there is a cached selection range, use it as current selection + * @param core The StandaloneEditorCore object + */ + focus: Focus; } /** @@ -397,17 +335,6 @@ export interface PortedCoreApiMap { * TODO: Port these core API */ export interface UnportedCoreApiMap { - /** - * Select content according to the given information. - * There are a bunch of allowed combination of parameters. See IEditor.select for more details - * @param core The editor core object - * @param arg1 A DOM Range, or SelectionRangeEx, or NodePosition, or Node, or Selection Path - * @param arg2 (optional) A NodePosition, or an offset number, or a PositionType, or a TableSelection, or null - * @param arg3 (optional) A Node - * @param arg4 (optional) An offset number, or a PositionType - */ - select: Select; - /** * Trigger a plugin event * @param core The StandaloneEditorCore object @@ -416,14 +343,6 @@ export interface UnportedCoreApiMap { */ triggerEvent: TriggerEvent; - /** - * Get current or cached selection range - * @param core The StandaloneEditorCore object - * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now - * @returns A Range object of the selection range - */ - getSelectionRangeEx: GetSelectionRangeEx; - /** * Edit and transform color of elements between light mode and dark mode * @param core The StandaloneEditorCore object @@ -447,36 +366,6 @@ export interface UnportedCoreApiMap { */ addUndoSnapshot: AddUndoSnapshot; - /** - * Change the editor selection to the given range - * @param core The StandaloneEditorCore object - * @param range The range to select - * @param skipSameRange When set to true, do nothing if the given range is the same with current selection - * in editor, otherwise it will always remove current selection range and set to the given one. - * This parameter is always treated as true in Edge to avoid some weird runtime exception. - */ - selectRange: SelectRange; - - /** - * Select a image and save data of the selected range - * @param core The StandaloneEditorCore object - * @param image image to select - * @param imageId the id of the image element - * @returns true if successful - */ - selectImage: SelectImage; - - /** - * Select a table and save data of the selected range - * @param core The StandaloneEditorCore object - * @param table table to select - * @param coordinates first and last cell of the selection, if this parameter is null, instead of - * selecting, will unselect the table. - * @param shouldAddStyles Whether need to update the style elements - * @returns true if successful - */ - selectTable: SelectTable; - /** * Set HTML content to this editor. All existing content will be replaced. A ContentChanged event will be triggered * if triggerContentChangedEvent is set to true @@ -486,27 +375,6 @@ export interface UnportedCoreApiMap { */ setContent: SetContent; - /** - * Get current or cached selection range - * @param core The StandaloneEditorCore object - * @param tryGetFromCache Set to true to retrieve the selection range from cache if editor doesn't own the focus now - * @returns A Range object of the selection range - */ - getSelectionRange: GetSelectionRange; - - /** - * Check if the editor has focus now - * @param core The StandaloneEditorCore object - * @returns True if the editor has focus, otherwise false - */ - hasFocus: HasFocus; - - /** - * Focus to editor. If there is a cached selection range, use it as current selection - * @param core The StandaloneEditorCore object - */ - focus: Focus; - /** * Insert a DOM node into editor content * @param core The StandaloneEditorCore object. No op if null. diff --git a/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelSelectionChangedEvent.ts b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelSelectionChangedEvent.ts new file mode 100644 index 00000000000..d573bc77ba7 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelSelectionChangedEvent.ts @@ -0,0 +1,30 @@ +import type { DOMSelection } from '../selection/DOMSelection'; +import type { + CompatibleSelectionChangedEvent, + SelectionChangedEvent, + SelectionChangedEventData, +} from 'roosterjs-editor-types'; + +/** + * Data of ContentModelSelectionChangedEvent + */ +export interface ContentModelSelectionChangedEventData extends SelectionChangedEventData { + /** + * The new selection after change + */ + newSelection: DOMSelection | null; +} + +/** + * Represents an event that will be fired when the user changed the selection + */ +export interface ContentModelSelectionChangedEvent + extends ContentModelSelectionChangedEventData, + SelectionChangedEvent {} + +/** + * Represents an event that will be fired when the user changed the selection + */ +export interface CompatibleContentModelSelectionChangedEvent + extends ContentModelSelectionChangedEventData, + CompatibleSelectionChangedEvent {} diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index 066317a05b4..8e81f78be21 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -206,18 +206,12 @@ export { StandaloneEditorCore, StandaloneEditorDefaultSettings, SwitchShadowEdit, - Select, TriggerEvent, - GetSelectionRangeEx, TransformColor, AddUndoSnapshot, - SelectRange, PortedCoreApiMap, UnportedCoreApiMap, - SelectImage, - SelectTable, SetContent, - GetSelectionRange, HasFocus, Focus, InsertNode, @@ -275,3 +269,8 @@ export { ContentModelContentChangedEventData, ChangedEntity, } from './event/ContentModelContentChangedEvent'; +export { + CompatibleContentModelSelectionChangedEvent, + ContentModelSelectionChangedEvent, + ContentModelSelectionChangedEventData, +} from './event/ContentModelSelectionChangedEvent'; diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts index b58bf24eb37..02390d727ef 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts @@ -1,4 +1,4 @@ -import type { ImageSelectionRange, TableSelectionRange } from 'roosterjs-editor-types'; +import type { DOMSelection } from '../selection/DOMSelection'; /** * The state object for SelectionPlugin @@ -7,17 +7,7 @@ export interface SelectionPluginState { /** * Cached selection range */ - selectionRange: Range | null; - - /** - * Table selection range - */ - tableSelectionRange: TableSelectionRange | null; - - /** - * Image selection range - */ - imageSelectionRange: ImageSelectionRange | null; + selection: DOMSelection | null; /** * A style node in current document to help implement image and table selection From f136d03447de2cce65068413d634564a58f9367a Mon Sep 17 00:00:00 2001 From: Andres-CT98 <107568016+Andres-CT98@users.noreply.github.com> Date: Thu, 7 Dec 2023 09:52:22 -0600 Subject: [PATCH 075/111] added parameter, fix tests (#2247) --- .../lib/paste/ContentModelPastePlugin.ts | 12 ++++++++++-- .../lib/paste/Excel/processPastedContentFromExcel.ts | 5 +++-- .../test/paste/ContentModelPastePluginTest.ts | 12 ++++++++---- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts index a4ff2e111e7..43e842fe8a0 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/ContentModelPastePlugin.ts @@ -49,8 +49,12 @@ export class ContentModelPastePlugin implements EditorPlugin { /** * Construct a new instance of Paste class * @param unknownTagReplacement Replace solution of unknown tags, default behavior is to replace with SPAN + * @param allowExcelNoBorderTable Allow table copied from Excel without border */ - constructor(private unknownTagReplacement: string = 'SPAN') {} + constructor( + private unknownTagReplacement: string = 'SPAN', + private allowExcelNoBorderTable?: boolean + ) {} /** * Get name of this plugin @@ -110,7 +114,11 @@ export class ContentModelPastePlugin implements EditorPlugin { case 'excelDesktop': if (pasteType === 'normal' || pasteType === 'mergeFormat') { // Handle HTML copied from Excel - processPastedContentFromExcel(ev, this.editor.getTrustedHTMLHandler()); + processPastedContentFromExcel( + ev, + this.editor.getTrustedHTMLHandler(), + this.allowExcelNoBorderTable + ); } break; case 'googleSheets': diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts b/packages-content-model/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts index 08fd346901c..dcd39ef1e22 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/paste/Excel/processPastedContentFromExcel.ts @@ -18,7 +18,8 @@ const DEFAULT_BORDER_STYLE = 'solid 1px #d4d4d4'; export function processPastedContentFromExcel( event: ContentModelBeforePasteEvent, - trustedHTMLHandler: TrustedHTMLHandler + trustedHTMLHandler: TrustedHTMLHandler, + allowExcelNoBorderTable?: boolean ) { const { fragment, htmlBefore, clipboardData } = event; const html = clipboardData.html ? excelHandler(clipboardData.html, htmlBefore) : undefined; @@ -53,7 +54,7 @@ export function processPastedContentFromExcel( } addParser(event.domToModelOption, 'tableCell', (format, element) => { - if (element.style.borderStyle === 'none') { + if (!allowExcelNoBorderTable && element.style.borderStyle === 'none') { format.borderBottom = DEFAULT_BORDER_STYLE; format.borderLeft = DEFAULT_BORDER_STYLE; format.borderRight = DEFAULT_BORDER_STYLE; diff --git a/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts b/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts index b9d35c35a6d..faf5cf00b0d 100644 --- a/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-plugins/test/paste/ContentModelPastePluginTest.ts @@ -105,7 +105,8 @@ describe('Content Model Paste Plugin Test', () => { expect(ExcelFile.processPastedContentFromExcel).toHaveBeenCalledWith( event, - trustedHTMLHandler + trustedHTMLHandler, + undefined /*allowExcelNoBorderTable*/ ); expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 3); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(1); @@ -122,7 +123,8 @@ describe('Content Model Paste Plugin Test', () => { expect(ExcelFile.processPastedContentFromExcel).not.toHaveBeenCalledWith( event, - trustedHTMLHandler + trustedHTMLHandler, + undefined /*allowExcelNoBorderTable*/ ); expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED); expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(1); @@ -138,7 +140,8 @@ describe('Content Model Paste Plugin Test', () => { expect(ExcelFile.processPastedContentFromExcel).toHaveBeenCalledWith( event, - trustedHTMLHandler + trustedHTMLHandler, + undefined /*allowExcelNoBorderTable*/ ); expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(1); @@ -154,7 +157,8 @@ describe('Content Model Paste Plugin Test', () => { expect(ExcelFile.processPastedContentFromExcel).toHaveBeenCalledWith( event, - trustedHTMLHandler + trustedHTMLHandler, + undefined /*allowExcelNoBorderTable*/ ); expect(addParser.default).toHaveBeenCalledTimes(DEFAULT_TIMES_ADD_PARSER_CALLED + 1); expect(setProcessor.setProcessor).toHaveBeenCalledTimes(1); From 787493080ecfb254464484540783dd1f81f8b84e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 7 Dec 2023 16:26:40 -0300 Subject: [PATCH 076/111] cut list --- .../corePlugin/ContentModelCopyPastePlugin.ts | 5 +- .../publicApi/selection/deleteSelection.ts | 52 +- .../selection/hasSelectionInBlock.ts | 27 + .../selection/hasSelectionInBlockGroup.ts | 18 + .../selection/hasSelectionInSegment.ts | 13 + .../selection/deleteSelectionTest.ts | 766 +++++++++++++++++- 6 files changed, 878 insertions(+), 3 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/hasSelectionInBlock.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/hasSelectionInBlockGroup.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/hasSelectionInSegment.ts diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts index 03576c2c392..18b22a1e3f6 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts @@ -169,7 +169,10 @@ class ContentModelCopyPastePlugin implements PluginWithState { - if (deleteSelection(model, [], context).deleteResult == 'range') { + if ( + deleteSelection(model, [], context, true /** isCut */) + .deleteResult == 'range' + ) { normalizeContentModel(model); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts index 8a734613210..18d755944b5 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts @@ -1,5 +1,9 @@ +import hasSelectionInBlock from './hasSelectionInBlock'; +import hasSelectionInBlockGroup from './hasSelectionInBlockGroup'; import { deleteExpandedSelection } from '../../modelApi/edit/deleteExpandedSelection'; +import { getClosestAncestorBlockGroupIndex } from '../model/getClosestAncestorBlockGroupIndex'; import type { + ContentModelBlock, ContentModelDocument, DeleteSelectionContext, DeleteSelectionResult, @@ -13,12 +17,14 @@ import type { * @param model The model to delete selected content from * @param additionalSteps @optional Addition delete steps * @param formatContext @optional A context object provided by formatContentModel API + * @param isCut @optional True if this is a cut operation, false if this is a delete operation * @returns A DeleteSelectionResult object to specify the deletion result */ export function deleteSelection( model: ContentModelDocument, additionalSteps: (DeleteSelectionStep | null)[] = [], - formatContext?: FormatWithContentModelContext + formatContext?: FormatWithContentModelContext, + isCut?: boolean ): DeleteSelectionResult { const context = deleteExpandedSelection(model, formatContext); @@ -33,6 +39,7 @@ export function deleteSelection( }); mergeParagraphAfterDelete(context); + deleteEmptyList(context, isCut); return context; } @@ -59,3 +66,46 @@ function mergeParagraphAfterDelete(context: DeleteSelectionContext) { lastParagraph.segments = []; } } + +function isEmptyBlock(block: ContentModelBlock | undefined): boolean { + if (block && block.blockType == 'Paragraph') { + return block.segments.every( + segment => segment.segmentType !== 'SelectionMarker' && segment.segmentType == 'Br' + ); + } + + if (block && block.blockType == 'BlockGroup') { + return block.blocks.every(isEmptyBlock); + } + + return !!block; +} + +//Verify if we need to remove the list item levels +//If the first item o the list is selected in a expanded selection, we need to remove the list item levels +function deleteEmptyList(context: DeleteSelectionContext, isCut?: boolean) { + const { insertPoint, deleteResult } = context; + if (deleteResult == 'range' && insertPoint?.path && isCut) { + const index = getClosestAncestorBlockGroupIndex(insertPoint.path, ['ListItem']); + const item = insertPoint.path[index]; + if (index >= 0 && item && item.blockGroupType == 'ListItem') { + const listItemIndex = insertPoint.path[index + 1].blocks.indexOf(item); + const previousBlock = + listItemIndex > -1 + ? insertPoint.path[index + 1].blocks[listItemIndex - 1] + : undefined; + const nextBlock = + listItemIndex > -1 + ? insertPoint.path[index + 1].blocks[listItemIndex + 1] + : undefined; + if ( + hasSelectionInBlockGroup(item) && + (!previousBlock || hasSelectionInBlock(previousBlock)) && + nextBlock && + isEmptyBlock(nextBlock) + ) { + item.levels = []; + } + } + } +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/hasSelectionInBlock.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/hasSelectionInBlock.ts new file mode 100644 index 00000000000..a0144bcb1e9 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/hasSelectionInBlock.ts @@ -0,0 +1,27 @@ +import hasSelectionInBlockGroup from './hasSelectionInBlockGroup'; +import hasSelectionInSegment from './hasSelectionInSegment'; +import type { ContentModelBlock } from 'roosterjs-content-model-types'; + +/** + * Check if there is selection within the given block + * @param block The block to check + */ +export default function hasSelectionInBlock(block: ContentModelBlock): boolean { + switch (block.blockType) { + case 'Paragraph': + return block.segments.some(hasSelectionInSegment); + + case 'Table': + return block.rows.some(row => row.cells.some(hasSelectionInBlockGroup)); + + case 'BlockGroup': + return hasSelectionInBlockGroup(block); + + case 'Divider': + case 'Entity': + return !!block.isSelected; + + default: + return false; + } +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/hasSelectionInBlockGroup.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/hasSelectionInBlockGroup.ts new file mode 100644 index 00000000000..a6bc83ede70 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/hasSelectionInBlockGroup.ts @@ -0,0 +1,18 @@ +import hasSelectionInBlock from './hasSelectionInBlock'; +import type { ContentModelBlockGroup } from 'roosterjs-content-model-types'; + +/** + * Check if there is selection within the given block + * @param block The block to check + */ +export default function hasSelectionInBlockGroup(group: ContentModelBlockGroup): boolean { + if (group.blockGroupType == 'TableCell' && group.isSelected) { + return true; + } + + if (group.blocks.some(hasSelectionInBlock)) { + return true; + } + + return false; +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/hasSelectionInSegment.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/hasSelectionInSegment.ts new file mode 100644 index 00000000000..8d059ab6196 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/hasSelectionInSegment.ts @@ -0,0 +1,13 @@ +import hasSelectionInBlock from './hasSelectionInBlock'; +import type { ContentModelSegment } from 'roosterjs-content-model-types'; + +/** + * Check if there is selection within the given segment + * @param segment The segment to check + */ +export default function hasSelectionInSegment(segment: ContentModelSegment): boolean { + return ( + segment.isSelected || + (segment.segmentType == 'General' && segment.blocks.some(hasSelectionInBlock)) + ); +} diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts index e8c0cef5414..71ff195e9c7 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts @@ -1,5 +1,9 @@ -import { ContentModelSelectionMarker, DeletedEntity } from 'roosterjs-content-model-types'; import { deleteSelection } from '../../../lib/publicApi/selection/deleteSelection'; +import { + ContentModelBlockGroup, + ContentModelSelectionMarker, + DeletedEntity, +} from 'roosterjs-content-model-types'; import { createContentModelDocument, createDivider, @@ -7,11 +11,14 @@ import { createGeneralBlock, createGeneralSegment, createImage, + createListItem, + createListLevel, createParagraph, createSelectionMarker, createTable, createTableCell, createText, + normalizeContentModel, } from 'roosterjs-content-model-dom'; describe('deleteSelection - selectionOnly', () => { @@ -963,3 +970,760 @@ describe('deleteSelection - selectionOnly', () => { }); }); }); + +describe('deleteSelection - list - when cut', () => { + it('Delete all list', () => { + const model = createContentModelDocument(); + const level = createListLevel('OL'); + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }; + const listItem1 = createListItem([level]); + const listItem2 = createListItem([level]); + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test1'); + text1.isSelected = true; + const text2 = createText('test1'); + text2.isSelected = true; + + listItem1.blocks.push(para1); + listItem2.blocks.push(para2); + para1.segments.push(marker, text1); + para2.segments.push(text2, marker); + model.blocks.push(listItem1, listItem2); + + const result = deleteSelection(model, [], undefined, true /*isCut*/); + normalizeContentModel(model); + + const path: ContentModelBlockGroup[] = [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + }, + ]; + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para1, + path: path, + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + }); + }); + + it('Delete first list item', () => { + const model = createContentModelDocument(); + const level = createListLevel('OL'); + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }; + const listItem1 = createListItem([level]); + const listItem2 = createListItem([level]); + const para1 = createParagraph(); + const para2 = createParagraph(); + const text1 = createText('test1'); + text1.isSelected = true; + const text2 = createText('test2'); + + listItem1.blocks.push(para1); + listItem2.blocks.push(para2); + para1.segments.push(text1); + para2.segments.push(text2); + model.blocks.push(listItem1, listItem2); + + const result = deleteSelection(model, [], undefined, true /*isCut*/); + normalizeContentModel(model); + + const path: ContentModelBlockGroup[] = [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + }, + ]; + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para1, + path: path, + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + }); + }); + + it('Delete text on list item', () => { + const model = createContentModelDocument(); + const level = createListLevel('OL'); + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }; + const listItem1 = createListItem([level]); + const para1 = createParagraph(); + const text1 = createText('test1'); + text1.isSelected = true; + + listItem1.blocks.push(para1); + para1.segments.push(text1); + model.blocks.push(listItem1); + + const result = deleteSelection(model, [], undefined, true /*isCut*/); + normalizeContentModel(model); + + const path: ContentModelBlockGroup[] = [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + }, + ]; + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: para1, + path: path, + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + }); + }); + + it('Delete in the middle on the list', () => { + const model = createContentModelDocument(); + const level = createListLevel('OL'); + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }; + const listItem1 = createListItem([level]); + const listItem2 = createListItem([level]); + const listItem3 = createListItem([level]); + const listItem4 = createListItem([level]); + const para1 = createParagraph(); + const para2 = createParagraph(); + const para3 = createParagraph(); + const para4 = createParagraph(); + const text1 = createText('test1'); + const text2 = createText('test2'); + const text3 = createText('test3'); + const text4 = createText('test4'); + + text2.isSelected = true; + text3.isSelected = true; + + listItem1.blocks.push(para1); + listItem2.blocks.push(para2); + listItem3.blocks.push(para3); + listItem4.blocks.push(para4); + + para1.segments.push(text1); + para2.segments.push(marker, text2); + para3.segments.push(text3, marker); + para4.segments.push(text4); + model.blocks.push(listItem1, listItem2, listItem3, listItem4); + + const result = deleteSelection(model, [], undefined, true /*isCut*/); + normalizeContentModel(model); + + const path: ContentModelBlockGroup[] = [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test4', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + }, + ]; + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + path: path, + tableContext: undefined, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test4', + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'OL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + }); + }); +}); From d06675eb46ca1aa7a65710e780874838c2851dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 7 Dec 2023 16:35:19 -0300 Subject: [PATCH 077/111] fix build --- .../model/ContentModelDocumentView.tsx | 2 +- .../model/ContentModelFormatContainerView.tsx | 2 +- .../model/ContentModelGeneralView.tsx | 2 +- .../model/ContentModelListItemView.tsx | 2 +- .../model/ContentModelParagraphView.tsx | 2 +- .../model/ContentModelTableCellView.tsx | 3 +-- .../model/ContentModelTableRowView.tsx | 2 +- .../model/ContentModelTableView.tsx | 2 +- .../roosterjs-content-model-api/lib/index.ts | 3 --- .../lib/modelApi/table/getSelectedCells.ts | 2 +- .../selection/hasSelectionInBlock.ts | 27 ------------------- .../selection/hasSelectionInBlockGroup.ts | 18 ------------- .../selection/hasSelectionInSegment.ts | 13 --------- .../lib/publicApi/table/editTable.ts | 2 +- .../lib/publicApi/table/setTableCellShade.ts | 2 +- .../modelApi/table/deleteTableColumnTest.ts | 2 +- .../test/modelApi/table/deleteTableRowTest.ts | 2 +- .../roosterjs-content-model-core/lib/index.ts | 3 +++ .../selection/hasSelectionInBlockTest.ts | 0 .../selection/hasSelectionInSegmentTest.ts | 0 20 files changed, 16 insertions(+), 75 deletions(-) delete mode 100644 packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInBlock.ts delete mode 100644 packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInBlockGroup.ts delete mode 100644 packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInSegment.ts rename packages-content-model/{roosterjs-content-model-api => roosterjs-content-model-core}/test/publicApi/selection/hasSelectionInBlockTest.ts (100%) rename packages-content-model/{roosterjs-content-model-api => roosterjs-content-model-core}/test/publicApi/selection/hasSelectionInSegmentTest.ts (100%) diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelDocumentView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelDocumentView.tsx index cb2729be51d..801c852e275 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelDocumentView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelDocumentView.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { BlockGroupContentView } from './BlockGroupContentView'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { ContentModelView } from '../ContentModelView'; -import { hasSelectionInBlockGroup } from 'roosterjs-content-model-api'; +import { hasSelectionInBlockGroup } from 'roosterjs-content-model-core'; const styles = require('./ContentModelDocumentView.scss'); diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelFormatContainerView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelFormatContainerView.tsx index e7d93eba233..a335649db28 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelFormatContainerView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelFormatContainerView.tsx @@ -5,7 +5,7 @@ import { ContentModelView } from '../ContentModelView'; import { DisplayFormatRenderer } from '../format/formatPart/DisplayFormatRenderer'; import { FormatRenderer } from '../format/utils/FormatRenderer'; import { FormatView } from '../format/FormatView'; -import { hasSelectionInBlock } from 'roosterjs-content-model-api'; +import { hasSelectionInBlock } from 'roosterjs-content-model-core'; import { SegmentFormatView } from '../format/SegmentFormatView'; import { SizeFormatRenderers } from '../format/formatPart/SizeFormatRenderers'; import { diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelGeneralView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelGeneralView.tsx index d42b2ef1eb0..89a85101f9c 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelGeneralView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelGeneralView.tsx @@ -3,7 +3,7 @@ import { BlockGroupContentView } from './BlockGroupContentView'; import { ContentModelCodeView } from './ContentModelCodeView'; import { ContentModelLinkView } from './ContentModelLinkView'; import { ContentModelView } from '../ContentModelView'; -import { hasSelectionInBlock } from 'roosterjs-content-model-api'; +import { hasSelectionInBlock } from 'roosterjs-content-model-core'; import { SegmentFormatView } from '../format/SegmentFormatView'; import { ContentModelGeneralBlock, diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelListItemView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelListItemView.tsx index 4d75adf63a1..5f138e0b185 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelListItemView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelListItemView.tsx @@ -7,7 +7,7 @@ import { FontFamilyFormatRenderer } from '../format/formatPart/FontFamilyFormatR import { FontSizeFormatRenderer } from '../format/formatPart/FontSizeFormatRenderer'; import { FormatRenderer } from '../format/utils/FormatRenderer'; import { FormatView } from '../format/FormatView'; -import { hasSelectionInBlockGroup } from 'roosterjs-content-model-api'; +import { hasSelectionInBlockGroup } from 'roosterjs-content-model-core'; import { LineHeightFormatRenderer } from '../format/formatPart/LineHeightFormatRenderer'; import { MarginFormatRenderer } from '../format/formatPart/MarginFormatRenderer'; import { TextAlignFormatRenderer } from '../format/formatPart/TextAlignFormatRenderer'; diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelParagraphView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelParagraphView.tsx index 10fd06f9ef5..36a626bf5be 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelParagraphView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelParagraphView.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { BlockFormatView } from '../format/BlockFormatView'; import { ContentModelSegmentView } from './ContentModelSegmentView'; import { ContentModelView } from '../ContentModelView'; -import { hasSelectionInBlock } from 'roosterjs-content-model-api'; +import { hasSelectionInBlock } from 'roosterjs-content-model-core'; import { SegmentFormatView } from '../format/SegmentFormatView'; import { useProperty } from '../../hooks/useProperty'; import { diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelTableCellView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelTableCellView.tsx index 065c8ead528..a932ab5ba96 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelTableCellView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelTableCellView.tsx @@ -8,7 +8,7 @@ import { ContentModelView } from '../ContentModelView'; import { DirectionFormatRenderer } from '../format/formatPart/DirectionFormatRenderer'; import { FormatRenderer } from '../format/utils/FormatRenderer'; import { FormatView } from '../format/FormatView'; -import { hasSelectionInBlockGroup } from 'roosterjs-content-model-api'; +import { hasSelectionInBlockGroup, updateTableCellMetadata } from 'roosterjs-content-model-core'; import { HtmlAlignFormatRenderer } from '../format/formatPart/HtmlAlignFormatRenderer'; import { MetadataView } from '../format/MetadataView'; import { PaddingFormatRenderer } from '../format/formatPart/PaddingFormatRenderer'; @@ -16,7 +16,6 @@ import { SizeFormatRenderers } from '../format/formatPart/SizeFormatRenderers'; import { TableCellMetadataFormatRenders } from '../format/formatPart/TableCellMetadataFormatRenders'; import { TextAlignFormatRenderer } from '../format/formatPart/TextAlignFormatRenderer'; import { TextColorFormatRenderer } from '../format/formatPart/TextColorFormatRenderer'; -import { updateTableCellMetadata } from 'roosterjs-content-model-core'; import { useProperty } from '../../hooks/useProperty'; import { VerticalAlignFormatRenderer } from '../format/formatPart/VerticalAlignFormatRenderer'; import { WordBreakFormatRenderer } from '../format/formatPart/WordBreakFormatRenderer'; diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelTableRowView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelTableRowView.tsx index 7a70f087476..4e025ac61e0 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelTableRowView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelTableRowView.tsx @@ -5,7 +5,7 @@ import { ContentModelBlockGroupView } from './ContentModelBlockGroupView'; import { ContentModelView } from '../ContentModelView'; import { FormatRenderer } from '../format/utils/FormatRenderer'; import { FormatView } from '../format/FormatView'; -import { hasSelectionInBlockGroup } from 'roosterjs-content-model-api'; +import { hasSelectionInBlockGroup } from 'roosterjs-content-model-core'; import { useProperty } from '../../hooks/useProperty'; const styles = require('./ContentModelTableRowView.scss'); diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelTableView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelTableView.tsx index 744d9ae4db2..94944051fa4 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelTableView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelTableView.tsx @@ -8,7 +8,7 @@ import { ContentModelView } from '../ContentModelView'; import { DisplayFormatRenderer } from '../format/formatPart/DisplayFormatRenderer'; import { FormatRenderer } from '../format/utils/FormatRenderer'; import { FormatView } from '../format/FormatView'; -import { hasSelectionInBlock } from 'roosterjs-content-model-api'; +import { hasSelectionInBlock } from 'roosterjs-content-model-core'; import { IdFormatRenderer } from '../format/formatPart/IdFormatRenderer'; import { MarginFormatRenderer } from '../format/formatPart/MarginFormatRenderer'; import { MetadataView } from '../format/MetadataView'; diff --git a/packages-content-model/roosterjs-content-model-api/lib/index.ts b/packages-content-model/roosterjs-content-model-api/lib/index.ts index 1db5ab16114..8bef29554bd 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/index.ts @@ -21,9 +21,6 @@ export { default as changeCapitalization } from './publicApi/segment/changeCapit export { default as insertImage } from './publicApi/image/insertImage'; export { default as setListStyle } from './publicApi/list/setListStyle'; export { default as setListStartNumber } from './publicApi/list/setListStartNumber'; -export { default as hasSelectionInBlock } from './publicApi/selection/hasSelectionInBlock'; -export { default as hasSelectionInSegment } from './publicApi/selection/hasSelectionInSegment'; -export { default as hasSelectionInBlockGroup } from './publicApi/selection/hasSelectionInBlockGroup'; export { default as setIndentation } from './publicApi/block/setIndentation'; export { default as setAlignment } from './publicApi/block/setAlignment'; export { default as setDirection } from './publicApi/block/setDirection'; diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/getSelectedCells.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/getSelectedCells.ts index f06354a3567..e13fd087e13 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/getSelectedCells.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/table/getSelectedCells.ts @@ -1,4 +1,4 @@ -import hasSelectionInBlockGroup from '../../publicApi/selection/hasSelectionInBlockGroup'; +import { hasSelectionInBlockGroup } from 'roosterjs-content-model-core'; import type { ContentModelTable } from 'roosterjs-content-model-types'; /** diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInBlock.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInBlock.ts deleted file mode 100644 index a0144bcb1e9..00000000000 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInBlock.ts +++ /dev/null @@ -1,27 +0,0 @@ -import hasSelectionInBlockGroup from './hasSelectionInBlockGroup'; -import hasSelectionInSegment from './hasSelectionInSegment'; -import type { ContentModelBlock } from 'roosterjs-content-model-types'; - -/** - * Check if there is selection within the given block - * @param block The block to check - */ -export default function hasSelectionInBlock(block: ContentModelBlock): boolean { - switch (block.blockType) { - case 'Paragraph': - return block.segments.some(hasSelectionInSegment); - - case 'Table': - return block.rows.some(row => row.cells.some(hasSelectionInBlockGroup)); - - case 'BlockGroup': - return hasSelectionInBlockGroup(block); - - case 'Divider': - case 'Entity': - return !!block.isSelected; - - default: - return false; - } -} diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInBlockGroup.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInBlockGroup.ts deleted file mode 100644 index a6bc83ede70..00000000000 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInBlockGroup.ts +++ /dev/null @@ -1,18 +0,0 @@ -import hasSelectionInBlock from './hasSelectionInBlock'; -import type { ContentModelBlockGroup } from 'roosterjs-content-model-types'; - -/** - * Check if there is selection within the given block - * @param block The block to check - */ -export default function hasSelectionInBlockGroup(group: ContentModelBlockGroup): boolean { - if (group.blockGroupType == 'TableCell' && group.isSelected) { - return true; - } - - if (group.blocks.some(hasSelectionInBlock)) { - return true; - } - - return false; -} diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInSegment.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInSegment.ts deleted file mode 100644 index 8d059ab6196..00000000000 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/selection/hasSelectionInSegment.ts +++ /dev/null @@ -1,13 +0,0 @@ -import hasSelectionInBlock from './hasSelectionInBlock'; -import type { ContentModelSegment } from 'roosterjs-content-model-types'; - -/** - * Check if there is selection within the given segment - * @param segment The segment to check - */ -export default function hasSelectionInSegment(segment: ContentModelSegment): boolean { - return ( - segment.isSelected || - (segment.segmentType == 'General' && segment.blocks.some(hasSelectionInBlock)) - ); -} diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts index 1ffadcc164f..62ea4ddcc41 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts @@ -1,9 +1,9 @@ -import hasSelectionInBlock from '../selection/hasSelectionInBlock'; import { alignTable } from '../../modelApi/table/alignTable'; import { deleteTable } from '../../modelApi/table/deleteTable'; import { deleteTableColumn } from '../../modelApi/table/deleteTableColumn'; import { deleteTableRow } from '../../modelApi/table/deleteTableRow'; import { ensureFocusableParagraphForTable } from '../../modelApi/table/ensureFocusableParagraphForTable'; +import { hasSelectionInBlock } from 'roosterjs-content-model-core'; import { insertTableColumn } from '../../modelApi/table/insertTableColumn'; import { insertTableRow } from '../../modelApi/table/insertTableRow'; import { mergeTableCells } from '../../modelApi/table/mergeTableCells'; diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/setTableCellShade.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/setTableCellShade.ts index 46a284f4d3a..fee572ec8bf 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/setTableCellShade.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/setTableCellShade.ts @@ -1,5 +1,5 @@ -import hasSelectionInBlockGroup from '../selection/hasSelectionInBlockGroup'; import { + hasSelectionInBlockGroup, getFirstSelectedTable, normalizeTable, setTableCellBackgroundColor, diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/table/deleteTableColumnTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/deleteTableColumnTest.ts index b84c6701b1e..75f6801b19c 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/table/deleteTableColumnTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/deleteTableColumnTest.ts @@ -1,6 +1,6 @@ -import hasSelectionInBlock from '../../../lib/publicApi/selection/hasSelectionInBlock'; import { createTable, createTableCell } from 'roosterjs-content-model-dom'; import { deleteTableColumn } from '../../../lib/modelApi/table/deleteTableColumn'; +import { hasSelectionInBlock } from 'roosterjs-content-model-core'; describe('deleteTableColumn', () => { it('empty table', () => { diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/table/deleteTableRowTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/deleteTableRowTest.ts index c1aee30bb18..a0da945b69a 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/table/deleteTableRowTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/table/deleteTableRowTest.ts @@ -1,6 +1,6 @@ -import hasSelectionInBlock from '../../../lib/publicApi/selection/hasSelectionInBlock'; import { createTable, createTableCell } from 'roosterjs-content-model-dom'; import { deleteTableRow } from '../../../lib/modelApi/table/deleteTableRow'; +import { hasSelectionInBlock } from 'roosterjs-content-model-core'; describe('deleteTableRow', () => { it('empty table', () => { diff --git a/packages-content-model/roosterjs-content-model-core/lib/index.ts b/packages-content-model/roosterjs-content-model-core/lib/index.ts index 73d35cdb658..ecb5651ab4f 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/index.ts @@ -18,6 +18,9 @@ export { getSelectionRootNode } from './publicApi/selection/getSelectionRootNode export { deleteSelection } from './publicApi/selection/deleteSelection'; export { deleteSegment } from './publicApi/selection/deleteSegment'; export { deleteBlock } from './publicApi/selection/deleteBlock'; +export { default as hasSelectionInBlock } from './publicApi/selection/hasSelectionInBlock'; +export { default as hasSelectionInSegment } from './publicApi/selection/hasSelectionInSegment'; +export { default as hasSelectionInBlockGroup } from './publicApi/selection/hasSelectionInBlockGroup'; export { OperationalBlocks, getFirstSelectedListItem, diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/selection/hasSelectionInBlockTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/hasSelectionInBlockTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-api/test/publicApi/selection/hasSelectionInBlockTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/selection/hasSelectionInBlockTest.ts diff --git a/packages-content-model/roosterjs-content-model-api/test/publicApi/selection/hasSelectionInSegmentTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/hasSelectionInSegmentTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-api/test/publicApi/selection/hasSelectionInSegmentTest.ts rename to packages-content-model/roosterjs-content-model-core/test/publicApi/selection/hasSelectionInSegmentTest.ts From 917e69e84219df4150ef615c4515ddc5713eb847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 7 Dec 2023 16:37:46 -0300 Subject: [PATCH 078/111] fix build --- .../lib/publicApi/table/editTable.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts index 62ea4ddcc41..78adfc5d5f1 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/publicApi/table/editTable.ts @@ -3,7 +3,6 @@ import { deleteTable } from '../../modelApi/table/deleteTable'; import { deleteTableColumn } from '../../modelApi/table/deleteTableColumn'; import { deleteTableRow } from '../../modelApi/table/deleteTableRow'; import { ensureFocusableParagraphForTable } from '../../modelApi/table/ensureFocusableParagraphForTable'; -import { hasSelectionInBlock } from 'roosterjs-content-model-core'; import { insertTableColumn } from '../../modelApi/table/insertTableColumn'; import { insertTableRow } from '../../modelApi/table/insertTableRow'; import { mergeTableCells } from '../../modelApi/table/mergeTableCells'; @@ -11,7 +10,9 @@ import { mergeTableColumn } from '../../modelApi/table/mergeTableColumn'; import { mergeTableRow } from '../../modelApi/table/mergeTableRow'; import { splitTableCellHorizontally } from '../../modelApi/table/splitTableCellHorizontally'; import { splitTableCellVertically } from '../../modelApi/table/splitTableCellVertically'; + import { + hasSelectionInBlock, applyTableFormat, getFirstSelectedTable, normalizeTable, From 312e55a68e95219439b332a98ebdb9afbc02f9b8 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 8 Dec 2023 09:16:58 -0800 Subject: [PATCH 079/111] Standalone Editor: Selection API step 3: Port ImageSelection plugin (#2235) * Standalone Editor: CreateStandaloneEditorCore * Standalone Editor: Port LifecyclePlugin * fix build * fix test * improve * fix test * Standalone Editor: Support keyboard input (init step) * Standalone Editor: Port EntityPlugin * improve * Add test * improve * port selection api * improve * improve * fix build * fix build * fix build * improve * Improve * improve * improve * fix test * improve * add test * remove unused code * Standalone Editor: port ImageSelection plugin * add test * improve --- .../lib/corePlugin/SelectionPlugin.ts | 100 ++++- .../test/corePlugin/SelectionPluginTest.ts | 421 +++++++++++++++++- .../lib/corePlugins/ImageSelection.ts | 107 ----- .../lib/corePlugins/createCorePlugins.ts | 2 - .../lib/editor/createEditorCore.ts | 1 - .../publicTypes/ContentModelCorePlugins.ts | 6 - .../test/editor/createEditorCoreTest.ts | 5 - 7 files changed, 518 insertions(+), 124 deletions(-) delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ImageSelection.ts diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts index 6eccc72c235..2bb1e448c89 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts @@ -1,10 +1,16 @@ -import type { IEditor, PluginWithState } from 'roosterjs-editor-types'; +import { isElementOfType, isNodeOfType, toArray } from 'roosterjs-content-model-dom'; +import { isModifierKey } from '../publicApi/domUtils/eventUtils'; +import { PluginEventType } from 'roosterjs-editor-types'; +import type { IEditor, PluginEvent, PluginWithState } from 'roosterjs-editor-types'; import type { + DOMSelection, IStandaloneEditor, SelectionPluginState, StandaloneEditorOptions, } from 'roosterjs-content-model-types'; +const MouseMiddleButton = 1; + class SelectionPlugin implements PluginWithState { private editor: (IStandaloneEditor & IEditor) | null = null; private state: SelectionPluginState; @@ -77,6 +83,98 @@ class SelectionPlugin implements PluginWithState { return this.state; } + onPluginEvent(event: PluginEvent) { + if (!this.editor) { + return; + } + + let image: HTMLImageElement | null; + let selection: DOMSelection | null; + + switch (event.eventType) { + case PluginEventType.MouseUp: + if ( + (image = this.getClickingImage(event.rawEvent)) && + image.isContentEditable && + event.rawEvent.button != MouseMiddleButton && + event.isClicking + ) { + this.selectImage(this.editor, image); + } + break; + + case PluginEventType.MouseDown: + selection = this.editor.getDOMSelection(); + + if (selection?.type == 'image' && selection.image !== event.rawEvent.target) { + this.selectBeforeImage(this.editor, selection.image); + } + break; + + case PluginEventType.KeyDown: + const rawEvent = event.rawEvent; + const key = rawEvent.key; + selection = this.editor.getDOMSelection(); + + if ( + !isModifierKey(rawEvent) && + !rawEvent.shiftKey && + selection?.type == 'image' && + selection.image.parentNode + ) { + if (key === 'Escape') { + this.selectBeforeImage(this.editor, selection.image); + event.rawEvent.stopPropagation(); + } else if (key !== 'Delete' && key !== 'Backspace') { + this.selectBeforeImage(this.editor, selection.image); + } + } + break; + + case PluginEventType.ContextMenu: + selection = this.editor.getDOMSelection(); + + if ( + (image = this.getClickingImage(event.rawEvent)) && + (selection?.type != 'image' || selection.image != image) + ) { + this.selectImage(this.editor, image); + } + } + } + + private selectImage(editor: IStandaloneEditor, image: HTMLImageElement) { + editor.setDOMSelection({ + type: 'image', + image: image, + }); + } + + private selectBeforeImage(editor: IStandaloneEditor, image: HTMLImageElement) { + const doc = editor.getDocument(); + const parent = image.parentNode; + const index = parent && toArray(parent.childNodes).indexOf(image); + + if (parent && index !== null && index >= 0) { + const range = doc.createRange(); + range.setStart(parent, index); + range.collapse(); + + editor.setDOMSelection({ + type: 'range', + range: range, + }); + } + } + + private getClickingImage(event: UIEvent): HTMLImageElement | null { + const target = event.target as Node; + + return isNodeOfType(target, 'ELEMENT_NODE') && isElementOfType(target, 'img') + ? target + : null; + } + private onFocus = () => { if (!this.state.skipReselectOnFocus && this.state.selection) { this.editor?.setDOMSelection(this.state.selection); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts index c44a3119c25..df48b8cd15e 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts @@ -1,5 +1,5 @@ import { createSelectionPlugin } from '../../lib/corePlugin/SelectionPlugin'; -import { IEditor, PluginWithState } from 'roosterjs-editor-types'; +import { EditorPlugin, IEditor, PluginEventType, PluginWithState } from 'roosterjs-editor-types'; import { IStandaloneEditor, SelectionPluginState } from 'roosterjs-content-model-types'; const MockedStyleNode = 'STYLENODE' as any; @@ -87,7 +87,7 @@ describe('SelectionPlugin', () => { }); }); -describe('DOMEventPlugin handle onFocus and onBlur event', () => { +describe('SelectionPlugin handle onFocus and onBlur event', () => { let plugin: PluginWithState; let triggerPluginEvent: jasmine.Spy; let eventMap: Record; @@ -167,3 +167,420 @@ describe('DOMEventPlugin handle onFocus and onBlur event', () => { }); }); }); + +describe('SelectionPlugin handle image selection', () => { + let plugin: EditorPlugin; + let editor: IEditor; + let getDOMSelectionSpy: jasmine.Spy; + let setDOMSelectionSpy: jasmine.Spy; + let getDocumentSpy: jasmine.Spy; + let createElementSpy: jasmine.Spy; + let createRangeSpy: jasmine.Spy; + + beforeEach(() => { + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + createElementSpy = jasmine.createSpy('createElement').and.returnValue(MockedStyleNode); + createRangeSpy = jasmine.createSpy('createRange'); + getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ + createElement: createElementSpy, + createRange: createRangeSpy, + head: { + appendChild: () => {}, + }, + }); + + editor = { + getDOMSelection: getDOMSelectionSpy, + setDOMSelection: setDOMSelectionSpy, + getDocument: getDocumentSpy, + getEnvironment: () => ({}), + addDomEventHandler: (map: Record) => { + return jasmine.createSpy('disposer'); + }, + } as any; + plugin = createSelectionPlugin({}); + plugin.initialize(editor); + }); + + it('No selection, mouse down to div', () => { + const node = document.createElement('div'); + plugin.onPluginEvent({ + eventType: PluginEventType.MouseDown, + rawEvent: { + target: node, + } as any, + }); + + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('Image selection, mouse down to div', () => { + const mockedImage = { + parentNode: { childNodes: [] }, + } as any; + + mockedImage.parentNode.childNodes.push(mockedImage); + + const mockedRange = { + setStart: jasmine.createSpy('setStart'), + collapse: jasmine.createSpy('collapse'), + }; + + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, + }); + + createRangeSpy.and.returnValue(mockedRange); + + const node = document.createElement('div'); + plugin.onPluginEvent({ + eventType: PluginEventType.MouseDown, + rawEvent: { + target: node, + } as any, + }); + + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + }); + }); + + it('Image selection, mouse down to div, no parent of image', () => { + const mockedImage = { + parentNode: { childNodes: [] }, + } as any; + const mockedRange = { + setStart: jasmine.createSpy('setStart'), + collapse: jasmine.createSpy('collapse'), + }; + + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, + }); + + createRangeSpy.and.returnValue(mockedRange); + + const node = document.createElement('div'); + plugin.onPluginEvent({ + eventType: PluginEventType.MouseDown, + rawEvent: { + target: node, + } as any, + }); + + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); + + it('Image selection, mouse down to same image', () => { + const mockedImage = { + parentNode: { childNodes: [] }, + } as any; + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, + }); + + plugin.onPluginEvent({ + eventType: PluginEventType.MouseDown, + rawEvent: { + target: mockedImage, + } as any, + }); + + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); + + it('no selection, mouse up to image, is clicking, isEditable', () => { + const mockedImage = document.createElement('img'); + + mockedImage.contentEditable = 'true'; + + plugin.onPluginEvent({ + eventType: PluginEventType.MouseUp, + isClicking: true, + rawEvent: { + target: mockedImage, + } as any, + }); + + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'image', + image: mockedImage, + }); + }); + + it('no selection, mouse up to image, is clicking, not isEditable', () => { + const mockedImage = document.createElement('img'); + + mockedImage.contentEditable = 'false'; + + plugin.onPluginEvent({ + eventType: PluginEventType.MouseUp, + isClicking: true, + rawEvent: { + target: mockedImage, + } as any, + }); + + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); + + it('no selection, mouse up to image, is not clicking, isEditable', () => { + const mockedImage = document.createElement('img'); + + mockedImage.contentEditable = 'true'; + + plugin.onPluginEvent({ + eventType: PluginEventType.MouseUp, + isClicking: false, + rawEvent: { + target: mockedImage, + } as any, + }); + + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); + + it('key down - ESCAPE, no selection', () => { + const rawEvent = { + key: 'Escape', + } as any; + getDOMSelectionSpy.and.returnValue(null); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); + + it('key down - ESCAPE, range selection', () => { + const rawEvent = { + key: 'Escape', + } as any; + getDOMSelectionSpy.and.returnValue({ + type: 'range', + }); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); + + it('key down - ESCAPE, image selection', () => { + const stopPropagationSpy = jasmine.createSpy('stopPropagation'); + const rawEvent = { + key: 'Escape', + stopPropagation: stopPropagationSpy, + } as any; + + const mockedImage = { + parentNode: { childNodes: [] }, + } as any; + + mockedImage.parentNode.childNodes.push(mockedImage); + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, + }); + + const mockedRange = { + setStart: jasmine.createSpy('setStart'), + collapse: jasmine.createSpy('collapse'), + }; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(stopPropagationSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + }); + }); + + it('key down - other key', () => { + const stopPropagationSpy = jasmine.createSpy('stopPropagation'); + const rawEvent = { + key: 'A', + stopPropagation: stopPropagationSpy, + } as any; + + const mockedImage = { + parentNode: { childNodes: [] }, + } as any; + + mockedImage.parentNode.childNodes.push(mockedImage); + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, + }); + + const mockedRange = { + setStart: jasmine.createSpy('setStart'), + collapse: jasmine.createSpy('collapse'), + }; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(stopPropagationSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + }); + }); + + it('key down - other key with modifier key', () => { + const stopPropagationSpy = jasmine.createSpy('stopPropagation'); + const rawEvent = { + key: 'A', + stopPropagation: stopPropagationSpy, + ctrlKey: true, + } as any; + + const mockedImage = { + parentNode: { childNodes: [] }, + } as any; + + mockedImage.parentNode.childNodes.push(mockedImage); + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, + }); + + const mockedRange = { + setStart: jasmine.createSpy('setStart'), + collapse: jasmine.createSpy('collapse'), + }; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(stopPropagationSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('key down - other key, image has no parent', () => { + const stopPropagationSpy = jasmine.createSpy('stopPropagation'); + const rawEvent = { + key: 'A', + stopPropagation: stopPropagationSpy, + } as any; + + const mockedImage = { + parentNode: { childNodes: [] }, + } as any; + + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, + }); + + const mockedRange = { + setStart: jasmine.createSpy('setStart'), + collapse: jasmine.createSpy('collapse'), + }; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent, + }); + + expect(stopPropagationSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('context menu, no selection, click on image', () => { + const mockedImage1 = document.createElement('img'); + + const rawEvent = { + target: mockedImage1, + } as any; + + plugin.onPluginEvent({ + eventType: PluginEventType.ContextMenu, + rawEvent: rawEvent, + } as any); + + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'image', + image: mockedImage1, + }); + }); + + it('context menu, image selection, click on same image', () => { + const mockedImage1 = document.createElement('img'); + + const rawEvent = { + target: mockedImage1, + } as any; + + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage1, + }); + + plugin.onPluginEvent({ + eventType: PluginEventType.ContextMenu, + rawEvent: rawEvent, + } as any); + + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('context menu, image selection, click on different image', () => { + const mockedImage1 = document.createElement('img'); + const mockedImage2 = document.createElement('img'); + + mockedImage1.id = 'image1'; + mockedImage2.id = 'image2'; + + const rawEvent = { + target: mockedImage1, + } as any; + + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage2, + }); + + plugin.onPluginEvent({ + eventType: PluginEventType.ContextMenu, + rawEvent: rawEvent, + } as any); + + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'image', + image: mockedImage1, + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ImageSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ImageSelection.ts deleted file mode 100644 index 9871e9ce162..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/ImageSelection.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { PluginEventType, PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { safeInstanceOf } from 'roosterjs-editor-dom'; -import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-editor-types'; - -const Escape = 'Escape'; -const Delete = 'Delete'; -const mouseMiddleButton = 1; - -/** - * Detect image selection and help highlight the image - */ -class ImageSelection implements EditorPlugin { - private editor: IEditor | null = null; - - /** - * Get a friendly name of this plugin - */ - getName() { - return 'ImageSelection'; - } - - /** - * Initialize this plugin. This should only be called from Editor - * @param editor Editor instance - */ - initialize(editor: IEditor) { - this.editor = editor; - } - - /** - * Dispose this plugin - */ - dispose() { - this.editor?.select(null); - this.editor = null; - } - - onPluginEvent(event: PluginEvent) { - if (this.editor) { - switch (event.eventType) { - case PluginEventType.MouseUp: - const target = event.rawEvent.target; - if ( - safeInstanceOf(target, 'HTMLImageElement') && - target.isContentEditable && - event.rawEvent.button != mouseMiddleButton - ) { - this.editor.select(target); - } - break; - case PluginEventType.MouseDown: - const mouseTarget = event.rawEvent.target; - const mouseSelection = this.editor.getSelectionRangeEx(); - if ( - mouseSelection && - mouseSelection.type === SelectionRangeTypes.ImageSelection && - mouseSelection.image !== mouseTarget - ) { - this.editor.select(null); - } - break; - case PluginEventType.KeyDown: - const rawEvent = event.rawEvent; - const key = rawEvent.key; - const keyDownSelection = this.editor.getSelectionRangeEx(); - if ( - !rawEvent.ctrlKey && - !rawEvent.altKey && - !rawEvent.shiftKey && - !rawEvent.metaKey && - keyDownSelection.type === SelectionRangeTypes.ImageSelection - ) { - const imageParent = keyDownSelection.image?.parentNode; - if (key === Escape && imageParent) { - this.editor.select(keyDownSelection.image, PositionType.Before); - this.editor.getSelectionRange()?.collapse(); - event.rawEvent.stopPropagation(); - } else if (key === Delete) { - this.editor.deleteNode(keyDownSelection.image); - event.rawEvent.preventDefault(); - } else if (imageParent) { - this.editor.select(keyDownSelection.image, PositionType.Before); - } - } - break; - case PluginEventType.ContextMenu: - const contextMenuTarget = event.rawEvent.target; - const actualSelection = this.editor.getSelectionRangeEx(); - if ( - safeInstanceOf(contextMenuTarget, 'HTMLImageElement') && - (actualSelection.type !== SelectionRangeTypes.ImageSelection || - actualSelection.image !== contextMenuTarget) - ) { - this.editor.select(contextMenuTarget); - } - } - } - } -} - -/** - * @internal - * Create a new instance of ImageSelection. - */ -export function createImageSelection(): EditorPlugin { - return new ImageSelection(); -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts index 856041c5f9a..fbb0276c7f1 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts @@ -1,6 +1,5 @@ import { createEditPlugin } from './EditPlugin'; import { createEventTypeTranslatePlugin } from './EventTypeTranslatePlugin'; -import { createImageSelection } from './ImageSelection'; import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; import { createUndoPlugin } from './UndoPlugin'; import type { UnportedCorePlugins } from '../publicTypes/ContentModelCorePlugins'; @@ -21,7 +20,6 @@ export function createCorePlugins(options: ContentModelEditorOptions): UnportedC eventTranslate: map.eventTranslate || createEventTypeTranslatePlugin(), edit: map.edit || createEditPlugin(), undo: map.undo || createUndoPlugin(options), - imageSelection: map.imageSelection || createImageSelection(), normalizeTable: map.normalizeTable || createNormalizeTablePlugin(), }; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts index 0bb00a625e6..6c4d3aa2034 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts @@ -22,7 +22,6 @@ export function createEditorCore( corePlugins.edit, ...(options.plugins ?? []), corePlugins.undo, - corePlugins.imageSelection, corePlugins.normalizeTable, ].filter(x => !!x); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts index 6e861005bf7..7e8766fb7f0 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts @@ -26,12 +26,6 @@ export interface UnportedCorePlugins { */ readonly undo: PluginWithState; - /** - * Image selection Plugin detects image selection and help highlight the image - */ - - readonly imageSelection: EditorPlugin; - /** * NormalizeTable plugin makes sure each table in editor has TBODY/THEAD/TFOOT tag around TR tags */ diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts index 78155c6c09d..030fc9c2e48 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts @@ -6,7 +6,6 @@ import * as DOMEventPlugin from 'roosterjs-content-model-core/lib/corePlugin/DOM import * as EditPlugin from '../../lib/corePlugins/EditPlugin'; import * as EntityPlugin from 'roosterjs-content-model-core/lib/corePlugin/EntityPlugin'; import * as EventTranslate from '../../lib/corePlugins/EventTypeTranslatePlugin'; -import * as ImageSelection from '../../lib/corePlugins/ImageSelection'; import * as LifecyclePlugin from 'roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin'; import * as NormalizeTablePlugin from '../../lib/corePlugins/NormalizeTablePlugin'; import * as SelectionPlugin from 'roosterjs-content-model-core/lib/corePlugin/SelectionPlugin'; @@ -50,7 +49,6 @@ const mockedEntityPlugin = { const mockedSelectionPlugin = { getState: () => mockedSelectionState, } as any; -const mockedImageSelection = 'ImageSelection' as any; const mockedNormalizeTablePlugin = 'NormalizeTablePlugin' as any; const mockedLifecyclePlugin = { getState: () => mockedLifecycleState, @@ -82,7 +80,6 @@ describe('createEditorCore', () => { spyOn(DOMEventPlugin, 'createDOMEventPlugin').and.returnValue(mockedDOMEventPlugin); spyOn(SelectionPlugin, 'createSelectionPlugin').and.returnValue(mockedSelectionPlugin); spyOn(EntityPlugin, 'createEntityPlugin').and.returnValue(mockedEntityPlugin); - spyOn(ImageSelection, 'createImageSelection').and.returnValue(mockedImageSelection); spyOn(NormalizeTablePlugin, 'createNormalizeTablePlugin').and.returnValue( mockedNormalizeTablePlugin ); @@ -112,7 +109,6 @@ describe('createEditorCore', () => { mockedEventTranslatePlugin, mockedEditPlugin, mockedUndoPlugin, - mockedImageSelection, mockedNormalizeTablePlugin, mockedLifecyclePlugin, ], @@ -169,7 +165,6 @@ describe('createEditorCore', () => { mockedEventTranslatePlugin, mockedEditPlugin, mockedUndoPlugin, - mockedImageSelection, mockedNormalizeTablePlugin, mockedLifecyclePlugin, ], From f76fd9c90ce5e32528308ec51f1d509c0049efe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 8 Dec 2023 15:25:34 -0300 Subject: [PATCH 080/111] wip --- .../corePlugin/ContentModelCopyPastePlugin.ts | 3 +- .../lib/corePlugin/utils/deleteEmptyList.ts | 47 ++++++++++++++++ .../publicApi/selection/deleteSelection.ts | 53 +------------------ .../selection/deleteSelectionTest.ts | 9 ++-- 4 files changed, 55 insertions(+), 57 deletions(-) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts index 18b22a1e3f6..8dc3b0868d3 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts @@ -2,6 +2,7 @@ import { addRangeToSelection } from './utils/addRangeToSelection'; import { ChangeSource } from '../constants/ChangeSource'; import { cloneModel } from '../publicApi/model/cloneModel'; import { ColorTransformDirection, PluginEventType } from 'roosterjs-editor-types'; +import { deleteEmptyList } from './utils/deleteEmptyList'; import { deleteSelection } from '../publicApi/selection/deleteSelection'; import { extractClipboardItems } from 'roosterjs-editor-dom'; import { iterateSelections } from '../publicApi/selection/iterateSelections'; @@ -170,7 +171,7 @@ class ContentModelCopyPastePlugin implements PluginWithState { if ( - deleteSelection(model, [], context, true /** isCut */) + deleteSelection(model, [deleteEmptyList], context) .deleteResult == 'range' ) { normalizeContentModel(model); diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts new file mode 100644 index 00000000000..b007a2efd64 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts @@ -0,0 +1,47 @@ +import hasSelectionInBlock from '../../publicApi/selection/hasSelectionInBlock'; +import hasSelectionInBlockGroup from '../../publicApi/selection/hasSelectionInBlockGroup'; +import { ContentModelBlock, DeleteSelectionContext } from 'roosterjs-content-model-types'; +import { getClosestAncestorBlockGroupIndex } from '../../publicApi/model/getClosestAncestorBlockGroupIndex'; + +function isEmptyBlock(block: ContentModelBlock | undefined): boolean { + if (block && block.blockType == 'Paragraph') { + return block.segments.every( + segment => segment.segmentType !== 'SelectionMarker' && segment.segmentType == 'Br' + ); + } + + if (block && block.blockType == 'BlockGroup') { + return block.blocks.every(isEmptyBlock); + } + + return !!block; +} + +//Verify if we need to remove the list item levels +//If the first item o the list is selected in a expanded selection, we need to remove the list item levels +export function deleteEmptyList(context: DeleteSelectionContext) { + const { insertPoint, deleteResult } = context; + if (deleteResult == 'range' && insertPoint?.path) { + const index = getClosestAncestorBlockGroupIndex(insertPoint.path, ['ListItem']); + const item = insertPoint.path[index]; + if (index >= 0 && item && item.blockGroupType == 'ListItem') { + const listItemIndex = insertPoint.path[index + 1].blocks.indexOf(item); + const previousBlock = + listItemIndex > -1 + ? insertPoint.path[index + 1].blocks[listItemIndex - 1] + : undefined; + const nextBlock = + listItemIndex > -1 + ? insertPoint.path[index + 1].blocks[listItemIndex + 1] + : undefined; + if ( + hasSelectionInBlockGroup(item) && + (!previousBlock || hasSelectionInBlock(previousBlock)) && + nextBlock && + isEmptyBlock(nextBlock) + ) { + item.levels = []; + } + } + } +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts index 18d755944b5..5be655f6916 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts @@ -1,9 +1,5 @@ -import hasSelectionInBlock from './hasSelectionInBlock'; -import hasSelectionInBlockGroup from './hasSelectionInBlockGroup'; import { deleteExpandedSelection } from '../../modelApi/edit/deleteExpandedSelection'; -import { getClosestAncestorBlockGroupIndex } from '../model/getClosestAncestorBlockGroupIndex'; import type { - ContentModelBlock, ContentModelDocument, DeleteSelectionContext, DeleteSelectionResult, @@ -17,14 +13,12 @@ import type { * @param model The model to delete selected content from * @param additionalSteps @optional Addition delete steps * @param formatContext @optional A context object provided by formatContentModel API - * @param isCut @optional True if this is a cut operation, false if this is a delete operation * @returns A DeleteSelectionResult object to specify the deletion result */ export function deleteSelection( model: ContentModelDocument, additionalSteps: (DeleteSelectionStep | null)[] = [], - formatContext?: FormatWithContentModelContext, - isCut?: boolean + formatContext?: FormatWithContentModelContext ): DeleteSelectionResult { const context = deleteExpandedSelection(model, formatContext); @@ -39,8 +33,6 @@ export function deleteSelection( }); mergeParagraphAfterDelete(context); - deleteEmptyList(context, isCut); - return context; } @@ -66,46 +58,3 @@ function mergeParagraphAfterDelete(context: DeleteSelectionContext) { lastParagraph.segments = []; } } - -function isEmptyBlock(block: ContentModelBlock | undefined): boolean { - if (block && block.blockType == 'Paragraph') { - return block.segments.every( - segment => segment.segmentType !== 'SelectionMarker' && segment.segmentType == 'Br' - ); - } - - if (block && block.blockType == 'BlockGroup') { - return block.blocks.every(isEmptyBlock); - } - - return !!block; -} - -//Verify if we need to remove the list item levels -//If the first item o the list is selected in a expanded selection, we need to remove the list item levels -function deleteEmptyList(context: DeleteSelectionContext, isCut?: boolean) { - const { insertPoint, deleteResult } = context; - if (deleteResult == 'range' && insertPoint?.path && isCut) { - const index = getClosestAncestorBlockGroupIndex(insertPoint.path, ['ListItem']); - const item = insertPoint.path[index]; - if (index >= 0 && item && item.blockGroupType == 'ListItem') { - const listItemIndex = insertPoint.path[index + 1].blocks.indexOf(item); - const previousBlock = - listItemIndex > -1 - ? insertPoint.path[index + 1].blocks[listItemIndex - 1] - : undefined; - const nextBlock = - listItemIndex > -1 - ? insertPoint.path[index + 1].blocks[listItemIndex + 1] - : undefined; - if ( - hasSelectionInBlockGroup(item) && - (!previousBlock || hasSelectionInBlock(previousBlock)) && - nextBlock && - isEmptyBlock(nextBlock) - ) { - item.levels = []; - } - } - } -} diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts index 71ff195e9c7..9479b8abdf9 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts @@ -1,3 +1,4 @@ +import { deleteEmptyList } from '../../../lib/corePlugin/utils/deleteEmptyList'; import { deleteSelection } from '../../../lib/publicApi/selection/deleteSelection'; import { ContentModelBlockGroup, @@ -995,7 +996,7 @@ describe('deleteSelection - list - when cut', () => { para2.segments.push(text2, marker); model.blocks.push(listItem1, listItem2); - const result = deleteSelection(model, [], undefined, true /*isCut*/); + const result = deleteSelection(model, [deleteEmptyList], undefined); normalizeContentModel(model); const path: ContentModelBlockGroup[] = [ @@ -1100,7 +1101,7 @@ describe('deleteSelection - list - when cut', () => { para2.segments.push(text2); model.blocks.push(listItem1, listItem2); - const result = deleteSelection(model, [], undefined, true /*isCut*/); + const result = deleteSelection(model, [deleteEmptyList], undefined); normalizeContentModel(model); const path: ContentModelBlockGroup[] = [ @@ -1304,7 +1305,7 @@ describe('deleteSelection - list - when cut', () => { para1.segments.push(text1); model.blocks.push(listItem1); - const result = deleteSelection(model, [], undefined, true /*isCut*/); + const result = deleteSelection(model, [deleteEmptyList], undefined); normalizeContentModel(model); const path: ContentModelBlockGroup[] = [ @@ -1466,7 +1467,7 @@ describe('deleteSelection - list - when cut', () => { para4.segments.push(text4); model.blocks.push(listItem1, listItem2, listItem3, listItem4); - const result = deleteSelection(model, [], undefined, true /*isCut*/); + const result = deleteSelection(model, [deleteEmptyList], undefined); normalizeContentModel(model); const path: ContentModelBlockGroup[] = [ From 4e76c8e28be6f05a62b4fe50c0292d9e891e4176 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 8 Dec 2023 16:23:00 -0800 Subject: [PATCH 081/111] Content Model: Do not add entity delimiter to editable entity (#2252) --- .../lib/entityDelimiter/EntityDelimiterPlugin.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts b/packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts index 600228855fc..6d0bb852b17 100644 --- a/packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts +++ b/packages-content-model/roosterjs-content-model-plugins/lib/entityDelimiter/EntityDelimiterPlugin.ts @@ -147,7 +147,11 @@ export function normalizeDelimitersInEditor(editor: IEditor) { function addDelimitersIfNeeded(nodes: Element[] | NodeListOf) { nodes.forEach(node => { - if (isEntityElement(node)) { + if ( + isNodeOfType(node, 'ELEMENT_NODE') && + isEntityElement(node) && + !node.isContentEditable + ) { addDelimiters(node.ownerDocument, node as HTMLElement); } }); From 803ec9688523e9565eb24300450e28bb97b58101 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Sat, 9 Dec 2023 15:14:37 -0800 Subject: [PATCH 082/111] Standalone Editor: Port UndoPlugin (#2237) * Standalone Editor: CreateStandaloneEditorCore * Standalone Editor: Port LifecyclePlugin * fix build * fix test * improve * fix test * Standalone Editor: Support keyboard input (init step) * Standalone Editor: Port EntityPlugin * improve * Add test * improve * port selection api * improve * improve * fix build * fix build * fix build * improve * Improve * improve * improve * fix test * improve * add test * remove unused code * Standalone Editor: port ImageSelection plugin * add test * Standalone Editor: Port UndoPlugin * improve * Improve * Improve * Add test --- .../controls/ContentModelEditorMainPane.tsx | 2 +- .../corePlugin/ContentModelFormatPlugin.ts | 14 +- .../lib/corePlugin/EntityPlugin.ts | 24 +- .../lib/corePlugin}/UndoPlugin.ts | 136 ++- .../createStandaloneEditorCorePlugins.ts | 2 + .../lib/editor/UndoSnapshotsServiceImpl.ts | 115 ++ .../lib/editor/createStandaloneEditorCore.ts | 2 + .../lib/publicApi/domUtils/eventUtils.ts | 21 + .../test/corePlugin/EntityPluginTest.ts | 59 ++ .../test/corePlugin/UndoPluginTest.ts | 978 ++++++++++++++++++ .../editor/UndoSnapshotsServiceImplTest.ts | 641 ++++++++++++ .../lib/coreApi/addUndoSnapshot.ts | 3 +- .../lib/corePlugins/createCorePlugins.ts | 3 - .../lib/editor/createEditorCore.ts | 1 - .../publicTypes/ContentModelCorePlugins.ts | 12 +- .../lib/publicTypes/IContentModelEditor.ts | 14 +- .../test/editor/createEditorCoreTest.ts | 6 +- .../lib/editor/IStandaloneEditor.ts | 16 + .../lib/editor/StandaloneEditorCorePlugins.ts | 6 + .../lib/editor/StandaloneEditorOptions.ts | 13 +- .../event/ContentModelContentChangedEvent.ts | 8 +- .../lib/index.ts | 3 + .../FormatWithContentModelContext.ts | 34 + .../lib/parameter/Snapshot.ts | 28 + .../StandaloneEditorPluginState.ts | 13 +- .../lib/pluginState/UndoPluginState.ts | 42 + 26 files changed, 2071 insertions(+), 125 deletions(-) rename packages-content-model/{roosterjs-content-model-editor/lib/corePlugins => roosterjs-content-model-core/lib/corePlugin}/UndoPlugin.ts (63%) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/editor/UndoSnapshotsServiceImpl.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/corePlugin/UndoPluginTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/editor/UndoSnapshotsServiceImplTest.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/pluginState/UndoPluginState.ts diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 84751b237c0..7a44556425d 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -239,7 +239,7 @@ class ContentModelEditorMainPane extends MainPaneBase inDarkMode={this.state.isDarkMode} getDarkColor={getDarkColor} experimentalFeatures={this.state.initState.experimentalFeatures} - undoMetadataSnapshotService={this.snapshotPlugin.getSnapshotService()} + undoSnapshotService={this.snapshotPlugin.getSnapshotService()} trustedHTMLHandler={trustedHTMLHandler} zoomScale={this.state.scale} initialContent={this.content} diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts index ae05ee4b1e4..6a93175b0a0 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelFormatPlugin.ts @@ -1,7 +1,7 @@ import { applyDefaultFormat } from './utils/applyDefaultFormat'; import { applyPendingFormat } from './utils/applyPendingFormat'; import { getObjectKeys } from 'roosterjs-content-model-dom'; -import { isCharacterValue } from '../publicApi/domUtils/eventUtils'; +import { isCharacterValue, isCursorMovingKey } from '../publicApi/domUtils/eventUtils'; import { PluginEventType } from 'roosterjs-editor-types'; import type { IEditor, PluginEvent, PluginWithState } from 'roosterjs-editor-types'; import type { @@ -12,16 +12,6 @@ import type { // During IME input, KeyDown event will have "Process" as key const ProcessKey = 'Process'; -const CursorMovingKeys = new Set([ - 'ArrowUp', - 'ArrowDown', - 'ArrowLeft', - 'ArrowRight', - 'Home', - 'End', - 'PageUp', - 'PageDown', -]); /** * ContentModelFormat plugins helps editor to do formatting on top of content model. @@ -111,7 +101,7 @@ class ContentModelFormatPlugin implements PluginWithState { editor: IStandaloneEditor & IEditor, event?: ContentChangedEvent ) { + const cmEvent = event as ContentModelContentChangedEvent | undefined; const modifiedEntities: ChangedEntity[] = - (event as ContentModelContentChangedEvent)?.changedEntities ?? - this.getChangedEntities(editor); + cmEvent?.changedEntities ?? this.getChangedEntities(editor); + const entityStates = cmEvent?.entityStates; modifiedEntities.forEach(entry => { const { entity, operation, rawEvent } = entry; @@ -173,6 +174,21 @@ class EntityPlugin implements PluginWithState { } } }); + + entityStates?.forEach(entityState => { + const { id, state } = entityState; + const wrapper = this.state.entityMap[id]?.element; + + if (wrapper) { + this.triggerEvent( + editor, + wrapper, + 'updateEntityState', + undefined /*rawEvent*/, + state + ); + } + }); } private getChangedEntities(editor: IStandaloneEditor): ChangedEntity[] { @@ -232,7 +248,8 @@ class EntityPlugin implements PluginWithState { editor: IEditor & IStandaloneEditor, wrapper: HTMLElement, operation: EntityOperation, - rawEvent?: Event + rawEvent?: Event, + state?: string ) { const format: ContentModelEntityFormat = {}; wrapper.classList.forEach(name => { @@ -249,6 +266,7 @@ class EntityPlugin implements PluginWithState { isReadonly: !!format.isReadonly, wrapper, }, + state: operation == 'updateEntityState' ? state : undefined, }) : null; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts similarity index 63% rename from packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts rename to packages-content-model/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts index 446705a7b00..f496c0f9c12 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/UndoPlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts @@ -1,47 +1,43 @@ -import { ChangeSource, Keys, PluginEventType } from 'roosterjs-editor-types'; +import { ChangeSource } from '../constants/ChangeSource'; +import { createUndoSnapshotsService } from '../editor/UndoSnapshotsServiceImpl'; +import { isCursorMovingKey } from '../publicApi/domUtils/eventUtils'; +import { PluginEventType } from 'roosterjs-editor-types'; +import type { + IStandaloneEditor, + StandaloneEditorOptions, + UndoPluginState, +} from 'roosterjs-content-model-types'; import type { ContentChangedEvent, IEditor, PluginEvent, PluginWithState, - Snapshot, - UndoPluginState, - UndoSnapshotsService, } from 'roosterjs-editor-types'; -import { - addSnapshotV2, - canMoveCurrentSnapshot, - clearProceedingSnapshotsV2, - createSnapshots, - isCtrlOrMetaPressed, - moveCurrentSnapshot, - canUndoAutoComplete, -} from 'roosterjs-editor-dom'; -import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; -// Max stack size that cannot be exceeded. When exceeded, old undo history will be dropped -// to keep size under limit. This is kept at 10MB -const MAX_SIZE_LIMIT = 1e7; +const Backspace = 'Backspace'; +const Delete = 'Delete'; +const Enter = 'Enter'; /** * Provides snapshot based undo service for Editor */ class UndoPlugin implements PluginWithState { - private editor: IEditor | null = null; - private lastKeyPress: number | null = null; + private editor: (IStandaloneEditor & IEditor) | null = null; private state: UndoPluginState; /** * Construct a new instance of UndoPlugin * @param options The wrapper of the state object */ - constructor(options: ContentModelEditorOptions) { + constructor(options: StandaloneEditorOptions) { this.state = { - snapshotsService: options.undoMetadataSnapshotService || createUndoSnapshots(), + snapshotsService: options.undoSnapshotService || createUndoSnapshotsService(), isRestoring: false, hasNewContent: false, isNested: false, - autoCompletePosition: null, + posContainer: null, + posOffset: null, + lastKeyPress: null, }; } @@ -57,7 +53,7 @@ class UndoPlugin implements PluginWithState { * @param editor Editor instance */ initialize(editor: IEditor): void { - this.editor = editor; + this.editor = editor as IEditor & IStandaloneEditor; } /** @@ -80,10 +76,11 @@ class UndoPlugin implements PluginWithState { */ willHandleEventExclusively(event: PluginEvent) { return ( + !!this.editor && event.eventType == PluginEventType.KeyDown && - event.rawEvent.which == Keys.BACKSPACE && + event.rawEvent.key == Backspace && !event.rawEvent.ctrlKey && - this.canUndoAutoComplete() + this.canUndoAutoComplete(this.editor) ); } @@ -107,10 +104,10 @@ class UndoPlugin implements PluginWithState { } break; case PluginEventType.KeyDown: - this.onKeyDown(event.rawEvent); + this.onKeyDown(this.editor, event.rawEvent); break; case PluginEventType.KeyPress: - this.onKeyPress(event.rawEvent); + this.onKeyPress(this.editor, event.rawEvent); break; case PluginEventType.CompositionEnd: this.clearRedoForInput(); @@ -125,64 +122,68 @@ class UndoPlugin implements PluginWithState { } } - private onKeyDown(evt: KeyboardEvent): void { + private onKeyDown(editor: IStandaloneEditor, evt: KeyboardEvent): void { // Handle backspace/delete when there is a selection to take a snapshot // since we want the state prior to deletion restorable // Ignore if keycombo is ALT+BACKSPACE - if ((evt.which == Keys.BACKSPACE && !evt.altKey) || evt.which == Keys.DELETE) { - if (evt.which == Keys.BACKSPACE && !evt.ctrlKey && this.canUndoAutoComplete()) { + if ((evt.key == Backspace && !evt.altKey) || evt.key == Delete) { + if (evt.key == Backspace && !evt.ctrlKey && this.canUndoAutoComplete(editor)) { evt.preventDefault(); - this.editor?.undo(); - this.state.autoCompletePosition = null; - this.lastKeyPress = evt.which; + editor.undo(); + this.state.posContainer = null; + this.state.posOffset = null; + this.state.lastKeyPress = evt.key; } else if (!evt.defaultPrevented) { - const selectionRange = this.editor?.getSelectionRange(); + const selection = editor.getDOMSelection(); // Add snapshot when // 1. Something has been selected (not collapsed), or // 2. It has a different key code from the last keyDown event (to prevent adding too many snapshot when keeping press the same key), or // 3. Ctrl/Meta key is pressed so that a whole word will be deleted if ( - selectionRange && - (!selectionRange.collapsed || - this.lastKeyPress != evt.which || - isCtrlOrMetaPressed(evt)) + selection && + (selection.type != 'range' || + !selection.range.collapsed || + this.state.lastKeyPress != evt.key || + this.isCtrlOrMetaPressed(editor, evt)) ) { this.addUndoSnapshot(); } // Since some content is deleted, always set hasNewContent to true so that we will take undo snapshot next time this.state.hasNewContent = true; - this.lastKeyPress = evt.which; + this.state.lastKeyPress = evt.key; } - } else if (evt.which >= Keys.PAGEUP && evt.which <= Keys.DOWN) { + } else if (isCursorMovingKey(evt)) { // PageUp, PageDown, Home, End, Left, Right, Up, Down if (this.state.hasNewContent) { this.addUndoSnapshot(); } - this.lastKeyPress = 0; - } else if (this.lastKeyPress == Keys.BACKSPACE || this.lastKeyPress == Keys.DELETE) { + this.state.lastKeyPress = null; + } else if (this.state.lastKeyPress == Backspace || this.state.lastKeyPress == Delete) { if (this.state.hasNewContent) { this.addUndoSnapshot(); } } } - private onKeyPress(evt: KeyboardEvent): void { + private onKeyPress(editor: IStandaloneEditor, evt: KeyboardEvent): void { if (evt.metaKey) { // if metaKey is pressed, simply return since no actual effect will be taken on the editor. // this is to prevent changing hasNewContent to true when meta + v to paste on Safari. return; } - const range = this.editor?.getSelectionRange(); + const selection = editor.getDOMSelection(); + if ( - (range && !range.collapsed) || - (evt.which == Keys.SPACE && this.lastKeyPress != Keys.SPACE) || - evt.which == Keys.ENTER + (selection && (selection.type != 'range' || !selection.range.collapsed)) || + (evt.key == ' ' && this.state.lastKeyPress != ' ') || + evt.key == Enter ) { this.addUndoSnapshot(); - if (evt.which == Keys.ENTER) { + + if (evt.key == Enter) { // Treat ENTER as new content so if there is no input after ENTER and undo, // we restore the snapshot before ENTER this.state.hasNewContent = true; @@ -191,18 +192,18 @@ class UndoPlugin implements PluginWithState { this.clearRedoForInput(); } - this.lastKeyPress = evt.which; + this.state.lastKeyPress = evt.key; } private onBeforeKeyboardEditing(event: KeyboardEvent) { // For keyboard event (triggered from Content Model), we can get its keycode from event.data // And when user is keep pressing the same key, mark editor with "hasNewContent" so that next time user // do some other action or press a different key, we will add undo snapshot - if (event.which != this.lastKeyPress) { + if (event.key != this.state.lastKeyPress) { this.addUndoSnapshot(); } - this.lastKeyPress = event.which; + this.state.lastKeyPress = event.key; this.state.hasNewContent = true; } @@ -221,36 +222,33 @@ class UndoPlugin implements PluginWithState { private clearRedoForInput() { this.state.snapshotsService.clearRedo(); - this.lastKeyPress = 0; + this.state.lastKeyPress = null; this.state.hasNewContent = true; } - private canUndoAutoComplete() { - const focusedPosition = this.editor?.getFocusedPosition(); + private canUndoAutoComplete(editor: IStandaloneEditor) { + const selection = editor.getDOMSelection(); + return ( this.state.snapshotsService.canUndoAutoComplete() && - !!focusedPosition && - !!this.state.autoCompletePosition?.equalTo(focusedPosition) + selection?.type == 'range' && + selection.range.collapsed && + selection.range.startContainer == this.state.posContainer && + selection.range.startOffset == this.state.posOffset ); } private addUndoSnapshot() { this.editor?.addUndoSnapshot(); - this.state.autoCompletePosition = null; + this.state.posContainer = null; + this.state.posOffset = null; } -} -function createUndoSnapshots(): UndoSnapshotsService { - const snapshots = createSnapshots(MAX_SIZE_LIMIT); + private isCtrlOrMetaPressed(editor: IStandaloneEditor, event: KeyboardEvent) { + const env = editor.getEnvironment(); - return { - canMove: (delta: number): boolean => canMoveCurrentSnapshot(snapshots, delta), - move: (delta: number): Snapshot | null => moveCurrentSnapshot(snapshots, delta), - addSnapshot: (snapshot: Snapshot, isAutoCompleteSnapshot: boolean) => - addSnapshotV2(snapshots, snapshot, isAutoCompleteSnapshot), - clearRedo: () => clearProceedingSnapshotsV2(snapshots), - canUndoAutoComplete: () => canUndoAutoComplete(snapshots), - }; + return env.isMac ? event.metaKey : event.ctrlKey; + } } /** @@ -259,7 +257,7 @@ function createUndoSnapshots(): UndoSnapshotsService { * @param option The editor option */ export function createUndoPlugin( - option: ContentModelEditorOptions + option: StandaloneEditorOptions ): PluginWithState { return new UndoPlugin(option); } diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts index de2080bc6c3..9ecbed73c79 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/createStandaloneEditorCorePlugins.ts @@ -5,6 +5,7 @@ import { createDOMEventPlugin } from './DOMEventPlugin'; import { createEntityPlugin } from './EntityPlugin'; import { createLifecyclePlugin } from './LifecyclePlugin'; import { createSelectionPlugin } from './SelectionPlugin'; +import { createUndoPlugin } from './UndoPlugin'; import type { StandaloneEditorCorePlugins, StandaloneEditorOptions, @@ -27,5 +28,6 @@ export function createStandaloneEditorCorePlugins( lifecycle: createLifecyclePlugin(options, contentDiv), entity: createEntityPlugin(), selection: createSelectionPlugin(options), + undo: createUndoPlugin(options), }; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/UndoSnapshotsServiceImpl.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/UndoSnapshotsServiceImpl.ts new file mode 100644 index 00000000000..d540593dfcb --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/UndoSnapshotsServiceImpl.ts @@ -0,0 +1,115 @@ +import type { Snapshot } from 'roosterjs-content-model-types'; +import type { Snapshots, UndoSnapshotsService } from 'roosterjs-editor-types'; + +// Max stack size that cannot be exceeded. When exceeded, old undo history will be dropped +// to keep size under limit. This is kept at 10MB +const MAX_SIZE_LIMIT = 1e7; + +class UndoSnapshotsServiceImpl implements UndoSnapshotsService { + private snapshots: Snapshots; + + constructor(snapshots?: Snapshots) { + this.snapshots = snapshots ?? { + snapshots: [], + totalSize: 0, + currentIndex: -1, + autoCompleteIndex: -1, + maxSize: MAX_SIZE_LIMIT, + }; + } + + canMove(step: number): boolean { + const newIndex = this.snapshots.currentIndex + step; + return newIndex >= 0 && newIndex < this.snapshots.snapshots.length; + } + + move(step: number): Snapshot | null { + if (this.canMove(step)) { + this.snapshots.currentIndex += step; + this.snapshots.autoCompleteIndex = -1; + return this.snapshots.snapshots[this.snapshots.currentIndex]; + } else { + return null; + } + } + + addSnapshot(snapshot: Snapshot, isAutoCompleteSnapshot: boolean): void { + const currentSnapshot = this.snapshots.snapshots[this.snapshots.currentIndex]; + const isSameSnapshot = + currentSnapshot && + currentSnapshot.html == snapshot.html && + !currentSnapshot.entityStates && + !snapshot.entityStates; + + if (this.snapshots.currentIndex < 0 || !currentSnapshot || !isSameSnapshot) { + this.clearRedo(); + this.snapshots.snapshots.push(snapshot); + this.snapshots.currentIndex++; + this.snapshots.totalSize += this.getSnapshotLength(snapshot); + + let removeCount = 0; + while ( + removeCount < this.snapshots.snapshots.length && + this.snapshots.totalSize > this.snapshots.maxSize + ) { + this.snapshots.totalSize -= this.getSnapshotLength( + this.snapshots.snapshots[removeCount] + ); + removeCount++; + } + + if (removeCount > 0) { + this.snapshots.snapshots.splice(0, removeCount); + this.snapshots.currentIndex -= removeCount; + + if (this.snapshots.autoCompleteIndex >= 0) { + this.snapshots.autoCompleteIndex -= removeCount; + } + } + + if (isAutoCompleteSnapshot) { + this.snapshots.autoCompleteIndex = this.snapshots.currentIndex; + } + } else if (isSameSnapshot) { + // replace the currentSnapshot's metadata so the selection is updated + this.snapshots.snapshots.splice(this.snapshots.currentIndex, 1, snapshot); + } + } + + clearRedo(): void { + if (this.canMove(1)) { + let removedSize = 0; + for ( + let i = this.snapshots.currentIndex + 1; + i < this.snapshots.snapshots.length; + i++ + ) { + removedSize += this.getSnapshotLength(this.snapshots.snapshots[i]); + } + + this.snapshots.snapshots.splice(this.snapshots.currentIndex + 1); + this.snapshots.totalSize -= removedSize; + this.snapshots.autoCompleteIndex = -1; + } + } + + canUndoAutoComplete(): boolean { + return ( + this.snapshots.autoCompleteIndex >= 0 && + this.snapshots.currentIndex - this.snapshots.autoCompleteIndex == 1 + ); + } + + private getSnapshotLength(snapshot: Snapshot) { + return snapshot.html?.length ?? 0; + } +} + +/** + * @internal + */ +export function createUndoSnapshotsService( + snapshots?: Snapshots +): UndoSnapshotsService { + return new UndoSnapshotsServiceImpl(snapshots); +} diff --git a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts index c561fa1b6a1..103b22a98d5 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/editor/createStandaloneEditorCore.ts @@ -39,6 +39,7 @@ export function createStandaloneEditorCore( corePlugins.selection, corePlugins.entity, ...tempPlugins, + corePlugins.undo, corePlugins.lifecycle, ], environment: createEditorEnvironment(), @@ -83,6 +84,7 @@ function getPluginState(corePlugins: StandaloneEditorCorePlugins): StandaloneEdi lifecycle: corePlugins.lifecycle.getState(), entity: corePlugins.entity.getState(), selection: corePlugins.selection.getState(), + undo: corePlugins.undo.getState(), }; } diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/eventUtils.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/eventUtils.ts index adf658532ca..9afba07973d 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/eventUtils.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/domUtils/eventUtils.ts @@ -2,6 +2,17 @@ const CTRL_CHAR_CODE = 'Control'; const ALT_CHAR_CODE = 'Alt'; const META_CHAR_CODE = 'Meta'; +const CursorMovingKeys = new Set([ + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', + 'PageUp', + 'PageDown', +]); + /** * Returns true when the event was fired from a modifier key, otherwise false * @param event The keyboard event object @@ -24,3 +35,13 @@ export function isModifierKey(event: KeyboardEvent): boolean { export function isCharacterValue(event: KeyboardEvent): boolean { return !isModifierKey(event) && !!event.key && event.key.length == 1; } + +/** + * @internal + * Returns true if the given event is a cursor moving event (Left, Right, Up, Down, Home, End, Page Up, Page Down). + * This does not check modifier keys (Ctrl, Alt, Meta). So if there are modifier keys pressed, it can still return true if one of the modifier key is pressed + * @param event The keyboard event to check + */ +export function isCursorMovingKey(event: KeyboardEvent): boolean { + return CursorMovingKeys.has(event.key); +} diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts index f303a9a430e..15b6a037d22 100644 --- a/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts @@ -95,6 +95,7 @@ describe('EntityPlugin', () => { isReadonly: true, wrapper: wrapper, }, + state: undefined, }); expect(transformToDarkColorSpy).not.toHaveBeenCalled(); }); @@ -137,6 +138,7 @@ describe('EntityPlugin', () => { isReadonly: true, wrapper: wrapper, }, + state: undefined, }); expect(transformToDarkColorSpy).not.toHaveBeenCalled(); }); @@ -178,6 +180,7 @@ describe('EntityPlugin', () => { isReadonly: true, wrapper: wrapper, }, + state: undefined, }); expect(transformToDarkColorSpy).not.toHaveBeenCalled(); }); @@ -218,6 +221,7 @@ describe('EntityPlugin', () => { isReadonly: true, wrapper: wrapper, }, + state: undefined, }); expect(transformToDarkColorSpy).toHaveBeenCalledTimes(1); expect(transformToDarkColorSpy).toHaveBeenCalledWith( @@ -272,6 +276,7 @@ describe('EntityPlugin', () => { isReadonly: true, wrapper: wrapper, }, + state: undefined, }); expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { operation: EntityOperation.Overwrite, @@ -282,6 +287,7 @@ describe('EntityPlugin', () => { isReadonly: true, wrapper: wrapper2, }, + state: undefined, }); expect(transformToDarkColorSpy).not.toHaveBeenCalled(); }); @@ -356,6 +362,7 @@ describe('EntityPlugin', () => { isReadonly: true, wrapper: wrapper, }, + state: undefined, }); expect(transformToDarkColorSpy).not.toHaveBeenCalled(); }); @@ -420,6 +427,7 @@ describe('EntityPlugin', () => { isReadonly: true, wrapper: wrapper2, }, + state: undefined, }); expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { operation: EntityOperation.RemoveFromStart, @@ -430,6 +438,7 @@ describe('EntityPlugin', () => { isReadonly: true, wrapper: wrapper1, }, + state: undefined, }); expect(transformToDarkColorSpy).not.toHaveBeenCalled(); }); @@ -487,9 +496,55 @@ describe('EntityPlugin', () => { isReadonly: true, wrapper: wrapper2, }, + state: undefined, }); expect(transformToDarkColorSpy).not.toHaveBeenCalled(); }); + + it('With content state', () => { + const id = 'ID'; + const entityType = 'Entity1'; + const entityState = 'STATE'; + const state = plugin.getState(); + const wrapper = document.createElement('div'); + const entity = createEntity(wrapper, true, undefined, entityType, id); + const doc = createContentModelDocument(); + + wrapper.className = entityUtils.generateEntityClassNames({ + entityType, + id: id, + isReadonly: true, + }); + doc.blocks.push(entity); + createContentModelSpy.and.returnValue(doc); + + state.entityMap[id] = { + element: wrapper, + }; + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + entityStates: [ + { + id, + state: entityState, + }, + ], + } as any); + + expect(triggerPluginEventSpy).toHaveBeenCalledTimes(1); + expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + operation: EntityOperation.UpdateEntityState, + rawEvent: undefined, + entity: { + id, + type: entityType, + isReadonly: true, + wrapper, + }, + state: entityState, + }); + }); }); describe('MouseUp event', () => { @@ -540,6 +595,7 @@ describe('EntityPlugin', () => { isReadonly: false, wrapper: mockedNode, }, + state: undefined, }); }); @@ -575,6 +631,7 @@ describe('EntityPlugin', () => { isReadonly: false, wrapper: mockedNode1, }, + state: undefined, }); }); @@ -634,6 +691,7 @@ describe('EntityPlugin', () => { isReadonly: true, wrapper: wrapper1, }, + state: undefined, }); expect(triggerPluginEventSpy).toHaveBeenCalledWith(PluginEventType.EntityOperation, { operation: EntityOperation.ReplaceTemporaryContent, @@ -644,6 +702,7 @@ describe('EntityPlugin', () => { isReadonly: true, wrapper: wrapper2, }, + state: undefined, }); }); }); diff --git a/packages-content-model/roosterjs-content-model-core/test/corePlugin/UndoPluginTest.ts b/packages-content-model/roosterjs-content-model-core/test/corePlugin/UndoPluginTest.ts new file mode 100644 index 00000000000..20818b1632e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/corePlugin/UndoPluginTest.ts @@ -0,0 +1,978 @@ +import * as createUndoSnapshotsService from '../../lib/editor/UndoSnapshotsServiceImpl'; +import { ChangeSource } from '../../lib/constants/ChangeSource'; +import { createUndoPlugin } from '../../lib/corePlugin/UndoPlugin'; +import { IStandaloneEditor, Snapshot, UndoPluginState } from 'roosterjs-content-model-types'; +import { + IEditor, + PluginEventType, + PluginWithState, + UndoSnapshotsService, +} from 'roosterjs-editor-types'; + +describe('UndoPlugin', () => { + let editor: IEditor & IStandaloneEditor; + let createUndoSnapshotsServiceSpy: jasmine.Spy; + let getDOMSelectionSpy: jasmine.Spy; + let canUndoAutoCompleteSpy: jasmine.Spy; + let isInIMESpy: jasmine.Spy; + let getUndoStateSpy: jasmine.Spy; + let addUndoSnapshotSpy: jasmine.Spy; + let undoSpy: jasmine.Spy; + let clearRedoSpy: jasmine.Spy; + let mockedUndoSnapshotsService: UndoSnapshotsService; + + beforeEach(() => { + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + canUndoAutoCompleteSpy = jasmine.createSpy('canUndoAutoComplete'); + isInIMESpy = jasmine.createSpy('isInIME'); + getUndoStateSpy = jasmine.createSpy('getUndoState'); + addUndoSnapshotSpy = jasmine.createSpy('addUndoSnapshot'); + undoSpy = jasmine.createSpy('undo'); + clearRedoSpy = jasmine.createSpy('clearRedo'); + + mockedUndoSnapshotsService = { + canUndoAutoComplete: canUndoAutoCompleteSpy, + clearRedo: clearRedoSpy, + } as any; + + createUndoSnapshotsServiceSpy = spyOn( + createUndoSnapshotsService, + 'createUndoSnapshotsService' + ).and.returnValue(mockedUndoSnapshotsService); + + editor = { + getDOMSelection: getDOMSelectionSpy, + isInIME: isInIMESpy, + getUndoState: getUndoStateSpy, + addUndoSnapshot: addUndoSnapshotSpy, + undo: undoSpy, + } as any; + }); + + describe('Ctor', () => { + it('ctor without option', () => { + const plugin = createUndoPlugin({}); + const state = plugin.getState(); + + expect(state).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: false, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: null, + }); + expect(createUndoSnapshotsServiceSpy).toHaveBeenCalledWith(); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('ctor with option', () => { + const mockedService = 'SERVICE' as any; + const plugin = createUndoPlugin({ + undoSnapshotService: mockedService, + }); + const state = plugin.getState(); + + expect(state).toEqual({ + snapshotsService: mockedService, + isRestoring: false, + hasNewContent: false, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: null, + }); + expect(createUndoSnapshotsServiceSpy).not.toHaveBeenCalled(); + expect(undoSpy).not.toHaveBeenCalled(); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + }); + + describe('willHandleEventExclusively', () => { + let plugin: PluginWithState; + + beforeEach(() => { + plugin = createUndoPlugin({}); + plugin.initialize(editor); + }); + + afterEach(() => { + plugin.dispose(); + }); + + it('Not handled exclusively for EditorReady event', () => { + const result = plugin.willHandleEventExclusively({ + eventType: PluginEventType.EditorReady, + }); + + expect(result).toBeFalse(); + expect(undoSpy).not.toHaveBeenCalled(); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('Not handled exclusively for ContentChanged event', () => { + const result = plugin.willHandleEventExclusively({ + eventType: PluginEventType.ContentChanged, + } as any); + + expect(result).toBeFalse(); + expect(undoSpy).not.toHaveBeenCalled(); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('Not handled exclusively for MouseDown event', () => { + const result = plugin.willHandleEventExclusively({ + eventType: PluginEventType.MouseDown, + } as any); + + expect(result).toBeFalse(); + expect(undoSpy).not.toHaveBeenCalled(); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('Not handled exclusively for KeyDown event with Enter key', () => { + const result = plugin.willHandleEventExclusively({ + eventType: PluginEventType.KeyDown, + rawEvent: { + key: 'Enter', + }, + } as any); + + expect(result).toBeFalse(); + expect(undoSpy).not.toHaveBeenCalled(); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('Not handled exclusively for KeyDown event with Ctrl key', () => { + const result = plugin.willHandleEventExclusively({ + eventType: PluginEventType.KeyDown, + rawEvent: { + key: 'Backspace', + ctrlKey: true, + }, + } as any); + + expect(result).toBeFalse(); + expect(undoSpy).not.toHaveBeenCalled(); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('Not handled exclusively for KeyDown event, canUndoAutoComplete returns false', () => { + canUndoAutoCompleteSpy.and.returnValue(false); + + const result = plugin.willHandleEventExclusively({ + eventType: PluginEventType.KeyDown, + rawEvent: { + key: 'Backspace', + }, + } as any); + + expect(result).toBeFalse(); + expect(undoSpy).not.toHaveBeenCalled(); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('Not handled exclusively for KeyDown event, selection is not range selection', () => { + canUndoAutoCompleteSpy.and.returnValue(true); + getDOMSelectionSpy.and.returnValue({ + type: 'image', + }); + + const result = plugin.willHandleEventExclusively({ + eventType: PluginEventType.KeyDown, + rawEvent: { + key: 'Backspace', + }, + } as any); + + expect(result).toBeFalse(); + expect(undoSpy).not.toHaveBeenCalled(); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('Not handled exclusively for KeyDown event, selection is not collapsed', () => { + canUndoAutoCompleteSpy.and.returnValue(true); + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: false, + }, + }); + + const result = plugin.willHandleEventExclusively({ + eventType: PluginEventType.KeyDown, + rawEvent: { + key: 'Backspace', + }, + } as any); + + expect(result).toBeFalse(); + expect(undoSpy).not.toHaveBeenCalled(); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('Not handled exclusively for KeyDown event, selection is not the same', () => { + const state = plugin.getState(); + + state.posContainer = 'P1' as any; + state.posOffset = 'O1' as any; + + canUndoAutoCompleteSpy.and.returnValue(true); + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: true, + startContainer: 'P2' as any, + startOffset: 'O2' as any, + }, + }); + + const result = plugin.willHandleEventExclusively({ + eventType: PluginEventType.KeyDown, + rawEvent: { + key: 'Backspace', + }, + } as any); + + expect(result).toBeFalse(); + expect(undoSpy).not.toHaveBeenCalled(); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('Handled exclusively for KeyDown event', () => { + const state = plugin.getState(); + + state.posContainer = 'P1' as any; + state.posOffset = 'O1' as any; + + canUndoAutoCompleteSpy.and.returnValue(true); + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: true, + startContainer: 'P1' as any, + startOffset: 'O1' as any, + }, + }); + + const result = plugin.willHandleEventExclusively({ + eventType: PluginEventType.KeyDown, + rawEvent: { + key: 'Backspace', + }, + } as any); + + expect(result).toBeTrue(); + expect(undoSpy).not.toHaveBeenCalled(); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + }); + + describe('onPluginEvent', () => { + let plugin: PluginWithState; + + beforeEach(() => { + plugin = createUndoPlugin({}); + plugin.initialize(editor); + }); + + afterEach(() => { + plugin.dispose(); + }); + + it('EditorReady event, no undo/redo', () => { + getUndoStateSpy.and.returnValue({ + canUndo: false, + canRedo: false, + }); + + plugin.onPluginEvent({ + eventType: PluginEventType.EditorReady, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: false, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: null, + }); + expect(undoSpy).not.toHaveBeenCalled(); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('EditorReady event, has undo', () => { + getUndoStateSpy.and.returnValue({ + canUndo: true, + canRedo: false, + }); + + plugin.onPluginEvent({ + eventType: PluginEventType.EditorReady, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: false, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: null, + }); + expect(undoSpy).not.toHaveBeenCalled(); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('EditorReady event, has redo', () => { + getUndoStateSpy.and.returnValue({ + canUndo: true, + canRedo: false, + }); + + plugin.onPluginEvent({ + eventType: PluginEventType.EditorReady, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: false, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: null, + }); + expect(undoSpy).not.toHaveBeenCalled(); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('KeyDown event, Backspace, no ALT key, no CTRL key: undo auto complete', () => { + canUndoAutoCompleteSpy.and.returnValue(true); + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: true, + startContainer: 'C1', + startOffset: 'O1', + }, + }); + + const state = plugin.getState(); + + state.posContainer = 'C1' as any; + state.posOffset = 'O1' as any; + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent: { + key: 'Backspace', + altKey: false, + ctrlKey: false, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(0); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(undoSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: false, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: 'Backspace', + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('KeyDown event, Backspace, expanded range do not undo auto complete', () => { + canUndoAutoCompleteSpy.and.returnValue(false); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + getDOMSelectionSpy.and.returnValue({ + type: 'image', + }); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent: { + key: 'Backspace', + altKey: false, + ctrlKey: false, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: true, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: 'Backspace', + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('KeyDown event, Delete', () => { + canUndoAutoCompleteSpy.and.returnValue(false); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent: { + key: 'Delete', + altKey: false, + ctrlKey: false, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(0); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: true, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: 'Delete', + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('KeyDown event, Up', () => { + canUndoAutoCompleteSpy.and.returnValue(false); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent: { + key: 'Up', + altKey: false, + ctrlKey: false, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(0); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: false, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: null, + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('KeyDown event, Up, has new content', () => { + canUndoAutoCompleteSpy.and.returnValue(false); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const state = plugin.getState(); + + state.hasNewContent = true; + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent: { + key: 'Up', + altKey: false, + ctrlKey: false, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(0); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: true, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: null, + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('KeyDown event, other key, has new content, lastKey is Backspace', () => { + canUndoAutoCompleteSpy.and.returnValue(false); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const state = plugin.getState(); + + state.hasNewContent = true; + state.lastKeyPress = 'Backspace'; + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent: { + key: 'A', + altKey: false, + ctrlKey: false, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: true, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: 'Backspace', + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('KeyDown event, Delete key, defaultPrevented', () => { + canUndoAutoCompleteSpy.and.returnValue(false); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyDown, + rawEvent: { + key: 'A', + altKey: false, + ctrlKey: false, + preventDefault: preventDefaultSpy, + defaultPrevented: true, + } as any, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(0); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: false, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: null, + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('KeyPress event, Enter key, expanded selection', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: false, + }, + }); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyPress, + rawEvent: { + key: 'Enter', + altKey: false, + ctrlKey: false, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: true, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: 'Enter', + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('KeyPress event, other key, expanded selection', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + collapsed: false, + }, + }); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyPress, + rawEvent: { + key: 'A', + altKey: false, + ctrlKey: false, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: false, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: 'A', + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('KeyPress event, Space key, last key is not space', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const state = plugin.getState(); + + state.lastKeyPress = 'A'; + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyPress, + rawEvent: { + key: ' ', + altKey: false, + ctrlKey: false, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: false, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: ' ', + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('KeyPress event, Space key, last key is space', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const state = plugin.getState(); + + state.lastKeyPress = ' '; + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyPress, + rawEvent: { + key: ' ', + altKey: false, + ctrlKey: false, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(0); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: true, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: ' ', + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(1); + }); + + it('KeyPress event, Enter key', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyPress, + rawEvent: { + key: 'Enter', + altKey: false, + ctrlKey: false, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: true, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: 'Enter', + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('KeyPress event, other key', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + plugin.onPluginEvent({ + eventType: PluginEventType.KeyPress, + rawEvent: { + key: 'A', + altKey: false, + ctrlKey: false, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(0); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: true, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: 'A', + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(1); + }); + + it('CompositionEnd event', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + plugin.onPluginEvent({ + eventType: PluginEventType.CompositionEnd, + rawEvent: { + key: 'Test', + }, + } as any); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: true, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: null, + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(1); + }); + + it('onContentChanged event, isRestoring, other source', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const state = plugin.getState(); + + state.isRestoring = true; + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + source: 'Test', + } as any); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(0); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: true, + hasNewContent: false, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: null, + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('onContentChanged event, SwitchToDarkMode', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + source: ChangeSource.SwitchToDarkMode, + } as any); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(0); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: false, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: null, + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('onContentChanged event, SwitchToLightMode', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + source: ChangeSource.SwitchToLightMode, + } as any); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(0); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: false, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: null, + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('onContentChanged event, Keyboard', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + source: ChangeSource.Keyboard, + } as any); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(0); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: false, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: null, + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('onContentChanged event, other source', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + plugin.onPluginEvent({ + eventType: PluginEventType.ContentChanged, + source: 'Test', + } as any); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(0); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: true, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: null, + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(1); + }); + + it('BeforeKeyboardEditing event, key is not same', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const state = plugin.getState(); + + state.lastKeyPress = 'A'; + + plugin.onPluginEvent({ + eventType: PluginEventType.BeforeKeyboardEditing, + rawEvent: { + key: 'B', + }, + } as any); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(1); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: true, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: 'B', + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + + it('BeforeKeyboardEditing event, key is the same', () => { + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const state = plugin.getState(); + + state.lastKeyPress = 'A'; + + plugin.onPluginEvent({ + eventType: PluginEventType.BeforeKeyboardEditing, + rawEvent: { + key: 'A', + }, + } as any); + + expect(addUndoSnapshotSpy).toHaveBeenCalledTimes(0); + expect(preventDefaultSpy).toHaveBeenCalledTimes(0); + expect(undoSpy).toHaveBeenCalledTimes(0); + expect(plugin.getState()).toEqual({ + snapshotsService: mockedUndoSnapshotsService, + isRestoring: false, + hasNewContent: true, + isNested: false, + posContainer: null, + posOffset: null, + lastKeyPress: 'A', + }); + expect(clearRedoSpy).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-core/test/editor/UndoSnapshotsServiceImplTest.ts b/packages-content-model/roosterjs-content-model-core/test/editor/UndoSnapshotsServiceImplTest.ts new file mode 100644 index 00000000000..7fa985b1410 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-core/test/editor/UndoSnapshotsServiceImplTest.ts @@ -0,0 +1,641 @@ +import { createUndoSnapshotsService } from '../../lib/editor/UndoSnapshotsServiceImpl'; +import { Snapshot } from 'roosterjs-content-model-types'; +import { Snapshots, UndoSnapshotsService } from 'roosterjs-editor-types'; + +describe('UndoSnapshotsServiceImpl.ctor', () => { + it('No param', () => { + const service = createUndoSnapshotsService(); + + expect((service as any).snapshots).toEqual({ + snapshots: [], + totalSize: 0, + currentIndex: -1, + autoCompleteIndex: -1, + maxSize: 1e7, + }); + }); + + it('Has param', () => { + const mockedSnapshots = 'SNAPSHOTS' as any; + const service = createUndoSnapshotsService(mockedSnapshots); + + expect((service as any).snapshots).toEqual(mockedSnapshots); + }); +}); + +describe('UndoSnapshotsServiceImpl.addSnapshot', () => { + let service: UndoSnapshotsService; + let snapshots: Snapshots; + + beforeEach(() => { + snapshots = { + snapshots: [], + totalSize: 0, + currentIndex: -1, + autoCompleteIndex: -1, + maxSize: 1e7, + }; + service = createUndoSnapshotsService(snapshots); + }); + + function runTest( + maxSize: number, + action: () => void, + currentIndex: number, + totalSize: number, + snapshotArray: Snapshot[], + autoCompleteIndex: number + ) { + (snapshots as any).maxSize = maxSize; + + action(); + expect(snapshots.currentIndex).toBe(currentIndex); + expect(snapshots.totalSize).toBe(totalSize); + expect(snapshots.snapshots).toEqual(snapshotArray); + expect(snapshots.autoCompleteIndex).toBe(autoCompleteIndex); + } + + it('Add first snapshot', () => { + runTest( + 100, + () => { + service.addSnapshot( + { + html: 'test', + knownColors: [], + metadata: {} as any, + }, + false + ); + }, + 0, + 4, + [{ html: 'test', knownColors: [], metadata: {} as any }], + -1 + ); + }); + + it('Add snapshot as autoComplete', () => { + runTest( + 100, + () => { + service.addSnapshot( + { + html: 'test', + knownColors: [], + metadata: {} as any, + }, + true + ); + }, + 0, + 4, + [{ html: 'test', knownColors: [], metadata: {} as any }], + 0 + ); + }); + + it('Add second snapshot', () => { + runTest( + 100, + () => { + service.addSnapshot( + { + html: 'test1', + knownColors: [], + metadata: {} as any, + }, + false + ); + service.addSnapshot( + { + html: 'test2', + knownColors: [], + metadata: {} as any, + }, + false + ); + }, + 1, + 10, + [ + { html: 'test1', knownColors: [], metadata: {} as any }, + { html: 'test2', knownColors: [], metadata: {} as any }, + ], + -1 + ); + }); + + it('Add oversize snapshot', () => { + runTest( + 5, + () => { + service.addSnapshot( + { + html: 'test01', + knownColors: [], + metadata: {} as any, + }, + false + ); + }, + -1, + 0, + [], + -1 + ); + }); + + it('Add snapshot that need to remove existing one because over size', () => { + runTest( + 5, + () => { + service.addSnapshot( + { + html: 'test1', + knownColors: [], + metadata: {} as any, + }, + false + ); + service.addSnapshot( + { + html: 'test2', + knownColors: [], + metadata: {} as any, + }, + false + ); + }, + 0, + 5, + [ + { + html: 'test2', + knownColors: [], + metadata: {} as any, + }, + ], + -1 + ); + }); + + it('Add snapshot that need to remove proceeding snapshots', () => { + runTest( + 100, + () => { + service.addSnapshot( + { + html: 'test1', + knownColors: [], + metadata: {} as any, + }, + false + ); + service.addSnapshot( + { + html: 'test2', + knownColors: [], + metadata: {} as any, + }, + false + ); + snapshots.currentIndex = 0; + service.addSnapshot( + { + html: 'test03', + knownColors: [], + metadata: {} as any, + }, + false + ); + }, + 1, + 11, + [ + { + html: 'test1', + knownColors: [], + metadata: {} as any, + }, + { + html: 'test03', + knownColors: [], + metadata: {} as any, + }, + ], + -1 + ); + }); + + it('Add identical snapshot', () => { + runTest( + 100, + () => { + service.addSnapshot( + { + html: 'test1', + knownColors: [], + metadata: {} as any, + }, + false + ); + service.addSnapshot( + { + html: 'test1', + knownColors: [], + metadata: {} as any, + }, + false + ); + }, + 0, + 5, + [ + { + html: 'test1', + knownColors: [], + metadata: {} as any, + }, + ], + -1 + ); + }); + + it('Add snapshot with entity state', () => { + const mockedMetadata = 'METADATA' as any; + const mockedEntityStates = 'ENTITYSTATES' as any; + + service.addSnapshot( + { + html: 'test', + metadata: null, + knownColors: [], + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + metadata: null, + knownColors: [], + }, + ]); + + service.addSnapshot( + { + html: 'test', + metadata: mockedMetadata, + knownColors: [], + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + metadata: mockedMetadata, + knownColors: [], + }, + ]); + + service.addSnapshot( + { + html: 'test', + metadata: null, + knownColors: [], + entityStates: mockedEntityStates, + }, + false + ); + + expect(snapshots.snapshots).toEqual([ + { + html: 'test', + metadata: mockedMetadata, + knownColors: [], + }, + { + html: 'test', + metadata: null, + knownColors: [], + entityStates: mockedEntityStates, + }, + ]); + }); +}); + +describe('UndoSnapshotsServiceImpl.canMove', () => { + let service: UndoSnapshotsService; + let snapshots: Snapshots; + + beforeEach(() => { + snapshots = { + snapshots: [], + totalSize: 0, + currentIndex: -1, + autoCompleteIndex: -1, + maxSize: 100, + }; + service = createUndoSnapshotsService(snapshots); + }); + + function runTest( + currentIndex: number, + snapshotArray: string[], + result1: boolean, + resultMinus1: boolean, + result2: boolean, + resultMinus2: boolean, + result5: boolean, + resultMinus5: boolean + ) { + snapshots.currentIndex = currentIndex; + snapshots.totalSize = snapshotArray.reduce((v, s) => { + v += s.length; + return v; + }, 0); + + snapshots.snapshots = snapshotArray.map( + x => + ({ + html: x, + } as any) + ); + + expect(service.canMove(1)).toBe(result1, 'Move with 1'); + expect(service.canMove(-1)).toBe(resultMinus1, 'Move with -1'); + expect(service.canMove(2)).toBe(result2, 'Move with 2'); + expect(service.canMove(-2)).toBe(resultMinus2, 'Move with -2'); + expect(service.canMove(5)).toBe(result5, 'Move with 5'); + expect(service.canMove(-5)).toBe(resultMinus5, 'Move with -5'); + } + + it('Empty snapshots', () => { + runTest(-1, [], false, false, false, false, false, false); + }); + + it('One snapshots, start from -1', () => { + runTest(-1, ['test1'], true, false, false, false, false, false); + }); + + it('One snapshots, start from 0', () => { + runTest(0, ['test1'], false, false, false, false, false, false); + }); + + it('One snapshots, start from 1', () => { + runTest(1, ['test1'], false, true, false, false, false, false); + }); + + it('Two snapshots, start from 0', () => { + runTest(0, ['test1', 'test2'], true, false, false, false, false, false); + }); + + it('Two snapshots, start from 1', () => { + runTest(1, ['test1', 'test2'], false, true, false, false, false, false); + }); + + it('10 snapshots, start from 0', () => { + runTest( + 0, + [ + 'test1', + 'test2', + 'test3', + 'test4', + 'test5', + 'test1', + 'test2', + 'test3', + 'test4', + 'test5', + ], + true, + false, + true, + false, + true, + false + ); + }); + + it('10 snapshots, start from 5', () => { + runTest( + 5, + [ + 'test1', + 'test2', + 'test3', + 'test4', + 'test5', + 'test1', + 'test2', + 'test3', + 'test4', + 'test5', + ], + true, + true, + true, + true, + false, + true + ); + }); +}); + +describe('UndoSnapshotsServiceImpl.move', () => { + let service: UndoSnapshotsService; + let snapshots: Snapshots; + + beforeEach(() => { + snapshots = { + snapshots: [], + totalSize: 0, + currentIndex: -1, + autoCompleteIndex: -1, + maxSize: 100, + }; + service = createUndoSnapshotsService(snapshots); + }); + + function runTest( + currentIndex: number, + snapshotArray: string[], + step: number, + expectedIndex: number, + expectedSnapshot: string | null + ) { + snapshots.currentIndex = currentIndex; + snapshots.totalSize = snapshotArray.reduce((v, s) => { + v += s.length; + return v; + }, 0); + + snapshots.snapshots = snapshotArray.map( + x => + ({ + html: x, + } as any) + ); + + const result = service.move(step); + + expect(snapshots.currentIndex).toEqual(expectedIndex); + + if (expectedSnapshot) { + expect(result!.html).toBe(expectedSnapshot); + } else { + expect(result).toBeNull(); + } + } + + it('Empty snapshots', () => { + runTest(-1, [], 0, -1, null); + }); + + it('One snapshots, start from -1', () => { + runTest(-1, ['test1'], 1, 0, 'test1'); + }); + + it('One snapshots, start from 0, move 0', () => { + runTest(0, ['test1'], 0, 0, 'test1'); + }); + + it('One snapshots, start from 0, move 1', () => { + runTest(0, ['test1'], 1, 0, null); + }); + + it('One snapshots, start from 0, move -1', () => { + runTest(0, ['test1'], -1, 0, null); + }); + + it('Two snapshots, start from 0, move 1', () => { + runTest(0, ['test1', 'test2'], 1, 1, 'test2'); + }); + + it('Two snapshots, start from 0, move -1', () => { + runTest(0, ['test1', 'test2'], -1, 0, null); + }); + + it('Two snapshots, start from 1, move -1', () => { + runTest(1, ['test1', 'test2'], -1, 0, 'test1'); + }); + + it('3 snapshots, start from 1, move 2', () => { + runTest(1, ['test1', 'test2', 'test3'], 2, 1, null); + }); +}); + +describe('UndoSnapshotsServiceImpl.clearRedo', () => { + let service: UndoSnapshotsService; + let snapshots: Snapshots; + + beforeEach(() => { + snapshots = { + snapshots: [], + totalSize: 0, + currentIndex: -1, + autoCompleteIndex: -1, + maxSize: 100, + }; + service = createUndoSnapshotsService(snapshots); + }); + + function runTest( + currentIndex: number, + snapshotArray: string[], + expectedSize: number, + expectedArray: string[] + ) { + snapshots.currentIndex = currentIndex; + snapshots.totalSize = snapshotArray.reduce((v, s) => { + v += s.length; + return v; + }, 0); + + snapshots.snapshots = snapshotArray.map( + x => + ({ + html: x, + } as any) + ); + + service.clearRedo(); + + expect(snapshots.snapshots).toEqual( + expectedArray.map( + x => + ({ + html: x, + } as any) + ) + ); + expect(snapshots.totalSize).toBe(expectedSize); + } + + it('Empty snapshots', () => { + runTest(-1, [], 0, []); + }); + + it('One snapshots, start from -1', () => { + runTest(-1, ['test1'], 0, []); + }); + + it('One snapshots, start from 0', () => { + runTest(0, ['test1'], 5, ['test1']); + }); + + it('Two snapshots, start from 0', () => { + runTest(0, ['test1', 'test2'], 5, ['test1']); + }); + + it('Two snapshots, start from 1', () => { + runTest(1, ['test1', 'test2'], 10, ['test1', 'test2']); + }); +}); + +describe('UndoSnapshotsServiceImpl.canUndoAutoComplete', () => { + let service: UndoSnapshotsService; + let snapshots: Snapshots; + + beforeEach(() => { + snapshots = { + snapshots: [], + totalSize: 0, + currentIndex: -1, + autoCompleteIndex: -1, + maxSize: 100, + }; + service = createUndoSnapshotsService(snapshots); + }); + + it('can undo', () => { + snapshots.autoCompleteIndex = 1; + snapshots.currentIndex = 2; + + expect(service.canUndoAutoComplete()).toBeTrue(); + }); + + it('cannot undo - 1', () => { + snapshots.autoCompleteIndex = 1; + snapshots.currentIndex = 3; + + expect(service.canUndoAutoComplete()).toBeFalse(); + }); + + it('cannot undo - 2', () => { + snapshots.autoCompleteIndex = 1; + snapshots.currentIndex = 1; + + expect(service.canUndoAutoComplete()).toBeFalse(); + }); + + it('cannot undo - 3', () => { + snapshots.autoCompleteIndex = -1; + snapshots.currentIndex = 0; + + expect(service.canUndoAutoComplete()).toBeFalse(); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts index 1fd5865cba6..974fcc4c8ab 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts @@ -70,7 +70,8 @@ export const addUndoSnapshot: AddUndoSnapshot = ( if (selection?.type == 'range') { core.undo.hasNewContent = false; - core.undo.autoCompletePosition = Position.getStart(selection.range); + core.undo.posContainer = selection.range.startContainer; + core.undo.posOffset = selection.range.startOffset; } } }; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts index fbb0276c7f1..5b7706a9834 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/corePlugins/createCorePlugins.ts @@ -1,7 +1,6 @@ import { createEditPlugin } from './EditPlugin'; import { createEventTypeTranslatePlugin } from './EventTypeTranslatePlugin'; import { createNormalizeTablePlugin } from './NormalizeTablePlugin'; -import { createUndoPlugin } from './UndoPlugin'; import type { UnportedCorePlugins } from '../publicTypes/ContentModelCorePlugins'; import type { UnportedCorePluginState } from 'roosterjs-content-model-types'; import type { ContentModelEditorOptions } from '../publicTypes/IContentModelEditor'; @@ -19,7 +18,6 @@ export function createCorePlugins(options: ContentModelEditorOptions): UnportedC return { eventTranslate: map.eventTranslate || createEventTypeTranslatePlugin(), edit: map.edit || createEditPlugin(), - undo: map.undo || createUndoPlugin(options), normalizeTable: map.normalizeTable || createNormalizeTablePlugin(), }; } @@ -32,6 +30,5 @@ export function createCorePlugins(options: ContentModelEditorOptions): UnportedC export function getPluginState(corePlugins: UnportedCorePlugins): UnportedCorePluginState { return { edit: corePlugins.edit.getState(), - undo: corePlugins.undo.getState(), }; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts index 6c4d3aa2034..6174bdc7958 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/createEditorCore.ts @@ -21,7 +21,6 @@ export function createEditorCore( corePlugins.eventTranslate, corePlugins.edit, ...(options.plugins ?? []), - corePlugins.undo, corePlugins.normalizeTable, ].filter(x => !!x); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts index 7e8766fb7f0..73332c80dfc 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelCorePlugins.ts @@ -1,10 +1,5 @@ import type { StandaloneEditorCorePlugins } from 'roosterjs-content-model-types'; -import type { - EditPluginState, - EditorPlugin, - PluginWithState, - UndoPluginState, -} from 'roosterjs-editor-types'; +import type { EditPluginState, EditorPlugin, PluginWithState } from 'roosterjs-editor-types'; /** * An interface for unported core plugins @@ -21,11 +16,6 @@ export interface UnportedCorePlugins { */ readonly edit: PluginWithState; - /** - * Undo plugin provides the ability to undo/redo - */ - readonly undo: PluginWithState; - /** * NormalizeTable plugin makes sure each table in editor has TBODY/THEAD/TFOOT tag around TR tags */ diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index e248d13b966..e25aa2bf649 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -1,11 +1,5 @@ import type { ContentModelCorePlugins } from './ContentModelCorePlugins'; -import type { - EditorPlugin, - ExperimentalFeatures, - IEditor, - Snapshot, - UndoSnapshotsService, -} from 'roosterjs-editor-types'; +import type { EditorPlugin, ExperimentalFeatures, IEditor } from 'roosterjs-editor-types'; import type { StandaloneEditorOptions, IStandaloneEditor } from 'roosterjs-content-model-types'; /** @@ -18,12 +12,6 @@ export interface IContentModelEditor extends IEditor, IStandaloneEditor {} * Options for Content Model editor */ export interface ContentModelEditorOptions extends StandaloneEditorOptions { - /** - * Undo snapshot service based on content metadata. Use this parameter to customize the undo snapshot service. - * When this property is set, value of undoSnapshotService will be ignored. - */ - undoMetadataSnapshotService?: UndoSnapshotsService; - /** * Initial HTML content * Default value is whatever already inside the editor content DIV diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts index 030fc9c2e48..ffa4589a29c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/createEditorCoreTest.ts @@ -9,7 +9,7 @@ import * as EventTranslate from '../../lib/corePlugins/EventTypeTranslatePlugin' import * as LifecyclePlugin from 'roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin'; import * as NormalizeTablePlugin from '../../lib/corePlugins/NormalizeTablePlugin'; import * as SelectionPlugin from 'roosterjs-content-model-core/lib/corePlugin/SelectionPlugin'; -import * as UndoPlugin from '../../lib/corePlugins/UndoPlugin'; +import * as UndoPlugin from 'roosterjs-content-model-core/lib/corePlugin/UndoPlugin'; import { coreApiMap } from '../../lib/coreApi/coreApiMap'; import { createEditorCore } from '../../lib/editor/createEditorCore'; import { defaultTrustHtmlHandler } from 'roosterjs-content-model-core/lib/editor/createStandaloneEditorCore'; @@ -108,8 +108,8 @@ describe('createEditorCore', () => { mockedEntityPlugin, mockedEventTranslatePlugin, mockedEditPlugin, - mockedUndoPlugin, mockedNormalizeTablePlugin, + mockedUndoPlugin, mockedLifecyclePlugin, ], domEvent: mockedDomEventState, @@ -164,8 +164,8 @@ describe('createEditorCore', () => { mockedEntityPlugin, mockedEventTranslatePlugin, mockedEditPlugin, - mockedUndoPlugin, mockedNormalizeTablePlugin, + mockedUndoPlugin, mockedLifecyclePlugin, ], domEvent: mockedDomEventState, diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts index deb0d2b1268..ced9efd18f3 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/IStandaloneEditor.ts @@ -135,5 +135,21 @@ export interface IStandaloneEditor { */ getZoomScale(): number; + /** + * Undo last edit operation + */ + undo(): void; + + /** + * Redo next edit operation + */ + redo(): void; + + /** + * Check if editor is in IME input sequence + * @returns True if editor is in IME input sequence, otherwise false + */ + isInIME(): boolean; + //#endregion } diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts index 064a00aab53..b878b0d40ff 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorCorePlugins.ts @@ -1,3 +1,4 @@ +import type { UndoPluginState } from '../pluginState/UndoPluginState'; import type { SelectionPluginState } from '../pluginState/SelectionPluginState'; import type { EntityPluginState } from '../pluginState/EntityPluginState'; import type { LifecyclePluginState } from '../pluginState/LifecyclePluginState'; @@ -40,6 +41,11 @@ export interface StandaloneEditorCorePlugins { */ readonly entity: PluginWithState; + /** + * Undo plugin provides the ability to undo/redo + */ + readonly undo: PluginWithState; + /** * Lifecycle plugin handles editor initialization and disposing */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts index 43b52d4b30e..841b627394d 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/editor/StandaloneEditorOptions.ts @@ -1,6 +1,11 @@ +import type { Snapshot } from '../parameter/Snapshot'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; import type { StandaloneCoreApiMap } from './StandaloneEditorCore'; -import type { EditorPlugin, TrustedHTMLHandler } from 'roosterjs-editor-types'; +import type { + EditorPlugin, + TrustedHTMLHandler, + UndoSnapshotsService, +} from 'roosterjs-editor-types'; import type { DomToModelOption } from '../context/DomToModelOption'; import type { ModelToDomOption } from '../context/ModelToDomOption'; import type { ContentModelDocument } from '../group/ContentModelDocument'; @@ -89,4 +94,10 @@ export interface StandaloneEditorOptions { * If the editor is currently in dark mode */ inDarkMode?: boolean; + + /** + * Undo snapshot service based on content metadata. Use this parameter to customize the undo snapshot service. + * When this property is set, value of undoSnapshotService will be ignored. + */ + undoSnapshotService?: UndoSnapshotsService; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts index 5ab4eec9b1f..0440e5e05df 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/event/ContentModelContentChangedEvent.ts @@ -1,3 +1,4 @@ +import type { EntityState } from '../parameter/FormatWithContentModelContext'; import type { ContentModelEntity } from '../entity/ContentModelEntity'; import type { EntityRemovalOperation } from '../enum/EntityOperation'; import type { ContentModelDocument } from '../group/ContentModelDocument'; @@ -23,7 +24,7 @@ export interface ChangedEntity { operation: EntityRemovalOperation | 'newEntity'; /** - * @optional Raw DOM event that causes the chagne + * @optional Raw DOM event that causes the change */ rawEvent?: Event; } @@ -46,6 +47,11 @@ export interface ContentModelContentChangedEventData extends ContentChangedEvent * Entities got changed (added or removed) during the content change process */ readonly changedEntities?: ChangedEntity[]; + + /** + * Entity states related to this event + */ + readonly entityStates?: EntityState[]; } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index 8e81f78be21..19f0c2148ad 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -237,9 +237,11 @@ export { DOMEventPluginState } from './pluginState/DOMEventPluginState'; export { LifecyclePluginState } from './pluginState/LifecyclePluginState'; export { EntityPluginState, KnownEntityItem } from './pluginState/EntityPluginState'; export { SelectionPluginState } from './pluginState/SelectionPluginState'; +export { UndoPluginState } from './pluginState/UndoPluginState'; export { EditorEnvironment } from './parameter/EditorEnvironment'; export { + EntityState, DeletedEntity, FormatWithContentModelContext, } from './parameter/FormatWithContentModelContext'; @@ -257,6 +259,7 @@ export { DeleteSelectionStep, ValidDeleteSelectionContext, } from './parameter/DeleteSelectionStep'; +export { Snapshot } from './parameter/Snapshot'; export { ContentModelBeforePasteEvent, diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelContext.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelContext.ts index 8278725ae51..9a54666c7ac 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelContext.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/FormatWithContentModelContext.ts @@ -3,6 +3,28 @@ import type { ContentModelImage } from '../segment/ContentModelImage'; import type { ContentModelSegmentFormat } from '../format/ContentModelSegmentFormat'; import type { EntityRemovalOperation } from '../enum/EntityOperation'; +/** + * State for an entity. This is used for storing entity undo snapshot + */ +export interface EntityState { + /** + * Type of the entity + */ + type: string; + + /** + * Id of the entity + */ + id: string; + + /** + * The state of this entity to store into undo snapshot. + * The state can be any string, or a serialized JSON object. + * We are using string here instead of a JSON object to make sure the whole state is serializable. + */ + state: string; +} + /** * Represents an entity that is deleted by a specified entity operation */ @@ -63,4 +85,16 @@ export interface FormatWithContentModelContext { * Otherwise, leave it there and editor will automatically decide if the original pending format is still available */ newPendingFormat?: ContentModelSegmentFormat | 'preserve'; + + /** + * @optional Entity states related to the format API that will be added together with undo snapshot. + * When entity states are set, each entity state will cause an EntityOperation event with operation = EntityOperation.UpdateEntityState + * when undo/redo to this snapshot + */ + entityStates?: EntityState[]; + + /** + * @optional Set to true if this action can be undone when user press Backspace key (aka Auto Complete). + */ + canUndoByBackspace?: boolean; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts b/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts new file mode 100644 index 00000000000..47324d20bce --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/parameter/Snapshot.ts @@ -0,0 +1,28 @@ +import type { EntityState } from './FormatWithContentModelContext'; +import type { ContentMetadata, ModeIndependentColor } from 'roosterjs-editor-types'; + +/** + * A serializable snapshot of editor content, including the html content and metadata + */ +export interface Snapshot { + /** + * HTML content string + */ + html: string; + + /** + * Metadata of the editor content state + */ + metadata: ContentMetadata | null; + + /** + * Known colors for dark mode + */ + knownColors: Readonly[]; + + /** + * Entity states related to this undo snapshots. When undo/redo to this snapshot, each entity state will trigger + * an EntityOperation event with operation = EntityOperation.UpdateEntityState + */ + entityStates?: EntityState[]; +} diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts index 5198de965ff..364248a2cb8 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/StandaloneEditorPluginState.ts @@ -1,9 +1,6 @@ +import type { UndoPluginState } from './UndoPluginState'; import type { SelectionPluginState } from './SelectionPluginState'; -import type { - CopyPastePluginState, - EditPluginState, - UndoPluginState, -} from 'roosterjs-editor-types'; +import type { CopyPastePluginState, EditPluginState } from 'roosterjs-editor-types'; import type { ContentModelCachePluginState } from './ContentModelCachePluginState'; import type { ContentModelFormatPluginState } from './ContentModelFormatPluginState'; import type { DOMEventPluginState } from './DOMEventPluginState'; @@ -49,6 +46,11 @@ export interface StandaloneEditorCorePluginState { * Plugin state for SelectionPlugin */ selection: SelectionPluginState; + + /** + * Plugin state for UndoPlugin + */ + undo: UndoPluginState; } /** @@ -56,6 +58,5 @@ export interface StandaloneEditorCorePluginState { * TODO: Port these plugins */ export interface UnportedCorePluginState { - undo: UndoPluginState; edit: EditPluginState; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/pluginState/UndoPluginState.ts b/packages-content-model/roosterjs-content-model-types/lib/pluginState/UndoPluginState.ts new file mode 100644 index 00000000000..fcfa3dcd386 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/pluginState/UndoPluginState.ts @@ -0,0 +1,42 @@ +import type { UndoSnapshotsService } from 'roosterjs-editor-types'; +import type { Snapshot } from '../parameter/Snapshot'; + +/** + * The state object for UndoPlugin + */ +export interface UndoPluginState { + /** + * Snapshot service for undo, it helps handle snapshot add, remove and retrieve + */ + snapshotsService: UndoSnapshotsService; + + /** + * Whether restoring of undo snapshot is in progress. + */ + isRestoring: boolean; + + /** + * Whether there is new content change after last undo snapshot is taken + */ + hasNewContent: boolean; + + /** + * If addUndoSnapshot() is called nested in another one, this will be true + */ + isNested: boolean; + + /** + * Container after last auto complete. Undo autoComplete only works if the current position matches this one + */ + posContainer: Node | null; + + /** + * Offset after last auto complete. Undo autoComplete only works if the current position matches this one + */ + posOffset: number | null; + + /** + * Last key user pressed + */ + lastKeyPress: string | null; +} From 4bb96c11cebca2da28f092ced575a208358e7010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 11 Dec 2023 13:20:23 -0300 Subject: [PATCH 083/111] create list indented paragraph --- .../lib/modelApi/list/setListType.ts | 27 +++ .../test/modelApi/list/setListTypeTest.ts | 225 +++++++++++++++++- 2 files changed, 251 insertions(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts index 6e83bedbf44..a522f5cb441 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts @@ -31,12 +31,26 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') paragraphOrListItems.forEach(({ block, parent }, itemIndex) => { if (isBlockGroupOfType(block, 'ListItem')) { const level = block.levels.pop(); + console.log('level', level); if (!alreadyInExpectedType && level) { level.listType = listType; block.levels.push(level); } else if (block.blocks.length == 1) { setParagraphNotImplicit(block.blocks[0]); + block.blocks.forEach(x => { + if (block.format.marginLeft) { + x.format.marginLeft = block.format.marginLeft; + } + + if (block.format.marginRight) { + x.format.marginRight = block.format.marginRight; + } + + if (block.format.textAlign) { + x.format.textAlign = block.format.textAlign; + } + }); } } else { const index = parent.blocks.indexOf(block); @@ -79,6 +93,19 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') newListItem.blocks.push(block); + if (block.format.marginRight) { + newListItem.format.marginRight = block.format.marginRight; + block.format.marginRight = undefined; + } + if (block.format.marginLeft) { + newListItem.format.marginLeft = block.format.marginLeft; + block.format.marginLeft = undefined; + } + + if (block.format.textAlign) { + newListItem.format.textAlign = block.format.textAlign; + } + parent.blocks.splice(index, 1, newListItem); existingListItems.push(newListItem); } else { diff --git a/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts b/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts index 72013ea6e08..609b2ac2ee1 100644 --- a/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts +++ b/packages-content-model/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts @@ -1,5 +1,8 @@ import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; +import { ContentModelDocument } from 'roosterjs-content-model-types'; import { setListType } from '../../../lib/modelApi/list/setListType'; +import { setModelIndentation } from '../../../lib/modelApi/block/setModelIndentation'; + import { createBr, createContentModelDocument, @@ -8,6 +11,7 @@ import { createListItem, createListLevel, createParagraph, + createSelectionMarker, createTable, createTableCell, createText, @@ -381,7 +385,9 @@ describe('indent', () => { }, isSelected: true, }, - format: {}, + format: { + textAlign: 'start', + }, }, ], }); @@ -698,4 +704,221 @@ describe('indent', () => { ], }); }); + + it('turn on list on indented paragraph', () => { + const group = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + const marker = createSelectionMarker(); + para1.segments.push(marker, createText('test1')); + para2.segments.push(createText('test1'), marker); + group.blocks.push(para1, para2); + setModelIndentation(group, 'indent'); + setListType(group, 'UL'); + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [para1], + levels: [ + { + listType: 'UL', + format: { + startNumberOverride: 1, + direction: undefined, + textAlign: undefined, + marginTop: undefined, + marginBlockEnd: '0px', + marginBlockStart: '0px', + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + }, + format: { + marginLeft: '40px', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [para2], + levels: [ + { + listType: 'UL', + format: { + startNumberOverride: undefined, + direction: undefined, + textAlign: undefined, + marginTop: undefined, + marginBlockEnd: '0px', + marginBlockStart: '0px', + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: { + fontFamily: undefined, + fontSize: undefined, + textColor: undefined, + }, + }, + format: { + marginLeft: '40px', + }, + }, + ], + }); + }); + + it('turn off list on indented paragraph', () => { + const group: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test1', + format: {}, + isSelected: true, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'UL', + format: { + marginBlockStart: '0px', + marginBlockEnd: '0px', + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + marginLeft: '40px', + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + isSelected: true, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + levels: [ + { + listType: 'UL', + format: { + marginBlockStart: '0px', + marginBlockEnd: '0px', + }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: { + marginLeft: '40px', + }, + }, + ], + format: {}, + }; + normalizeContentModelSpy.and.callThrough(); + setListType(group, 'UL'); + + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Text', + text: 'test1', + format: {}, + isSelected: true, + }, + ], + format: { + marginLeft: '40px', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + isSelected: true, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: { + marginLeft: '40px', + }, + }, + ], + format: {}, + }); + }); }); From 8f2788498fc992ff32b060b566c9e48b0cf1a628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 11 Dec 2023 13:26:07 -0300 Subject: [PATCH 084/111] fix build --- .../roosterjs-content-model-api/lib/modelApi/list/setListType.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts index a522f5cb441..a39249cea2c 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts @@ -31,7 +31,6 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') paragraphOrListItems.forEach(({ block, parent }, itemIndex) => { if (isBlockGroupOfType(block, 'ListItem')) { const level = block.levels.pop(); - console.log('level', level); if (!alreadyInExpectedType && level) { level.listType = listType; From 78e4c5170a850bba0a7332aac6618928b00279cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 11 Dec 2023 14:29:36 -0300 Subject: [PATCH 085/111] refactor --- .../corePlugin/ContentModelCopyPastePlugin.ts | 2 +- .../lib/corePlugin/utils/deleteEmptyList.ts | 6 +- .../publicApi/selection/deleteSelection.ts | 7 +- .../selection/deleteSelectionTest.ts | 310 +++++++++++++++++- 4 files changed, 317 insertions(+), 8 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts index 8dc3b0868d3..bef19c9300e 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/ContentModelCopyPastePlugin.ts @@ -171,7 +171,7 @@ class ContentModelCopyPastePlugin implements PluginWithState { if ( - deleteSelection(model, [deleteEmptyList], context) + deleteSelection(model, [deleteEmptyList], context, 'range') .deleteResult == 'range' ) { normalizeContentModel(model); diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts index b007a2efd64..f1bf34916f4 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts @@ -22,7 +22,11 @@ function isEmptyBlock(block: ContentModelBlock | undefined): boolean { export function deleteEmptyList(context: DeleteSelectionContext) { const { insertPoint, deleteResult } = context; if (deleteResult == 'range' && insertPoint?.path) { - const index = getClosestAncestorBlockGroupIndex(insertPoint.path, ['ListItem']); + const index = getClosestAncestorBlockGroupIndex( + insertPoint.path, + ['ListItem'], + ['TableCell'] + ); const item = insertPoint.path[index]; if (index >= 0 && item && item.blockGroupType == 'ListItem') { const listItemIndex = insertPoint.path[index + 1].blocks.indexOf(item); diff --git a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts index 5be655f6916..0b7e90f07ee 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/publicApi/selection/deleteSelection.ts @@ -1,6 +1,7 @@ import { deleteExpandedSelection } from '../../modelApi/edit/deleteExpandedSelection'; import type { ContentModelDocument, + DeleteResult, DeleteSelectionContext, DeleteSelectionResult, DeleteSelectionStep, @@ -13,12 +14,14 @@ import type { * @param model The model to delete selected content from * @param additionalSteps @optional Addition delete steps * @param formatContext @optional A context object provided by formatContentModel API + * @param additionalStepsResult @optional The delete result to trigger the additional steps @default 'notDeleted' * @returns A DeleteSelectionResult object to specify the deletion result */ export function deleteSelection( model: ContentModelDocument, additionalSteps: (DeleteSelectionStep | null)[] = [], - formatContext?: FormatWithContentModelContext + formatContext?: FormatWithContentModelContext, + additionalStepsResult: DeleteResult = 'notDeleted' ): DeleteSelectionResult { const context = deleteExpandedSelection(model, formatContext); @@ -26,7 +29,7 @@ export function deleteSelection( if ( step && isValidDeleteSelectionContext(context) && - context.deleteResult == 'notDeleted' + context.deleteResult == additionalStepsResult ) { step(context); } diff --git a/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts index 9479b8abdf9..460cce0cf4d 100644 --- a/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts @@ -996,7 +996,7 @@ describe('deleteSelection - list - when cut', () => { para2.segments.push(text2, marker); model.blocks.push(listItem1, listItem2); - const result = deleteSelection(model, [deleteEmptyList], undefined); + const result = deleteSelection(model, [deleteEmptyList], undefined, 'range'); normalizeContentModel(model); const path: ContentModelBlockGroup[] = [ @@ -1101,7 +1101,7 @@ describe('deleteSelection - list - when cut', () => { para2.segments.push(text2); model.blocks.push(listItem1, listItem2); - const result = deleteSelection(model, [deleteEmptyList], undefined); + const result = deleteSelection(model, [deleteEmptyList], undefined, 'range'); normalizeContentModel(model); const path: ContentModelBlockGroup[] = [ @@ -1305,7 +1305,7 @@ describe('deleteSelection - list - when cut', () => { para1.segments.push(text1); model.blocks.push(listItem1); - const result = deleteSelection(model, [deleteEmptyList], undefined); + const result = deleteSelection(model, [deleteEmptyList], undefined, 'range'); normalizeContentModel(model); const path: ContentModelBlockGroup[] = [ @@ -1467,7 +1467,7 @@ describe('deleteSelection - list - when cut', () => { para4.segments.push(text4); model.blocks.push(listItem1, listItem2, listItem3, listItem4); - const result = deleteSelection(model, [deleteEmptyList], undefined); + const result = deleteSelection(model, [deleteEmptyList], undefined, 'range'); normalizeContentModel(model); const path: ContentModelBlockGroup[] = [ @@ -1727,4 +1727,306 @@ describe('deleteSelection - list - when cut', () => { ], }); }); + + it('Delete list with table', () => { + const model = createContentModelDocument(); + const level = createListLevel('UL'); + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + format: {}, + isSelected: true, + }; + const listItem1 = createListItem([level]); + const table = createTable(1); + const cell = createTableCell(); + const para = createParagraph(); + const text = createText('test1'); + para.segments.push(text); + cell.blocks.push(para); + text.isSelected = true; + para.segments.push(marker); + table.rows[0].cells.push(cell); + listItem1.blocks.push(table); + model.blocks.push(listItem1); + + const result = deleteSelection(model, [deleteEmptyList], undefined, 'range'); + normalizeContentModel(model); + + const path: ContentModelBlockGroup[] = [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + levels: [ + { + listType: 'UL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + levels: [ + { + listType: 'UL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + }, + ]; + + expect(result.deleteResult).toBe('range'); + expect(result.insertPoint).toEqual({ + marker: marker, + paragraph: { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + path: path, + tableContext: { + table: { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + rowIndex: 0, + colIndex: 0, + isWholeTableSelected: false, + }, + }); + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Br', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + levels: [ + { + listType: 'UL', + format: {}, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + ], + }); + }); }); From ed0341bc3909961f782303e59590162cec5a1ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 11 Dec 2023 14:51:32 -0300 Subject: [PATCH 086/111] remove loop --- .../lib/modelApi/list/setListType.ts | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts index a39249cea2c..e6629530cf4 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts @@ -37,19 +37,16 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') block.levels.push(level); } else if (block.blocks.length == 1) { setParagraphNotImplicit(block.blocks[0]); - block.blocks.forEach(x => { - if (block.format.marginLeft) { - x.format.marginLeft = block.format.marginLeft; - } - - if (block.format.marginRight) { - x.format.marginRight = block.format.marginRight; - } - - if (block.format.textAlign) { - x.format.textAlign = block.format.textAlign; - } - }); + const listBlock = block.blocks[0]; + if (block.format.marginLeft) { + listBlock.format.marginLeft = block.format.marginLeft; + } + if (block.format.marginRight) { + listBlock.format.marginRight = block.format.marginRight; + } + if (block.format.textAlign) { + listBlock.format.textAlign = block.format.textAlign; + } } } else { const index = parent.blocks.indexOf(block); From c5f83d27d136d3c3bbe64d89bddaf138057bbee3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 11 Dec 2023 14:53:26 -0300 Subject: [PATCH 087/111] add type --- .../lib/corePlugin/utils/deleteEmptyList.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts index f1bf34916f4..4e7f20f1605 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts @@ -1,7 +1,7 @@ import hasSelectionInBlock from '../../publicApi/selection/hasSelectionInBlock'; import hasSelectionInBlockGroup from '../../publicApi/selection/hasSelectionInBlockGroup'; -import { ContentModelBlock, DeleteSelectionContext } from 'roosterjs-content-model-types'; import { getClosestAncestorBlockGroupIndex } from '../../publicApi/model/getClosestAncestorBlockGroupIndex'; +import type { ContentModelBlock, DeleteSelectionContext } from 'roosterjs-content-model-types'; function isEmptyBlock(block: ContentModelBlock | undefined): boolean { if (block && block.blockType == 'Paragraph') { From b65152bfd9a6e6fef291cf689d7a3f3c879593cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 11 Dec 2023 15:03:56 -0300 Subject: [PATCH 088/111] multiple subblocks --- .../lib/modelApi/list/setListType.ts | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts index e6629530cf4..49a9b183e08 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts @@ -37,16 +37,22 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') block.levels.push(level); } else if (block.blocks.length == 1) { setParagraphNotImplicit(block.blocks[0]); - const listBlock = block.blocks[0]; - if (block.format.marginLeft) { - listBlock.format.marginLeft = block.format.marginLeft; - } - if (block.format.marginRight) { - listBlock.format.marginRight = block.format.marginRight; - } - if (block.format.textAlign) { - listBlock.format.textAlign = block.format.textAlign; - } + } + + if (alreadyInExpectedType) { + block.blocks.forEach(x => { + if (block.format.marginLeft) { + x.format.marginLeft = block.format.marginLeft; + } + + if (block.format.marginRight) { + x.format.marginRight = block.format.marginRight; + } + + if (block.format.textAlign) { + x.format.textAlign = block.format.textAlign; + } + }); } } else { const index = parent.blocks.indexOf(block); From 8feb08632f8738b57bac154ed984a52c3f3234ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 11 Dec 2023 15:29:44 -0300 Subject: [PATCH 089/111] comments --- .../roosterjs-content-model-api/lib/modelApi/list/setListType.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts index 49a9b183e08..bac375bd0cb 100644 --- a/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts +++ b/packages-content-model/roosterjs-content-model-api/lib/modelApi/list/setListType.ts @@ -40,6 +40,7 @@ export function setListType(model: ContentModelDocument, listType: 'OL' | 'UL') } if (alreadyInExpectedType) { + //if the list item has margins or textAlign, we need to apply them to the block to preserve the indention and alignment block.blocks.forEach(x => { if (block.format.marginLeft) { x.format.marginLeft = block.format.marginLeft; From c24efe3adcdee573080f6f6bb743601474678e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 11 Dec 2023 15:42:36 -0300 Subject: [PATCH 090/111] comment --- .../lib/corePlugin/utils/deleteEmptyList.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts index 4e7f20f1605..61dcbe74bb8 100644 --- a/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts +++ b/packages-content-model/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts @@ -17,8 +17,11 @@ function isEmptyBlock(block: ContentModelBlock | undefined): boolean { return !!block; } -//Verify if we need to remove the list item levels -//If the first item o the list is selected in a expanded selection, we need to remove the list item levels +/** + * @internal + * If the first item o the list is selected in a expanded selection, we need to remove the list item levels + * @param context A context object provided by formatContentModel API + */ export function deleteEmptyList(context: DeleteSelectionContext) { const { insertPoint, deleteResult } = context; if (deleteResult == 'range' && insertPoint?.path) { From cb55c5bb3cb1fb5d5a7e9635e1da128af33cb179 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 11 Dec 2023 12:47:57 -0800 Subject: [PATCH 091/111] Standalone Editor: Port undo related core API (#2249) * Standalone Editor: CreateStandaloneEditorCore * Standalone Editor: Port LifecyclePlugin * fix build * fix test * improve * fix test * Standalone Editor: Support keyboard input (init step) * Standalone Editor: Port EntityPlugin * improve * Add test * improve * port selection api * improve * improve * fix build * fix build * fix build * improve * Improve * improve * improve * fix test * improve * add test * remove unused code * Standalone Editor: port ImageSelection plugin * add test * Standalone Editor: Port UndoPlugin * improve * Port undo api * fix test * improve * improve * fix build * Improve * Improve * Improve * fix build * Improve * Add test * fix test * Add undo/redo API --- .../controls/ContentModelEditorMainPane.tsx | 20 +- demo/scripts/controls/MainPane.tsx | 1 + demo/scripts/controls/MainPaneBase.tsx | 4 +- .../contentModel/ContentModelRibbon.tsx | 4 + .../ribbonButtons/contentModel/redoButton.ts | 20 + .../ribbonButtons/contentModel/undoButton.ts | 20 + .../snapshot/ContentModelSnapshotPane.tsx | 144 ++++++ .../snapshot/ContentModelSnapshotPlugin.tsx | 156 +++++++ .../lib/publicApi/format/getFormatState.ts | 4 +- .../publicApi/format/getFormatStateTest.ts | 6 +- .../lib/coreApi/addUndoSnapshot.ts | 29 ++ .../lib/coreApi/formatContentModel.ts | 92 ++-- .../lib/coreApi/restoreUndoSnapshot.ts | 44 ++ .../lib/corePlugin/DOMEventPlugin.ts | 14 +- .../lib/corePlugin/UndoPlugin.ts | 35 +- .../corePlugin/utils/applyDefaultFormat.ts | 3 +- ...ServiceImpl.ts => SnapshotsManagerImpl.ts} | 24 +- .../lib/editor/standaloneCoreApiMap.ts | 4 + .../roosterjs-content-model-core/lib/index.ts | 2 + .../lib/publicApi/undo/redo.ts | 16 + .../lib/publicApi/undo/undo.ts | 21 + .../lib/utils/createSnapshotSelection.ts | 104 +++++ .../lib/utils/restoreSnapshotColors.ts | 26 ++ .../lib/utils/restoreSnapshotHTML.ts | 89 ++++ .../lib/utils/restoreSnapshotSelection.ts | 84 ++++ .../test/coreApi/addUndoSnapshotTest.ts | 145 ++++++ .../test/coreApi/formatContentModelTest.ts | 140 +++++- .../test/coreApi/restoreUndoSnapshotTest.ts | 98 ++++ .../ContentModelFormatPluginTest.ts | 8 +- .../test/corePlugin/DomEventPluginTest.ts | 14 +- .../test/corePlugin/UndoPluginTest.ts | 224 +++++----- .../utils/applyDefaultFormatTest.ts | 22 +- ...mplTest.ts => SnapshotsManagerImplTest.ts} | 106 +++-- .../test/publicApi/undo/redoTest.ts | 78 ++++ .../test/publicApi/undo/undoTest.ts | 78 ++++ .../test/utils/createSnapshotSelectionTest.ts | 179 ++++++++ .../test/utils/restoreSnapshotColorsTest.ts | 169 +++++++ .../test/utils/restoreSnapshotHTMLTest.ts | 423 ++++++++++++++++++ .../utils/restoreSnapshotSelectionTest.ts | 263 +++++++++++ .../utils => domUtils}/reuseCachedElement.ts | 10 +- .../roosterjs-content-model-dom/lib/index.ts | 1 + .../lib/modelToDom/handlers/handleDivider.ts | 2 +- .../lib/modelToDom/handlers/handleEntity.ts | 2 +- .../handlers/handleFormatContainer.ts | 2 +- .../modelToDom/handlers/handleGeneralModel.ts | 2 +- .../modelToDom/handlers/handleParagraph.ts | 2 +- .../lib/modelToDom/handlers/handleTable.ts | 2 +- .../reuseCachedElementTest.ts | 4 +- .../lib/coreApi/addUndoSnapshot.ts | 103 ----- .../lib/coreApi/coreApiMap.ts | 4 - .../lib/coreApi/restoreUndoSnapshot.ts | 69 --- .../lib/editor/ContentModelEditor.ts | 117 ++++- .../lib/edit/keyboardInput.ts | 2 +- .../test/edit/keyboardInputTest.ts | 28 +- .../lib/editor/IStandaloneEditor.ts | 22 +- .../lib/editor/StandaloneEditorCore.ts | 52 ++- .../lib/editor/StandaloneEditorOptions.ts | 10 +- .../lib/index.ts | 11 +- .../lib/parameter/Snapshot.ts | 121 ++++- .../lib/parameter/SnapshotsManager.ts | 41 ++ .../lib/pluginState/UndoPluginState.ts | 12 +- 61 files changed, 2974 insertions(+), 558 deletions(-) create mode 100644 demo/scripts/controls/ribbonButtons/contentModel/redoButton.ts create mode 100644 demo/scripts/controls/ribbonButtons/contentModel/undoButton.ts create mode 100644 demo/scripts/controls/sidePane/snapshot/ContentModelSnapshotPane.tsx create mode 100644 demo/scripts/controls/sidePane/snapshot/ContentModelSnapshotPlugin.tsx create mode 100644 packages-content-model/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot.ts rename packages-content-model/roosterjs-content-model-core/lib/editor/{UndoSnapshotsServiceImpl.ts => SnapshotsManagerImpl.ts} (85%) create mode 100644 packages-content-model/roosterjs-content-model-core/lib/publicApi/undo/redo.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/publicApi/undo/undo.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotColors.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotHTML.ts create mode 100644 packages-content-model/roosterjs-content-model-core/lib/utils/restoreSnapshotSelection.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshotTest.ts rename packages-content-model/roosterjs-content-model-core/test/editor/{UndoSnapshotsServiceImplTest.ts => SnapshotsManagerImplTest.ts} (83%) create mode 100644 packages-content-model/roosterjs-content-model-core/test/publicApi/undo/redoTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/publicApi/undo/undoTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotColorsTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotHTMLTest.ts create mode 100644 packages-content-model/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts rename packages-content-model/roosterjs-content-model-dom/lib/{modelToDom/utils => domUtils}/reuseCachedElement.ts (63%) rename packages-content-model/roosterjs-content-model-dom/test/{modelToDom/utils => domUtils}/reuseCachedElementTest.ts (94%) delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/addUndoSnapshot.ts delete mode 100644 packages-content-model/roosterjs-content-model-editor/lib/coreApi/restoreUndoSnapshot.ts create mode 100644 packages-content-model/roosterjs-content-model-types/lib/parameter/SnapshotsManager.ts diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 7a44556425d..b6e9a6e5720 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -8,18 +8,18 @@ import ContentModelFormatStatePlugin from './sidePane/formatState/ContentModelFo import ContentModelPanePlugin from './sidePane/contentModel/ContentModelPanePlugin'; import ContentModelRibbon from './ribbonButtons/contentModel/ContentModelRibbon'; import ContentModelRooster from './contentModel/editor/ContentModelRooster'; +import ContentModelSnapshotPlugin from './sidePane/snapshot/ContentModelSnapshotPlugin'; import getToggleablePlugins from './getToggleablePlugins'; import MainPaneBase, { MainPaneBaseState } from './MainPaneBase'; import SampleEntityPlugin from './sampleEntity/SampleEntityPlugin'; import SidePane from './sidePane/SidePane'; -import SnapshotPlugin from './sidePane/snapshot/SnapshotPlugin'; import TitleBar from './titleBar/TitleBar'; import { arrayPush } from 'roosterjs-editor-dom'; import { ContentModelEditPlugin, EntityDelimiterPlugin } from 'roosterjs-content-model-plugins'; import { ContentModelRibbonPlugin } from './ribbonButtons/contentModel/ContentModelRibbonPlugin'; -import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; +import { ContentModelSegmentFormat, Snapshot } from 'roosterjs-content-model-types'; import { createEmojiPlugin, createPasteOptionPlugin, RibbonPlugin } from 'roosterjs-react'; -import { EditorPlugin } from 'roosterjs-editor-types'; +import { EditorPlugin, Snapshots } from 'roosterjs-editor-types'; import { getDarkColor } from 'roosterjs-color-utils'; import { PartialTheme } from '@fluentui/react/lib/Theme'; import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; @@ -99,19 +99,29 @@ class ContentModelEditorMainPane extends MainPaneBase private contentModelRibbonPlugin: RibbonPlugin; private pasteOptionPlugin: EditorPlugin; private emojiPlugin: EditorPlugin; + private snapshotPlugin: ContentModelSnapshotPlugin; private entityDelimiterPlugin: EntityDelimiterPlugin; private toggleablePlugins: EditorPlugin[] | null = null; private formatPainterPlugin: ContentModelFormatPainterPlugin; private sampleEntityPlugin: SampleEntityPlugin; + private snapshots: Snapshots; constructor(props: {}) { super(props); + this.snapshots = { + snapshots: [], + totalSize: 0, + currentIndex: -1, + autoCompleteIndex: -1, + maxSize: 1e7, + }; + this.formatStatePlugin = new ContentModelFormatStatePlugin(); this.editorOptionPlugin = new ContentModelEditorOptionsPlugin(); this.eventViewPlugin = new ContentModelEventViewPlugin(); this.apiPlaygroundPlugin = new ApiPlaygroundPlugin(); - this.snapshotPlugin = new SnapshotPlugin(); + this.snapshotPlugin = new ContentModelSnapshotPlugin(this.snapshots); this.contentModelPanePlugin = new ContentModelPanePlugin(); this.contentModelEditPlugin = new ContentModelEditPlugin(); this.contentModelRibbonPlugin = new ContentModelRibbonPlugin(); @@ -239,7 +249,7 @@ class ContentModelEditorMainPane extends MainPaneBase inDarkMode={this.state.isDarkMode} getDarkColor={getDarkColor} experimentalFeatures={this.state.initState.experimentalFeatures} - undoSnapshotService={this.snapshotPlugin.getSnapshotService()} + snapshotsManager={this.snapshotPlugin.getSnapshotsManager()} trustedHTMLHandler={trustedHTMLHandler} zoomScale={this.state.scale} initialContent={this.content} diff --git a/demo/scripts/controls/MainPane.tsx b/demo/scripts/controls/MainPane.tsx index ad5e38acdc4..6aaf65631a3 100644 --- a/demo/scripts/controls/MainPane.tsx +++ b/demo/scripts/controls/MainPane.tsx @@ -109,6 +109,7 @@ class MainPane extends MainPaneBase { private emojiPlugin: EditorPlugin; private toggleablePlugins: EditorPlugin[] | null = null; private sampleEntityPlugin: SampleEntityPlugin; + private snapshotPlugin: SnapshotPlugin; private mainWindowButtons: RibbonButton[]; private popoutWindowButtons: RibbonButton[]; diff --git a/demo/scripts/controls/MainPaneBase.tsx b/demo/scripts/controls/MainPaneBase.tsx index 00b375a339d..15c12a9efcd 100644 --- a/demo/scripts/controls/MainPaneBase.tsx +++ b/demo/scripts/controls/MainPaneBase.tsx @@ -2,14 +2,13 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import BuildInPluginState from './BuildInPluginState'; import SidePane from './sidePane/SidePane'; -import SnapshotPlugin from './sidePane/snapshot/SnapshotPlugin'; import { Border } from 'roosterjs-content-model-types'; +import { createUpdateContentPlugin, UpdateContentPlugin, UpdateMode } from 'roosterjs-react'; import { EditorPlugin } from 'roosterjs-editor-types'; import { PartialTheme, ThemeProvider } from '@fluentui/react/lib/Theme'; import { registerWindowForCss, unregisterWindowForCss } from '../utils/cssMonitor'; import { trustedHTMLHandler } from '../utils/trustedHTMLHandler'; import { WindowProvider } from '@fluentui/react/lib/WindowProvider'; -import { createUpdateContentPlugin, UpdateContentPlugin, UpdateMode } from 'roosterjs-react'; export interface MainPaneBaseState { showSidePane: boolean; @@ -37,7 +36,6 @@ export default abstract class MainPaneBase extends protected sidePane = React.createRef(); protected updateContentPlugin: UpdateContentPlugin; - protected snapshotPlugin: SnapshotPlugin; protected content: string = ''; protected themeMatch = window.matchMedia?.('(prefers-color-scheme: dark)'); diff --git a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx index 8d917331220..97ece1f2b28 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx +++ b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx @@ -33,6 +33,7 @@ import { ltrButton } from './ltrButton'; import { numberedListButton } from './numberedListButton'; import { pasteButton } from './pasteButton'; import { popout } from '../popout'; +import { redoButton } from './redoButton'; import { removeLinkButton } from './removeLinkButton'; import { Ribbon, RibbonButton, RibbonPlugin } from 'roosterjs-react'; import { rtlButton } from './rtlButton'; @@ -52,6 +53,7 @@ import { tableBorderStyleButton } from './tableBorderStyleButton'; import { tableBorderWidthButton } from './tableBorderWidthButton'; import { textColorButton } from './textColorButton'; import { underlineButton } from './underlineButton'; +import { undoButton } from './undoButton'; import { zoom } from '../zoom'; import { tableAlignCellButton, @@ -92,6 +94,8 @@ const buttons = [ codeButton, ltrButton, rtlButton, + undoButton, + redoButton, clearFormatButton, setBulletedListStyleButton, setNumberedListStyleButton, diff --git a/demo/scripts/controls/ribbonButtons/contentModel/redoButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/redoButton.ts new file mode 100644 index 00000000000..856eb6277cf --- /dev/null +++ b/demo/scripts/controls/ribbonButtons/contentModel/redoButton.ts @@ -0,0 +1,20 @@ +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { redo } from 'roosterjs-content-model-core'; +import { RedoButtonStringKey, RibbonButton } from 'roosterjs-react'; + +/** + * @internal + * "Undo" button on the format ribbon + */ +export const redoButton: RibbonButton = { + key: 'buttonNameRedo', + unlocalizedText: 'Redo', + iconName: 'Redo', + isDisabled: formatState => !formatState.canRedo, + onClick: editor => { + if (isContentModelEditor(editor)) { + redo(editor); + } + return true; + }, +}; diff --git a/demo/scripts/controls/ribbonButtons/contentModel/undoButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/undoButton.ts new file mode 100644 index 00000000000..0ae337f7ce0 --- /dev/null +++ b/demo/scripts/controls/ribbonButtons/contentModel/undoButton.ts @@ -0,0 +1,20 @@ +import { isContentModelEditor } from 'roosterjs-content-model-editor'; +import { RibbonButton, UndoButtonStringKey } from 'roosterjs-react'; +import { undo } from 'roosterjs-content-model-core'; + +/** + * @internal + * "Undo" button on the format ribbon + */ +export const undoButton: RibbonButton = { + key: 'buttonNameUndo', + unlocalizedText: 'Undo', + iconName: 'undo', + isDisabled: formatState => !formatState.canUndo, + onClick: editor => { + if (isContentModelEditor(editor)) { + undo(editor); + } + return true; + }, +}; diff --git a/demo/scripts/controls/sidePane/snapshot/ContentModelSnapshotPane.tsx b/demo/scripts/controls/sidePane/snapshot/ContentModelSnapshotPane.tsx new file mode 100644 index 00000000000..810eb298cec --- /dev/null +++ b/demo/scripts/controls/sidePane/snapshot/ContentModelSnapshotPane.tsx @@ -0,0 +1,144 @@ +import * as React from 'react'; +import { EntityState, Snapshot, SnapshotSelection } from 'roosterjs-content-model-types'; +import { ModeIndependentColor } from 'roosterjs-editor-types'; + +const styles = require('./SnapshotPane.scss'); + +export interface ContentModelSnapshotPaneProps { + onTakeSnapshot: () => Snapshot; + onRestoreSnapshot: (snapshot: Snapshot, triggerContentChangedEvent: boolean) => void; + onMove: (moveStep: number) => void; +} + +export interface ContentModelSnapshotPaneState { + snapshots: Snapshot[]; + currentIndex: number; + autoCompleteIndex: number; +} + +export default class ContentModelSnapshotPane extends React.Component< + ContentModelSnapshotPaneProps, + ContentModelSnapshotPaneState +> { + private html = React.createRef(); + private knownColors = React.createRef(); + private entityStates = React.createRef(); + private isDarkColor = React.createRef(); + private selection = React.createRef(); + + constructor(props: ContentModelSnapshotPaneProps) { + super(props); + + this.state = { + snapshots: [], + currentIndex: -1, + autoCompleteIndex: -1, + }; + } + + render() { + return ( +
                                                          +

                                                          Undo Snapshots

                                                          +
                                                          + {this.state.snapshots.map(this.renderItem)} +
                                                          +

                                                          Selected Snapshot

                                                          +
                                                          + {' '} + +
                                                          +
                                                          HTML:
                                                          +