From 6a4c0a49ba558f0df8a99ff96e2999a3fda1c2a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Thu, 7 Mar 2024 18:28:27 -0300 Subject: [PATCH 01/73] entity on enter --- .../corePlugin/utils/entityDelimiterUtils.ts | 10 ++ .../corePlugin/utils/delimiterUtilsTest.ts | 161 ++++++++++++++++++ 2 files changed, 171 insertions(+) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts b/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts index 53d84e232b2..d353a28ab8e 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts @@ -292,6 +292,16 @@ export const handleEnterInlineEntity: ContentModelFormatter = model => { selectionBlock.segmentFormat, selectionBlock.decorator ); + + if ( + selectionBlock.segments.every( + x => x.segmentType == 'SelectionMarker' || x.segmentType == 'Br' + ) || + segmentsAfterMarker.every(x => x.segmentType == 'SelectionMarker') + ) { + newPara.segments.push(createBr(selectionBlock.format)); + } + newPara.segments.push(...segmentsAfterMarker); const selectionBlockIndex = selectionBlockParent.blocks.indexOf(selectionBlock); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts index 203c28c4e4f..985a08c5640 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts @@ -1125,4 +1125,165 @@ describe('handleEnterInlineEntity', () => { format: {}, }); }); + + it('handle before entity as first segment', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: true, + }, + wrapper: {}, + }, + { + segmentType: 'Text', + text: '_', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + DelimiterFile.handleEnterInlineEntity(model, {}); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: true, + }, + wrapper: jasmine.anything(), + }, + { + segmentType: 'Text', + text: '_', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + }); + + it('handle after entity as last segment', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '_', + format: {}, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: true, + }, + wrapper: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }; + + DelimiterFile.handleEnterInlineEntity(model, {}); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: '_', + format: {}, + }, + + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + entityFormat: { + entityType: '', + isReadonly: true, + }, + wrapper: jasmine.anything(), + }, + ], + + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Br', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + }); + }); }); From 4e32cd944cceed98da1b0c1197a4f8d7666570c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 8 Mar 2024 15:28:11 -0300 Subject: [PATCH 02/73] WIP --- .../corePlugin/utils/entityDelimiterUtils.ts | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts b/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts index d353a28ab8e..b1548eff5c6 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts @@ -1,5 +1,6 @@ import { isCharacterValue } from '../../publicApi/domUtils/eventUtils'; import { iterateSelections } from '../../publicApi/selection/iterateSelections'; + import type { CompositionEndEvent, ContentModelBlockGroup, @@ -18,6 +19,7 @@ import { isEntityDelimiter, isEntityElement, isNodeOfType, + parseEntityFormat, } from 'roosterjs-content-model-dom'; const DelimiterBefore = 'entityDelimiterBefore'; @@ -201,7 +203,7 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent return; } const isEnter = rawEvent.key === 'Enter'; - if (selection.range.collapsed && (isCharacterValue(rawEvent) || isEnter)) { + if (selection.range.collapsed && isCharacterValue(rawEvent)) { const helper = editor.getDOMHelper(); const node = getFocusedElement(selection); if (node && isEntityDelimiter(node) && helper.isNodeInEditor(node)) { @@ -209,6 +211,10 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent if (blockEntityContainer && helper.isNodeInEditor(blockEntityContainer)) { const isAfter = node.classList.contains(DelimiterAfter); + if (isEnter) { + event.rawEvent.preventDefault(); + } + if (isAfter) { selection.range.setStartAfter(blockEntityContainer); } else { @@ -216,10 +222,6 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent } selection.range.collapse(true /* toStart */); - if (isEnter) { - event.rawEvent.preventDefault(); - } - editor.formatContentModel(handleKeyDownInBlockDelimiter, { selectionOverride: { type: 'range', @@ -241,6 +243,16 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent } } } + } else { + const helper = editor.getDOMHelper(); + const entity = getSelectedEntity(selection); + + if (entity && isNodeOfType(entity, 'ELEMENT_NODE') && helper.isNodeInEditor(entity)) { + selection.range.selectNode(entity); + if (isEnter) { + triggerEntityEventOnEnter(editor, entity, rawEvent); + } + } } } @@ -313,3 +325,31 @@ export const handleEnterInlineEntity: ContentModelFormatter = model => { return true; }; + +const triggerEntityEventOnEnter = ( + editor: IEditor, + wrapper: HTMLElement, + rawEvent: KeyboardEvent +) => { + const format = parseEntityFormat(wrapper); + if (format.id && format.entityType && !format.isFakeEntity) { + editor.triggerEvent('entityOperation', { + operation: 'click', + entity: { + id: format.id, + type: format.entityType, + isReadonly: !!format.isReadonly, + wrapper, + }, + rawEvent: rawEvent, + }); + } +}; + +const getSelectedEntity = (selection: RangeSelection) => { + let node = selection.range.startContainer; + while (node && node.parentElement && !isEntityElement(node)) { + node = node.parentElement; + } + return isEntityElement(node) ? node : null; +}; From e6b6ba38146dab9f295483a5d118ed4fb15ae611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Fri, 8 Mar 2024 16:18:33 -0300 Subject: [PATCH 03/73] WIP --- .../corePlugin/utils/entityDelimiterUtils.ts | 12 +- .../corePlugin/utils/delimiterUtilsTest.ts | 112 +++++++++++++++++- 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts b/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts index b1548eff5c6..3a30693bb4a 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts @@ -1,6 +1,5 @@ import { isCharacterValue } from '../../publicApi/domUtils/eventUtils'; import { iterateSelections } from '../../publicApi/selection/iterateSelections'; - import type { CompositionEndEvent, ContentModelBlockGroup, @@ -203,7 +202,7 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent return; } const isEnter = rawEvent.key === 'Enter'; - if (selection.range.collapsed && isCharacterValue(rawEvent)) { + if (selection.range.collapsed && (isCharacterValue(rawEvent) || isEnter)) { const helper = editor.getDOMHelper(); const node = getFocusedElement(selection); if (node && isEntityDelimiter(node) && helper.isNodeInEditor(node)) { @@ -211,10 +210,6 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent if (blockEntityContainer && helper.isNodeInEditor(blockEntityContainer)) { const isAfter = node.classList.contains(DelimiterAfter); - if (isEnter) { - event.rawEvent.preventDefault(); - } - if (isAfter) { selection.range.setStartAfter(blockEntityContainer); } else { @@ -222,6 +217,10 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent } selection.range.collapse(true /* toStart */); + if (isEnter) { + event.rawEvent.preventDefault(); + } + editor.formatContentModel(handleKeyDownInBlockDelimiter, { selectionOverride: { type: 'range', @@ -246,7 +245,6 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent } else { const helper = editor.getDOMHelper(); const entity = getSelectedEntity(selection); - if (entity && isNodeOfType(entity, 'ELEMENT_NODE') && helper.isNodeInEditor(entity)) { selection.range.selectNode(entity); if (isEnter) { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts index 985a08c5640..3fda02263ec 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts @@ -17,6 +17,7 @@ const BlockEntityContainer = '_E_EBlockEntityContainer'; describe('EntityDelimiterUtils |', () => { let queryElementsSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; + let triggerPluginEventSpy: jasmine.Spy; let mockedEditor: any; beforeEach(() => { mockedEditor = ({ @@ -24,6 +25,7 @@ describe('EntityDelimiterUtils |', () => { queryElements: queryElementsSpy, isNodeInEditor: () => true, }), + triggerPluginEvent: triggerPluginEventSpy, getPendingFormat: ((): any => null), }) as Partial; }); @@ -499,7 +501,7 @@ describe('EntityDelimiterUtils |', () => { ); }); - it('Handle, range selection & delimiter after wrapped in block entity', () => { + it('Handle, range selection & delimiter after wrapped in block entity | Enter key', () => { const div = document.createElement('div'); const parent = document.createElement('span'); const el = document.createElement('span'); @@ -553,6 +555,114 @@ describe('EntityDelimiterUtils |', () => { } ); }); + + it('Handle, range expanded selection ', () => { + const div = document.createElement('div'); + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterAfter'); + div.classList.add(BlockEntityContainer); + div.appendChild(parent); + + const setStartBeforeSpy = jasmine.createSpy('setStartBeforeSpy'); + const setStartAfterSpy = jasmine.createSpy('setStartAfterSpy'); + const collapseSpy = jasmine.createSpy('collapseSpy'); + const preventDefaultSpy = jasmine.createSpy('preventDefaultSpy'); + const selectNodeSpy = jasmine.createSpy('selectNode'); + + mockedSelection = { + type: 'range', + range: { + endContainer: text, + endOffset: 0, + collapsed: false, + setStartAfter: setStartAfterSpy, + setStartBefore: setStartBeforeSpy, + collapse: collapseSpy, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityElement').and.returnValue(true); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + preventDefault: preventDefaultSpy, + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).toHaveBeenCalled(); + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(collapseSpy).not.toHaveBeenCalled(); + expect(selectNodeSpy).toHaveBeenCalledTimes(1); + expect(selectNodeSpy).toHaveBeenCalledWith(el); + }); + + it('Handle, range expanded selection | EnterKey', () => { + const div = document.createElement('div'); + const parent = document.createElement('span'); + const el = document.createElement('span'); + const text = document.createTextNode('span'); + el.appendChild(text); + parent.appendChild(el); + el.classList.add('entityDelimiterAfter'); + div.classList.add(BlockEntityContainer); + div.appendChild(parent); + + const setStartBeforeSpy = jasmine.createSpy('setStartBeforeSpy'); + const setStartAfterSpy = jasmine.createSpy('setStartAfterSpy'); + const collapseSpy = jasmine.createSpy('collapseSpy'); + const preventDefaultSpy = jasmine.createSpy('preventDefaultSpy'); + const selectNodeSpy = jasmine.createSpy('selectNode'); + + mockedSelection = { + type: 'range', + range: { + endContainer: text, + endOffset: 0, + collapsed: false, + setStartAfter: setStartAfterSpy, + setStartBefore: setStartBeforeSpy, + collapse: collapseSpy, + }, + isReverted: false, + }; + spyOn(entityUtils, 'isEntityElement').and.returnValue(true); + + handleDelimiterKeyDownEvent(mockedEditor, { + eventType: 'keyDown', + rawEvent: { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'A', + preventDefault: preventDefaultSpy, + }, + }); + + expect(rafSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); + expect(setStartBeforeSpy).not.toHaveBeenCalled(); + expect(collapseSpy).not.toHaveBeenCalled(); + expect(selectNodeSpy).toHaveBeenCalledTimes(1); + expect(selectNodeSpy).toHaveBeenCalledWith(el); + expect(triggerPluginEventSpy).toHaveBeenCalledWith('entityOperation', { + operation: 'click', + entityType: 'span', + format: {}, + entity: el, + }); + }); }); }); From 8dad9cb6df4526bca730189b56741ee67a25cb7f Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Fri, 15 Mar 2024 16:49:46 -0300 Subject: [PATCH 04/73] import model button --- .../inputDialog/component/InputDialog.tsx | 28 +++++++++++-- .../inputDialog/component/InputDialogItem.tsx | 5 ++- .../inputDialog/utils/showInputDialog.tsx | 4 +- .../contentModel/ContentModelPane.tsx | 3 +- .../contentModel/buttons/importModelButton.ts | 41 +++++++++++++++++++ 5 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts diff --git a/demo/scripts/controlsV2/roosterjsReact/inputDialog/component/InputDialog.tsx b/demo/scripts/controlsV2/roosterjsReact/inputDialog/component/InputDialog.tsx index 2307e35c966..958a4ea2f36 100644 --- a/demo/scripts/controlsV2/roosterjsReact/inputDialog/component/InputDialog.tsx +++ b/demo/scripts/controlsV2/roosterjsReact/inputDialog/component/InputDialog.tsx @@ -1,9 +1,15 @@ import * as React from 'react'; import InputDialogItem from './InputDialogItem'; import { DefaultButton, PrimaryButton } from '@fluentui/react/lib/Button'; -import { Dialog, DialogFooter, DialogType } from '@fluentui/react/lib/Dialog'; import { getLocalizedString } from '../../common/index'; import { getObjectKeys } from 'roosterjs-content-model-dom'; +import { + Dialog, + DialogFooter, + DialogType, + IDialogContentProps, + IDialogStyles, +} from '@fluentui/react/lib/Dialog'; import type { DialogItem } from '../type/DialogItem'; import type { CancelButtonStringKey, @@ -26,6 +32,7 @@ export interface InputDialogProps Record | null; onOk: (values: Record) => void; onCancel: () => void; + rows?: number; } /** @@ -34,11 +41,25 @@ export interface InputDialogProps( props: InputDialogProps ) { - const { items, strings, dialogTitleKey, unlocalizedTitle, onOk, onCancel, onChange } = props; - const dialogContentProps = React.useMemo( + const { + items, + strings, + dialogTitleKey, + unlocalizedTitle, + onOk, + onCancel, + onChange, + rows, + } = props; + const dialogContentProps: IDialogContentProps = React.useMemo( () => ({ type: DialogType.normal, title: getLocalizedString(strings, dialogTitleKey, unlocalizedTitle), + styles: { + innerContent: { + height: rows ? '200px' : undefined, + }, + }, }), [strings, dialogTitleKey, unlocalizedTitle] ); @@ -80,6 +101,7 @@ export default function InputDialog ))} diff --git a/demo/scripts/controlsV2/roosterjsReact/inputDialog/component/InputDialogItem.tsx b/demo/scripts/controlsV2/roosterjsReact/inputDialog/component/InputDialogItem.tsx index 4c642972037..69a62c97249 100644 --- a/demo/scripts/controlsV2/roosterjsReact/inputDialog/component/InputDialogItem.tsx +++ b/demo/scripts/controlsV2/roosterjsReact/inputDialog/component/InputDialogItem.tsx @@ -15,6 +15,7 @@ export interface InputDialogItemProps; onEnterKey: () => void; onChanged: (itemName: ItemNames, newValue: string) => void; + rows?: number; } const classNames = mergeStyleSets({ @@ -33,7 +34,7 @@ const classNames = mergeStyleSets({ export default function InputDialogItem( props: InputDialogItemProps ) { - const { itemName, strings, items, currentValues, onChanged, onEnterKey } = props; + const { itemName, strings, items, currentValues, onChanged, onEnterKey, rows } = props; const { labelKey, unlocalizedLabel, autoFocus } = items[itemName]; const value = currentValues[itemName]; const onValueChange = React.useCallback( @@ -64,6 +65,8 @@ export default function InputDialogItem diff --git a/demo/scripts/controlsV2/roosterjsReact/inputDialog/utils/showInputDialog.tsx b/demo/scripts/controlsV2/roosterjsReact/inputDialog/utils/showInputDialog.tsx index 4a69eb5d869..1e7746ae38e 100644 --- a/demo/scripts/controlsV2/roosterjsReact/inputDialog/utils/showInputDialog.tsx +++ b/demo/scripts/controlsV2/roosterjsReact/inputDialog/utils/showInputDialog.tsx @@ -28,7 +28,8 @@ export function showInputDialog - ) => Record | null + ) => Record | null, + rows?: number ): Promise | null> { return new Promise | null>(resolve => { let disposer: null | (() => void) = null; @@ -49,6 +50,7 @@ export function showInputDialog ); diff --git a/demo/scripts/controlsV2/sidePane/contentModel/ContentModelPane.tsx b/demo/scripts/controlsV2/sidePane/contentModel/ContentModelPane.tsx index b7d4e006b90..a492c87dd4b 100644 --- a/demo/scripts/controlsV2/sidePane/contentModel/ContentModelPane.tsx +++ b/demo/scripts/controlsV2/sidePane/contentModel/ContentModelPane.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { ContentModelDocumentView } from './components/model/ContentModelDocumentView'; import { exportButton } from './buttons/exportButton'; +import { importModelButton } from './buttons/importModelButton'; import { refreshButton } from './buttons/refreshButton'; import { Ribbon, RibbonButton, RibbonPlugin } from '../../roosterjsReact/ribbon'; import { SidePaneElementProps } from '../SidePaneElement'; @@ -25,7 +26,7 @@ export class ContentModelPane extends React.Component< constructor(props: ContentModelPaneProps) { super(props); - this.contentModelButtons = [refreshButton, exportButton]; + this.contentModelButtons = [refreshButton, exportButton, importModelButton]; this.state = { model: null, diff --git a/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts b/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts new file mode 100644 index 00000000000..178f95dc6bc --- /dev/null +++ b/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts @@ -0,0 +1,41 @@ +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { showInputDialog } from '../../../roosterjsReact/inputDialog/utils/showInputDialog'; +import type { RibbonButton } from '../../../roosterjsReact/ribbon/type/RibbonButton'; + +/** + * @internal + * "Import Model" button on the format ribbon + */ +export const importModelButton: RibbonButton<'buttonNameImportModel'> = { + key: 'buttonNameImportModel', + unlocalizedText: 'Import Model', + iconName: 'Installation', + isChecked: formatState => formatState.isBold, + onClick: (editor, _, strings, uiUtilities) => { + showInputDialog( + uiUtilities, + 'buttonNameImportModel', + 'Import Model', + { + model: { + autoFocus: true, + labelKey: 'buttonNameImportModel' as const, + unlocalizedLabel: 'Insert model', + initValue: '', + }, + }, + strings, + undefined /* onChange */, + 10 /* rows */ + ).then(values => { + const importedModel = JSON.parse(values.model) as ContentModelDocument; + editor.formatContentModel(model => { + if (importedModel) { + model.blocks = importedModel.blocks; + return true; + } + return false; + }); + }); + }, +}; From ea284cf8ed845122880316d27e76dd438f656a39 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 15 Mar 2024 17:55:09 -0700 Subject: [PATCH 05/73] Enable Content Model cache by default (#2498) --- demo/scripts/controlsV2/mainPane/MainPane.tsx | 2 +- .../sidePane/editorOptions/EditorOptionsPlugin.ts | 2 +- .../sidePane/editorOptions/OptionState.ts | 2 +- .../sidePane/editorOptions/OptionsPane.tsx | 14 +++++++------- .../lib/corePlugin/CachePlugin.ts | 8 ++++---- .../test/corePlugin/CachePluginTest.ts | 14 ++++++++------ .../test/paste/e2e/testUtils.ts | 1 + .../test/tableEdit/tableResizerTest.ts | 1 - .../lib/editor/EditorOptions.ts | 6 ++++-- .../test/editor/EditorAdapterTest.ts | 3 ++- 10 files changed, 29 insertions(+), 24 deletions(-) diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index aab2f694eef..e25d50f5d88 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -300,7 +300,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { editorCreator={this.state.editorCreator} dir={this.state.isRtl ? 'rtl' : 'ltr'} knownColors={this.knownColors} - cacheModel={this.state.initState.cacheModel} + disableCache={this.state.initState.disableCache} /> )} diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 871a524ace4..88d10463987 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -32,7 +32,7 @@ const initialState: OptionState = { forcePreserveRatio: false, applyChangesOnMouseUp: false, isRtl: false, - cacheModel: true, + disableCache: false, tableFeaturesContainerSelector: '#' + 'EditorContainer', allowExcelNoBorderTable: false, imageMenu: true, diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index 2fc3eb9767d..848414a01a7 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -45,7 +45,7 @@ export interface OptionState { // Editor options isRtl: boolean; - cacheModel: boolean; + disableCache: boolean; applyChangesOnMouseUp: boolean; } diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx index cb728ff3eb2..86f76fbc50b 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionsPane.tsx @@ -31,7 +31,7 @@ export class OptionsPane extends React.Component { private exportForm = React.createRef(); private exportData = React.createRef(); private rtl = React.createRef(); - private cacheModel = React.createRef(); + private disableCache = React.createRef(); constructor(props: OptionPaneProps) { super(props); @@ -79,13 +79,13 @@ export class OptionsPane extends React.Component {
- +

@@ -133,7 +133,7 @@ export class OptionsPane extends React.Component { forcePreserveRatio: this.state.forcePreserveRatio, applyChangesOnMouseUp: this.state.applyChangesOnMouseUp, isRtl: this.state.isRtl, - cacheModel: this.state.cacheModel, + disableCache: this.state.disableCache, tableFeaturesContainerSelector: this.state.tableFeaturesContainerSelector, allowExcelNoBorderTable: this.state.allowExcelNoBorderTable, listMenu: this.state.listMenu, @@ -175,7 +175,7 @@ export class OptionsPane extends React.Component { private onToggleCacheModel = () => { this.resetState(state => { - state.cacheModel = this.cacheModel.current.checked; + state.disableCache = this.disableCache.current.checked; }, true); }; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts index 8b9cf407e7d..2ccfb3a724f 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts @@ -22,12 +22,12 @@ class CachePlugin implements PluginWithState { * @param contentDiv The editor content DIV */ constructor(option: EditorOptions, contentDiv: HTMLDivElement) { - this.state = option.cacheModel - ? { + this.state = option.disableCache + ? {} + : { domIndexer: domIndexerImpl, textMutationObserver: createTextMutationObserver(contentDiv, this.onMutation), - } - : {}; + }; } /** diff --git a/packages/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts index f5f76c1aab5..03ce3265675 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts @@ -55,7 +55,7 @@ describe('CachePlugin', () => { }); it('initialize', () => { - init({}); + init({ disableCache: true }); expect(addEventListenerSpy).toHaveBeenCalledWith('selectionchange', jasmine.anything()); expect(plugin.getState()).toEqual({}); }); @@ -71,7 +71,7 @@ describe('CachePlugin', () => { mockedObserver ); init({ - cacheModel: true, + disableCache: false, }); expect(addEventListenerSpy).toHaveBeenCalledWith('selectionchange', jasmine.anything()); expect(plugin.getState()).toEqual({ @@ -84,7 +84,7 @@ describe('CachePlugin', () => { describe('KeyDown event', () => { beforeEach(() => { - init({}); + init({ disableCache: true }); }); afterEach(() => { plugin.dispose(); @@ -177,7 +177,9 @@ describe('CachePlugin', () => { describe('Input event', () => { beforeEach(() => { - init({}); + init({ + disableCache: true, + }); }); afterEach(() => { plugin.dispose(); @@ -203,7 +205,7 @@ describe('CachePlugin', () => { describe('SelectionChanged', () => { beforeEach(() => { - init({}); + init({ disableCache: true }); }); afterEach(() => { plugin.dispose(); @@ -289,7 +291,7 @@ describe('CachePlugin', () => { describe('ContentChanged', () => { beforeEach(() => { - init({}); + init({ disableCache: true }); }); afterEach(() => { plugin.dispose(); diff --git a/packages/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts b/packages/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts index f9663cf89e1..321b1bdc630 100644 --- a/packages/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts +++ b/packages/roosterjs-content-model-plugins/test/paste/e2e/testUtils.ts @@ -9,6 +9,7 @@ export function initEditor(id: string): IEditor { let options: EditorOptions = { plugins: [new PastePlugin()], + disableCache: true, coreApiOverride: { getVisibleViewport: () => { return { diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableResizerTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableResizerTest.ts index 9a32065e85a..a1a410397c5 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableResizerTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableResizerTest.ts @@ -1,4 +1,3 @@ -import * as TestHelper from '../TestHelper'; import { getModelTable } from './tableData'; import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; import { diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts b/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts index 91847753530..0c08db6a0be 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorOptions.ts @@ -24,9 +24,11 @@ export interface EditorOptions { defaultModelToDomOptions?: ModelToDomOption; /** - * Reuse existing DOM structure if possible, and update the model when content or selection is changed + * Whether content model should be cached in order to improve editing performance. + * Pass true to disable the cache. + * @default false */ - cacheModel?: boolean; + disableCache?: boolean; /** * List of plugins. diff --git a/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts index b958bb7a5b7..ce565a221b3 100644 --- a/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts +++ b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts @@ -1,9 +1,9 @@ import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; 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, EditorCore } from 'roosterjs-content-model-types'; import { EditorAdapter } from '../../lib/editor/EditorAdapter'; import { EditorPlugin, PluginEventType } from 'roosterjs-editor-types'; -import { ContentModelDocument, EditorContext, EditorCore } from 'roosterjs-content-model-types'; const editorContext: EditorContext = { isDarkMode: false, @@ -105,6 +105,7 @@ describe('EditorAdapter', () => { }; const editor = new EditorAdapter(div, { legacyPlugins: [plugin], + disableCache: true, }); editor.dispose(); From 08a74118865da8e9c48a900fc827b348641349cc Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Fri, 15 Mar 2024 18:11:05 -0700 Subject: [PATCH 06/73] Move public type definitions into roosterjs-content-model-type package (#2502) --- .../modelApi/block/toggleModelBlockQuote.ts | 2 +- .../roosterjs-content-model-core/lib/index.ts | 14 ++--- .../modelApi/edit/deleteExpandedSelection.ts | 2 +- .../getClosestAncestorBlockGroupIndex.ts | 9 +--- .../lib/publicApi/model/isBlockGroupOfType.ts | 7 ++- .../lib/publicApi/model/mergeModel.ts | 30 +---------- .../publicApi/selection/collectSelections.ts | 25 ++------- .../publicApi/selection/iterateSelections.ts | 52 +----------------- .../lib/utils/paste/mergePasteContent.ts | 2 +- .../selection/collectSelectionsTest.ts | 2 +- .../selection/iterateSelectionsTest.ts | 6 +-- .../lib/domUtils/isNodeOfType.ts | 45 +--------------- .../roosterjs-content-model-dom/lib/index.ts | 2 +- .../test/tableEdit/tableEditPluginTest.ts | 2 +- .../lib/index.ts | 8 +++ .../lib/parameter/IterateSelectionsOption.ts | 53 +++++++++++++++++++ .../lib/parameter/MergeModelOption.ts | 30 +++++++++++ .../lib/parameter/NodeTypeMap.ts | 44 +++++++++++++++ .../lib/parameter/OperationalBlocks.ts | 22 ++++++++ .../lib/parameter/TypeOfBlockGroup.ts | 9 ++++ 20 files changed, 190 insertions(+), 176 deletions(-) create mode 100644 packages/roosterjs-content-model-types/lib/parameter/IterateSelectionsOption.ts create mode 100644 packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts create mode 100644 packages/roosterjs-content-model-types/lib/parameter/NodeTypeMap.ts create mode 100644 packages/roosterjs-content-model-types/lib/parameter/OperationalBlocks.ts create mode 100644 packages/roosterjs-content-model-types/lib/parameter/TypeOfBlockGroup.ts diff --git a/packages/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts b/packages/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts index d1cc624b43c..d02cc240643 100644 --- a/packages/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts +++ b/packages/roosterjs-content-model-api/lib/modelApi/block/toggleModelBlockQuote.ts @@ -1,7 +1,6 @@ import { areSameFormats, createFormatContainer, unwrapBlock } from 'roosterjs-content-model-dom'; import { getOperationalBlocks, isBlockGroupOfType } from 'roosterjs-content-model-core'; import { wrapBlockStep1, wrapBlockStep2 } from '../common/wrapBlock'; -import type { OperationalBlocks } from 'roosterjs-content-model-core'; import type { WrapBlockStep1Result } from '../common/wrapBlock'; import type { ContentModelBlock, @@ -10,6 +9,7 @@ import type { ContentModelFormatContainer, ContentModelFormatContainerFormat, ContentModelListItem, + OperationalBlocks, } from 'roosterjs-content-model-types'; /** diff --git a/packages/roosterjs-content-model-core/lib/index.ts b/packages/roosterjs-content-model-core/lib/index.ts index c52185ef8ee..4ac64b2a9fe 100644 --- a/packages/roosterjs-content-model-core/lib/index.ts +++ b/packages/roosterjs-content-model-core/lib/index.ts @@ -1,19 +1,12 @@ export { cloneModel } from './publicApi/model/cloneModel'; -export { mergeModel, MergeModelOption } from './publicApi/model/mergeModel'; +export { mergeModel } from './publicApi/model/mergeModel'; export { isBlockGroupOfType } from './publicApi/model/isBlockGroupOfType'; -export { - getClosestAncestorBlockGroupIndex, - TypeOfBlockGroup, -} from './publicApi/model/getClosestAncestorBlockGroupIndex'; +export { getClosestAncestorBlockGroupIndex } from './publicApi/model/getClosestAncestorBlockGroupIndex'; export { isBold } from './publicApi/model/isBold'; export { createModelFromHtml } from './publicApi/model/createModelFromHtml'; export { exportContent } from './publicApi/model/exportContent'; -export { - iterateSelections, - IterateSelectionsCallback, - IterateSelectionsOption, -} from './publicApi/selection/iterateSelections'; +export { iterateSelections } from './publicApi/selection/iterateSelections'; export { getSelectionRootNode } from './publicApi/selection/getSelectionRootNode'; export { deleteSelection } from './publicApi/selection/deleteSelection'; export { deleteSegment } from './publicApi/selection/deleteSegment'; @@ -22,7 +15,6 @@ export { hasSelectionInBlock } from './publicApi/selection/hasSelectionInBlock'; export { hasSelectionInSegment } from './publicApi/selection/hasSelectionInSegment'; export { hasSelectionInBlockGroup } from './publicApi/selection/hasSelectionInBlockGroup'; export { - OperationalBlocks, getFirstSelectedListItem, getFirstSelectedTable, getOperationalBlocks, diff --git a/packages/roosterjs-content-model-core/lib/modelApi/edit/deleteExpandedSelection.ts b/packages/roosterjs-content-model-core/lib/modelApi/edit/deleteExpandedSelection.ts index 8edc146005a..41e391e4285 100644 --- a/packages/roosterjs-content-model-core/lib/modelApi/edit/deleteExpandedSelection.ts +++ b/packages/roosterjs-content-model-core/lib/modelApi/edit/deleteExpandedSelection.ts @@ -2,7 +2,6 @@ import { deleteBlock } from '../../publicApi/selection/deleteBlock'; import { deleteSegment } from '../../publicApi/selection/deleteSegment'; import { getSegmentTextFormat } from '../../publicApi/domUtils/getSegmentTextFormat'; import { iterateSelections } from '../../publicApi/selection/iterateSelections'; -import type { IterateSelectionsOption } from '../../publicApi/selection/iterateSelections'; import type { ContentModelBlockGroup, ContentModelDocument, @@ -11,6 +10,7 @@ import type { DeleteSelectionContext, FormatContentModelContext, InsertPoint, + IterateSelectionsOption, TableSelectionContext, } from 'roosterjs-content-model-types'; import { diff --git a/packages/roosterjs-content-model-core/lib/publicApi/model/getClosestAncestorBlockGroupIndex.ts b/packages/roosterjs-content-model-core/lib/publicApi/model/getClosestAncestorBlockGroupIndex.ts index 19823cdc894..27a95cef4c5 100644 --- a/packages/roosterjs-content-model-core/lib/publicApi/model/getClosestAncestorBlockGroupIndex.ts +++ b/packages/roosterjs-content-model-core/lib/publicApi/model/getClosestAncestorBlockGroupIndex.ts @@ -1,16 +1,9 @@ import type { ContentModelBlockGroup, - ContentModelBlockGroupBase, ContentModelBlockGroupType, + TypeOfBlockGroup, } from 'roosterjs-content-model-types'; -/** - * Retrieve block group type string from a given block group - */ -export type TypeOfBlockGroup< - T extends ContentModelBlockGroup -> = T extends ContentModelBlockGroupBase ? U : never; - /** * 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 diff --git a/packages/roosterjs-content-model-core/lib/publicApi/model/isBlockGroupOfType.ts b/packages/roosterjs-content-model-core/lib/publicApi/model/isBlockGroupOfType.ts index 8c4c61003ee..a851e2fe166 100644 --- a/packages/roosterjs-content-model-core/lib/publicApi/model/isBlockGroupOfType.ts +++ b/packages/roosterjs-content-model-core/lib/publicApi/model/isBlockGroupOfType.ts @@ -1,5 +1,8 @@ -import type { ContentModelBlock, ContentModelBlockGroup } from 'roosterjs-content-model-types'; -import type { TypeOfBlockGroup } from './getClosestAncestorBlockGroupIndex'; +import type { + ContentModelBlock, + ContentModelBlockGroup, + TypeOfBlockGroup, +} from 'roosterjs-content-model-types'; /** * Check if the given content model block or block group is of the expected block group type diff --git a/packages/roosterjs-content-model-core/lib/publicApi/model/mergeModel.ts b/packages/roosterjs-content-model-core/lib/publicApi/model/mergeModel.ts index 94a90f13b35..a2d70ee5a70 100644 --- a/packages/roosterjs-content-model-core/lib/publicApi/model/mergeModel.ts +++ b/packages/roosterjs-content-model-core/lib/publicApi/model/mergeModel.ts @@ -22,39 +22,11 @@ import type { ContentModelTable, FormatContentModelContext, InsertPoint, + MergeModelOption, } from 'roosterjs-content-model-types'; const HeadingTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; -/** - * Options to specify how to merge models - */ -export interface MergeModelOption { - /** - * When there is only a table to merge, whether merge this table into current table (if any), or just directly insert (nested table). - * This is usually used when paste table inside a table - * @default false - */ - mergeTable?: boolean; - - /** - * Use this insert position to merge instead of querying selection from target model - * @default undefined - */ - insertPosition?: InsertPoint; - - /** - * Use this to decide whether to change the source model format when doing the merge. - * 'mergeAll': segment format of the insert position will be merged into the content that is merged into current model. - * If the source model already has some format, it will not be overwritten. - * 'keepSourceEmphasisFormat': format of the insert position will be set into the content that is merged into current model. - * If the source model already has emphasis format, such as, fontWeight, Italic or underline different than the default style, it will not be overwritten. - * 'none' the source segment format will not be modified. - * @default undefined - */ - mergeFormat?: 'mergeAll' | 'keepSourceEmphasisFormat' | 'none'; -} - /** * Merge source model into target mode * @param target Target Content Model that will merge content into diff --git a/packages/roosterjs-content-model-core/lib/publicApi/selection/collectSelections.ts b/packages/roosterjs-content-model-core/lib/publicApi/selection/collectSelections.ts index b70b54c1d08..2e8f9157a8a 100644 --- a/packages/roosterjs-content-model-core/lib/publicApi/selection/collectSelections.ts +++ b/packages/roosterjs-content-model-core/lib/publicApi/selection/collectSelections.ts @@ -1,7 +1,6 @@ import { getClosestAncestorBlockGroupIndex } from '../model/getClosestAncestorBlockGroupIndex'; import { isBlockGroupOfType } from '../model/isBlockGroupOfType'; import { iterateSelections } from './iterateSelections'; -import type { IterateSelectionsOption } from './iterateSelections'; import type { ContentModelBlock, ContentModelBlockGroup, @@ -11,29 +10,11 @@ import type { ContentModelParagraph, ContentModelSegment, ContentModelTable, + IterateSelectionsOption, + OperationalBlocks, TableSelectionContext, + TypeOfBlockGroup, } from 'roosterjs-content-model-types'; -import type { TypeOfBlockGroup } from '../model/getClosestAncestorBlockGroupIndex'; - -/** - * 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; - - /** - * Selection path of this block - */ - path: ContentModelBlockGroup[]; -}; /** * Get an array of selected parent paragraph and child segment pair diff --git a/packages/roosterjs-content-model-core/lib/publicApi/selection/iterateSelections.ts b/packages/roosterjs-content-model-core/lib/publicApi/selection/iterateSelections.ts index 2e1f45d84b9..9fe47705b7f 100644 --- a/packages/roosterjs-content-model-core/lib/publicApi/selection/iterateSelections.ts +++ b/packages/roosterjs-content-model-core/lib/publicApi/selection/iterateSelections.ts @@ -1,60 +1,12 @@ import type { - ContentModelBlock, ContentModelBlockGroup, ContentModelBlockWithCache, ContentModelSegment, + IterateSelectionsCallback, + IterateSelectionsOption, TableSelectionContext, } from 'roosterjs-content-model-types'; -/** - * Options for iterateSelections API - */ -export interface IterateSelectionsOption { - /** - * For selected table cell, this property determines how do we handle its content. - * include: No matter if table cell is selected, always invoke callback function for selected content (default value) - * ignoreForTable: When the whole table is selected we invoke callback for the table (using block parameter) but skip - * all its cells and content, otherwise keep invoking callback for table cell and content - * ignoreForTableOrCell: If whole table is selected, same with ignoreForTable, or if a table cell is selected, only - * invoke callback for the table cell itself but not for its content, otherwise keep invoking callback for content. - * @default include - */ - contentUnderSelectedTableCell?: 'include' | 'ignoreForTable' | 'ignoreForTableOrCell'; - - /** - * For a selected general element, this property determines how do we handle its content. - * contentOnly: (Default) When the whole general element is selected, we only invoke callback for its selected content - * generalElementOnly: When the whole general element is selected, we only invoke callback for the general element (using block or - * segment parameter depends on if it is a block or segment), but skip all its content. - * both: When general element is selected, we invoke callback first for its content, then for general element itself - */ - contentUnderSelectedGeneralElement?: 'contentOnly' | 'generalElementOnly' | 'both'; - - /** - * Whether call the callback for the list item format holder segment - * anySegment: call the callback if any segment is selected under a list item - * allSegments: call the callback only when all segments under the list item are selected - * never: never call the callback for list item format holder - * @default allSegments - */ - includeListFormatHolder?: 'anySegment' | 'allSegments' | 'never'; -} - -/** - * 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 = ( - path: ContentModelBlockGroup[], - tableContext?: TableSelectionContext, - block?: ContentModelBlock, - segments?: ContentModelSegment[] -) => void | boolean; - /** * Iterate all selected elements in a given model * @param group The given Content Model to iterate selection from diff --git a/packages/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts b/packages/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts index d28afd81b7e..3564585cf01 100644 --- a/packages/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts +++ b/packages/roosterjs-content-model-core/lib/utils/paste/mergePasteContent.ts @@ -5,7 +5,6 @@ import { domToContentModel } from 'roosterjs-content-model-dom'; import { getSegmentTextFormat } from '../../publicApi/domUtils/getSegmentTextFormat'; import { getSelectedSegments } from '../../publicApi/selection/collectSelections'; import { mergeModel } from '../../publicApi/model/mergeModel'; -import type { MergeModelOption } from '../../publicApi/model/mergeModel'; import type { BeforePasteEvent, ClipboardData, @@ -13,6 +12,7 @@ import type { ContentModelDocument, ContentModelSegmentFormat, IEditor, + MergeModelOption, } from 'roosterjs-content-model-types'; const EmptySegmentFormat: Required = { diff --git a/packages/roosterjs-content-model-core/test/publicApi/selection/collectSelectionsTest.ts b/packages/roosterjs-content-model-core/test/publicApi/selection/collectSelectionsTest.ts index afc3fe35601..2b79d18aa5e 100644 --- a/packages/roosterjs-content-model-core/test/publicApi/selection/collectSelectionsTest.ts +++ b/packages/roosterjs-content-model-core/test/publicApi/selection/collectSelectionsTest.ts @@ -6,6 +6,7 @@ import { ContentModelParagraph, ContentModelSegment, ContentModelTable, + OperationalBlocks, TableSelectionContext, } from 'roosterjs-content-model-types'; import { @@ -26,7 +27,6 @@ import { getFirstSelectedListItem, getFirstSelectedTable, getOperationalBlocks, - OperationalBlocks, getSelectedSegmentsAndParagraphs, } from '../../../lib/publicApi/selection/collectSelections'; diff --git a/packages/roosterjs-content-model-core/test/publicApi/selection/iterateSelectionsTest.ts b/packages/roosterjs-content-model-core/test/publicApi/selection/iterateSelectionsTest.ts index 9bb7a27769c..dd30d975590 100644 --- a/packages/roosterjs-content-model-core/test/publicApi/selection/iterateSelectionsTest.ts +++ b/packages/roosterjs-content-model-core/test/publicApi/selection/iterateSelectionsTest.ts @@ -1,3 +1,5 @@ +import { iterateSelections } from '../../../lib/publicApi/selection/iterateSelections'; +import { IterateSelectionsCallback } from 'roosterjs-content-model-types'; import { addSegment, createContentModelDocument, @@ -14,10 +16,6 @@ import { createTableCell, createText, } from 'roosterjs-content-model-dom'; -import { - iterateSelections, - IterateSelectionsCallback, -} from '../../../lib/publicApi/selection/iterateSelections'; describe('iterateSelections', () => { let callback: jasmine.Spy; diff --git a/packages/roosterjs-content-model-dom/lib/domUtils/isNodeOfType.ts b/packages/roosterjs-content-model-dom/lib/domUtils/isNodeOfType.ts index d6a2e08462b..08c37f2986c 100644 --- a/packages/roosterjs-content-model-dom/lib/domUtils/isNodeOfType.ts +++ b/packages/roosterjs-content-model-dom/lib/domUtils/isNodeOfType.ts @@ -1,47 +1,4 @@ -/** - * A type map from node type number to its type declaration. This is used by utility function isNodeOfType() - */ -export interface NodeTypeMap { - /** - * Attribute node - */ - ATTRIBUTE_NODE: Attr; - - /** - * Comment node - */ - COMMENT_NODE: Comment; - - /** - * DocumentFragment node - */ - DOCUMENT_FRAGMENT_NODE: DocumentFragment; - - /** - * Document node - */ - DOCUMENT_NODE: Document; - - /** - * DocumentType node - */ - DOCUMENT_TYPE_NODE: DocumentType; - - /** - * HTMLElement node - */ - ELEMENT_NODE: HTMLElement; - - /** - * ProcessingInstruction node - */ - PROCESSING_INSTRUCTION_NODE: ProcessingInstruction; - - /** - * Text node - */ - TEXT_NODE: Text; -} +import type { NodeTypeMap } from 'roosterjs-content-model-types'; /** * Type checker for Node. Return true if it of the specified node type diff --git a/packages/roosterjs-content-model-dom/lib/index.ts b/packages/roosterjs-content-model-dom/lib/index.ts index 442f048b043..b5d2cb29c62 100644 --- a/packages/roosterjs-content-model-dom/lib/index.ts +++ b/packages/roosterjs-content-model-dom/lib/index.ts @@ -15,7 +15,7 @@ export { areSameFormats } from './domToModel/utils/areSameFormats'; export { isBlockElement } from './domToModel/utils/isBlockElement'; export { updateMetadata, hasMetadata } from './domUtils/metadata/updateMetadata'; -export { isNodeOfType, NodeTypeMap } from './domUtils/isNodeOfType'; +export { isNodeOfType } from './domUtils/isNodeOfType'; export { isElementOfType } from './domUtils/isElementOfType'; export { getObjectKeys } from './domUtils/getObjectKeys'; export { toArray } from './domUtils/toArray'; diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts index 93e5b98bd9d..5b0b2187b58 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts @@ -1,6 +1,6 @@ import * as TestHelper from '../TestHelper'; import { createElement } from '../../lib/pluginUtils/CreateElement/createElement'; -import { DOMEventHandlerFunction, IEditor } from 'roosterjs-editor-types'; +import { DOMEventHandlerFunction, IEditor } from 'roosterjs-content-model-types'; import { getModelTable } from './tableData'; import { TableEditPlugin } from '../../lib/tableEdit/TableEditPlugin'; import { diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 015c491a883..30f5823abbb 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -284,6 +284,14 @@ export { DOMHelper } from './parameter/DOMHelper'; export { ImageEditOperation, ImageEditor } from './parameter/ImageEditor'; export { CachedElementHandler, CloneModelOptions } from './parameter/CloneModelOptions'; export { LinkData } from './parameter/LinkData'; +export { MergeModelOption } from './parameter/MergeModelOption'; +export { + IterateSelectionsCallback, + IterateSelectionsOption, +} from './parameter/IterateSelectionsOption'; +export { NodeTypeMap } from './parameter/NodeTypeMap'; +export { TypeOfBlockGroup } from './parameter/TypeOfBlockGroup'; +export { OperationalBlocks } from './parameter/OperationalBlocks'; export { BasePluginEvent, BasePluginDomEvent } from './event/BasePluginEvent'; export { BeforeCutCopyEvent } from './event/BeforeCutCopyEvent'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/IterateSelectionsOption.ts b/packages/roosterjs-content-model-types/lib/parameter/IterateSelectionsOption.ts new file mode 100644 index 00000000000..49b182f71be --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/parameter/IterateSelectionsOption.ts @@ -0,0 +1,53 @@ +import type { ContentModelBlock } from '../block/ContentModelBlock'; +import type { ContentModelBlockGroup } from '../group/ContentModelBlockGroup'; +import type { ContentModelSegment } from '../segment/ContentModelSegment'; +import type { TableSelectionContext } from '../selection/TableSelectionContext'; + +/** + * Options for iterateSelections API + */ +export interface IterateSelectionsOption { + /** + * For selected table cell, this property determines how do we handle its content. + * include: No matter if table cell is selected, always invoke callback function for selected content (default value) + * ignoreForTable: When the whole table is selected we invoke callback for the table (using block parameter) but skip + * all its cells and content, otherwise keep invoking callback for table cell and content + * ignoreForTableOrCell: If whole table is selected, same with ignoreForTable, or if a table cell is selected, only + * invoke callback for the table cell itself but not for its content, otherwise keep invoking callback for content. + * @default include + */ + contentUnderSelectedTableCell?: 'include' | 'ignoreForTable' | 'ignoreForTableOrCell'; + + /** + * For a selected general element, this property determines how do we handle its content. + * contentOnly: (Default) When the whole general element is selected, we only invoke callback for its selected content + * generalElementOnly: When the whole general element is selected, we only invoke callback for the general element (using block or + * segment parameter depends on if it is a block or segment), but skip all its content. + * both: When general element is selected, we invoke callback first for its content, then for general element itself + */ + contentUnderSelectedGeneralElement?: 'contentOnly' | 'generalElementOnly' | 'both'; + + /** + * Whether call the callback for the list item format holder segment + * anySegment: call the callback if any segment is selected under a list item + * allSegments: call the callback only when all segments under the list item are selected + * never: never call the callback for list item format holder + * @default allSegments + */ + includeListFormatHolder?: 'anySegment' | 'allSegments' | 'never'; +} + +/** + * 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 = ( + path: ContentModelBlockGroup[], + tableContext?: TableSelectionContext, + block?: ContentModelBlock, + segments?: ContentModelSegment[] +) => void | boolean; diff --git a/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts b/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts new file mode 100644 index 00000000000..90f84d91f52 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/parameter/MergeModelOption.ts @@ -0,0 +1,30 @@ +import type { InsertPoint } from '../selection/InsertPoint'; + +/** + * Options to specify how to merge models + */ +export interface MergeModelOption { + /** + * When there is only a table to merge, whether merge this table into current table (if any), or just directly insert (nested table). + * This is usually used when paste table inside a table + * @default false + */ + mergeTable?: boolean; + + /** + * Use this insert position to merge instead of querying selection from target model + * @default undefined + */ + insertPosition?: InsertPoint; + + /** + * Use this to decide whether to change the source model format when doing the merge. + * 'mergeAll': segment format of the insert position will be merged into the content that is merged into current model. + * If the source model already has some format, it will not be overwritten. + * 'keepSourceEmphasisFormat': format of the insert position will be set into the content that is merged into current model. + * If the source model already has emphasis format, such as, fontWeight, Italic or underline different than the default style, it will not be overwritten. + * 'none' the source segment format will not be modified. + * @default undefined + */ + mergeFormat?: 'mergeAll' | 'keepSourceEmphasisFormat' | 'none'; +} diff --git a/packages/roosterjs-content-model-types/lib/parameter/NodeTypeMap.ts b/packages/roosterjs-content-model-types/lib/parameter/NodeTypeMap.ts new file mode 100644 index 00000000000..9e1773873b8 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/parameter/NodeTypeMap.ts @@ -0,0 +1,44 @@ +/** + * A type map from node type number to its type declaration. This is used by utility function isNodeOfType() + */ +export interface NodeTypeMap { + /** + * Attribute node + */ + ATTRIBUTE_NODE: Attr; + + /** + * Comment node + */ + COMMENT_NODE: Comment; + + /** + * DocumentFragment node + */ + DOCUMENT_FRAGMENT_NODE: DocumentFragment; + + /** + * Document node + */ + DOCUMENT_NODE: Document; + + /** + * DocumentType node + */ + DOCUMENT_TYPE_NODE: DocumentType; + + /** + * HTMLElement node + */ + ELEMENT_NODE: HTMLElement; + + /** + * ProcessingInstruction node + */ + PROCESSING_INSTRUCTION_NODE: ProcessingInstruction; + + /** + * Text node + */ + TEXT_NODE: Text; +} diff --git a/packages/roosterjs-content-model-types/lib/parameter/OperationalBlocks.ts b/packages/roosterjs-content-model-types/lib/parameter/OperationalBlocks.ts new file mode 100644 index 00000000000..9f29d320eaa --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/parameter/OperationalBlocks.ts @@ -0,0 +1,22 @@ +import type { ContentModelBlock } from '../block/ContentModelBlock'; +import type { ContentModelBlockGroup } from '../group/ContentModelBlockGroup'; + +/** + * 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; + + /** + * Selection path of this block + */ + path: ContentModelBlockGroup[]; +}; diff --git a/packages/roosterjs-content-model-types/lib/parameter/TypeOfBlockGroup.ts b/packages/roosterjs-content-model-types/lib/parameter/TypeOfBlockGroup.ts new file mode 100644 index 00000000000..2a1dd3e01e2 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/parameter/TypeOfBlockGroup.ts @@ -0,0 +1,9 @@ +import type { ContentModelBlockGroup } from '../group/ContentModelBlockGroup'; +import type { ContentModelBlockGroupBase } from '../group/ContentModelBlockGroupBase'; + +/** + * Retrieve block group type string from a given block group + */ +export type TypeOfBlockGroup< + T extends ContentModelBlockGroup +> = T extends ContentModelBlockGroupBase ? U : never; From 5f83453470ccf9835f652d42c539cc248c44d235 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 18 Mar 2024 10:37:25 -0300 Subject: [PATCH 07/73] import model button --- .../roosterjsReact/inputDialog/component/InputDialog.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/demo/scripts/controlsV2/roosterjsReact/inputDialog/component/InputDialog.tsx b/demo/scripts/controlsV2/roosterjsReact/inputDialog/component/InputDialog.tsx index 958a4ea2f36..9576cdd066b 100644 --- a/demo/scripts/controlsV2/roosterjsReact/inputDialog/component/InputDialog.tsx +++ b/demo/scripts/controlsV2/roosterjsReact/inputDialog/component/InputDialog.tsx @@ -3,13 +3,7 @@ import InputDialogItem from './InputDialogItem'; import { DefaultButton, PrimaryButton } from '@fluentui/react/lib/Button'; import { getLocalizedString } from '../../common/index'; import { getObjectKeys } from 'roosterjs-content-model-dom'; -import { - Dialog, - DialogFooter, - DialogType, - IDialogContentProps, - IDialogStyles, -} from '@fluentui/react/lib/Dialog'; +import { Dialog, DialogFooter, DialogType, IDialogContentProps } from '@fluentui/react/lib/Dialog'; import type { DialogItem } from '../type/DialogItem'; import type { CancelButtonStringKey, From d3bd81b176719620c7ea594bc9d67e7e707ca715 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 18 Mar 2024 13:09:54 -0300 Subject: [PATCH 08/73] fixes --- .../contentModel/buttons/importModelButton.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts b/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts index 178f95dc6bc..fa3e7a7d287 100644 --- a/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts +++ b/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts @@ -28,14 +28,17 @@ export const importModelButton: RibbonButton<'buttonNameImportModel'> = { undefined /* onChange */, 10 /* rows */ ).then(values => { - const importedModel = JSON.parse(values.model) as ContentModelDocument; - editor.formatContentModel(model => { - if (importedModel) { - model.blocks = importedModel.blocks; - return true; + try { + const importedModel = JSON.parse(values.model) as ContentModelDocument; + if (importedModel && importedModel.blocks && importedModel.blocks.length > 0) { + editor.formatContentModel(model => { + model.blocks = importedModel.blocks; + return true; + }); } - return false; - }); + } catch (e) { + throw new Error('Invalid model'); + } }); }, }; From f9a70a13c06ae549f2c116531d4b156fa9b4e96a Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 18 Mar 2024 13:14:17 -0300 Subject: [PATCH 09/73] fixes --- .../sidePane/contentModel/buttons/importModelButton.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts b/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts index fa3e7a7d287..a6e69dc3f90 100644 --- a/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts +++ b/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts @@ -1,4 +1,5 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { isBlockGroupOfType } from 'roosterjs-content-model-core'; import { showInputDialog } from '../../../roosterjsReact/inputDialog/utils/showInputDialog'; import type { RibbonButton } from '../../../roosterjsReact/ribbon/type/RibbonButton'; @@ -30,7 +31,7 @@ export const importModelButton: RibbonButton<'buttonNameImportModel'> = { ).then(values => { try { const importedModel = JSON.parse(values.model) as ContentModelDocument; - if (importedModel && importedModel.blocks && importedModel.blocks.length > 0) { + if (isBlockGroupOfType(importedModel, 'Document')) { editor.formatContentModel(model => { model.blocks = importedModel.blocks; return true; From 7eca6ba7acfcd3a09dcf5cd3ac5c635ded1e0ee3 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 18 Mar 2024 13:25:45 -0300 Subject: [PATCH 10/73] remove typecast --- .../sidePane/contentModel/buttons/importModelButton.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts b/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts index a6e69dc3f90..6e00b2aa5e2 100644 --- a/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts +++ b/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts @@ -1,4 +1,3 @@ -import { ContentModelDocument } from 'roosterjs-content-model-types'; import { isBlockGroupOfType } from 'roosterjs-content-model-core'; import { showInputDialog } from '../../../roosterjsReact/inputDialog/utils/showInputDialog'; import type { RibbonButton } from '../../../roosterjsReact/ribbon/type/RibbonButton'; @@ -30,7 +29,7 @@ export const importModelButton: RibbonButton<'buttonNameImportModel'> = { 10 /* rows */ ).then(values => { try { - const importedModel = JSON.parse(values.model) as ContentModelDocument; + const importedModel = JSON.parse(values.model); if (isBlockGroupOfType(importedModel, 'Document')) { editor.formatContentModel(model => { model.blocks = importedModel.blocks; From 4c5087c1695740324b0617e277feac86d135c793 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 18 Mar 2024 09:34:39 -0700 Subject: [PATCH 11/73] FHL: Port WatermarkPlugin (#2503) * FHL: Port WatermarkPlugin * improve --- .../demoButtons/formatPainterButton.ts | 22 +- demo/scripts/controlsV2/mainPane/MainPane.tsx | 14 +- .../controlsV2/mainPane/ribbonButtons.ts | 2 - .../controlsV2/plugins/FormatPainterPlugin.ts | 58 +-- .../controlsV2/plugins/createLegacyPlugins.ts | 2 - .../editorOptions/EditorOptionsPlugin.ts | 2 +- .../sidePane/editorOptions/OptionState.ts | 4 +- .../sidePane/editorOptions/Plugins.tsx | 24 +- .../editorOptions/codes/PluginsCode.ts | 2 +- .../lib/coreApi/setDOMSelection.ts | 111 ++--- .../coreApi/setEditorStyle/ensureUniqueId.ts | 15 + .../coreApi/setEditorStyle/setEditorStyle.ts | 77 ++++ .../lib/corePlugin/LifecyclePlugin.ts | 10 +- .../lib/corePlugin/SelectionPlugin.ts | 12 - .../lib/editor/Editor.ts | 17 + .../lib/editor/coreApiMap.ts | 2 + .../test/coreApi/setContentModelTest.ts | 3 - .../test/coreApi/setDOMSelectionTest.ts | 432 ++++++------------ .../setEditorStyle/ensureUniqueIdTest.ts | 47 ++ .../setEditorStyle/setEditorStyleTest.ts | 214 +++++++++ .../test/corePlugin/LifecyclePluginTest.ts | 37 +- .../test/corePlugin/SelectionPluginTest.ts | 30 -- .../test/editor/EditorTest.ts | 34 ++ .../lib/index.ts | 2 + .../lib/watermark/WatermarkFormat.ts | 10 + .../lib/watermark/WatermarkPlugin.ts | 99 ++++ .../lib/watermark/isModelEmptyFast.ts | 31 ++ .../test/watermark/WatermarkPluginTest.ts | 119 +++++ .../test/watermark/isModelEmptyFastTest.ts | 189 ++++++++ .../lib/editor/EditorCore.ts | 28 ++ .../lib/editor/IEditor.ts | 13 + .../lib/index.ts | 1 + .../lib/pluginState/LifecyclePluginState.ts | 5 + .../lib/pluginState/SelectionPluginState.ts | 5 - 34 files changed, 1194 insertions(+), 479 deletions(-) create mode 100644 packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts create mode 100644 packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/setEditorStyle.ts create mode 100644 packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/ensureUniqueIdTest.ts create mode 100644 packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/setEditorStyleTest.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/watermark/WatermarkFormat.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts create mode 100644 packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts create mode 100644 packages/roosterjs-content-model-plugins/test/watermark/isModelEmptyFastTest.ts diff --git a/demo/scripts/controlsV2/demoButtons/formatPainterButton.ts b/demo/scripts/controlsV2/demoButtons/formatPainterButton.ts index a5cc4a75611..ee406679c60 100644 --- a/demo/scripts/controlsV2/demoButtons/formatPainterButton.ts +++ b/demo/scripts/controlsV2/demoButtons/formatPainterButton.ts @@ -1,15 +1,19 @@ -import { FormatPainterPlugin } from '../plugins/FormatPainterPlugin'; +import { FormatPainterHandler } from '../plugins/FormatPainterPlugin'; import type { RibbonButton } from '../roosterjsReact/ribbon'; /** * @internal * "Format Painter" button on the format ribbon */ -export const formatPainterButton: RibbonButton<'formatPainter'> = { - key: 'formatPainter', - unlocalizedText: 'Format painter', - iconName: 'Brush', - onClick: () => { - FormatPainterPlugin.startFormatPainter(); - }, -}; +export function createFormatPainterButton( + handler: FormatPainterHandler +): RibbonButton<'formatPainter'> { + return { + key: 'formatPainter', + unlocalizedText: 'Format painter', + iconName: 'Brush', + onClick: () => { + handler.startFormatPainter(); + }, + }; +} diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index e25d50f5d88..df358269189 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -7,11 +7,12 @@ import { buttons, buttonsWithPopout } from './ribbonButtons'; import { Colors, EditorPlugin, IEditor, Snapshots } from 'roosterjs-content-model-types'; import { ContentModelPanePlugin } from '../sidePane/contentModel/ContentModelPanePlugin'; import { createEmojiPlugin } from '../roosterjsReact/emoji'; +import { createFormatPainterButton } from '../demoButtons/formatPainterButton'; import { createImageEditMenuProvider } from '../roosterjsReact/contextMenu/menus/createImageEditMenuProvider'; import { createLegacyPlugins } from '../plugins/createLegacyPlugins'; import { createListEditMenuProvider } from '../roosterjsReact/contextMenu/menus/createListEditMenuProvider'; import { createPasteOptionPlugin } from '../roosterjsReact/pasteOptions'; -import { createRibbonPlugin, Ribbon, RibbonPlugin } from '../roosterjsReact/ribbon'; +import { createRibbonPlugin, Ribbon, RibbonButton, RibbonPlugin } from '../roosterjsReact/ribbon'; import { Editor } from 'roosterjs-content-model-core'; import { EditorAdapter } from 'roosterjs-editor-adapter'; import { EditorOptionsPlugin } from '../sidePane/editorOptions/EditorOptionsPlugin'; @@ -41,6 +42,7 @@ import { PastePlugin, ShortcutPlugin, TableEditPlugin, + WatermarkPlugin, } from 'roosterjs-content-model-plugins'; const styles = require('./MainPane.scss'); @@ -75,6 +77,8 @@ export class MainPane extends React.Component<{}, MainPaneState> { private snapshotPlugin: SnapshotPlugin; private formatPainterPlugin: FormatPainterPlugin; private snapshots: Snapshots; + private buttons: RibbonButton[]; + private buttonsWithPopout: RibbonButton[]; protected sidePane = React.createRef(); protected updateContentPlugin: UpdateContentPlugin; @@ -110,6 +114,10 @@ export class MainPane extends React.Component<{}, MainPaneState> { this.contentModelPanePlugin = new ContentModelPanePlugin(); this.ribbonPlugin = createRibbonPlugin(); this.formatPainterPlugin = new FormatPainterPlugin(); + + const baseButtons = [createFormatPainterButton(this.formatPainterPlugin)]; + this.buttons = baseButtons.concat(buttons); + this.buttonsWithPopout = baseButtons.concat(buttonsWithPopout); this.state = { showSidePane: window.location.hash != '', popoutWindow: null, @@ -228,7 +236,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { private renderRibbon(isPopout: boolean) { return ( @@ -417,6 +425,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { listMenu, tableMenu, imageMenu, + watermarkText, } = this.state.initState; return [ pluginList.autoFormat && new AutoFormatPlugin(), @@ -424,6 +433,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { pluginList.paste && new PastePlugin(allowExcelNoBorderTable), pluginList.shortcut && new ShortcutPlugin(), pluginList.tableEdit && new TableEditPlugin(), + pluginList.watermark && new WatermarkPlugin(watermarkText), pluginList.emoji && createEmojiPlugin(), pluginList.pasteOption && createPasteOptionPlugin(), pluginList.sampleEntity && new SampleEntityPlugin(), diff --git a/demo/scripts/controlsV2/mainPane/ribbonButtons.ts b/demo/scripts/controlsV2/mainPane/ribbonButtons.ts index e1f8e0a94d0..3baace42176 100644 --- a/demo/scripts/controlsV2/mainPane/ribbonButtons.ts +++ b/demo/scripts/controlsV2/mainPane/ribbonButtons.ts @@ -15,7 +15,6 @@ import { decreaseIndentButton } from '../roosterjsReact/ribbon/buttons/decreaseI import { exportContentButton } from '../demoButtons/exportContentButton'; import { fontButton } from '../roosterjsReact/ribbon/buttons/fontButton'; import { fontSizeButton } from '../roosterjsReact/ribbon/buttons/fontSizeButton'; -import { formatPainterButton } from '../demoButtons/formatPainterButton'; import { formatTableButton } from '../demoButtons/formatTableButton'; import { imageBorderColorButton } from '../demoButtons/imageBorderColorButton'; import { imageBorderRemoveButton } from '../demoButtons/imageBorderRemoveButton'; @@ -65,7 +64,6 @@ import { import type { RibbonButton } from '../roosterjsReact/ribbon'; export const buttons: RibbonButton[] = [ - formatPainterButton, boldButton, italicButton, underlineButton, diff --git a/demo/scripts/controlsV2/plugins/FormatPainterPlugin.ts b/demo/scripts/controlsV2/plugins/FormatPainterPlugin.ts index 8bf0968bb10..8c2798aa6a7 100644 --- a/demo/scripts/controlsV2/plugins/FormatPainterPlugin.ts +++ b/demo/scripts/controlsV2/plugins/FormatPainterPlugin.ts @@ -1,5 +1,4 @@ import { applySegmentFormat, getFormatState } from 'roosterjs-content-model-api'; -import { MainPane } from '../mainPane/MainPane'; import { ContentModelSegmentFormat, EditorPlugin, @@ -9,16 +8,26 @@ import { const FORMATPAINTERCURSOR_SVG = require('./formatpaintercursor.svg'); const FORMATPAINTERCURSOR_STYLE = `cursor: url("${FORMATPAINTERCURSOR_SVG}") 8.5 16, auto`; +const FORMAT_PAINTER_STYLE_KEY = '_FormatPainter'; + +/** + * Format painter handler works together with a format painter button tot let implement format painter functioinality + */ +export interface FormatPainterHandler { + /** + * Let editor enter format painter state + */ + startFormatPainter(): void; +} -export class FormatPainterPlugin implements EditorPlugin { +/** + * Format painter plugin helps implement format painter functionality. + * To use this plugin, you need a button to let editor enter format painter state by calling formatPainterPlugin.startFormatPainter(), + * then this plugin will handle the rest work. + */ +export class FormatPainterPlugin implements EditorPlugin, FormatPainterHandler { private editor: IEditor | null = null; - private styleNode: HTMLStyleElement | null = null; private painterFormat: ContentModelSegmentFormat | null = null; - private static instance: FormatPainterPlugin | undefined; - - constructor() { - FormatPainterPlugin.instance = this; - } getName() { return 'FormatPainter'; @@ -26,20 +35,10 @@ export class FormatPainterPlugin implements EditorPlugin { initialize(editor: IEditor) { this.editor = editor; - - const doc = this.editor.getDocument(); - this.styleNode = doc.createElement('style'); - - doc.head.appendChild(this.styleNode); } dispose() { this.editor = null; - - if (this.styleNode) { - this.styleNode.parentNode?.removeChild(this.styleNode); - this.styleNode = null; - } } onPluginEvent(event: PluginEvent) { @@ -53,26 +52,19 @@ export class FormatPainterPlugin implements EditorPlugin { } private setFormatPainterCursor(format: ContentModelSegmentFormat | null) { - const sheet = this.styleNode.sheet; - - if (this.painterFormat) { - for (let i = sheet.cssRules.length - 1; i >= 0; i--) { - sheet.deleteRule(i); - } - } - this.painterFormat = format; - if (this.painterFormat) { - sheet.insertRule(`#${MainPane.editorDivId} {${FORMATPAINTERCURSOR_STYLE}}`); - } + this.editor?.setEditorStyle( + FORMAT_PAINTER_STYLE_KEY, + this.painterFormat ? FORMATPAINTERCURSOR_STYLE : null + ); } - static startFormatPainter() { - const format = getSegmentFormat(this.instance.editor); + startFormatPainter() { + if (this.editor) { + const format = getSegmentFormat(this.editor); - if (format) { - this.instance.setFormatPainterCursor(format); + this.setFormatPainterCursor(format); } } } diff --git a/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts b/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts index f6cd26cf4dc..2d858317d1e 100644 --- a/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts +++ b/demo/scripts/controlsV2/plugins/createLegacyPlugins.ts @@ -6,7 +6,6 @@ import { HyperLink, ImageEdit, TableCellSelection, - Watermark, } from 'roosterjs-editor-plugins'; import { LegacyPluginList, @@ -28,7 +27,6 @@ export function createLegacyPlugins(initState: OptionState): LegacyEditorPlugin[ : null ) : null, - watermark: pluginList.watermark ? new Watermark(initState.watermarkText) : null, imageEdit: pluginList.imageEdit ? new ImageEdit({ preserveRatio: initState.forcePreserveRatio, diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 88d10463987..8cfea08f8bc 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -12,6 +12,7 @@ const initialState: OptionState = { shortcut: true, tableEdit: true, contextMenu: true, + watermark: true, emoji: true, pasteOption: true, sampleEntity: true, @@ -19,7 +20,6 @@ const initialState: OptionState = { // Legacy plugins contentEdit: false, hyperlink: false, - watermark: false, imageEdit: false, tableCellSelection: true, customReplace: false, diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index 848414a01a7..371ec790044 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -5,7 +5,6 @@ import type { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; export interface LegacyPluginList { contentEdit: boolean; hyperlink: boolean; - watermark: boolean; imageEdit: boolean; tableCellSelection: boolean; customReplace: boolean; @@ -19,6 +18,7 @@ export interface NewPluginList { shortcut: boolean; tableEdit: boolean; contextMenu: boolean; + watermark: boolean; emoji: boolean; pasteOption: boolean; sampleEntity: boolean; @@ -34,12 +34,12 @@ export interface OptionState { listMenu: boolean; tableMenu: boolean; imageMenu: boolean; + watermarkText: string; // Legacy plugin options contentEditFeatures: ContentEditFeatureSettings; defaultFormat: ContentModelSegmentFormat; linkTitle: string; - watermarkText: string; forcePreserveRatio: boolean; tableFeaturesContainerSelector: string; diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx index ebc2503dab6..6623560090f 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/Plugins.tsx @@ -104,7 +104,6 @@ abstract class PluginsBase extends Re export class LegacyPlugins extends PluginsBase { private linkTitle = React.createRef(); - private watermarkText = React.createRef(); private forcePreserveRatio = React.createRef(); render() { @@ -130,17 +129,6 @@ export class LegacyPlugins extends PluginsBase { (state, value) => (state.linkTitle = value) ) )} - {this.renderPluginItem( - 'watermark', - 'Watermark Plugin', - this.renderInputBox( - 'Watermark text: ', - this.watermarkText, - this.props.state.watermarkText, - '', - (state, value) => (state.watermarkText = value) - ) - )} {this.renderPluginItem( 'imageEdit', 'Image Edit Plugin', @@ -165,6 +153,7 @@ export class Plugins extends PluginsBase { private listMenu = React.createRef(); private tableMenu = React.createRef(); private imageMenu = React.createRef(); + private watermarkText = React.createRef(); render(): JSX.Element { return ( @@ -208,6 +197,17 @@ export class Plugins extends PluginsBase { )} )} + {this.renderPluginItem( + 'watermark', + 'Watermark Plugin', + this.renderInputBox( + 'Watermark text: ', + this.watermarkText, + this.props.state.watermarkText, + '', + (state, value) => (state.watermarkText = value) + ) + )} {this.renderPluginItem('emoji', 'Emoji')} {this.renderPluginItem('pasteOption', 'PasteOptions')} {this.renderPluginItem('sampleEntity', 'SampleEntity')} diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts index 7b298b17e6b..61a0953da1c 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts @@ -44,6 +44,7 @@ export class PluginsCode extends PluginsCodeBase { pluginList.paste && new PastePluginCode(), pluginList.tableEdit && new TableEditPluginCode(), pluginList.shortcut && new ShortcutPluginCode(), + pluginList.watermark && new WatermarkCode(state.watermarkText), ]); } } @@ -55,7 +56,6 @@ export class LegacyPluginCode extends PluginsCodeBase { const plugins: CodeElement[] = [ pluginList.contentEdit && new ContentEditCode(state.contentEditFeatures), pluginList.hyperlink && new HyperLinkCode(state.linkTitle), - pluginList.watermark && new WatermarkCode(state.watermarkText), pluginList.imageEdit && new ImageEditCode(), pluginList.customReplace && new CustomReplaceCode(), pluginList.tableCellSelection && new TableCellSelectionCode(), diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts index db7ba578f61..248d9578130 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts @@ -1,4 +1,5 @@ import { addRangeToSelection } from '../corePlugin/utils/addRangeToSelection'; +import { ensureUniqueId } from './setEditorStyle/ensureUniqueId'; import { isNodeOfType, toArray } from 'roosterjs-content-model-dom'; import { parseTableCells } from '../publicApi/domUtils/tableCellUtils'; import type { @@ -7,13 +8,13 @@ import type { TableSelection, } from 'roosterjs-content-model-types'; +const DOM_SELECTION_CSS_KEY = '_DOMSelection'; +const HIDE_CURSOR_CSS_KEY = '_DOMSelectionHideCursor'; 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;}'; -const CARET_CSS_RULE = '{caret-color: transparent}'; -const MAX_RULE_SELECTOR_LENGTH = 9000; +const TABLE_CSS_RULE = 'background-color:#C6C6C6!important;'; +const CARET_CSS_RULE = 'caret-color: transparent'; /** * @internal @@ -24,36 +25,44 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC const skipReselectOnFocus = core.selection.skipReselectOnFocus; const doc = core.physicalRoot.ownerDocument; - const sheet = core.selection.selectionStyleNode?.sheet; core.selection.skipReselectOnFocus = true; + core.api.setEditorStyle(core, DOM_SELECTION_CSS_KEY, null /*cssRule*/); + core.api.setEditorStyle(core, HIDE_CURSOR_CSS_KEY, null /*cssRule*/); try { - let selectionRules: string[] | undefined; - const rootSelector = '#' + addUniqueId(core.physicalRoot, 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; + core.api.setEditorStyle( + core, + DOM_SELECTION_CSS_KEY, + `outline-style:auto!important; outline-color:${ + core.selection.imageSelectionBorderColor || DEFAULT_SELECTION_BORDER_COLOR + }!important;`, + [`#${ensureUniqueId(image, IMAGE_ID)}`] + ); + core.api.setEditorStyle(core, HIDE_CURSOR_CSS_KEY, CARET_CSS_RULE); setRangeSelection(doc, image); break; case 'table': const { table, firstColumn, firstRow } = selection; - - selectionRules = buildTableCss( - rootSelector, - addUniqueId(table, TABLE_ID), + const tableSelectors = buildTableSelectors( + ensureUniqueId(table, TABLE_ID), selection ); + core.selection.selection = selection; + core.api.setEditorStyle( + core, + DOM_SELECTION_CSS_KEY, + TABLE_CSS_RULE, + tableSelectors + ); + core.api.setEditorStyle(core, HIDE_CURSOR_CSS_KEY, CARET_CSS_RULE); setRangeSelection(doc, table.rows[firstRow]?.cells[firstColumn]); break; @@ -67,18 +76,6 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC 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; } @@ -93,20 +90,7 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC } }; -function buildImageCSS(editorSelector: string, imageId: string, borderColor?: string): string[] { - const color = borderColor || DEFAULT_SELECTION_BORDER_COLOR; - - return [ - `${editorSelector} #${imageId} {outline-style:auto!important;outline-color:${color}!important;}`, - `${editorSelector} ${CARET_CSS_RULE}`, - ]; -} - -function buildTableCss( - editorSelector: string, - tableId: string, - selection: TableSelection -): string[] { +function buildTableSelectors(tableId: string, selection: TableSelection): string[] { const { firstColumn, firstRow, lastColumn, lastRow } = selection; const cells = parseTableCells(selection.table); const isAllTableSelected = @@ -114,31 +98,13 @@ function buildTableCss( firstColumn == 0 && lastRow == cells.length - 1 && lastColumn == (cells[lastRow]?.length ?? 0) - 1; - const rootSelector = editorSelector + ' #' + tableId; - const selectors = isAllTableSelected - ? [rootSelector, `${rootSelector} *`] - : handleTableSelected(rootSelector, selection, cells); - - const cssRules: string[] = [`${editorSelector} ${CARET_CSS_RULE}`]; - 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; + return isAllTableSelected + ? [`#${tableId}`, `#${tableId} *`] + : handleTableSelected(tableId, selection, cells); } function handleTableSelected( - rootSelector: string, + tableId: string, selection: TableSelection, cells: (HTMLTableCellElement | null)[][] ) { @@ -189,7 +155,7 @@ function handleTableSelected( cellIndex >= firstColumn && cellIndex <= lastColumn ) { - const selector = `${rootSelector}${middleElSelector} tr:nth-child(${currentRow})>${cell.tagName}:nth-child(${tdCount})`; + const selector = `#${tableId}${middleElSelector} tr:nth-child(${currentRow})>${cell.tagName}:nth-child(${tdCount})`; selectors.push(selector, selector + ' *'); } @@ -210,16 +176,3 @@ function setRangeSelection(doc: Document, element: HTMLElement | undefined) { 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/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts b/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts new file mode 100644 index 00000000000..c3cb20c5363 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/ensureUniqueId.ts @@ -0,0 +1,15 @@ +/** + * @internal + */ +export function ensureUniqueId(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/roosterjs-content-model-core/lib/coreApi/setEditorStyle/setEditorStyle.ts b/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/setEditorStyle.ts new file mode 100644 index 00000000000..a26a905e1f4 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/coreApi/setEditorStyle/setEditorStyle.ts @@ -0,0 +1,77 @@ +import { ensureUniqueId } from './ensureUniqueId'; +import type { SetEditorStyle } from 'roosterjs-content-model-types'; + +const MAX_RULE_SELECTOR_LENGTH = 9000; +const CONTENT_DIV_ID = 'contentDiv'; + +/** + * @internal + */ +export const setEditorStyle: SetEditorStyle = ( + core, + key, + cssRule, + subSelectors, + maxRuleLength = MAX_RULE_SELECTOR_LENGTH +) => { + let styleElement = core.lifecycle.styleElements[key]; + + if (!styleElement && cssRule) { + const doc = core.physicalRoot.ownerDocument; + + styleElement = doc.createElement('style'); + doc.head.appendChild(styleElement); + + styleElement.dataset.roosterjsStyleKey = key; + core.lifecycle.styleElements[key] = styleElement; + } + + const sheet = styleElement?.sheet; + + if (sheet) { + for (let i = sheet.cssRules.length - 1; i >= 0; i--) { + sheet.deleteRule(i); + } + + if (cssRule) { + const rootSelector = '#' + ensureUniqueId(core.physicalRoot, CONTENT_DIV_ID); + const selectors = !subSelectors + ? [rootSelector] + : typeof subSelectors === 'string' + ? [`${rootSelector}::${subSelectors}`] + : buildSelectors( + rootSelector, + subSelectors, + maxRuleLength - cssRule.length - 3 // minus 3 for " {}" + ); + + selectors.forEach(selector => { + sheet.insertRule(`${selector} {${cssRule}}`); + }); + } + } +}; + +function buildSelectors(rootSelector: string, subSelectors: string[], maxLen: number): string[] { + const result: string[] = []; + + let stringBuilder: string[] = []; + let len = 0; + + subSelectors.forEach(subSelector => { + if (len >= maxLen) { + result.push(stringBuilder.join(',')); + stringBuilder = []; + len = 0; + } + + const selector = `${rootSelector} ${subSelector}`; + + len += selector.length + 1; // Add 1 for potential "," between selectors + stringBuilder.push(selector); + }); + + result.push(stringBuilder.join(',')); + + return result; +} diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts index e98d81313d4..e01e9df0463 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts @@ -1,5 +1,5 @@ import { ChangeSource } from '../constants/ChangeSource'; -import { setColor } from 'roosterjs-content-model-dom'; +import { getObjectKeys, setColor } from 'roosterjs-content-model-dom'; import type { IEditor, LifecyclePluginState, @@ -48,6 +48,7 @@ class LifecyclePlugin implements PluginWithState { this.state = { isDarkMode: !!options.inDarkMode, shadowEditFragment: null, + styleElements: {}, }; } @@ -81,6 +82,13 @@ class LifecyclePlugin implements PluginWithState { dispose() { this.editor?.triggerEvent('beforeDispose', {}, true /*broadcast*/); + getObjectKeys(this.state.styleElements).forEach(key => { + const element = this.state.styleElements[key]; + + element.parentElement?.removeChild(element); + delete this.state.styleElements[key]; + }); + if (this.disposer) { this.disposer(); this.disposer = null; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts index 535c686a52f..9c6c08f99a7 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts @@ -22,7 +22,6 @@ class SelectionPlugin implements PluginWithState { constructor(options: EditorOptions) { this.state = { selection: null, - selectionStyleNode: null, imageSelectionBorderColor: options.imageSelectionBorderColor, }; } @@ -34,12 +33,6 @@ class SelectionPlugin implements PluginWithState { initialize(editor: IEditor) { this.editor = editor; - 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(); @@ -62,11 +55,6 @@ class SelectionPlugin implements PluginWithState { ?.getDocument() .removeEventListener('selectionchange', this.onSelectionChangeSafari); - if (this.state.selectionStyleNode) { - this.state.selectionStyleNode.parentNode?.removeChild(this.state.selectionStyleNode); - this.state.selectionStyleNode = null; - } - if (this.disposer) { this.disposer(); this.disposer = null; diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 7b4f4a6660a..7ce41d6581f 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -371,6 +371,23 @@ export class Editor implements IEditor { return this.getCore().api.getVisibleViewport(this.getCore()); } + /** + * Add CSS rules for editor + * @param key A string to identify the CSS rule type. When set CSS rules with the same key again, existing rules with the same key will be replaced. + * @param cssRule The CSS rule string, must be a valid CSS rule string, or browser may throw exception. Pass null to clear existing rules + * @param subSelectors @optional If the rule is used for child element under editor, use this parameter to specify the child elements. Each item will be + * combined with root selector together to build a separate rule. + */ + setEditorStyle( + key: string, + cssRule: string | null, + subSelectors?: 'before' | 'after' | string[] + ): void { + const core = this.getCore(); + + core.api.setEditorStyle(core, key, cssRule, subSelectors); + } + /** * @returns the current EditorCore object * @throws a standard Error if there's no core object diff --git a/packages/roosterjs-content-model-core/lib/editor/coreApiMap.ts b/packages/roosterjs-content-model-core/lib/editor/coreApiMap.ts index 06d323c5227..11e2abccbc2 100644 --- a/packages/roosterjs-content-model-core/lib/editor/coreApiMap.ts +++ b/packages/roosterjs-content-model-core/lib/editor/coreApiMap.ts @@ -9,6 +9,7 @@ import { getVisibleViewport } from '../coreApi/getVisibleViewport'; import { restoreUndoSnapshot } from '../coreApi/restoreUndoSnapshot'; import { setContentModel } from '../coreApi/setContentModel'; import { setDOMSelection } from '../coreApi/setDOMSelection'; +import { setEditorStyle } from '../coreApi/setEditorStyle/setEditorStyle'; import { switchShadowEdit } from '../coreApi/switchShadowEdit'; import { triggerEvent } from '../coreApi/triggerEvent'; import type { CoreApiMap } from 'roosterjs-content-model-types'; @@ -35,4 +36,5 @@ export const coreApiMap: CoreApiMap = { switchShadowEdit: switchShadowEdit, getVisibleViewport: getVisibleViewport, + setEditorStyle: setEditorStyle, }; diff --git a/packages/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts index af1d6ae755b..cf4774a4b1d 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts @@ -159,7 +159,6 @@ describe('setContentModel', () => { core.selection = { selection: null, - selectionStyleNode: null, }; setContentModel(core, mockedModel, { ignoreSelection: true, @@ -192,7 +191,6 @@ describe('setContentModel', () => { core.selection = { selection: null, - selectionStyleNode: null, }; setContentModel(core, mockedModel, { ignoreSelection: true, @@ -221,7 +219,6 @@ describe('setContentModel', () => { core.selection = { selection: null, - selectionStyleNode: null, }; setContentModel(core, mockedModel, { ignoreSelection: true, diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts index d7859196dd8..ea75d2bbc27 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts @@ -9,12 +9,10 @@ describe('setDOMSelection', () => { let triggerEventSpy: jasmine.Spy; let addRangeToSelectionSpy: jasmine.Spy; let createRangeSpy: jasmine.Spy; - let deleteRuleSpy: jasmine.Spy; - let insertRuleSpy: jasmine.Spy; + let setEditorStyleSpy: jasmine.Spy; let doc: Document; let contentDiv: HTMLDivElement; let mockedRange = 'RANGE' as any; - let mockedStyleNode: HTMLStyleElement; beforeEach(() => { querySelectorAllSpy = jasmine.createSpy('querySelectorAll'); @@ -26,8 +24,7 @@ describe('setDOMSelection', () => { } ); createRangeSpy = jasmine.createSpy('createRange'); - deleteRuleSpy = jasmine.createSpy('deleteRule'); - insertRuleSpy = jasmine.createSpy('insertRule'); + setEditorStyleSpy = jasmine.createSpy('setEditorStyle'); doc = { querySelectorAll: querySelectorAllSpy, @@ -37,22 +34,14 @@ describe('setDOMSelection', () => { contentDiv = { ownerDocument: doc, } as any; - mockedStyleNode = { - sheet: { - cssRules: [], - deleteRule: deleteRuleSpy, - insertRule: insertRuleSpy, - }, - } as any; core = { physicalRoot: contentDiv, logicalRoot: contentDiv, - selection: { - selectionStyleNode: mockedStyleNode, - }, + selection: {}, api: { triggerEvent: triggerEventSpy, + setEditorStyle: setEditorStyleSpy, }, domHelper: { hasFocus: hasFocusSpy, @@ -67,14 +56,12 @@ describe('setDOMSelection', () => { 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, @@ -85,11 +72,9 @@ describe('setDOMSelection', () => { 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(); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); } it('From null selection', () => { @@ -131,8 +116,6 @@ describe('setDOMSelection', () => { isReverted: false, } as any; - (core.selection.selectionStyleNode!.sheet!.cssRules as any) = ['Rule1', 'Rule2']; - querySelectorAllSpy.and.returnValue([]); hasFocusSpy.and.returnValue(true); @@ -141,8 +124,10 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: null, - selectionStyleNode: mockedStyleNode, } as any); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); expect(triggerEventSpy).toHaveBeenCalledWith( core, { @@ -156,41 +141,6 @@ describe('setDOMSelection', () => { mockedRange, false /* isReverted */ ); - 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: 'selectionChanged', - newSelection: mockedSelection, - }, - true - ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, undefined); - 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', () => { @@ -208,13 +158,12 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: null, - selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).not.toHaveBeenCalled(); expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); - expect(contentDiv.id).toBe('contentDiv_0'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).not.toHaveBeenCalled(); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); }); it('range selection, editor id is unique, editor does not have focus', () => { @@ -232,107 +181,6 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, - selectionStyleNode: mockedStyleNode, - } as any); - expect(triggerEventSpy).toHaveBeenCalledWith( - core, - { - eventType: 'selectionChanged', - newSelection: mockedSelection, - }, - true - ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); - 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, - isReverted: false, - } 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: 'selectionChanged', - newSelection: mockedSelection, - }, - true - ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); - 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, - isReverted: false, - } 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: 'selectionChanged', - newSelection: mockedSelection, - }, - true - ); - expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); - 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, - isReverted: false, - } 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, @@ -343,9 +191,9 @@ describe('setDOMSelection', () => { true ); expect(addRangeToSelectionSpy).toHaveBeenCalledWith(doc, mockedRange, false); - expect(contentDiv.id).toBe('testId_1'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).not.toHaveBeenCalled(); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); }); }); @@ -380,7 +228,6 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, - selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -393,13 +240,20 @@ describe('setDOMSelection', () => { 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).toHaveBeenCalledTimes(2); - expect(insertRuleSpy).toHaveBeenCalledWith('#contentDiv_0 {caret-color: transparent}'); - expect(insertRuleSpy).toHaveBeenCalledWith( - '#contentDiv_0 #image_0 {outline-style:auto!important;outline-color:#DB626C!important;}' + expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelection', + 'outline-style:auto!important; outline-color:#DB626C!important;', + ['#image_0'] + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + 'caret-color: transparent' ); }); @@ -428,7 +282,6 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, - selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -441,13 +294,19 @@ describe('setDOMSelection', () => { 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).toHaveBeenCalledTimes(2); - expect(insertRuleSpy).toHaveBeenCalledWith('#contentDiv_0 {caret-color: transparent}'); - expect(insertRuleSpy).toHaveBeenCalledWith( - '#contentDiv_0 #image_0_0 {outline-style:auto!important;outline-color:#DB626C!important;}' + expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelection', + 'outline-style:auto!important; outline-color:#DB626C!important;', + ['#image_0_0'] + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + 'caret-color: transparent' ); }); @@ -475,7 +334,6 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, - selectionStyleNode: mockedStyleNode, imageSelectionBorderColor: 'red', } as any); expect(triggerEventSpy).toHaveBeenCalledWith( @@ -489,13 +347,19 @@ describe('setDOMSelection', () => { 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).toHaveBeenCalledTimes(2); - expect(insertRuleSpy).toHaveBeenCalledWith('#contentDiv_0 {caret-color: transparent}'); - expect(insertRuleSpy).toHaveBeenCalledWith( - '#contentDiv_0 #image_0 {outline-style:auto!important;outline-color:red!important;}' + expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelection', + 'outline-style:auto!important; outline-color:red!important;', + ['#image_0'] + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + 'caret-color: transparent' ); }); @@ -523,7 +387,6 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, - selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -536,13 +399,20 @@ describe('setDOMSelection', () => { expect(selectNodeSpy).not.toHaveBeenCalled(); expect(collapseSpy).not.toHaveBeenCalled(); expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); - expect(contentDiv.id).toBe('contentDiv_0'); expect(mockedImage.id).toBe('image_0'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).toHaveBeenCalledTimes(2); - expect(insertRuleSpy).toHaveBeenCalledWith('#contentDiv_0 {caret-color: transparent}'); - expect(insertRuleSpy).toHaveBeenCalledWith( - '#contentDiv_0 #image_0 {outline-style:auto!important;outline-color:#DB626C!important;}' + expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelection', + 'outline-style:auto!important; outline-color:#DB626C!important;', + ['#image_0'] + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + 'caret-color: transparent' ); }); }); @@ -580,7 +450,6 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, - selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -593,11 +462,22 @@ describe('setDOMSelection', () => { 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).toHaveBeenCalledTimes(1); - expect(insertRuleSpy).toHaveBeenCalledWith('#contentDiv_0 {caret-color: transparent}'); + + expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelection', + 'background-color:#C6C6C6!important;', + [] + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + 'caret-color: transparent' + ); }); function runTest( @@ -606,7 +486,7 @@ describe('setDOMSelection', () => { firstRow: number, lastColumn: number, lastRow: number, - ...result: string[] + result: string[] ) { const mockedSelection = { type: 'table', @@ -633,7 +513,6 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, selection: mockedSelection, - selectionStyleNode: mockedStyleNode, } as any); expect(triggerEventSpy).toHaveBeenCalledWith( core, @@ -643,38 +522,39 @@ describe('setDOMSelection', () => { }, true ); - expect(contentDiv.id).toBe('contentDiv_0'); expect(mockedTable.id).toBe('table_0'); - expect(deleteRuleSpy).not.toHaveBeenCalled(); - expect(insertRuleSpy).toHaveBeenCalledTimes(result.length); - - result.forEach(rule => { - expect(insertRuleSpy).toHaveBeenCalledWith(rule); - }); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelection', + 'background-color:#C6C6C6!important;', + result + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + 'caret-color: transparent' + ); } it('Select Table Cells TR under Table Tag', () => { - runTest( - buildTable(true), - 1, - 0, - 1, - 1, - '#contentDiv_0 {caret-color: transparent}', - '#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;}' - ); + runTest(buildTable(true), 1, 0, 1, 1, [ + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2)', + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *', + '#table_0>TBODY> tr:nth-child(2)>TD:nth-child(2)', + '#table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) *', + ]); }); it('Select Table Cells TBODY', () => { - runTest( - buildTable(false), - 0, - 0, - 0, - 1, - '#contentDiv_0 {caret-color: transparent}', - '#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;}' - ); + runTest(buildTable(false), 0, 0, 0, 1, [ + '#table_0> tr:nth-child(1)>TD:nth-child(1)', + '#table_0> tr:nth-child(1)>TD:nth-child(1) *', + '#table_0> tr:nth-child(2)>TD:nth-child(1)', + '#table_0> tr:nth-child(2)>TD:nth-child(1) *', + ]); }); it('Select TH and TR in the same row', () => { @@ -699,75 +579,59 @@ describe('setDOMSelection', () => { table.appendChild(tr1); table.appendChild(tr2); - runTest( - table, - 0, - 0, - 0, - 1, - '#contentDiv_0 {caret-color: transparent}', - '#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;}' - ); + runTest(table, 0, 0, 0, 1, [ + '#table_0> tr:nth-child(1)>TH:nth-child(1)', + '#table_0> tr:nth-child(1)>TH:nth-child(1) *', + '#table_0> tr:nth-child(2)>TH:nth-child(1)', + '#table_0> tr:nth-child(2)>TH:nth-child(1) *', + ]); }); it('Select Table Cells THEAD, TBODY', () => { - runTest( - buildTable(true /* tbody */, true /* thead */), - 1, - 1, - 2, - 2, - '#contentDiv_0 {caret-color: transparent}', - '#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;}' - ); + runTest(buildTable(true /* tbody */, true /* thead */), 1, 1, 2, 2, [ + '#table_0>THEAD> tr:nth-child(2)>TD:nth-child(2)', + '#table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *', + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2)', + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *', + ]); }); it('Select Table Cells TBODY, TFOOT', () => { - runTest( - buildTable(true /* tbody */, false /* thead */, true /* tfoot */), - 1, - 1, - 2, - 2, - '#contentDiv_0 {caret-color: transparent}', - '#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;}' - ); + runTest(buildTable(true /* tbody */, false /* thead */, true /* tfoot */), 1, 1, 2, 2, [ + '#table_0>TBODY> tr:nth-child(2)>TD:nth-child(2)', + '#table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) *', + '#table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2)', + '#table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) *', + ]); }); it('Select Table Cells THEAD, TBODY, TFOOT', () => { - runTest( - buildTable(true /* tbody */, true /* thead */, true /* tfoot */), - 1, - 1, - 1, - 4, - '#contentDiv_0 {caret-color: transparent}', - '#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;}' - ); + runTest(buildTable(true /* tbody */, true /* thead */, true /* tfoot */), 1, 1, 1, 4, [ + '#table_0>THEAD> tr:nth-child(2)>TD:nth-child(2)', + '#table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *', + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2)', + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *', + '#table_0>TBODY> tr:nth-child(2)>TD:nth-child(2)', + '#table_0>TBODY> tr:nth-child(2)>TD:nth-child(2) *', + '#table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2)', + '#table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) *', + ]); }); it('Select Table Cells THEAD, TFOOT', () => { - runTest( - buildTable(false /* tbody */, true /* thead */, true /* tfoot */), - 1, - 1, - 1, - 2, - '#contentDiv_0 {caret-color: transparent}', - '#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;}' - ); + runTest(buildTable(false /* tbody */, true /* thead */, true /* tfoot */), 1, 1, 1, 2, [ + '#table_0>THEAD> tr:nth-child(2)>TD:nth-child(2)', + '#table_0>THEAD> tr:nth-child(2)>TD:nth-child(2) *', + '#table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2)', + '#table_0>TFOOT> tr:nth-child(1)>TD:nth-child(2) *', + ]); }); it('Select All', () => { - runTest( - buildTable(true /* tbody */, false, false), - 0, - 0, - 1, - 1, - '#contentDiv_0 {caret-color: transparent}', - '#contentDiv_0 #table_0,#contentDiv_0 #table_0 * {background-color: rgb(198,198,198) !important;}' - ); + runTest(buildTable(true /* tbody */, false, false), 0, 0, 1, 1, [ + '#table_0', + '#table_0 *', + ]); }); }); }); diff --git a/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/ensureUniqueIdTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/ensureUniqueIdTest.ts new file mode 100644 index 00000000000..d45aeeb931e --- /dev/null +++ b/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/ensureUniqueIdTest.ts @@ -0,0 +1,47 @@ +import { ensureUniqueId } from '../../../lib/coreApi/setEditorStyle/ensureUniqueId'; + +describe('ensureUniqueId', () => { + let doc: Document; + let querySelectorAllSpy: jasmine.Spy; + + beforeEach(() => { + querySelectorAllSpy = jasmine.createSpy('querySelectorAll'); + doc = { + querySelectorAll: querySelectorAllSpy, + } as any; + }); + + it('no id', () => { + const element = { + ownerDocument: doc, + } as any; + querySelectorAllSpy.and.returnValue([]); + const result = ensureUniqueId(element, 'prefix'); + + expect(result).toBe('prefix_0'); + }); + + it('Has unique id', () => { + const element = { + ownerDocument: doc, + id: 'unique', + } as any; + querySelectorAllSpy.and.returnValue([{}]); + const result = ensureUniqueId(element, 'prefix'); + + expect(result).toBe('unique'); + }); + + it('Has duplicated', () => { + const element = { + ownerDocument: doc, + id: 'dup', + } as any; + querySelectorAllSpy.and.callFake((selector: string) => + selector == '#dup' ? [{}, {}] : [] + ); + const result = ensureUniqueId(element, 'prefix'); + + expect(result).toBe('dup_0'); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/setEditorStyleTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/setEditorStyleTest.ts new file mode 100644 index 00000000000..2c9492f8d5d --- /dev/null +++ b/packages/roosterjs-content-model-core/test/coreApi/setEditorStyle/setEditorStyleTest.ts @@ -0,0 +1,214 @@ +import * as ensureUniqueId from '../../../lib/coreApi/setEditorStyle/ensureUniqueId'; +import { EditorCore } from 'roosterjs-content-model-types'; +import { setEditorStyle } from '../../../lib/coreApi/setEditorStyle/setEditorStyle'; + +describe('setEditorStyle', () => { + let core: EditorCore; + let createElementSpy: jasmine.Spy; + let appendChildSpy: jasmine.Spy; + let insertRuleSpy: jasmine.Spy; + let deleteRuleSpy: jasmine.Spy; + let ensureUniqueIdSpy: jasmine.Spy; + let mockedStyle: HTMLStyleElement; + + beforeEach(() => { + createElementSpy = jasmine.createSpy('createElement'); + appendChildSpy = jasmine.createSpy('appendChild'); + insertRuleSpy = jasmine.createSpy('insertRule'); + deleteRuleSpy = jasmine.createSpy('deleteRule'); + ensureUniqueIdSpy = spyOn(ensureUniqueId, 'ensureUniqueId').and.returnValue('uniqueId'); + core = { + physicalRoot: { + ownerDocument: { + createElement: createElementSpy, + head: { + appendChild: appendChildSpy, + }, + }, + }, + lifecycle: { + styleElements: {}, + }, + } as any; + + mockedStyle = { + dataset: {}, + sheet: { + cssRules: [], + insertRule: insertRuleSpy, + deleteRule: deleteRuleSpy, + }, + } as any; + }); + + it('New key, empty rule', () => { + setEditorStyle(core, 'key', null); + + expect(core.lifecycle.styleElements).toEqual({}); + expect(createElementSpy).not.toHaveBeenCalled(); + expect(appendChildSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).not.toHaveBeenCalled(); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(ensureUniqueIdSpy).not.toHaveBeenCalled(); + expect(core.lifecycle.styleElements).toEqual({}); + expect(mockedStyle.dataset).toEqual({}); + }); + + it('New key, valid rule, no sub selector', () => { + createElementSpy.and.returnValue(mockedStyle); + + setEditorStyle(core, 'key0', 'rule'); + + expect(createElementSpy).toHaveBeenCalledWith('style'); + expect(appendChildSpy).toHaveBeenCalledWith(mockedStyle); + expect(insertRuleSpy).toHaveBeenCalledTimes(1); + expect(insertRuleSpy).toHaveBeenCalledWith('#uniqueId {rule}'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(ensureUniqueIdSpy).toHaveBeenCalledTimes(1); + expect(core.lifecycle.styleElements).toEqual({ + key0: mockedStyle, + }); + expect(mockedStyle.dataset).toEqual({ roosterjsStyleKey: 'key0' }); + }); + + it('New key, valid rule, has sub selector array', () => { + createElementSpy.and.returnValue(mockedStyle); + + setEditorStyle(core, 'key0', 'rule', ['selector1', 'selector2']); + + expect(createElementSpy).toHaveBeenCalledWith('style'); + expect(appendChildSpy).toHaveBeenCalledWith(mockedStyle); + expect(insertRuleSpy).toHaveBeenCalledTimes(1); + expect(insertRuleSpy).toHaveBeenCalledWith( + '#uniqueId selector1,#uniqueId selector2 {rule}' + ); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(ensureUniqueIdSpy).toHaveBeenCalledTimes(1); + expect(core.lifecycle.styleElements).toEqual({ + key0: mockedStyle, + }); + expect(mockedStyle.dataset).toEqual({ roosterjsStyleKey: 'key0' }); + }); + + it('New key, valid rule, has sub selector pseudo class', () => { + createElementSpy.and.returnValue(mockedStyle); + + setEditorStyle(core, 'key0', 'rule', 'before'); + + expect(createElementSpy).toHaveBeenCalledWith('style'); + expect(appendChildSpy).toHaveBeenCalledWith(mockedStyle); + expect(insertRuleSpy).toHaveBeenCalledTimes(1); + expect(insertRuleSpy).toHaveBeenCalledWith('#uniqueId::before {rule}'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(ensureUniqueIdSpy).toHaveBeenCalledTimes(1); + expect(core.lifecycle.styleElements).toEqual({ + key0: mockedStyle, + }); + expect(mockedStyle.dataset).toEqual({ roosterjsStyleKey: 'key0' }); + }); + + it('Existing key, null rule', () => { + const existingStyle = { + sheet: { + cssRules: ['rule1', 'rule2'], + insertRule: insertRuleSpy, + deleteRule: deleteRuleSpy, + }, + } as any; + core.lifecycle.styleElements.key0 = existingStyle; + + insertRuleSpy.and.callFake((rule: string) => { + existingStyle.sheet.cssRules.push(rule); + }); + deleteRuleSpy.and.callFake((index: number) => { + existingStyle.sheet.cssRules.splice(index, 1); + }); + + setEditorStyle(core, 'key0', null); + + expect(createElementSpy).not.toHaveBeenCalled(); + expect(appendChildSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).toHaveBeenCalledTimes(0); + expect(deleteRuleSpy).toHaveBeenCalledTimes(2); + expect(deleteRuleSpy).toHaveBeenCalledWith(1); + expect(deleteRuleSpy).toHaveBeenCalledWith(0); + expect(ensureUniqueIdSpy).not.toHaveBeenCalled(); + expect(core.lifecycle.styleElements).toEqual({ + key0: { + sheet: { + cssRules: [], + insertRule: insertRuleSpy, + deleteRule: deleteRuleSpy, + }, + } as any, + }); + expect(mockedStyle.dataset).toEqual({}); + }); + + it('Existing key, valid rule', () => { + const existingStyle = { + sheet: { + cssRules: ['rule1', 'rule2'], + insertRule: insertRuleSpy, + deleteRule: deleteRuleSpy, + }, + } as any; + core.lifecycle.styleElements.key0 = existingStyle; + + insertRuleSpy.and.callFake((rule: string) => { + existingStyle.sheet.cssRules.push(rule); + }); + deleteRuleSpy.and.callFake((index: number) => { + existingStyle.sheet.cssRules.splice(index, 1); + }); + + setEditorStyle(core, 'key0', 'rule3'); + + expect(createElementSpy).not.toHaveBeenCalled(); + expect(appendChildSpy).not.toHaveBeenCalled(); + expect(insertRuleSpy).toHaveBeenCalledTimes(1); + expect(insertRuleSpy).toHaveBeenCalledWith('#uniqueId {rule3}'); + expect(deleteRuleSpy).toHaveBeenCalledTimes(2); + expect(ensureUniqueIdSpy).toHaveBeenCalledTimes(1); + expect(core.lifecycle.styleElements).toEqual({ + key0: { + sheet: { + cssRules: ['#uniqueId {rule3}'], + insertRule: insertRuleSpy, + deleteRule: deleteRuleSpy, + }, + } as any, + }); + expect(mockedStyle.dataset).toEqual({}); + }); + + it('New key, valid rule, has super long sub selector array', () => { + createElementSpy.and.returnValue(mockedStyle); + const s1 = 'longSelector1'; + const s2 = 'longSelector2'; + const s3 = 'longSelector3'; + const s4 = 'longSelector4'; + const s5 = 'longSelector5'; + + const selectors = [s1, s2, s3, s4, s5]; + + setEditorStyle(core, 'key0', 'rule', selectors, 50); + + expect(createElementSpy).toHaveBeenCalledWith('style'); + expect(appendChildSpy).toHaveBeenCalledWith(mockedStyle); + expect(insertRuleSpy).toHaveBeenCalledTimes(3); + expect(insertRuleSpy).toHaveBeenCalledWith( + '#uniqueId longSelector1,#uniqueId longSelector2 {rule}' + ); + expect(insertRuleSpy).toHaveBeenCalledWith( + '#uniqueId longSelector3,#uniqueId longSelector4 {rule}' + ); + expect(insertRuleSpy).toHaveBeenCalledWith('#uniqueId longSelector5 {rule}'); + expect(deleteRuleSpy).not.toHaveBeenCalled(); + expect(ensureUniqueIdSpy).toHaveBeenCalledTimes(1); + expect(core.lifecycle.styleElements).toEqual({ + key0: mockedStyle, + }); + expect(mockedStyle.dataset).toEqual({ roosterjsStyleKey: 'key0' }); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts index 7e76078b19b..2c9bed20df0 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts @@ -20,6 +20,7 @@ describe('LifecyclePlugin', () => { expect(state).toEqual({ isDarkMode: false, shadowEditFragment: null, + styleElements: {}, }); expect(div.isContentEditable).toBeTrue(); @@ -57,6 +58,7 @@ describe('LifecyclePlugin', () => { expect(state).toEqual({ isDarkMode: false, shadowEditFragment: null, + styleElements: {}, }); expect(div.isContentEditable).toBeTrue(); @@ -131,6 +133,7 @@ describe('LifecyclePlugin', () => { expect(state).toEqual({ isDarkMode: false, shadowEditFragment: null, + styleElements: {}, }); plugin.onPluginEvent({ @@ -161,6 +164,7 @@ describe('LifecyclePlugin', () => { expect(state).toEqual({ isDarkMode: false, shadowEditFragment: null, + styleElements: {}, }); const mockedIsDarkColor = 'Dark' as any; @@ -205,7 +209,7 @@ describe('LifecyclePlugin', () => { plugin.initialize(({ triggerEvent, - getDarkColorHandler: () => mockedDarkColorHandler, + getColorManager: () => mockedDarkColorHandler, })); expect(setColorSpy).toHaveBeenCalledTimes(0); @@ -213,6 +217,7 @@ describe('LifecyclePlugin', () => { expect(state).toEqual({ isDarkMode: false, shadowEditFragment: null, + styleElements: {}, }); const mockedIsDarkColor = 'Dark' as any; @@ -226,4 +231,34 @@ describe('LifecyclePlugin', () => { expect(setColorSpy).toHaveBeenCalledTimes(0); }); + + it('Dispose plugin and clean up style nodes', () => { + const div = document.createElement('div'); + const plugin = createLifecyclePlugin({}, div); + + plugin.initialize({ + getColorManager: jasmine.createSpy(), + triggerEvent: jasmine.createSpy(), + }); + + const state = plugin.getState(); + const removeChildSpy = jasmine.createSpy('removeChild'); + const style = { + parentElement: { + removeChild: removeChildSpy, + }, + } as any; + + state.styleElements.a = style; + + plugin.dispose(); + + expect(removeChildSpy).toHaveBeenCalledTimes(1); + expect(removeChildSpy).toHaveBeenCalledWith(style); + expect(state).toEqual({ + styleElements: {}, + isDarkMode: false, + shadowEditFragment: null, + }); + }); }); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts index e5b624c4027..73f53eb763a 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts @@ -6,20 +6,14 @@ import { 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 attachDomEvent = jasmine.createSpy('attachDomEvent').and.returnValue(disposer); const removeEventListenerSpy = jasmine.createSpy('removeEventListener'); const getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ - createElement: createElementSpy, head: { appendChild: appendChildSpy, }, @@ -36,7 +30,6 @@ describe('SelectionPlugin', () => { expect(state).toEqual({ selection: null, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(attachDomEvent).toHaveBeenCalled(); @@ -58,13 +51,9 @@ describe('SelectionPlugin', () => { const attachDomEvent = jasmine .createSpy('attachDomEvent') .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, }, @@ -79,7 +68,6 @@ describe('SelectionPlugin', () => { expect(state).toEqual({ selection: null, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: 'red', }); @@ -94,7 +82,6 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { let triggerEvent: 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; @@ -105,11 +92,9 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { beforeEach(() => { triggerEvent = jasmine.createSpy('triggerEvent'); 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, }, @@ -147,7 +132,6 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { eventMap.focus.beforeDispatch(); expect(plugin.getState()).toEqual({ selection: mockedRange, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, skipReselectOnFocus: false, }); @@ -163,7 +147,6 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { eventMap.focus.beforeDispatch(); expect(plugin.getState()).toEqual({ selection: mockedRange, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, skipReselectOnFocus: true, }); @@ -176,16 +159,13 @@ describe('SelectionPlugin handle image selection', () => { 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: () => {}, @@ -575,7 +555,6 @@ describe('SelectionPlugin handle image selection', () => { describe('SelectionPlugin on Safari', () => { let disposer: jasmine.Spy; - let createElementSpy: jasmine.Spy; let appendChildSpy: jasmine.Spy; let attachDomEvent: jasmine.Spy; let removeEventListenerSpy: jasmine.Spy; @@ -588,13 +567,11 @@ describe('SelectionPlugin on Safari', () => { beforeEach(() => { disposer = jasmine.createSpy('disposer'); - createElementSpy = jasmine.createSpy('createElement').and.returnValue(MockedStyleNode); appendChildSpy = jasmine.createSpy('appendChild'); attachDomEvent = jasmine.createSpy('attachDomEvent').and.returnValue(disposer); removeEventListenerSpy = jasmine.createSpy('removeEventListener'); addEventListenerSpy = jasmine.createSpy('addEventListener'); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ - createElement: createElementSpy, head: { appendChild: appendChildSpy, }, @@ -625,7 +602,6 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: null, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(attachDomEvent).toHaveBeenCalled(); @@ -660,7 +636,6 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); @@ -688,7 +663,6 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedNewSelection, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); @@ -716,7 +690,6 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); @@ -744,7 +717,6 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); @@ -772,7 +744,6 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); @@ -800,7 +771,6 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, - selectionStyleNode: MockedStyleNode, imageSelectionBorderColor: undefined, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index e92935f9527..310abf7a793 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -917,4 +917,38 @@ describe('Editor', () => { expect(resetSpy).toHaveBeenCalledWith(); expect(() => editor.getVisibleViewport()).toThrow(); }); + + it('setEditorStyle', () => { + const div = document.createElement('div'); + const mockedScrollContainer: Rect = { top: 0, bottom: 100, left: 0, right: 100 }; + const resetSpy = jasmine.createSpy('reset'); + const setEditorStyleSpy = jasmine.createSpy('setEditorStyle'); + const mockedCore = { + plugins: [], + darkColorHandler: { + updateKnownColor: updateKnownColorSpy, + reset: resetSpy, + }, + api: { + setContentModel: setContentModelSpy, + setEditorStyle: setEditorStyleSpy, + }, + domEvent: { scrollContainer: mockedScrollContainer }, + } as any; + + createEditorCoreSpy.and.returnValue(mockedCore); + + const editor = new Editor(div); + + editor.setEditorStyle('key', 'rule', ['rule1', 'rule2']); + + expect(setEditorStyleSpy).toHaveBeenCalledWith(mockedCore, 'key', 'rule', [ + 'rule1', + 'rule2', + ]); + + editor.dispose(); + expect(resetSpy).toHaveBeenCalledWith(); + expect(() => editor.getVisibleViewport()).toThrow(); + }); }); diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index d84caa771f5..fb189b8842b 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -22,3 +22,5 @@ export { export { ShortcutPlugin } from './shortcut/ShortcutPlugin'; export { ShortcutKeyDefinition, ShortcutCommand } from './shortcut/ShortcutCommand'; export { ContextMenuPluginBase, ContextMenuOptions } from './contextMenuBase/ContextMenuPluginBase'; +export { WatermarkPlugin } from './watermark/WatermarkPlugin'; +export { WatermarkFormat } from './watermark/WatermarkFormat'; diff --git a/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkFormat.ts b/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkFormat.ts new file mode 100644 index 00000000000..b457b4a54db --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkFormat.ts @@ -0,0 +1,10 @@ +import type { + FontFamilyFormat, + FontSizeFormat, + TextColorFormat, +} from 'roosterjs-content-model-types'; + +/** + * Format type of watermark text + */ +export type WatermarkFormat = FontFamilyFormat & FontSizeFormat & TextColorFormat; diff --git a/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts b/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts new file mode 100644 index 00000000000..1c0b9ea7cf0 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/watermark/WatermarkPlugin.ts @@ -0,0 +1,99 @@ +import { getObjectKeys } from 'roosterjs-content-model-dom'; +import { isModelEmptyFast } from './isModelEmptyFast'; +import type { WatermarkFormat } from './WatermarkFormat'; +import type { EditorPlugin, IEditor, PluginEvent } from 'roosterjs-content-model-types'; + +const WATERMARK_CONTENT_KEY = '_WatermarkContent'; +const styleMap: Record = { + fontFamily: 'font-family', + fontSize: 'font-size', + textColor: 'color', +}; + +/** + * A watermark plugin to manage watermark string for roosterjs + */ +export class WatermarkPlugin implements EditorPlugin { + private editor: IEditor | null = null; + private format: WatermarkFormat; + private isShowing = false; + + /** + * Create an instance of Watermark plugin + * @param watermark The watermark string + */ + constructor(private watermark: string, format?: WatermarkFormat) { + this.format = format || { + fontSize: '14px', + textColor: '#AAAAAA', + }; + } + + /** + * Get a friendly name of this plugin + */ + getName() { + return 'Watermark'; + } + + /** + * 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; + } + + /** + * Handle events triggered from editor + * @param event PluginEvent object + */ + onPluginEvent(event: PluginEvent) { + const editor = this.editor; + + if ( + editor && + (event.eventType == 'editorReady' || + event.eventType == 'contentChanged' || + event.eventType == 'input' || + event.eventType == 'beforeDispose') + ) { + editor.formatContentModel(model => { + const isEmpty = isModelEmptyFast(model); + + if (this.isShowing && !isEmpty) { + this.hide(editor); + } else if (!this.isShowing && isEmpty) { + this.show(editor); + } + return false; + }); + } + } + + protected show(editor: IEditor) { + let rule = `position: absolute; pointer-events: none; content: "${this.watermark}";`; + + getObjectKeys(styleMap).forEach(x => { + if (this.format[x]) { + rule += `${styleMap[x]}: ${this.format[x]}!important;`; + } + }); + + editor.setEditorStyle(WATERMARK_CONTENT_KEY, rule, 'before'); + + this.isShowing = true; + } + + protected hide(editor: IEditor) { + editor.setEditorStyle(WATERMARK_CONTENT_KEY, null); + this.isShowing = false; + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts b/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts new file mode 100644 index 00000000000..bf78a5b03eb --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/watermark/isModelEmptyFast.ts @@ -0,0 +1,31 @@ +import type { ContentModelDocument } from 'roosterjs-content-model-types'; + +/** + * @internal + * A fast way to check if content model is empty + */ +export function isModelEmptyFast(model: ContentModelDocument): boolean { + const firstBlock = model.blocks[0]; + + if (model.blocks.length > 1) { + return false; // Multiple blocks, treat as not empty + } else if (!firstBlock) { + return true; // No block, it is empty + } else if (firstBlock.blockType != 'Paragraph') { + return false; // First block is not paragraph, treat as not empty + } else if (firstBlock.segments.length == 0) { + return true; // No segment, it is empty + } else if ( + firstBlock.segments.some( + x => + x.segmentType == 'Entity' || + x.segmentType == 'Image' || + x.segmentType == 'General' || + (x.segmentType == 'Text' && x.text) + ) + ) { + return false; // Has meaningful segments, it is not empty + } else { + return firstBlock.segments.filter(x => x.segmentType == 'Br').length <= 1; // If there are more than one BR, it is not empty, otherwise it is empty + } +} diff --git a/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts b/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts new file mode 100644 index 00000000000..8691285530b --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/watermark/WatermarkPluginTest.ts @@ -0,0 +1,119 @@ +import * as isModelEmptyFast from '../../lib/watermark/isModelEmptyFast'; +import { IEditor } from 'roosterjs-content-model-types'; +import { WatermarkPlugin } from '../../lib/watermark/WatermarkPlugin'; + +describe('WatermarkPlugin', () => { + let editor: IEditor; + let formatContentModelSpy: jasmine.Spy; + let isModelEmptyFastSpy: jasmine.Spy; + let setEditorStyleSpy: jasmine.Spy; + + const mockedModel = 'Model' as any; + + beforeEach(() => { + isModelEmptyFastSpy = spyOn(isModelEmptyFast, 'isModelEmptyFast'); + setEditorStyleSpy = jasmine.createSpy('setEditorStyle'); + + formatContentModelSpy = jasmine + .createSpy('formatContentModel') + .and.callFake((callback: Function) => { + const result = callback(mockedModel); + + expect(result).toBeFalse(); + }); + editor = { + formatContentModel: formatContentModelSpy, + setEditorStyle: setEditorStyleSpy, + } as any; + }); + + it('No format, empty editor, with text', () => { + isModelEmptyFastSpy.and.returnValue(true); + + const plugin = new WatermarkPlugin('test'); + + plugin.initialize(editor); + + plugin.onPluginEvent({ eventType: 'editorReady' }); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + '_WatermarkContent', + 'position: absolute; pointer-events: none; content: "test";font-size: 14px!important;color: #AAAAAA!important;', + 'before' + ); + + plugin.onPluginEvent({ eventType: 'input' } as any); + expect(formatContentModelSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); + + isModelEmptyFastSpy.and.returnValue(false); + + plugin.onPluginEvent({ eventType: 'input' } as any); + expect(formatContentModelSpy).toHaveBeenCalledTimes(3); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledWith('_WatermarkContent', null); + }); + + it('No format, not empty editor, with text', () => { + isModelEmptyFastSpy.and.returnValue(false); + + const plugin = new WatermarkPlugin('test'); + + plugin.initialize(editor); + + plugin.onPluginEvent({ eventType: 'editorReady' }); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).not.toHaveBeenCalled(); + + plugin.onPluginEvent({ eventType: 'input' } as any); + expect(formatContentModelSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).not.toHaveBeenCalled(); + + isModelEmptyFastSpy.and.returnValue(true); + + plugin.onPluginEvent({ eventType: 'input' } as any); + expect(formatContentModelSpy).toHaveBeenCalledTimes(3); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + '_WatermarkContent', + 'position: absolute; pointer-events: none; content: "test";font-size: 14px!important;color: #AAAAAA!important;', + 'before' + ); + }); + + it('Has format, empty editor, with text', () => { + isModelEmptyFastSpy.and.returnValue(true); + + const plugin = new WatermarkPlugin('test', { + fontFamily: 'Arial', + fontSize: '20pt', + textColor: 'red', + }); + + plugin.initialize(editor); + + plugin.onPluginEvent({ eventType: 'editorReady' }); + + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + '_WatermarkContent', + 'position: absolute; pointer-events: none; content: "test";font-family: Arial!important;font-size: 20pt!important;color: red!important;', + 'before' + ); + + plugin.onPluginEvent({ eventType: 'input' } as any); + expect(formatContentModelSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(1); + + isModelEmptyFastSpy.and.returnValue(false); + + plugin.onPluginEvent({ eventType: 'input' } as any); + expect(formatContentModelSpy).toHaveBeenCalledTimes(3); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); + expect(setEditorStyleSpy).toHaveBeenCalledWith('_WatermarkContent', null); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/watermark/isModelEmptyFastTest.ts b/packages/roosterjs-content-model-plugins/test/watermark/isModelEmptyFastTest.ts new file mode 100644 index 00000000000..dac437b0c1a --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/watermark/isModelEmptyFastTest.ts @@ -0,0 +1,189 @@ +import { isModelEmptyFast } from '../../lib/watermark/isModelEmptyFast'; +import { + createBr, + createContentModelDocument, + createDivider, + createEntity, + createFormatContainer, + createImage, + createParagraph, + createSelectionMarker, + createText, +} from 'roosterjs-content-model-dom'; + +describe('isModelEmptyFast', () => { + it('Empty model', () => { + const model = createContentModelDocument(); + + const result = isModelEmptyFast(model); + + expect(result).toBeTrue(); + }); + + it('Single Divider block', () => { + const model = createContentModelDocument(); + + model.blocks.push(createDivider('div')); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); + + it('Single FormatContainer block', () => { + const model = createContentModelDocument(); + + model.blocks.push(createFormatContainer('div')); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); + + it('Single Entity block', () => { + const model = createContentModelDocument(); + + model.blocks.push(createEntity({} as any)); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); + + it('Single Paragraph block - no segment', () => { + const model = createContentModelDocument(); + + model.blocks.push(createParagraph()); + + const result = isModelEmptyFast(model); + + expect(result).toBeTrue(); + }); + + it('Single Paragraph block - one selection marker segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createSelectionMarker()); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeTrue(); + }); + + it('Single Paragraph block - one BR segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createBr()); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeTrue(); + }); + + it('Single Paragraph block - one selection marker and one BR segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createSelectionMarker(), createBr()); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeTrue(); + }); + + it('Single Paragraph block - two BR segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createBr(), createBr()); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); + + it('Single Paragraph block - one empty text segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createText('')); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeTrue(); + }); + + it('Single Paragraph block - two empty text segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createText(''), createText('')); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeTrue(); + }); + + it('Single Paragraph block - one text segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createText('test')); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); + + it('Single Paragraph block - one image segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createImage('')); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); + + it('Single Paragraph block - one entity segment', () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(createEntity({} as any)); + + model.blocks.push(para); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); + + it('Multiple blocks', () => { + const model = createContentModelDocument(); + + model.blocks.push({} as any, {} as any); + + const result = isModelEmptyFast(model); + + expect(result).toBeFalse(); + }); +}); diff --git a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts index 56169d4b2d6..7321fbed70d 100644 --- a/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts +++ b/packages/roosterjs-content-model-types/lib/editor/EditorCore.ts @@ -145,6 +145,24 @@ export type AttachDomEvent = ( */ export type RestoreUndoSnapshot = (core: EditorCore, snapshot: Snapshot) => void; +/** + * Add CSS rules for editor + * @param core The EditorCore object + * @param key A string to identify the CSS rule type. When set CSS rules with the same key again, existing rules with the same key will be replaced. + * @param cssRule The CSS rule string, must be a valid CSS rule string, or browser may throw exception. Pass null to remove existing rules + * @param subSelectors @optional If the rule is used for child element under editor, use this parameter to specify the child elements. Each item will be + * combined with root selector together to build a separate rule. It also accepts pseudo classes "before" and "after" to create pseudo class rule "::before" + * and "::after" to the editor root element itself + * @param maxRuleLength @optional Set maximum length for a single rule. This is used by test code only + */ +export type SetEditorStyle = ( + core: EditorCore, + key: string, + cssRule: string | null, + subSelectors?: 'before' | 'after' | string[], + maxRuleLength?: number +) => void; + /** * The interface for the map of core API for Editor. * Editor can call call API from this map under EditorCore object @@ -249,6 +267,16 @@ export interface CoreApiMap { * @param broadcast Set to true to skip the shouldHandleEventExclusively check */ triggerEvent: TriggerEvent; + + /** + * Add CSS rules for editor + * @param core The EditorCore object + * @param key A string to identify the CSS rule type. When set CSS rules with the same key again, existing rules with the same key will be replaced. + * @param cssRule The CSS rule string, must be a valid CSS rule string, or browser may throw exception + * @param subSelectors @optional If the rule is used for child element under editor, use this parameter to specify the child elements. Each item will be + * combined with root selector together to build a separate rule. + */ + setEditorStyle: SetEditorStyle; } /** diff --git a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts index 53ed1df5f2f..d185a9ca74c 100644 --- a/packages/roosterjs-content-model-types/lib/editor/IEditor.ts +++ b/packages/roosterjs-content-model-types/lib/editor/IEditor.ts @@ -197,4 +197,17 @@ export interface IEditor { * Retrieves the rect of the visible viewport of the editor. */ getVisibleViewport(): Rect | null; + + /** + * Add CSS rules for editor + * @param key A string to identify the CSS rule type. When set CSS rules with the same key again, existing rules with the same key will be replaced. + * @param cssRule The CSS rule string, must be a valid CSS rule string, or browser may throw exception. Pass null to clear existing rules + * @param subSelectors @optional If the rule is used for child element under editor, use this parameter to specify the child elements. Each item will be + * combined with root selector together to build a separate rule. + */ + setEditorStyle( + key: string, + cssRule: string | null, + subSelectors?: 'before' | 'after' | string[] + ): void; } diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index 30f5823abbb..e74dafa0d08 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -219,6 +219,7 @@ export { AttachDomEvent, RestoreUndoSnapshot, GetVisibleViewport, + SetEditorStyle, } from './editor/EditorCore'; export { EditorCorePlugins } from './editor/EditorCorePlugins'; export { EditorPlugin } from './editor/EditorPlugin'; diff --git a/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts index f8c9a263881..fb7d1f07f24 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/LifecyclePluginState.ts @@ -11,4 +11,9 @@ export interface LifecyclePluginState { * Cached document fragment for original content */ shadowEditFragment: DocumentFragment | null; + + /** + * Style elements used for adding CSS rules for editor + */ + readonly styleElements: Record; } diff --git a/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts index 02390d727ef..d37315b11f1 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts @@ -9,11 +9,6 @@ export interface SelectionPluginState { */ selection: DOMSelection | 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 */ From 9ccde1a157d6ef222b1a3776d6b5e6d4d124e0bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Mar 2024 09:44:51 -0700 Subject: [PATCH 12/73] Bump follow-redirects from 1.15.4 to 1.15.6 (#2512) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.4 to 1.15.6. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.4...v1.15.6) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Jiuqing Song --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6c4d59c9629..375fab391ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2982,9 +2982,9 @@ flatted@^3.2.7: integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ== follow-redirects@^1.0.0: - version "1.15.4" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf" - integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw== + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== for-each@^0.3.3: version "0.3.3" From 767edaabdebc36403f59dec7bbc889ada8492298 Mon Sep 17 00:00:00 2001 From: Jiuqing Song Date: Mon, 18 Mar 2024 10:06:28 -0700 Subject: [PATCH 13/73] Reorganize core API and core Plugin files (#2501) --- .../{ => addUndoSnapshot}/addUndoSnapshot.ts | 2 +- .../createSnapshotSelection.ts | 0 .../{ => attachDomEvent}/attachDomEvent.ts | 0 .../lib/coreApi/coreApiMap.ts | 40 + .../createContentModel.ts | 2 +- .../createEditorContext.ts | 2 +- .../getRootComputedStyleForContext.ts | 0 .../lib/coreApi/{ => focus}/focus.ts | 0 .../formatContentModel.ts | 2 +- .../{ => getDOMSelection}/getDOMSelection.ts | 0 .../getVisibleViewport.ts | 0 .../restoreSnapshotColors.ts | 2 +- .../restoreSnapshotHTML.ts | 0 .../restoreSnapshotSelection.ts | 0 .../restoreUndoSnapshot.ts | 8 +- .../{ => setContentModel}/setContentModel.ts | 0 .../setDOMSelection}/addRangeToSelection.ts | 0 .../{ => setDOMSelection}/setDOMSelection.ts | 6 +- .../switchShadowEdit.ts | 2 +- .../{ => triggerEvent}/triggerEvent.ts | 0 .../lib/corePlugin/{ => cache}/CachePlugin.ts | 6 +- .../{utils => cache}/areSameSelection.ts | 0 .../{utils => cache}/domIndexerImpl.ts | 0 .../{utils => cache}/textMutationObserver.ts | 0 .../{ => contextMenu}/ContextMenuPlugin.ts | 2 +- .../{ => copyPaste}/CopyPastePlugin.ts | 18 +- .../{utils => copyPaste}/deleteEmptyList.ts | 0 .../lib/corePlugin/createEditorCorePlugins.ts | 18 +- .../{ => domEvent}/DOMEventPlugin.ts | 4 +- .../corePlugin/{ => entity}/EntityPlugin.ts | 14 +- .../{utils => entity}/entityDelimiterUtils.ts | 18 +- .../{utils => entity}/findAllEntities.ts | 0 .../corePlugin/{ => format}/FormatPlugin.ts | 6 +- .../{utils => format}/applyDefaultFormat.ts | 0 .../{utils => format}/applyPendingFormat.ts | 2 +- .../{ => lifecycle}/LifecyclePlugin.ts | 2 +- .../{ => selection}/SelectionPlugin.ts | 2 +- .../undo}/SnapshotsManagerImpl.ts | 0 .../lib/corePlugin/{ => undo}/UndoPlugin.ts | 8 +- .../lib/editor/Editor.ts | 2 +- .../lib/editor/{ => core}/DOMHelperImpl.ts | 0 .../editor/{ => core}/DarkColorHandlerImpl.ts | 0 .../lib/editor/{ => core}/createEditorCore.ts | 4 +- .../{ => core}/createEditorDefaultSettings.ts | 7 +- .../lib/editor/coreApiMap.ts | 40 - .../createDomToModelContextForSanitizing.ts | 2 +- .../addUndoSnapshotTest.ts | 4 +- .../createSnapshotSelectionTest.ts | 2 +- .../attachDomEventTest.ts | 2 +- .../createContentModelTest.ts | 4 +- .../createEditorContextTest.ts | 2 +- .../test/coreApi/{ => focus}/focusTest.ts | 2 +- .../formatContentModelTest.ts | 6 +- .../getDOMSelectionTest.ts | 2 +- .../getVisibleViewportTest.ts | 2 +- .../restoreSnapshotColorsTest.ts | 4 +- .../restoreSnapshotHTMLTest.ts | 2 +- .../restoreSnapshotSelectionTest.ts | 2 +- .../restoreUndoSnapshotTest.ts | 10 +- .../setContentModelTest.ts | 2 +- .../setDOMSelectionTest.ts | 4 +- .../switchShadowEditTest.ts | 4 +- .../{ => triggerEvent}/triggerEventTest.ts | 2 +- .../corePlugin/{ => cache}/CachePluginTest.ts | 6 +- .../areSameSelectionTest.ts} | 2 +- .../{utils => cache}/domIndexerTest.ts | 2 +- .../textMutationObserverTest.ts | 2 +- .../ContextMenuPluginTest.ts | 4 +- .../{ => copyPaste}/CopyPastePluginTest.ts | 14 +- .../copyPaste/deleteEmptyListTest.ts | 1072 +++++++++++++++++ .../{ => domEvent}/DomEventPluginTest.ts | 6 +- .../{ => entity}/EntityPluginTest.ts | 8 +- .../{utils => entity}/delimiterUtilsTest.ts | 4 +- .../{utils => entity}/findAllEntitiesTest.ts | 2 +- .../{ => format}/FormatPluginTest.ts | 6 +- .../applyDefaultFormatTest.ts | 2 +- .../applyPendingFormatTest.ts | 2 +- .../{ => lifecycle}/LifecyclePluginTest.ts | 4 +- .../{ => selection}/SelectionPluginTest.ts | 2 +- .../undo}/SnapshotsManagerImplTest.ts | 2 +- .../corePlugin/{ => undo}/UndoPluginTest.ts | 8 +- .../test/editor/EditorTest.ts | 2 +- .../editor/{ => core}/DOMHelperImplTest.ts | 2 +- .../{ => core}/DarkColorHandlerImplTest.ts | 2 +- .../editor/{ => core}/createEditorCoreTest.ts | 12 +- .../createEditorDefaultSettingsTest.ts | 6 +- .../publicApi/color/transformColorTest.ts | 2 +- .../selection/deleteSelectionTest.ts | 1069 +--------------- .../test/TestHelper.ts | 9 +- .../test/editor/DarkColorHandlerImplTest.ts | 24 +- .../test/editor/EditorAdapterTest.ts | 2 +- 91 files changed, 1284 insertions(+), 1271 deletions(-) rename packages/roosterjs-content-model-core/lib/coreApi/{ => addUndoSnapshot}/addUndoSnapshot.ts (94%) rename packages/roosterjs-content-model-core/lib/{utils => coreApi/addUndoSnapshot}/createSnapshotSelection.ts (100%) rename packages/roosterjs-content-model-core/lib/coreApi/{ => attachDomEvent}/attachDomEvent.ts (100%) create mode 100644 packages/roosterjs-content-model-core/lib/coreApi/coreApiMap.ts rename packages/roosterjs-content-model-core/lib/coreApi/{ => createContentModel}/createContentModel.ts (96%) rename packages/roosterjs-content-model-core/lib/coreApi/{ => createEditorContext}/createEditorContext.ts (92%) rename packages/roosterjs-content-model-core/lib/{utils => coreApi/createEditorContext}/getRootComputedStyleForContext.ts (100%) rename packages/roosterjs-content-model-core/lib/coreApi/{ => focus}/focus.ts (100%) rename packages/roosterjs-content-model-core/lib/coreApi/{ => formatContentModel}/formatContentModel.ts (98%) rename packages/roosterjs-content-model-core/lib/coreApi/{ => getDOMSelection}/getDOMSelection.ts (100%) rename packages/roosterjs-content-model-core/lib/coreApi/{ => getVisibleViewport}/getVisibleViewport.ts (100%) rename packages/roosterjs-content-model-core/lib/{utils => coreApi/restoreUndoSnapshot}/restoreSnapshotColors.ts (89%) rename packages/roosterjs-content-model-core/lib/{utils => coreApi/restoreUndoSnapshot}/restoreSnapshotHTML.ts (100%) rename packages/roosterjs-content-model-core/lib/{utils => coreApi/restoreUndoSnapshot}/restoreSnapshotSelection.ts (100%) rename packages/roosterjs-content-model-core/lib/coreApi/{ => restoreUndoSnapshot}/restoreUndoSnapshot.ts (78%) rename packages/roosterjs-content-model-core/lib/coreApi/{ => setContentModel}/setContentModel.ts (100%) rename packages/roosterjs-content-model-core/lib/{corePlugin/utils => coreApi/setDOMSelection}/addRangeToSelection.ts (100%) rename packages/roosterjs-content-model-core/lib/coreApi/{ => setDOMSelection}/setDOMSelection.ts (96%) rename packages/roosterjs-content-model-core/lib/coreApi/{ => switchShadowEdit}/switchShadowEdit.ts (96%) rename packages/roosterjs-content-model-core/lib/coreApi/{ => triggerEvent}/triggerEvent.ts (100%) rename packages/roosterjs-content-model-core/lib/corePlugin/{ => cache}/CachePlugin.ts (96%) rename packages/roosterjs-content-model-core/lib/corePlugin/{utils => cache}/areSameSelection.ts (100%) rename packages/roosterjs-content-model-core/lib/corePlugin/{utils => cache}/domIndexerImpl.ts (100%) rename packages/roosterjs-content-model-core/lib/corePlugin/{utils => cache}/textMutationObserver.ts (100%) rename packages/roosterjs-content-model-core/lib/corePlugin/{ => contextMenu}/ContextMenuPlugin.ts (97%) rename packages/roosterjs-content-model-core/lib/corePlugin/{ => copyPaste}/CopyPastePlugin.ts (93%) rename packages/roosterjs-content-model-core/lib/corePlugin/{utils => copyPaste}/deleteEmptyList.ts (100%) rename packages/roosterjs-content-model-core/lib/corePlugin/{ => domEvent}/DOMEventPlugin.ts (97%) rename packages/roosterjs-content-model-core/lib/corePlugin/{ => entity}/EntityPlugin.ts (98%) rename packages/roosterjs-content-model-core/lib/corePlugin/{utils => entity}/entityDelimiterUtils.ts (100%) rename packages/roosterjs-content-model-core/lib/corePlugin/{utils => entity}/findAllEntities.ts (100%) rename packages/roosterjs-content-model-core/lib/corePlugin/{ => format}/FormatPlugin.ts (96%) rename packages/roosterjs-content-model-core/lib/corePlugin/{utils => format}/applyDefaultFormat.ts (100%) rename packages/roosterjs-content-model-core/lib/corePlugin/{utils => format}/applyPendingFormat.ts (100%) rename packages/roosterjs-content-model-core/lib/corePlugin/{ => lifecycle}/LifecyclePlugin.ts (98%) rename packages/roosterjs-content-model-core/lib/corePlugin/{ => selection}/SelectionPlugin.ts (99%) rename packages/roosterjs-content-model-core/lib/{editor => corePlugin/undo}/SnapshotsManagerImpl.ts (100%) rename packages/roosterjs-content-model-core/lib/corePlugin/{ => undo}/UndoPlugin.ts (97%) rename packages/roosterjs-content-model-core/lib/editor/{ => core}/DOMHelperImpl.ts (100%) rename packages/roosterjs-content-model-core/lib/editor/{ => core}/DarkColorHandlerImpl.ts (100%) rename packages/roosterjs-content-model-core/lib/editor/{ => core}/createEditorCore.ts (97%) rename packages/roosterjs-content-model-core/lib/editor/{ => core}/createEditorDefaultSettings.ts (89%) delete mode 100644 packages/roosterjs-content-model-core/lib/editor/coreApiMap.ts rename packages/roosterjs-content-model-core/test/coreApi/{ => addUndoSnapshot}/addUndoSnapshotTest.ts (96%) rename packages/roosterjs-content-model-core/test/{utils => coreApi/addUndoSnapshot}/createSnapshotSelectionTest.ts (99%) rename packages/roosterjs-content-model-core/test/coreApi/{ => attachDomEvent}/attachDomEventTest.ts (97%) rename packages/roosterjs-content-model-core/test/coreApi/{ => createContentModel}/createContentModelTest.ts (98%) rename packages/roosterjs-content-model-core/test/coreApi/{ => createEditorContext}/createEditorContextTest.ts (98%) rename packages/roosterjs-content-model-core/test/coreApi/{ => focus}/focusTest.ts (98%) rename packages/roosterjs-content-model-core/test/coreApi/{ => formatContentModel}/formatContentModelTest.ts (99%) rename packages/roosterjs-content-model-core/test/coreApi/{ => getDOMSelection}/getDOMSelectionTest.ts (99%) rename packages/roosterjs-content-model-core/test/coreApi/{ => getVisibleViewport}/getVisibleViewportTest.ts (92%) rename packages/roosterjs-content-model-core/test/{utils => coreApi/restoreUndoSnapshot}/restoreSnapshotColorsTest.ts (93%) rename packages/roosterjs-content-model-core/test/{utils => coreApi/restoreUndoSnapshot}/restoreSnapshotHTMLTest.ts (99%) rename packages/roosterjs-content-model-core/test/{utils => coreApi/restoreUndoSnapshot}/restoreSnapshotSelectionTest.ts (98%) rename packages/roosterjs-content-model-core/test/coreApi/{ => restoreUndoSnapshot}/restoreUndoSnapshotTest.ts (86%) rename packages/roosterjs-content-model-core/test/coreApi/{ => setContentModel}/setContentModelTest.ts (98%) rename packages/roosterjs-content-model-core/test/coreApi/{ => setDOMSelection}/setDOMSelectionTest.ts (99%) rename packages/roosterjs-content-model-core/test/coreApi/{ => switchShadowEdit}/switchShadowEditTest.ts (96%) rename packages/roosterjs-content-model-core/test/coreApi/{ => triggerEvent}/triggerEventTest.ts (98%) rename packages/roosterjs-content-model-core/test/corePlugin/{ => cache}/CachePluginTest.ts (97%) rename packages/roosterjs-content-model-core/test/corePlugin/{utils/areSameRangeExTest.ts => cache/areSameSelectionTest.ts} (99%) rename packages/roosterjs-content-model-core/test/corePlugin/{utils => cache}/domIndexerTest.ts (99%) rename packages/roosterjs-content-model-core/test/corePlugin/{utils => cache}/textMutationObserverTest.ts (97%) rename packages/roosterjs-content-model-core/test/corePlugin/{ => contextMenu}/ContextMenuPluginTest.ts (96%) rename packages/roosterjs-content-model-core/test/corePlugin/{ => copyPaste}/CopyPastePluginTest.ts (98%) create mode 100644 packages/roosterjs-content-model-core/test/corePlugin/copyPaste/deleteEmptyListTest.ts rename packages/roosterjs-content-model-core/test/corePlugin/{ => domEvent}/DomEventPluginTest.ts (98%) rename packages/roosterjs-content-model-core/test/corePlugin/{ => entity}/EntityPluginTest.ts (98%) rename packages/roosterjs-content-model-core/test/corePlugin/{utils => entity}/delimiterUtilsTest.ts (99%) rename packages/roosterjs-content-model-core/test/corePlugin/{utils => entity}/findAllEntitiesTest.ts (98%) rename packages/roosterjs-content-model-core/test/corePlugin/{ => format}/FormatPluginTest.ts (98%) rename packages/roosterjs-content-model-core/test/corePlugin/{utils => format}/applyDefaultFormatTest.ts (99%) rename packages/roosterjs-content-model-core/test/corePlugin/{utils => format}/applyPendingFormatTest.ts (99%) rename packages/roosterjs-content-model-core/test/corePlugin/{ => lifecycle}/LifecyclePluginTest.ts (98%) rename packages/roosterjs-content-model-core/test/corePlugin/{ => selection}/SelectionPluginTest.ts (99%) rename packages/roosterjs-content-model-core/test/{editor => corePlugin/undo}/SnapshotsManagerImplTest.ts (99%) rename packages/roosterjs-content-model-core/test/corePlugin/{ => undo}/UndoPluginTest.ts (99%) rename packages/roosterjs-content-model-core/test/editor/{ => core}/DOMHelperImplTest.ts (99%) rename packages/roosterjs-content-model-core/test/editor/{ => core}/DarkColorHandlerImplTest.ts (98%) rename packages/roosterjs-content-model-core/test/editor/{ => core}/createEditorCoreTest.ts (95%) rename packages/roosterjs-content-model-core/test/editor/{ => core}/createEditorDefaultSettingsTest.ts (95%) diff --git a/packages/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts b/packages/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot/addUndoSnapshot.ts similarity index 94% rename from packages/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts rename to packages/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot/addUndoSnapshot.ts index a4d44d45983..a4b60a0380c 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot/addUndoSnapshot.ts @@ -1,4 +1,4 @@ -import { createSnapshotSelection } from '../utils/createSnapshotSelection'; +import { createSnapshotSelection } from './createSnapshotSelection'; import type { AddUndoSnapshot, Snapshot } from 'roosterjs-content-model-types'; /** diff --git a/packages/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot/createSnapshotSelection.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/utils/createSnapshotSelection.ts rename to packages/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot/createSnapshotSelection.ts diff --git a/packages/roosterjs-content-model-core/lib/coreApi/attachDomEvent.ts b/packages/roosterjs-content-model-core/lib/coreApi/attachDomEvent/attachDomEvent.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/coreApi/attachDomEvent.ts rename to packages/roosterjs-content-model-core/lib/coreApi/attachDomEvent/attachDomEvent.ts diff --git a/packages/roosterjs-content-model-core/lib/coreApi/coreApiMap.ts b/packages/roosterjs-content-model-core/lib/coreApi/coreApiMap.ts new file mode 100644 index 00000000000..3620d11fd45 --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/coreApi/coreApiMap.ts @@ -0,0 +1,40 @@ +import { addUndoSnapshot } from './addUndoSnapshot/addUndoSnapshot'; +import { attachDomEvent } from './attachDomEvent/attachDomEvent'; +import { createContentModel } from './createContentModel/createContentModel'; +import { createEditorContext } from './createEditorContext/createEditorContext'; +import { focus } from './focus/focus'; +import { formatContentModel } from './formatContentModel/formatContentModel'; +import { getDOMSelection } from './getDOMSelection/getDOMSelection'; +import { getVisibleViewport } from './getVisibleViewport/getVisibleViewport'; +import { restoreUndoSnapshot } from './restoreUndoSnapshot/restoreUndoSnapshot'; +import { setContentModel } from './setContentModel/setContentModel'; +import { setDOMSelection } from './setDOMSelection/setDOMSelection'; +import { setEditorStyle } from './setEditorStyle/setEditorStyle'; +import { switchShadowEdit } from './switchShadowEdit/switchShadowEdit'; +import { triggerEvent } from './triggerEvent/triggerEvent'; +import type { CoreApiMap } from 'roosterjs-content-model-types'; + +/** + * @internal + * Core API map for Editor + */ +export const coreApiMap: CoreApiMap = { + createContentModel: createContentModel, + createEditorContext: createEditorContext, + formatContentModel: formatContentModel, + setContentModel: setContentModel, + + getDOMSelection: getDOMSelection, + setDOMSelection: setDOMSelection, + focus: focus, + + addUndoSnapshot: addUndoSnapshot, + restoreUndoSnapshot: restoreUndoSnapshot, + + attachDomEvent: attachDomEvent, + triggerEvent: triggerEvent, + + switchShadowEdit: switchShadowEdit, + getVisibleViewport: getVisibleViewport, + setEditorStyle: setEditorStyle, +}; diff --git a/packages/roosterjs-content-model-core/lib/coreApi/createContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts similarity index 96% rename from packages/roosterjs-content-model-core/lib/coreApi/createContentModel.ts rename to packages/roosterjs-content-model-core/lib/coreApi/createContentModel/createContentModel.ts index 992ed6af493..c2b935b78d1 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/createContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/createContentModel/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/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts b/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts similarity index 92% rename from packages/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts rename to packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts index 7e96ba42119..f80b25ec3d2 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/createEditorContext.ts @@ -1,4 +1,4 @@ -import { getRootComputedStyleForContext } from '../utils/getRootComputedStyleForContext'; +import { getRootComputedStyleForContext } from './getRootComputedStyleForContext'; import type { EditorContext, CreateEditorContext } from 'roosterjs-content-model-types'; /** diff --git a/packages/roosterjs-content-model-core/lib/utils/getRootComputedStyleForContext.ts b/packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/getRootComputedStyleForContext.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/utils/getRootComputedStyleForContext.ts rename to packages/roosterjs-content-model-core/lib/coreApi/createEditorContext/getRootComputedStyleForContext.ts diff --git a/packages/roosterjs-content-model-core/lib/coreApi/focus.ts b/packages/roosterjs-content-model-core/lib/coreApi/focus/focus.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/coreApi/focus.ts rename to packages/roosterjs-content-model-core/lib/coreApi/focus/focus.ts diff --git a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts similarity index 98% rename from packages/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts rename to packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts index dec67394a8a..ac8c17c5644 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/formatContentModel/formatContentModel.ts @@ -1,4 +1,4 @@ -import { ChangeSource } from '../constants/ChangeSource'; +import { ChangeSource } from '../../constants/ChangeSource'; import type { ChangedEntity, ContentChangedEvent, diff --git a/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection/getDOMSelection.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection.ts rename to packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection/getDOMSelection.ts diff --git a/packages/roosterjs-content-model-core/lib/coreApi/getVisibleViewport.ts b/packages/roosterjs-content-model-core/lib/coreApi/getVisibleViewport/getVisibleViewport.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/coreApi/getVisibleViewport.ts rename to packages/roosterjs-content-model-core/lib/coreApi/getVisibleViewport/getVisibleViewport.ts diff --git a/packages/roosterjs-content-model-core/lib/utils/restoreSnapshotColors.ts b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotColors.ts similarity index 89% rename from packages/roosterjs-content-model-core/lib/utils/restoreSnapshotColors.ts rename to packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotColors.ts index 733b7601757..3550980ad69 100644 --- a/packages/roosterjs-content-model-core/lib/utils/restoreSnapshotColors.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotColors.ts @@ -1,4 +1,4 @@ -import { transformColor } from '../publicApi/color/transformColor'; +import { transformColor } from '../../publicApi/color/transformColor'; import type { EditorCore, Snapshot } from 'roosterjs-content-model-types'; /** diff --git a/packages/roosterjs-content-model-core/lib/utils/restoreSnapshotHTML.ts b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/utils/restoreSnapshotHTML.ts rename to packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML.ts diff --git a/packages/roosterjs-content-model-core/lib/utils/restoreSnapshotSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotSelection.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/utils/restoreSnapshotSelection.ts rename to packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotSelection.ts diff --git a/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot.ts b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreUndoSnapshot.ts similarity index 78% rename from packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot.ts rename to packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreUndoSnapshot.ts index da85aa4c9c8..1bdb27157bc 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreUndoSnapshot.ts @@ -1,7 +1,7 @@ -import { ChangeSource } from '../constants/ChangeSource'; -import { restoreSnapshotColors } from '../utils/restoreSnapshotColors'; -import { restoreSnapshotHTML } from '../utils/restoreSnapshotHTML'; -import { restoreSnapshotSelection } from '../utils/restoreSnapshotSelection'; +import { ChangeSource } from '../../constants/ChangeSource'; +import { restoreSnapshotColors } from './restoreSnapshotColors'; +import { restoreSnapshotHTML } from './restoreSnapshotHTML'; +import { restoreSnapshotSelection } from './restoreSnapshotSelection'; import type { ContentChangedEvent, RestoreUndoSnapshot } from 'roosterjs-content-model-types'; /** diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setContentModel.ts b/packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/coreApi/setContentModel.ts rename to packages/roosterjs-content-model-core/lib/coreApi/setContentModel/setContentModel.ts diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/utils/addRangeToSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/corePlugin/utils/addRangeToSelection.ts rename to packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/addRangeToSelection.ts diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts similarity index 96% rename from packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts rename to packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts index 248d9578130..fba89dd2964 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts @@ -1,7 +1,7 @@ -import { addRangeToSelection } from '../corePlugin/utils/addRangeToSelection'; -import { ensureUniqueId } from './setEditorStyle/ensureUniqueId'; +import { addRangeToSelection } from './addRangeToSelection'; +import { ensureUniqueId } from '../setEditorStyle/ensureUniqueId'; import { isNodeOfType, toArray } from 'roosterjs-content-model-dom'; -import { parseTableCells } from '../publicApi/domUtils/tableCellUtils'; +import { parseTableCells } from '../../publicApi/domUtils/tableCellUtils'; import type { SelectionChangedEvent, SetDOMSelection, diff --git a/packages/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts b/packages/roosterjs-content-model-core/lib/coreApi/switchShadowEdit/switchShadowEdit.ts similarity index 96% rename from packages/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts rename to packages/roosterjs-content-model-core/lib/coreApi/switchShadowEdit/switchShadowEdit.ts index c703a641ee0..5117e765e23 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/switchShadowEdit.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/switchShadowEdit/switchShadowEdit.ts @@ -1,4 +1,4 @@ -import { iterateSelections } from '../publicApi/selection/iterateSelections'; +import { iterateSelections } from '../../publicApi/selection/iterateSelections'; import { moveChildNodes } from 'roosterjs-content-model-dom'; import type { SwitchShadowEdit } from 'roosterjs-content-model-types'; diff --git a/packages/roosterjs-content-model-core/lib/coreApi/triggerEvent.ts b/packages/roosterjs-content-model-core/lib/coreApi/triggerEvent/triggerEvent.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/coreApi/triggerEvent.ts rename to packages/roosterjs-content-model-core/lib/coreApi/triggerEvent/triggerEvent.ts diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts similarity index 96% rename from packages/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts index 2ccfb3a724f..ec89abf221e 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/CachePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/cache/CachePlugin.ts @@ -1,6 +1,6 @@ -import { areSameSelection } from './utils/areSameSelection'; -import { createTextMutationObserver } from './utils/textMutationObserver'; -import { domIndexerImpl } from './utils/domIndexerImpl'; +import { areSameSelection } from './areSameSelection'; +import { createTextMutationObserver } from './textMutationObserver'; +import { domIndexerImpl } from './domIndexerImpl'; import type { CachePluginState, IEditor, diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/utils/areSameSelection.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelection.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/corePlugin/utils/areSameSelection.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/cache/areSameSelection.ts diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/utils/domIndexerImpl.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/corePlugin/utils/domIndexerImpl.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/cache/domIndexerImpl.ts diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/utils/textMutationObserver.ts b/packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/corePlugin/utils/textMutationObserver.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/cache/textMutationObserver.ts diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/ContextMenuPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/contextMenu/ContextMenuPlugin.ts similarity index 97% rename from packages/roosterjs-content-model-core/lib/corePlugin/ContextMenuPlugin.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/contextMenu/ContextMenuPlugin.ts index e1d584ebd05..68780d0a597 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/ContextMenuPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/contextMenu/ContextMenuPlugin.ts @@ -1,4 +1,4 @@ -import { getSelectionRootNode } from '../publicApi/selection/getSelectionRootNode'; +import { getSelectionRootNode } from '../../publicApi/selection/getSelectionRootNode'; import type { ContextMenuPluginState, ContextMenuProvider, diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts similarity index 93% rename from packages/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts index 1ae1946bb52..79d3b6824ed 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/CopyPastePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts @@ -1,12 +1,12 @@ -import { addRangeToSelection } from './utils/addRangeToSelection'; -import { ChangeSource } from '../constants/ChangeSource'; -import { deleteEmptyList } from './utils/deleteEmptyList'; -import { deleteSelection } from '../publicApi/selection/deleteSelection'; -import { extractClipboardItems } from '../utils/extractClipboardItems'; -import { getSelectedCells } from '../publicApi/table/getSelectedCells'; -import { iterateSelections } from '../publicApi/selection/iterateSelections'; -import { onCreateCopyEntityNode } from '../override/pasteCopyBlockEntityParser'; -import { paste } from '../publicApi/paste/paste'; +import { addRangeToSelection } from '../../coreApi/setDOMSelection/addRangeToSelection'; +import { ChangeSource } from '../../constants/ChangeSource'; +import { deleteEmptyList } from './deleteEmptyList'; +import { deleteSelection } from '../../publicApi/selection/deleteSelection'; +import { extractClipboardItems } from '../../utils/extractClipboardItems'; +import { getSelectedCells } from '../../publicApi/table/getSelectedCells'; +import { iterateSelections } from '../../publicApi/selection/iterateSelections'; +import { onCreateCopyEntityNode } from '../../override/pasteCopyBlockEntityParser'; +import { paste } from '../../publicApi/paste/paste'; import { contentModelToDom, diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/deleteEmptyList.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/corePlugin/utils/deleteEmptyList.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/deleteEmptyList.ts diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/createEditorCorePlugins.ts b/packages/roosterjs-content-model-core/lib/corePlugin/createEditorCorePlugins.ts index df38c0e2572..f063130dbb1 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/createEditorCorePlugins.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/createEditorCorePlugins.ts @@ -1,12 +1,12 @@ -import { createCachePlugin } from './CachePlugin'; -import { createContextMenuPlugin } from './ContextMenuPlugin'; -import { createCopyPastePlugin } from './CopyPastePlugin'; -import { createDOMEventPlugin } from './DOMEventPlugin'; -import { createEntityPlugin } from './EntityPlugin'; -import { createFormatPlugin } from './FormatPlugin'; -import { createLifecyclePlugin } from './LifecyclePlugin'; -import { createSelectionPlugin } from './SelectionPlugin'; -import { createUndoPlugin } from './UndoPlugin'; +import { createCachePlugin } from './cache/CachePlugin'; +import { createContextMenuPlugin } from './contextMenu/ContextMenuPlugin'; +import { createCopyPastePlugin } from './copyPaste/CopyPastePlugin'; +import { createDOMEventPlugin } from './domEvent/DOMEventPlugin'; +import { createEntityPlugin } from './entity/EntityPlugin'; +import { createFormatPlugin } from './format/FormatPlugin'; +import { createLifecyclePlugin } from './lifecycle/LifecyclePlugin'; +import { createSelectionPlugin } from './selection/SelectionPlugin'; +import { createUndoPlugin } from './undo/UndoPlugin'; import type { EditorCorePlugins, EditorOptions } from 'roosterjs-content-model-types'; /** diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/domEvent/DOMEventPlugin.ts similarity index 97% rename from packages/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/domEvent/DOMEventPlugin.ts index 2cf35b040c7..9e4efcf400e 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/DOMEventPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/domEvent/DOMEventPlugin.ts @@ -1,5 +1,5 @@ -import { ChangeSource } from '../constants/ChangeSource'; -import { isCharacterValue, isCursorMovingKey } from '../publicApi/domUtils/eventUtils'; +import { ChangeSource } from '../../constants/ChangeSource'; +import { isCharacterValue, isCursorMovingKey } from '../../publicApi/domUtils/eventUtils'; import { isNodeOfType } from 'roosterjs-content-model-dom'; import type { DOMEventPluginState, diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/entity/EntityPlugin.ts similarity index 98% rename from packages/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/entity/EntityPlugin.ts index 4626936904a..c3a4631c282 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/EntityPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/entity/EntityPlugin.ts @@ -1,10 +1,5 @@ -import { findAllEntities } from './utils/findAllEntities'; -import { transformColor } from '../publicApi/color/transformColor'; -import { - handleCompositionEndEvent, - handleDelimiterContentChangedEvent, - handleDelimiterKeyDownEvent, -} from './utils/entityDelimiterUtils'; +import { findAllEntities } from './findAllEntities'; +import { transformColor } from '../../publicApi/color/transformColor'; import { createEntity, generateEntityClassNames, @@ -13,6 +8,11 @@ import { isEntityElement, parseEntityFormat, } from 'roosterjs-content-model-dom'; +import { + handleCompositionEndEvent, + handleDelimiterContentChangedEvent, + handleDelimiterKeyDownEvent, +} from './entityDelimiterUtils'; import type { ChangedEntity, ContentChangedEvent, diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts b/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts index 53d84e232b2..f55468cc3e4 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts @@ -1,5 +1,14 @@ import { isCharacterValue } from '../../publicApi/domUtils/eventUtils'; import { iterateSelections } from '../../publicApi/selection/iterateSelections'; +import { + addDelimiters, + createBr, + createModelToDomContext, + createParagraph, + isEntityDelimiter, + isEntityElement, + isNodeOfType, +} from 'roosterjs-content-model-dom'; import type { CompositionEndEvent, ContentModelBlockGroup, @@ -10,15 +19,6 @@ import type { KeyDownEvent, RangeSelection, } from 'roosterjs-content-model-types'; -import { - addDelimiters, - createBr, - createModelToDomContext, - createParagraph, - isEntityDelimiter, - isEntityElement, - isNodeOfType, -} from 'roosterjs-content-model-dom'; const DelimiterBefore = 'entityDelimiterBefore'; const DelimiterAfter = 'entityDelimiterAfter'; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities.ts b/packages/roosterjs-content-model-core/lib/corePlugin/entity/findAllEntities.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/entity/findAllEntities.ts diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts similarity index 96% rename from packages/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts index 812a1ab5fd7..5391270acd4 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/FormatPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/format/FormatPlugin.ts @@ -1,7 +1,7 @@ -import { applyDefaultFormat } from './utils/applyDefaultFormat'; -import { applyPendingFormat } from './utils/applyPendingFormat'; +import { applyDefaultFormat } from './applyDefaultFormat'; +import { applyPendingFormat } from './applyPendingFormat'; import { getObjectKeys, isBlockElement, isNodeOfType } from 'roosterjs-content-model-dom'; -import { isCharacterValue, isCursorMovingKey } from '../publicApi/domUtils/eventUtils'; +import { isCharacterValue, isCursorMovingKey } from '../../publicApi/domUtils/eventUtils'; import type { BackgroundColorFormat, FontFamilyFormat, diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts b/packages/roosterjs-content-model-core/lib/corePlugin/format/applyDefaultFormat.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/corePlugin/utils/applyDefaultFormat.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/format/applyDefaultFormat.ts diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/utils/applyPendingFormat.ts b/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/corePlugin/utils/applyPendingFormat.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts index 8113912ad09..a3fc39ec1f9 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/utils/applyPendingFormat.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/format/applyPendingFormat.ts @@ -1,10 +1,10 @@ import { iterateSelections } from '../../publicApi/selection/iterateSelections'; -import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model-types'; import { createText, normalizeContentModel, setParagraphNotImplicit, } from 'roosterjs-content-model-dom'; +import type { ContentModelSegmentFormat, IEditor } from 'roosterjs-content-model-types'; const ANSI_SPACE = '\u0020'; const NON_BREAK_SPACE = '\u00A0'; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts similarity index 98% rename from packages/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts index e01e9df0463..4455eab7ab8 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/LifecyclePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/lifecycle/LifecyclePlugin.ts @@ -1,4 +1,4 @@ -import { ChangeSource } from '../constants/ChangeSource'; +import { ChangeSource } from '../../constants/ChangeSource'; import { getObjectKeys, setColor } from 'roosterjs-content-model-dom'; import type { IEditor, diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts similarity index 99% rename from packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts index 9c6c08f99a7..f5c15052d74 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -1,5 +1,5 @@ import { isElementOfType, isNodeOfType, toArray } from 'roosterjs-content-model-dom'; -import { isModifierKey } from '../publicApi/domUtils/eventUtils'; +import { isModifierKey } from '../../publicApi/domUtils/eventUtils'; import type { DOMSelection, IEditor, diff --git a/packages/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts b/packages/roosterjs-content-model-core/lib/corePlugin/undo/SnapshotsManagerImpl.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/editor/SnapshotsManagerImpl.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/undo/SnapshotsManagerImpl.ts diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/undo/UndoPlugin.ts similarity index 97% rename from packages/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts rename to packages/roosterjs-content-model-core/lib/corePlugin/undo/UndoPlugin.ts index 600ed50a63b..eb4135a2849 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/UndoPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/undo/UndoPlugin.ts @@ -1,7 +1,7 @@ -import { ChangeSource } from '../constants/ChangeSource'; -import { createSnapshotsManager } from '../editor/SnapshotsManagerImpl'; -import { isCursorMovingKey } from '../publicApi/domUtils/eventUtils'; -import { undo } from '../publicApi/undo/undo'; +import { ChangeSource } from '../../constants/ChangeSource'; +import { createSnapshotsManager } from './SnapshotsManagerImpl'; +import { isCursorMovingKey } from '../../publicApi/domUtils/eventUtils'; +import { undo } from '../../publicApi/undo/undo'; import type { ContentChangedEvent, IEditor, diff --git a/packages/roosterjs-content-model-core/lib/editor/Editor.ts b/packages/roosterjs-content-model-core/lib/editor/Editor.ts index 7ce41d6581f..8ad26a411fc 100644 --- a/packages/roosterjs-content-model-core/lib/editor/Editor.ts +++ b/packages/roosterjs-content-model-core/lib/editor/Editor.ts @@ -1,6 +1,6 @@ import { ChangeSource } from '../constants/ChangeSource'; import { cloneModel } from '../publicApi/model/cloneModel'; -import { createEditorCore } from './createEditorCore'; +import { createEditorCore } from './core/createEditorCore'; import { createEmptyModel, tableProcessor } from 'roosterjs-content-model-dom'; import { reducedModelChildProcessor } from '../override/reducedModelChildProcessor'; import { transformColor } from '../publicApi/color/transformColor'; diff --git a/packages/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts b/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/editor/DOMHelperImpl.ts rename to packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts diff --git a/packages/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts b/packages/roosterjs-content-model-core/lib/editor/core/DarkColorHandlerImpl.ts similarity index 100% rename from packages/roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl.ts rename to packages/roosterjs-content-model-core/lib/editor/core/DarkColorHandlerImpl.ts diff --git a/packages/roosterjs-content-model-core/lib/editor/createEditorCore.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts similarity index 97% rename from packages/roosterjs-content-model-core/lib/editor/createEditorCore.ts rename to packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts index bd2ca2f18aa..4d1d0001ef1 100644 --- a/packages/roosterjs-content-model-core/lib/editor/createEditorCore.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorCore.ts @@ -1,8 +1,8 @@ -import { coreApiMap } from './coreApiMap'; +import { coreApiMap } from '../../coreApi/coreApiMap'; import { createDarkColorHandler } from './DarkColorHandlerImpl'; import { createDOMHelper } from './DOMHelperImpl'; import { createDomToModelSettings, createModelToDomSettings } from './createEditorDefaultSettings'; -import { createEditorCorePlugins } from '../corePlugin/createEditorCorePlugins'; +import { createEditorCorePlugins } from '../../corePlugin/createEditorCorePlugins'; import type { EditorEnvironment, PluginState, diff --git a/packages/roosterjs-content-model-core/lib/editor/createEditorDefaultSettings.ts b/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts similarity index 89% rename from packages/roosterjs-content-model-core/lib/editor/createEditorDefaultSettings.ts rename to packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts index 1340cb9d058..729fb0355e2 100644 --- a/packages/roosterjs-content-model-core/lib/editor/createEditorDefaultSettings.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/createEditorDefaultSettings.ts @@ -1,6 +1,9 @@ import { createDomToModelConfig, createModelToDomConfig } from 'roosterjs-content-model-dom'; -import { listItemMetadataApplier, listLevelMetadataApplier } from '../metadata/updateListMetadata'; -import { tablePreProcessor } from '../override/tablePreProcessor'; +import { tablePreProcessor } from '../../override/tablePreProcessor'; +import { + listItemMetadataApplier, + listLevelMetadataApplier, +} from '../../metadata/updateListMetadata'; import type { ContentModelSettings, DomToModelOption, diff --git a/packages/roosterjs-content-model-core/lib/editor/coreApiMap.ts b/packages/roosterjs-content-model-core/lib/editor/coreApiMap.ts deleted file mode 100644 index 11e2abccbc2..00000000000 --- a/packages/roosterjs-content-model-core/lib/editor/coreApiMap.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { addUndoSnapshot } from '../coreApi/addUndoSnapshot'; -import { attachDomEvent } from '../coreApi/attachDomEvent'; -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 { restoreUndoSnapshot } from '../coreApi/restoreUndoSnapshot'; -import { setContentModel } from '../coreApi/setContentModel'; -import { setDOMSelection } from '../coreApi/setDOMSelection'; -import { setEditorStyle } from '../coreApi/setEditorStyle/setEditorStyle'; -import { switchShadowEdit } from '../coreApi/switchShadowEdit'; -import { triggerEvent } from '../coreApi/triggerEvent'; -import type { CoreApiMap } from 'roosterjs-content-model-types'; - -/** - * @internal - * Core API map for Editor - */ -export const coreApiMap: CoreApiMap = { - createContentModel: createContentModel, - createEditorContext: createEditorContext, - formatContentModel: formatContentModel, - setContentModel: setContentModel, - - getDOMSelection: getDOMSelection, - setDOMSelection: setDOMSelection, - focus: focus, - - addUndoSnapshot: addUndoSnapshot, - restoreUndoSnapshot: restoreUndoSnapshot, - - attachDomEvent: attachDomEvent, - triggerEvent: triggerEvent, - - switchShadowEdit: switchShadowEdit, - getVisibleViewport: getVisibleViewport, - setEditorStyle: setEditorStyle, -}; diff --git a/packages/roosterjs-content-model-core/lib/utils/createDomToModelContextForSanitizing.ts b/packages/roosterjs-content-model-core/lib/utils/createDomToModelContextForSanitizing.ts index fa101508aab..3f0af412208 100644 --- a/packages/roosterjs-content-model-core/lib/utils/createDomToModelContextForSanitizing.ts +++ b/packages/roosterjs-content-model-core/lib/utils/createDomToModelContextForSanitizing.ts @@ -2,7 +2,7 @@ import { containerSizeFormatParser } from '../override/containerSizeFormatParser import { createDomToModelContext } from 'roosterjs-content-model-dom'; import { createPasteEntityProcessor } from '../override/pasteEntityProcessor'; import { createPasteGeneralProcessor } from '../override/pasteGeneralProcessor'; -import { getRootComputedStyleForContext } from './getRootComputedStyleForContext'; +import { getRootComputedStyleForContext } from '../coreApi/createEditorContext/getRootComputedStyleForContext'; import { pasteBlockEntityParser } from '../override/pasteCopyBlockEntityParser'; import { pasteDisplayFormatParser } from '../override/pasteDisplayFormatParser'; import { pasteTextProcessor } from '../override/pasteTextProcessor'; diff --git a/packages/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts b/packages/roosterjs-content-model-core/test/coreApi/addUndoSnapshot/addUndoSnapshotTest.ts similarity index 96% rename from packages/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/addUndoSnapshot/addUndoSnapshotTest.ts index 04133f4d1c0..d5460e4d753 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/addUndoSnapshotTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/addUndoSnapshot/addUndoSnapshotTest.ts @@ -1,5 +1,5 @@ -import * as createSnapshotSelection from '../../lib/utils/createSnapshotSelection'; -import { addUndoSnapshot } from '../../lib/coreApi/addUndoSnapshot'; +import * as createSnapshotSelection from '../../../lib/coreApi/addUndoSnapshot/createSnapshotSelection'; +import { addUndoSnapshot } from '../../../lib/coreApi/addUndoSnapshot/addUndoSnapshot'; import { EditorCore, SnapshotsManager } from 'roosterjs-content-model-types'; describe('addUndoSnapshot', () => { diff --git a/packages/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/addUndoSnapshot/createSnapshotSelectionTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/addUndoSnapshot/createSnapshotSelectionTest.ts index 866d65f06f2..b93a71df6e8 100644 --- a/packages/roosterjs-content-model-core/test/utils/createSnapshotSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/addUndoSnapshot/createSnapshotSelectionTest.ts @@ -1,4 +1,4 @@ -import { createSnapshotSelection } from '../../lib/utils/createSnapshotSelection'; +import { createSnapshotSelection } from '../../../lib/coreApi/addUndoSnapshot/createSnapshotSelection'; import { DOMSelection, EditorCore } from 'roosterjs-content-model-types'; describe('createSnapshotSelection', () => { diff --git a/packages/roosterjs-content-model-core/test/coreApi/attachDomEventTest.ts b/packages/roosterjs-content-model-core/test/coreApi/attachDomEvent/attachDomEventTest.ts similarity index 97% rename from packages/roosterjs-content-model-core/test/coreApi/attachDomEventTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/attachDomEvent/attachDomEventTest.ts index 0f8126e28ab..f443f8f259c 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/attachDomEventTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/attachDomEvent/attachDomEventTest.ts @@ -1,4 +1,4 @@ -import { attachDomEvent } from '../../lib/coreApi/attachDomEvent'; +import { attachDomEvent } from '../../../lib/coreApi/attachDomEvent/attachDomEvent'; import { EditorCore } from 'roosterjs-content-model-types'; describe('attachDomEvent', () => { diff --git a/packages/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts similarity index 98% rename from packages/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts index 5e64ec1f315..4ef79714fe8 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/createContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/createContentModel/createContentModelTest.ts @@ -1,7 +1,7 @@ -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 { createContentModel } from '../../lib/coreApi/createContentModel'; +import { createContentModel } from '../../../lib/coreApi/createContentModel/createContentModel'; import { EditorCore } from 'roosterjs-content-model-types'; const mockedEditorContext = 'EDITORCONTEXT' as any; diff --git a/packages/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts b/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts similarity index 98% rename from packages/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts index 3d9104ce3ce..6d35f1d500a 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/createEditorContextTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/createEditorContext/createEditorContextTest.ts @@ -1,4 +1,4 @@ -import { createEditorContext } from '../../lib/coreApi/createEditorContext'; +import { createEditorContext } from '../../../lib/coreApi/createEditorContext/createEditorContext'; import { EditorCore } from 'roosterjs-content-model-types'; describe('createEditorContext', () => { diff --git a/packages/roosterjs-content-model-core/test/coreApi/focusTest.ts b/packages/roosterjs-content-model-core/test/coreApi/focus/focusTest.ts similarity index 98% rename from packages/roosterjs-content-model-core/test/coreApi/focusTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/focus/focusTest.ts index f443b6d716a..b482fa86932 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/focusTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/focus/focusTest.ts @@ -1,5 +1,5 @@ import { EditorCore } from 'roosterjs-content-model-types'; -import { focus } from '../../lib/coreApi/focus'; +import { focus } from '../../../lib/coreApi/focus/focus'; describe('focus', () => { let div: HTMLDivElement; diff --git a/packages/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts index 33999ea7dfe..5ae3be6b09b 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/formatContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/formatContentModel/formatContentModelTest.ts @@ -1,7 +1,7 @@ -import * as transformColor from '../../lib/publicApi/color/transformColor'; -import { ChangeSource } from '../../lib/constants/ChangeSource'; +import * as transformColor from '../../../lib/publicApi/color/transformColor'; +import { ChangeSource } from '../../../lib/constants/ChangeSource'; import { createImage } from 'roosterjs-content-model-dom'; -import { formatContentModel } from '../../lib/coreApi/formatContentModel'; +import { formatContentModel } from '../../../lib/coreApi/formatContentModel/formatContentModel'; import { ContentModelDocument, ContentModelSegmentFormat, diff --git a/packages/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/getDOMSelection/getDOMSelectionTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/getDOMSelection/getDOMSelectionTest.ts index 6703f8c2afe..e3575d2b4b0 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/getDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/getDOMSelection/getDOMSelectionTest.ts @@ -1,5 +1,5 @@ import { EditorCore } from 'roosterjs-content-model-types'; -import { getDOMSelection } from '../../lib/coreApi/getDOMSelection'; +import { getDOMSelection } from '../../../lib/coreApi/getDOMSelection/getDOMSelection'; describe('getDOMSelection', () => { let core: EditorCore; diff --git a/packages/roosterjs-content-model-core/test/coreApi/getVisibleViewportTest.ts b/packages/roosterjs-content-model-core/test/coreApi/getVisibleViewport/getVisibleViewportTest.ts similarity index 92% rename from packages/roosterjs-content-model-core/test/coreApi/getVisibleViewportTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/getVisibleViewport/getVisibleViewportTest.ts index bc4ebd2644b..ea5e900ff44 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/getVisibleViewportTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/getVisibleViewport/getVisibleViewportTest.ts @@ -1,4 +1,4 @@ -import { getVisibleViewport } from '../../lib/coreApi/getVisibleViewport'; +import { getVisibleViewport } from '../../../lib/coreApi/getVisibleViewport/getVisibleViewport'; describe('getVisibleViewport', () => { it('scrollContainer is same with contentDiv', () => { diff --git a/packages/roosterjs-content-model-core/test/utils/restoreSnapshotColorsTest.ts b/packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreSnapshotColorsTest.ts similarity index 93% rename from packages/roosterjs-content-model-core/test/utils/restoreSnapshotColorsTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreSnapshotColorsTest.ts index bec0dcb1ff6..f10a623b479 100644 --- a/packages/roosterjs-content-model-core/test/utils/restoreSnapshotColorsTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreSnapshotColorsTest.ts @@ -1,6 +1,6 @@ -import * as transformColor from '../../lib/publicApi/color/transformColor'; +import * as transformColor from '../../../lib/publicApi/color/transformColor'; import { DarkColorHandler, EditorCore, Snapshot } from 'roosterjs-content-model-types'; -import { restoreSnapshotColors } from '../../lib/utils/restoreSnapshotColors'; +import { restoreSnapshotColors } from '../../../lib/coreApi/restoreUndoSnapshot/restoreSnapshotColors'; describe('restoreSnapshotColors', () => { let core: EditorCore; diff --git a/packages/roosterjs-content-model-core/test/utils/restoreSnapshotHTMLTest.ts b/packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreSnapshotHTMLTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/utils/restoreSnapshotHTMLTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreSnapshotHTMLTest.ts index fe16dd607f3..a238d818a6b 100644 --- a/packages/roosterjs-content-model-core/test/utils/restoreSnapshotHTMLTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreSnapshotHTMLTest.ts @@ -1,5 +1,5 @@ import { EditorCore, Snapshot } from 'roosterjs-content-model-types'; -import { restoreSnapshotHTML } from '../../lib/utils/restoreSnapshotHTML'; +import { restoreSnapshotHTML } from '../../../lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML'; import { wrap } from 'roosterjs-content-model-dom'; describe('restoreSnapshotHTML', () => { diff --git a/packages/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreSnapshotSelectionTest.ts similarity index 98% rename from packages/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreSnapshotSelectionTest.ts index aaad9729ce7..342187edc9e 100644 --- a/packages/roosterjs-content-model-core/test/utils/restoreSnapshotSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreSnapshotSelectionTest.ts @@ -1,5 +1,5 @@ import { EditorCore, Snapshot, SnapshotSelection } from 'roosterjs-content-model-types'; -import { restoreSnapshotSelection } from '../../lib/utils/restoreSnapshotSelection'; +import { restoreSnapshotSelection } from '../../../lib/coreApi/restoreUndoSnapshot/restoreSnapshotSelection'; describe('restoreSnapshotSelection', () => { let core: EditorCore; diff --git a/packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshotTest.ts b/packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreUndoSnapshotTest.ts similarity index 86% rename from packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshotTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreUndoSnapshotTest.ts index b1c2db93602..a06db50c9aa 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshotTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreUndoSnapshotTest.ts @@ -1,9 +1,9 @@ -import * as restoreSnapshotColors from '../../lib/utils/restoreSnapshotColors'; -import * as restoreSnapshotHTML from '../../lib/utils/restoreSnapshotHTML'; -import * as restoreSnapshotSelection from '../../lib/utils/restoreSnapshotSelection'; -import { ChangeSource } from '../../lib/constants/ChangeSource'; +import * as restoreSnapshotColors from '../../../lib/coreApi/restoreUndoSnapshot/restoreSnapshotColors'; +import * as restoreSnapshotHTML from '../../../lib/coreApi/restoreUndoSnapshot/restoreSnapshotHTML'; +import * as restoreSnapshotSelection from '../../../lib/coreApi/restoreUndoSnapshot/restoreSnapshotSelection'; +import { ChangeSource } from '../../../lib/constants/ChangeSource'; import { EditorCore, Snapshot } from 'roosterjs-content-model-types'; -import { restoreUndoSnapshot } from '../../lib/coreApi/restoreUndoSnapshot'; +import { restoreUndoSnapshot } from '../../../lib/coreApi/restoreUndoSnapshot/restoreUndoSnapshot'; describe('restoreUndoSnapshot', () => { let core: EditorCore; diff --git a/packages/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts similarity index 98% rename from packages/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts index cf4774a4b1d..1ef705b02a8 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts @@ -1,7 +1,7 @@ import * as contentModelToDom from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; import * as createModelToDomContext from 'roosterjs-content-model-dom/lib/modelToDom/context/createModelToDomContext'; import { EditorCore } from 'roosterjs-content-model-types'; -import { setContentModel } from '../../lib/coreApi/setContentModel'; +import { setContentModel } from '../../../lib/coreApi/setContentModel/setContentModel'; const mockedDoc = 'DOCUMENT' as any; const mockedModel = 'MODEL' as any; diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts index ea75d2bbc27..530ed49ec5e 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts @@ -1,6 +1,6 @@ -import * as addRangeToSelection from '../../lib/corePlugin/utils/addRangeToSelection'; +import * as addRangeToSelection from '../../../lib/coreApi/setDOMSelection/addRangeToSelection'; import { DOMSelection, EditorCore } from 'roosterjs-content-model-types'; -import { setDOMSelection } from '../../lib/coreApi/setDOMSelection'; +import { setDOMSelection } from '../../../lib/coreApi/setDOMSelection/setDOMSelection'; describe('setDOMSelection', () => { let core: EditorCore; diff --git a/packages/roosterjs-content-model-core/test/coreApi/switchShadowEditTest.ts b/packages/roosterjs-content-model-core/test/coreApi/switchShadowEdit/switchShadowEditTest.ts similarity index 96% rename from packages/roosterjs-content-model-core/test/coreApi/switchShadowEditTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/switchShadowEdit/switchShadowEditTest.ts index ea5469f628b..430534a4766 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/switchShadowEditTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/switchShadowEdit/switchShadowEditTest.ts @@ -1,6 +1,6 @@ -import * as iterateSelections from '../../lib/publicApi/selection/iterateSelections'; +import * as iterateSelections from '../../../lib/publicApi/selection/iterateSelections'; import { EditorCore } from 'roosterjs-content-model-types'; -import { switchShadowEdit } from '../../lib/coreApi/switchShadowEdit'; +import { switchShadowEdit } from '../../../lib/coreApi/switchShadowEdit/switchShadowEdit'; const mockedModel = 'MODEL' as any; const mockedCachedModel = 'CACHEMODEL' as any; diff --git a/packages/roosterjs-content-model-core/test/coreApi/triggerEventTest.ts b/packages/roosterjs-content-model-core/test/coreApi/triggerEvent/triggerEventTest.ts similarity index 98% rename from packages/roosterjs-content-model-core/test/coreApi/triggerEventTest.ts rename to packages/roosterjs-content-model-core/test/coreApi/triggerEvent/triggerEventTest.ts index 053410fbd1d..2dd5cdaa5f4 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/triggerEventTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/triggerEvent/triggerEventTest.ts @@ -1,5 +1,5 @@ import { EditorCore, EditorPlugin, PluginEvent } from 'roosterjs-content-model-types'; -import { triggerEvent } from '../../lib/coreApi/triggerEvent'; +import { triggerEvent } from '../../../lib/coreApi/triggerEvent/triggerEvent'; describe('triggerEvent', () => { let div: HTMLDivElement; diff --git a/packages/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/CachePluginTest.ts similarity index 97% rename from packages/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/cache/CachePluginTest.ts index 03ce3265675..330cda23390 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/CachePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/CachePluginTest.ts @@ -1,6 +1,6 @@ -import * as textMutationObserver from '../../lib/corePlugin/utils/textMutationObserver'; -import { createCachePlugin } from '../../lib/corePlugin/CachePlugin'; -import { domIndexerImpl } from '../../lib/corePlugin/utils/domIndexerImpl'; +import * as textMutationObserver from '../../../lib/corePlugin/cache/textMutationObserver'; +import { createCachePlugin } from '../../../lib/corePlugin/cache/CachePlugin'; +import { domIndexerImpl } from '../../../lib/corePlugin/cache/domIndexerImpl'; import { CachePluginState, DomIndexer, diff --git a/packages/roosterjs-content-model-core/test/corePlugin/utils/areSameRangeExTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/areSameSelectionTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/corePlugin/utils/areSameRangeExTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/cache/areSameSelectionTest.ts index a8fd0ffbb0f..1b79a77c946 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/utils/areSameRangeExTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/areSameSelectionTest.ts @@ -1,4 +1,4 @@ -import { areSameSelection } from '../../../lib/corePlugin/utils/areSameSelection'; +import { areSameSelection } from '../../../lib/corePlugin/cache/areSameSelection'; import { DOMSelection } from 'roosterjs-content-model-types'; describe('areSameSelection', () => { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/utils/domIndexerTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/corePlugin/utils/domIndexerTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerTest.ts index 90e0b391221..6b0aedd75de 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/utils/domIndexerTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/domIndexerTest.ts @@ -1,6 +1,6 @@ import * as setSelection from '../../../lib/publicApi/selection/setSelection'; import { createRange } from 'roosterjs-content-model-dom/test/testUtils'; -import { domIndexerImpl } from '../../../lib/corePlugin/utils/domIndexerImpl'; +import { domIndexerImpl } from '../../../lib/corePlugin/cache/domIndexerImpl'; import { ContentModelDocument, ContentModelSegment, diff --git a/packages/roosterjs-content-model-core/test/corePlugin/utils/textMutationObserverTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/cache/textMutationObserverTest.ts similarity index 97% rename from packages/roosterjs-content-model-core/test/corePlugin/utils/textMutationObserverTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/cache/textMutationObserverTest.ts index 7ba5a6df1b3..515ad8d418d 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/utils/textMutationObserverTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/cache/textMutationObserverTest.ts @@ -1,4 +1,4 @@ -import * as TextMutationObserver from '../../../lib/corePlugin/utils/textMutationObserver'; +import * as TextMutationObserver from '../../../lib/corePlugin/cache/textMutationObserver'; describe('TextMutationObserverImpl', () => { it('init', () => { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/ContextMenuPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/contextMenu/ContextMenuPluginTest.ts similarity index 96% rename from packages/roosterjs-content-model-core/test/corePlugin/ContextMenuPluginTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/contextMenu/ContextMenuPluginTest.ts index 42e3282e5ef..dba422a0537 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/ContextMenuPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/contextMenu/ContextMenuPluginTest.ts @@ -1,5 +1,5 @@ -import * as getSelectionRootNode from '../../lib/publicApi/selection/getSelectionRootNode'; -import { createContextMenuPlugin } from '../../lib/corePlugin/ContextMenuPlugin'; +import * as getSelectionRootNode from '../../../lib/publicApi/selection/getSelectionRootNode'; +import { createContextMenuPlugin } from '../../../lib/corePlugin/contextMenu/ContextMenuPlugin'; import { ContextMenuPluginState, DOMEventRecord, diff --git a/packages/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts similarity index 98% rename from packages/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts index f72dbb692eb..ff3957eabdd 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/CopyPastePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/CopyPastePluginTest.ts @@ -1,11 +1,11 @@ -import * as addRangeToSelection from '../../lib/corePlugin/utils/addRangeToSelection'; +import * as addRangeToSelection from '../../../lib/coreApi/setDOMSelection/addRangeToSelection'; import * as contentModelToDomFile from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; -import * as copyPasteEntityOverride from '../../lib/override/pasteCopyBlockEntityParser'; -import * as deleteSelectionsFile from '../../lib/publicApi/selection/deleteSelection'; -import * as extractClipboardItemsFile from '../../lib/utils/extractClipboardItems'; -import * as iterateSelectionsFile from '../../lib/publicApi/selection/iterateSelections'; +import * as copyPasteEntityOverride from '../../../lib/override/pasteCopyBlockEntityParser'; +import * as deleteSelectionsFile from '../../../lib/publicApi/selection/deleteSelection'; +import * as extractClipboardItemsFile from '../../../lib/utils/extractClipboardItems'; +import * as iterateSelectionsFile from '../../../lib/publicApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import * as paste from '../../lib/publicApi/paste/paste'; +import * as paste from '../../../lib/publicApi/paste/paste'; import { createModelToDomContext, createTable, createTableCell } from 'roosterjs-content-model-dom'; import { createRange } from 'roosterjs-content-model-dom/test/testUtils'; import { setEntityElementClasses } from 'roosterjs-content-model-dom/test/domUtils/entityUtilTest'; @@ -26,7 +26,7 @@ import { createCopyPastePlugin, onNodeCreated, preprocessTable, -} from '../../lib/corePlugin/CopyPastePlugin'; +} from '../../../lib/corePlugin/copyPaste/CopyPastePlugin'; const modelValue = { blocks: [], diff --git a/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/deleteEmptyListTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/deleteEmptyListTest.ts new file mode 100644 index 00000000000..337e59c0b35 --- /dev/null +++ b/packages/roosterjs-content-model-core/test/corePlugin/copyPaste/deleteEmptyListTest.ts @@ -0,0 +1,1072 @@ +import { ContentModelBlockGroup, ContentModelSelectionMarker } from 'roosterjs-content-model-types'; +import { deleteEmptyList } from '../../../lib/corePlugin/copyPaste/deleteEmptyList'; +import { deleteSelection } from '../../../lib/publicApi/selection/deleteSelection'; +import { + createContentModelDocument, + createListItem, + createListLevel, + createParagraph, + createTable, + createTableCell, + createText, + normalizeContentModel, +} from 'roosterjs-content-model-dom'; + +describe('deleteEmptyList - 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, [deleteEmptyList], undefined); + 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, [deleteEmptyList], undefined); + 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, [deleteEmptyList], undefined); + 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, [deleteEmptyList], undefined); + 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: {}, + }, + ], + }); + }); + + 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); + 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: {}, + }, + ], + }); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/domEvent/DomEventPluginTest.ts similarity index 98% rename from packages/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/domEvent/DomEventPluginTest.ts index 7f4890e0fe8..823870ffef2 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/DomEventPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/domEvent/DomEventPluginTest.ts @@ -1,6 +1,6 @@ -import * as eventUtils from '../../lib/publicApi/domUtils/eventUtils'; -import { ChangeSource } from '../../lib/constants/ChangeSource'; -import { createDOMEventPlugin } from '../../lib/corePlugin/DOMEventPlugin'; +import * as eventUtils from '../../../lib/publicApi/domUtils/eventUtils'; +import { ChangeSource } from '../../../lib/constants/ChangeSource'; +import { createDOMEventPlugin } from '../../../lib/corePlugin/domEvent/DOMEventPlugin'; import { DOMEventPluginState, IEditor, PluginWithState } from 'roosterjs-content-model-types'; const getDocument = () => document; diff --git a/packages/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/entity/EntityPluginTest.ts similarity index 98% rename from packages/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/entity/EntityPluginTest.ts index a79a9fa2f79..17167d39742 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/EntityPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/entity/EntityPluginTest.ts @@ -1,8 +1,8 @@ -import * as DelimiterUtils from '../../lib/corePlugin/utils/entityDelimiterUtils'; +import * as DelimiterUtils from '../../../lib/corePlugin/entity/entityDelimiterUtils'; import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; -import * as transformColor from '../../lib/publicApi/color/transformColor'; -import { createContentModelDocument, createEntity } from '../../../roosterjs-content-model-dom/lib'; -import { createEntityPlugin } from '../../lib/corePlugin/EntityPlugin'; +import * as transformColor from '../../../lib/publicApi/color/transformColor'; +import { createContentModelDocument, createEntity } from 'roosterjs-content-model-dom'; +import { createEntityPlugin } from '../../../lib/corePlugin/entity/EntityPlugin'; import { ContentModelDocument, DarkColorHandler, diff --git a/packages/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/entity/delimiterUtilsTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/entity/delimiterUtilsTest.ts index 203c28c4e4f..f565bc6fc9e 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/entity/delimiterUtilsTest.ts @@ -1,10 +1,10 @@ -import * as DelimiterFile from '../../../lib/corePlugin/utils/entityDelimiterUtils'; +import * as DelimiterFile from '../../../lib/corePlugin/entity/entityDelimiterUtils'; import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; import { ContentModelDocument, DOMSelection, IEditor } from 'roosterjs-content-model-types'; import { handleDelimiterContentChangedEvent, handleDelimiterKeyDownEvent, -} from '../../../lib/corePlugin/utils/entityDelimiterUtils'; +} from '../../../lib/corePlugin/entity/entityDelimiterUtils'; import { contentModelToDom, createEntity, diff --git a/packages/roosterjs-content-model-core/test/corePlugin/utils/findAllEntitiesTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/entity/findAllEntitiesTest.ts similarity index 98% rename from packages/roosterjs-content-model-core/test/corePlugin/utils/findAllEntitiesTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/entity/findAllEntitiesTest.ts index 2106383876f..1de197a0ace 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/utils/findAllEntitiesTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/entity/findAllEntitiesTest.ts @@ -1,5 +1,5 @@ import { ChangedEntity } from 'roosterjs-content-model-types'; -import { findAllEntities } from '../../../lib/corePlugin/utils/findAllEntities'; +import { findAllEntities } from '../../../lib/corePlugin/entity/findAllEntities'; import { createContentModelDocument, createEntity, diff --git a/packages/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts similarity index 98% rename from packages/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts index 9fd485fdc12..8b5b73aa3b6 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/FormatPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/format/FormatPluginTest.ts @@ -1,7 +1,7 @@ -import * as applyDefaultFormat from '../../lib/corePlugin/utils/applyDefaultFormat'; -import * as applyPendingFormat from '../../lib/corePlugin/utils/applyPendingFormat'; +import * as applyDefaultFormat from '../../../lib/corePlugin/format/applyDefaultFormat'; +import * as applyPendingFormat from '../../../lib/corePlugin/format/applyPendingFormat'; import { createContentModelDocument } from 'roosterjs-content-model-dom'; -import { createFormatPlugin } from '../../lib/corePlugin/FormatPlugin'; +import { createFormatPlugin } from '../../../lib/corePlugin/format/FormatPlugin'; import { IEditor } from 'roosterjs-content-model-types'; describe('FormatPlugin', () => { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/format/applyDefaultFormatTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/format/applyDefaultFormatTest.ts index fcc9944b030..ff647b3a1c6 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/utils/applyDefaultFormatTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/format/applyDefaultFormatTest.ts @@ -1,6 +1,6 @@ import * as deleteSelection from '../../../lib/publicApi/selection/deleteSelection'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import { applyDefaultFormat } from '../../../lib/corePlugin/utils/applyDefaultFormat'; +import { applyDefaultFormat } from '../../../lib/corePlugin/format/applyDefaultFormat'; import { ContentModelDocument, ContentModelFormatter, diff --git a/packages/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts index 6eabe6aa0c3..88d6ad7aa3e 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/utils/applyPendingFormatTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/format/applyPendingFormatTest.ts @@ -1,6 +1,6 @@ import * as iterateSelections from '../../../lib/publicApi/selection/iterateSelections'; import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; -import { applyPendingFormat } from '../../../lib/corePlugin/utils/applyPendingFormat'; +import { applyPendingFormat } from '../../../lib/corePlugin/format/applyPendingFormat'; import { ContentModelDocument, ContentModelParagraph, diff --git a/packages/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts similarity index 98% rename from packages/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts index 2c9bed20df0..a8f7fcecf42 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/LifecyclePluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/lifecycle/LifecyclePluginTest.ts @@ -1,6 +1,6 @@ import * as color from 'roosterjs-content-model-dom/lib/formatHandlers/utils/color'; -import { ChangeSource } from '../../lib/constants/ChangeSource'; -import { createLifecyclePlugin } from '../../lib/corePlugin/LifecyclePlugin'; +import { ChangeSource } from '../../../lib/constants/ChangeSource'; +import { createLifecyclePlugin } from '../../../lib/corePlugin/lifecycle/LifecyclePlugin'; import { DarkColorHandler, IEditor } from 'roosterjs-content-model-types'; describe('LifecyclePlugin', () => { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts index 73f53eb763a..18d79dc59f4 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -1,4 +1,4 @@ -import { createSelectionPlugin } from '../../lib/corePlugin/SelectionPlugin'; +import { createSelectionPlugin } from '../../../lib/corePlugin/selection/SelectionPlugin'; import { EditorPlugin, IEditor, diff --git a/packages/roosterjs-content-model-core/test/editor/SnapshotsManagerImplTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/undo/SnapshotsManagerImplTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/editor/SnapshotsManagerImplTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/undo/SnapshotsManagerImplTest.ts index 636f850d0b4..4f34d2ce74b 100644 --- a/packages/roosterjs-content-model-core/test/editor/SnapshotsManagerImplTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/undo/SnapshotsManagerImplTest.ts @@ -1,4 +1,4 @@ -import { createSnapshotsManager } from '../../lib/editor/SnapshotsManagerImpl'; +import { createSnapshotsManager } from '../../../lib/corePlugin/undo/SnapshotsManagerImpl'; import { Snapshot, Snapshots, SnapshotsManager } from 'roosterjs-content-model-types'; describe('SnapshotsManagerImpl.ctor', () => { diff --git a/packages/roosterjs-content-model-core/test/corePlugin/UndoPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/undo/UndoPluginTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/corePlugin/UndoPluginTest.ts rename to packages/roosterjs-content-model-core/test/corePlugin/undo/UndoPluginTest.ts index b487e57e37e..1ca16017842 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/UndoPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/undo/UndoPluginTest.ts @@ -1,7 +1,7 @@ -import * as SnapshotsManagerImpl from '../../lib/editor/SnapshotsManagerImpl'; -import * as undo from '../../lib/publicApi/undo/undo'; -import { ChangeSource } from '../../lib/constants/ChangeSource'; -import { createUndoPlugin } from '../../lib/corePlugin/UndoPlugin'; +import * as SnapshotsManagerImpl from '../../../lib/corePlugin/undo/SnapshotsManagerImpl'; +import * as undo from '../../../lib/publicApi/undo/undo'; +import { ChangeSource } from '../../../lib/constants/ChangeSource'; +import { createUndoPlugin } from '../../../lib/corePlugin/undo/UndoPlugin'; import { IEditor, PluginWithState, diff --git a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts index 310abf7a793..81f5de24bc0 100644 --- a/packages/roosterjs-content-model-core/test/editor/EditorTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/EditorTest.ts @@ -1,5 +1,5 @@ import * as cloneModel from '../../lib/publicApi/model/cloneModel'; -import * as createEditorCore from '../../lib/editor/createEditorCore'; +import * as createEditorCore from '../../lib/editor/core/createEditorCore'; import * as createEmptyModel from 'roosterjs-content-model-dom/lib/modelApi/creators/createEmptyModel'; import * as transformColor from '../../lib/publicApi/color/transformColor'; import { CachedElementHandler, EditorCore, Rect } from 'roosterjs-content-model-types'; diff --git a/packages/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts b/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts similarity index 99% rename from packages/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts rename to packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts index 67e4da3fcf3..7b370d4cfad 100644 --- a/packages/roosterjs-content-model-core/test/editor/DOMHelperImplTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts @@ -1,4 +1,4 @@ -import { createDOMHelper } from '../../lib/editor/DOMHelperImpl'; +import { createDOMHelper } from '../../../lib/editor/core/DOMHelperImpl'; import { DOMHelper } from 'roosterjs-content-model-types'; describe('DOMHelperImpl', () => { diff --git a/packages/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts b/packages/roosterjs-content-model-core/test/editor/core/DarkColorHandlerImplTest.ts similarity index 98% rename from packages/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts rename to packages/roosterjs-content-model-core/test/editor/core/DarkColorHandlerImplTest.ts index 71893b28d54..fc495d755fc 100644 --- a/packages/roosterjs-content-model-core/test/editor/DarkColorHandlerImplTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/DarkColorHandlerImplTest.ts @@ -1,4 +1,4 @@ -import { createDarkColorHandler } from '../../lib/editor/DarkColorHandlerImpl'; +import { createDarkColorHandler } from '../../../lib/editor/core/DarkColorHandlerImpl'; describe('DarkColorHandlerImpl.ctor', () => { it('no knownColors', () => { diff --git a/packages/roosterjs-content-model-core/test/editor/createEditorCoreTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts similarity index 95% rename from packages/roosterjs-content-model-core/test/editor/createEditorCoreTest.ts rename to packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts index 1c7479efafd..361612fe2aa 100644 --- a/packages/roosterjs-content-model-core/test/editor/createEditorCoreTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorCoreTest.ts @@ -1,14 +1,14 @@ -import * as createDefaultSettings from '../../lib/editor/createEditorDefaultSettings'; -import * as createEditorCorePlugins from '../../lib/corePlugin/createEditorCorePlugins'; -import * as DarkColorHandlerImpl from '../../lib/editor/DarkColorHandlerImpl'; -import * as DOMHelperImpl from '../../lib/editor/DOMHelperImpl'; -import { coreApiMap } from '../../lib/editor/coreApiMap'; +import * as createDefaultSettings from '../../../lib/editor/core/createEditorDefaultSettings'; +import * as createEditorCorePlugins from '../../../lib/corePlugin/createEditorCorePlugins'; +import * as DarkColorHandlerImpl from '../../../lib/editor/core/DarkColorHandlerImpl'; +import * as DOMHelperImpl from '../../../lib/editor/core/DOMHelperImpl'; +import { coreApiMap } from '../../../lib/coreApi/coreApiMap'; import { EditorCore, EditorOptions } from 'roosterjs-content-model-types'; import { createEditorCore, defaultTrustHtmlHandler, getDarkColorFallback, -} from '../../lib/editor/createEditorCore'; +} from '../../../lib/editor/core/createEditorCore'; describe('createEditorCore', () => { function createMockedPlugin(stateName: string): any { diff --git a/packages/roosterjs-content-model-core/test/editor/createEditorDefaultSettingsTest.ts b/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts similarity index 95% rename from packages/roosterjs-content-model-core/test/editor/createEditorDefaultSettingsTest.ts rename to packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts index fee3355d279..b4d776b5016 100644 --- a/packages/roosterjs-content-model-core/test/editor/createEditorDefaultSettingsTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/createEditorDefaultSettingsTest.ts @@ -1,14 +1,14 @@ 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 { tablePreProcessor } from '../../lib/override/tablePreProcessor'; +import { tablePreProcessor } from '../../../lib/override/tablePreProcessor'; import { listItemMetadataApplier, listLevelMetadataApplier, -} from '../../lib/metadata/updateListMetadata'; +} from '../../../lib/metadata/updateListMetadata'; import { createDomToModelSettings, createModelToDomSettings, -} from '../../lib/editor/createEditorDefaultSettings'; +} from '../../../lib/editor/core/createEditorDefaultSettings'; describe('createDomToModelSettings', () => { const mockedCalculatedConfig = 'CONFIG' as any; diff --git a/packages/roosterjs-content-model-core/test/publicApi/color/transformColorTest.ts b/packages/roosterjs-content-model-core/test/publicApi/color/transformColorTest.ts index 93574bc8337..046903c28b8 100644 --- a/packages/roosterjs-content-model-core/test/publicApi/color/transformColorTest.ts +++ b/packages/roosterjs-content-model-core/test/publicApi/color/transformColorTest.ts @@ -1,4 +1,4 @@ -import { createDarkColorHandler } from '../../../lib/editor/DarkColorHandlerImpl'; +import { createDarkColorHandler } from '../../../lib/editor/core/DarkColorHandlerImpl'; import { transformColor } from '../../../lib/publicApi/color/transformColor'; describe('transform to dark mode', () => { diff --git a/packages/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts b/packages/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts index ba1fc11cde2..06d706c3973 100644 --- a/packages/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/publicApi/selection/deleteSelectionTest.ts @@ -1,10 +1,5 @@ -import { deleteEmptyList } from '../../../lib/corePlugin/utils/deleteEmptyList'; +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, @@ -12,14 +7,11 @@ import { createGeneralBlock, createGeneralSegment, createImage, - createListItem, - createListLevel, createParagraph, createSelectionMarker, createTable, createTableCell, createText, - normalizeContentModel, } from 'roosterjs-content-model-dom'; describe('deleteSelection - selectionOnly', () => { @@ -1011,1062 +1003,3 @@ 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, [deleteEmptyList], undefined); - 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, [deleteEmptyList], undefined); - 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, [deleteEmptyList], undefined); - 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, [deleteEmptyList], undefined); - 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: {}, - }, - ], - }); - }); - - 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); - 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: {}, - }, - ], - }); - }); -}); diff --git a/packages/roosterjs-content-model-plugins/test/TestHelper.ts b/packages/roosterjs-content-model-plugins/test/TestHelper.ts index 4f1619b506b..3e69b1754ea 100644 --- a/packages/roosterjs-content-model-plugins/test/TestHelper.ts +++ b/packages/roosterjs-content-model-plugins/test/TestHelper.ts @@ -1,4 +1,9 @@ -import { ContentModelDocument, CoreApiMap, EditorPlugin } from 'roosterjs-content-model-types'; +import { + ContentModelDocument, + CoreApiMap, + EditorPlugin, + IEditor, +} from 'roosterjs-content-model-types'; import { Editor } from 'roosterjs-content-model-core'; export function initEditor( @@ -7,7 +12,7 @@ export function initEditor( initialModel?: ContentModelDocument, coreApiOverride?: Partial, anchorContainerSelector?: string -) { +): IEditor { let node = document.createElement('div'); node.id = id; diff --git a/packages/roosterjs-editor-adapter/test/editor/DarkColorHandlerImplTest.ts b/packages/roosterjs-editor-adapter/test/editor/DarkColorHandlerImplTest.ts index a7c02c0072f..c02c9a796bb 100644 --- a/packages/roosterjs-editor-adapter/test/editor/DarkColorHandlerImplTest.ts +++ b/packages/roosterjs-editor-adapter/test/editor/DarkColorHandlerImplTest.ts @@ -1,6 +1,6 @@ import { ColorKeyAndValue, DarkColorHandler } from 'roosterjs-editor-types'; import { createDarkColorHandler } from '../../lib/editor/DarkColorHandlerImpl'; -import { createDarkColorHandler as createInnderDarkColorHandler } from 'roosterjs-content-model-core/lib/editor/DarkColorHandlerImpl'; +import { createDarkColorHandler as createInnerDarkColorHandler } from 'roosterjs-content-model-core/lib/editor/core/DarkColorHandlerImpl'; function getDarkColor(color: string) { return 'Dark_' + color; @@ -9,7 +9,7 @@ function getDarkColor(color: string) { describe('DarkColorHandlerImpl.ctor', () => { it('No additional param', () => { const div = document.createElement('div'); - const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const innerHandler = createInnerDarkColorHandler(div, getDarkColor); const handler = createDarkColorHandler(innerHandler); expect(handler).toBeDefined(); @@ -17,7 +17,7 @@ describe('DarkColorHandlerImpl.ctor', () => { it('Calculate color using customized base color', () => { const div = document.createElement('div'); - const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const innerHandler = createInnerDarkColorHandler(div, getDarkColor); const handler = createDarkColorHandler(innerHandler); const darkColor = handler.registerColor('red', true); @@ -38,7 +38,7 @@ describe('DarkColorHandlerImpl.parseColorValue', () => { beforeEach(() => { div = document.createElement('div'); - const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const innerHandler = createInnerDarkColorHandler(div, getDarkColor); handler = createDarkColorHandler(innerHandler); }); @@ -142,7 +142,7 @@ describe('DarkColorHandlerImpl.registerColor', () => { setProperty, }, } as any) as HTMLElement; - const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const innerHandler = createInnerDarkColorHandler(div, getDarkColor); handler = createDarkColorHandler(innerHandler); }); @@ -233,7 +233,7 @@ describe('DarkColorHandlerImpl.reset', () => { removeProperty, }, } as any) as HTMLElement; - const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const innerHandler = createInnerDarkColorHandler(div, getDarkColor); const handler = createDarkColorHandler(innerHandler); (handler as any).innerHandler.knownColors = { @@ -258,7 +258,7 @@ describe('DarkColorHandlerImpl.reset', () => { describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { it('Not found', () => { const div = ({} as any) as HTMLElement; - const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const innerHandler = createInnerDarkColorHandler(div, getDarkColor); const handler = createDarkColorHandler(innerHandler); const result = handler.findLightColorFromDarkColor('#010203'); @@ -268,7 +268,7 @@ describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { it('Found: HEX to RGB', () => { const div = ({} as any) as HTMLElement; - const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const innerHandler = createInnerDarkColorHandler(div, getDarkColor); const handler = createDarkColorHandler(innerHandler); (handler as any).innerHandler.knownColors = { @@ -289,7 +289,7 @@ describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { it('Found: HEX to HEX', () => { const div = ({} as any) as HTMLElement; - const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const innerHandler = createInnerDarkColorHandler(div, getDarkColor); const handler = createDarkColorHandler(innerHandler); (handler as any).innerHandler.knownColors = { @@ -310,7 +310,7 @@ describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { it('Found: RGB to HEX', () => { const div = ({} as any) as HTMLElement; - const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const innerHandler = createInnerDarkColorHandler(div, getDarkColor); const handler = createDarkColorHandler(innerHandler); (handler as any).innerHandler.knownColors = { @@ -331,7 +331,7 @@ describe('DarkColorHandlerImpl.findLightColorFromDarkColor', () => { it('Found: RGB to RGB', () => { const div = ({} as any) as HTMLElement; - const innerHandler = createInnderDarkColorHandler(div, getDarkColor); + const innerHandler = createInnerDarkColorHandler(div, getDarkColor); const handler = createDarkColorHandler(innerHandler); (handler as any).innerHandler.knownColors = { @@ -357,7 +357,7 @@ describe('DarkColorHandlerImpl.transformElementColor', () => { beforeEach(() => { contentDiv = document.createElement('div'); - const innerHandler = createInnderDarkColorHandler(contentDiv, getDarkColor); + const innerHandler = createInnerDarkColorHandler(contentDiv, getDarkColor); handler = createDarkColorHandler(innerHandler); }); diff --git a/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts index ce565a221b3..707ec47d159 100644 --- a/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts +++ b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts @@ -1,6 +1,6 @@ import * as createDomToModelContext from 'roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; -import * as findAllEntities from 'roosterjs-content-model-core/lib/corePlugin/utils/findAllEntities'; +import * as findAllEntities from 'roosterjs-content-model-core/lib/corePlugin/entity/findAllEntities'; import { ContentModelDocument, EditorContext, EditorCore } from 'roosterjs-content-model-types'; import { EditorAdapter } from '../../lib/editor/EditorAdapter'; import { EditorPlugin, PluginEventType } from 'roosterjs-editor-types'; From f1e124ccb213f3076dcabdd9bcc483c41c584129 Mon Sep 17 00:00:00 2001 From: "Julia Roldi (from Dev Box)" Date: Mon, 18 Mar 2024 14:44:28 -0300 Subject: [PATCH 14/73] copy format --- .../sidePane/contentModel/buttons/importModelButton.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts b/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts index 6e00b2aa5e2..5caf0efd76b 100644 --- a/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts +++ b/demo/scripts/controlsV2/sidePane/contentModel/buttons/importModelButton.ts @@ -33,6 +33,7 @@ export const importModelButton: RibbonButton<'buttonNameImportModel'> = { if (isBlockGroupOfType(importedModel, 'Document')) { editor.formatContentModel(model => { model.blocks = importedModel.blocks; + model.format = importedModel.format; return true; }); } From dfe707352f3731180c2aa056af097262d22c0c80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Mon, 18 Mar 2024 16:26:24 -0300 Subject: [PATCH 15/73] fix test --- .../corePlugin/utils/entityDelimiterUtils.ts | 2 +- .../corePlugin/utils/delimiterUtilsTest.ts | 68 ++++++++++++++----- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts b/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts index 3a30693bb4a..f385d5c6105 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/utils/entityDelimiterUtils.ts @@ -202,6 +202,7 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent return; } const isEnter = rawEvent.key === 'Enter'; + const helper = editor.getDOMHelper(); if (selection.range.collapsed && (isCharacterValue(rawEvent) || isEnter)) { const helper = editor.getDOMHelper(); const node = getFocusedElement(selection); @@ -243,7 +244,6 @@ export function handleDelimiterKeyDownEvent(editor: IEditor, event: KeyDownEvent } } } else { - const helper = editor.getDOMHelper(); const entity = getSelectedEntity(selection); if (entity && isNodeOfType(entity, 'ELEMENT_NODE') && helper.isNodeInEditor(entity)) { selection.range.selectNode(entity); diff --git a/packages/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts index 3fda02263ec..f0609be927f 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/utils/delimiterUtilsTest.ts @@ -1,5 +1,6 @@ import * as DelimiterFile from '../../../lib/corePlugin/utils/entityDelimiterUtils'; import * as entityUtils from 'roosterjs-content-model-dom/lib/domUtils/entityUtils'; +import * as isNodeOfType from 'roosterjs-content-model-dom/lib/domUtils/isNodeOfType'; import { ContentModelDocument, DOMSelection, IEditor } from 'roosterjs-content-model-types'; import { handleDelimiterContentChangedEvent, @@ -17,7 +18,6 @@ const BlockEntityContainer = '_E_EBlockEntityContainer'; describe('EntityDelimiterUtils |', () => { let queryElementsSpy: jasmine.Spy; let formatContentModelSpy: jasmine.Spy; - let triggerPluginEventSpy: jasmine.Spy; let mockedEditor: any; beforeEach(() => { mockedEditor = ({ @@ -25,7 +25,6 @@ describe('EntityDelimiterUtils |', () => { queryElements: queryElementsSpy, isNodeInEditor: () => true, }), - triggerPluginEvent: triggerPluginEventSpy, getPendingFormat: ((): any => null), }) as Partial; }); @@ -159,12 +158,14 @@ describe('EntityDelimiterUtils |', () => { let mockedSelection: DOMSelection; let rafSpy: jasmine.Spy; let takeSnapshotSpy: jasmine.Spy; + let triggerEventSpy: jasmine.Spy; beforeEach(() => { mockedSelection = undefined!; rafSpy = jasmine.createSpy('requestAnimationFrame'); formatContentModelSpy = jasmine.createSpy('formatContentModel'); takeSnapshotSpy = jasmine.createSpy('takeSnapshot'); + triggerEventSpy = jasmine.createSpy('triggerEvent'); mockedEditor = ({ getDOMSelection: () => mockedSelection, @@ -179,6 +180,7 @@ describe('EntityDelimiterUtils |', () => { queryElements: queryElementsSpy, isNodeInEditor: () => true, }), + triggerEvent: triggerEventSpy, takeSnapshot: takeSnapshotSpy, }) as Partial; spyOn(DelimiterFile, 'preventTypeInDelimiter').and.callThrough(); @@ -560,8 +562,11 @@ describe('EntityDelimiterUtils |', () => { const div = document.createElement('div'); const parent = document.createElement('span'); const el = document.createElement('span'); + const textSpan = document.createElement('span'); const text = document.createTextNode('span'); - el.appendChild(text); + textSpan.appendChild(text); + textSpan.classList.add('_Entity'); + el.appendChild(textSpan); parent.appendChild(el); el.classList.add('entityDelimiterAfter'); div.classList.add(BlockEntityContainer); @@ -582,10 +587,17 @@ describe('EntityDelimiterUtils |', () => { setStartAfter: setStartAfterSpy, setStartBefore: setStartBeforeSpy, collapse: collapseSpy, + startContainer: textSpan, + selectNode: selectNodeSpy, }, isReverted: false, }; spyOn(entityUtils, 'isEntityElement').and.returnValue(true); + spyOn(isNodeOfType, 'isNodeOfType').and.returnValue(true); + spyOn(mockedEditor, 'getDOMSelection').and.returnValue({ + type: 'range', + range: mockedSelection.range, + }); handleDelimiterKeyDownEvent(mockedEditor, { eventType: 'keyDown', @@ -600,19 +612,22 @@ describe('EntityDelimiterUtils |', () => { expect(rafSpy).not.toHaveBeenCalled(); expect(preventDefaultSpy).not.toHaveBeenCalled(); - expect(setStartAfterSpy).toHaveBeenCalled(); + expect(setStartAfterSpy).not.toHaveBeenCalled(); expect(setStartBeforeSpy).not.toHaveBeenCalled(); expect(collapseSpy).not.toHaveBeenCalled(); expect(selectNodeSpy).toHaveBeenCalledTimes(1); - expect(selectNodeSpy).toHaveBeenCalledWith(el); + expect(selectNodeSpy).toHaveBeenCalledWith(textSpan); }); it('Handle, range expanded selection | EnterKey', () => { const div = document.createElement('div'); const parent = document.createElement('span'); const el = document.createElement('span'); + const textSpan = document.createElement('span'); const text = document.createTextNode('span'); - el.appendChild(text); + textSpan.appendChild(text); + textSpan.classList.add('_Entity'); + el.appendChild(textSpan); parent.appendChild(el); el.classList.add('entityDelimiterAfter'); div.classList.add(BlockEntityContainer); @@ -633,20 +648,33 @@ describe('EntityDelimiterUtils |', () => { setStartAfter: setStartAfterSpy, setStartBefore: setStartBeforeSpy, collapse: collapseSpy, + startContainer: textSpan, + selectNode: selectNodeSpy, }, isReverted: false, }; spyOn(entityUtils, 'isEntityElement').and.returnValue(true); + spyOn(isNodeOfType, 'isNodeOfType').and.returnValue(true); + spyOn(mockedEditor, 'getDOMSelection').and.returnValue({ + type: 'range', + range: mockedSelection.range, + }); + spyOn(entityUtils, 'parseEntityFormat').and.returnValue({ + isReadonly: true, + id: 'test', + entityType: 'test', + }); + const mockedEvent = { + ctrlKey: false, + altKey: false, + metaKey: false, + key: 'Enter', + preventDefault: preventDefaultSpy, + }; handleDelimiterKeyDownEvent(mockedEditor, { eventType: 'keyDown', - rawEvent: { - ctrlKey: false, - altKey: false, - metaKey: false, - key: 'A', - preventDefault: preventDefaultSpy, - }, + rawEvent: mockedEvent, }); expect(rafSpy).not.toHaveBeenCalled(); @@ -655,12 +683,16 @@ describe('EntityDelimiterUtils |', () => { expect(setStartBeforeSpy).not.toHaveBeenCalled(); expect(collapseSpy).not.toHaveBeenCalled(); expect(selectNodeSpy).toHaveBeenCalledTimes(1); - expect(selectNodeSpy).toHaveBeenCalledWith(el); - expect(triggerPluginEventSpy).toHaveBeenCalledWith('entityOperation', { + expect(selectNodeSpy).toHaveBeenCalledWith(textSpan); + expect(triggerEventSpy).toHaveBeenCalledWith('entityOperation', { operation: 'click', - entityType: 'span', - format: {}, - entity: el, + entity: { + id: 'test', + type: 'test', + isReadonly: true, + wrapper: textSpan, + }, + rawEvent: mockedEvent, }); }); }); From cbe2205b33d2cdf304ad01d711a37f07f3e63c90 Mon Sep 17 00:00:00 2001 From: Gani <107857762+gm-al@users.noreply.github.com> Date: Tue, 19 Mar 2024 05:34:51 +0300 Subject: [PATCH 16/73] fix convertGlobalCssToInlineCss() to process pseudo-classes correctly (#2493) * fix convertGlobalCssToInlineCss() to process pseudo-classes correctly * update * update -new * update * test case update --- .../lib/utils/convertInlineCss.ts | 16 ++++++- .../test/utils/convertInlineCssTest.ts | 43 +++++++++++++++++++ .../lib/htmlSanitizer/HtmlSanitizer.ts | 14 +++++- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/packages/roosterjs-content-model-core/lib/utils/convertInlineCss.ts b/packages/roosterjs-content-model-core/lib/utils/convertInlineCss.ts index 576cf8e563b..2e70912a611 100644 --- a/packages/roosterjs-content-model-core/lib/utils/convertInlineCss.ts +++ b/packages/roosterjs-content-model-core/lib/utils/convertInlineCss.ts @@ -8,6 +8,18 @@ export interface CssRule { text: string; } +/** + * @internal + * + * Splits CSS selectors, avoiding splits within parentheses + * @param selectorText The CSS selector string + * @return Array of trimmed selectors + */ +function splitSelectors(selectorText: string) { + const regex = /(?![^(]*\)),/; + return selectorText.split(regex).map(s => s.trim()); +} + /** * @internal */ @@ -23,7 +35,7 @@ export function retrieveCssRules(doc: Document): CssRule[] { if (rule.type == CSSRule.STYLE_RULE && rule.selectorText) { result.push({ - selectors: rule.selectorText.split(','), + selectors: splitSelectors(rule.selectorText), text: rule.style.cssText, }); } @@ -43,7 +55,7 @@ export function convertInlineCss(root: ParentNode, cssRules: CssRule[]) { const { selectors, text } = cssRules[i]; for (const selector of selectors) { - if (!selector || !selector.trim() || selector.indexOf(':') >= 0) { + if (!selector || !selector.trim()) { continue; } diff --git a/packages/roosterjs-content-model-core/test/utils/convertInlineCssTest.ts b/packages/roosterjs-content-model-core/test/utils/convertInlineCssTest.ts index a87322bab5c..9deed9dc9cb 100644 --- a/packages/roosterjs-content-model-core/test/utils/convertInlineCssTest.ts +++ b/packages/roosterjs-content-model-core/test/utils/convertInlineCssTest.ts @@ -120,3 +120,46 @@ describe('convertInlineCss', () => { ); }); }); + +describe('splitSelectors', () => { + function splitSelectors(selectorText: string) { + const regex = /(?![^(]*\)),/; + return selectorText.split(regex).map(s => s.trim()); + } + + it('testing regex', () => { + const inputSelector = 'div:not(.example, .sample), li:first-child'; + + expect(splitSelectors(inputSelector)).toEqual([ + 'div:not(.example, .sample)', + 'li:first-child', + ]); + }); + + it('Split pseudo-classes correctly, and avoid splitting whats inside parenthesis', () => { + const root = document.createElement('div'); + + root.innerHTML = '
test1
test2
'; + + const cssRules: CssRule[] = [ + { + selectors: ['div:not(.bar, .baz)'], + text: 'color:green;', + }, + { + selectors: ['.baz'], + text: 'color:blue;', + }, + { + selectors: ['div:not(.baz)'], + text: 'color:red;', + }, + ]; + + convertInlineCss(root, cssRules); + + expect(root.innerHTML).toEqual( + '
test1
test2
' + ); + }); +}); diff --git a/packages/roosterjs-editor-dom/lib/htmlSanitizer/HtmlSanitizer.ts b/packages/roosterjs-editor-dom/lib/htmlSanitizer/HtmlSanitizer.ts index 543e66f866e..4ce93c5bf08 100644 --- a/packages/roosterjs-editor-dom/lib/htmlSanitizer/HtmlSanitizer.ts +++ b/packages/roosterjs-editor-dom/lib/htmlSanitizer/HtmlSanitizer.ts @@ -118,6 +118,16 @@ export default class HtmlSanitizer { return (doc && doc.body && doc.body.innerHTML) || ''; } + /** + * Splits CSS selectors, avoiding splits within parentheses + * @param selectorText The CSS selector string + * @return Array of trimmed selectors + */ + private splitSelectors(selectorText: string) { + const regex = /(?![^(]*\)),/; + return selectorText.split(regex).map(s => s.trim()); + } + /** * Sanitize an HTML element, remove unnecessary or dangerous elements/attribute/CSS rules * @param rootNode Root node to sanitize @@ -152,8 +162,8 @@ export default class HtmlSanitizer { continue; } // Make sure the selector is not empty - for (const selector of styleRule.selectorText.split(',')) { - if (!selector || !selector.trim() || selector.indexOf(':') >= 0) { + for (const selector of this.splitSelectors(styleRule.selectorText)) { + if (!selector || !selector.trim()) { continue; } const nodes = toArray(rootNode.querySelectorAll(selector)); From 23390a80d6db1b493b2c2dc5b4c73e1a5d3b00fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Roldi?= Date: Tue, 19 Mar 2024 16:41:36 -0300 Subject: [PATCH 17/73] markdown --- demo/scripts/controlsV2/mainPane/MainPane.tsx | 2 + .../editorOptions/EditorOptionsPlugin.ts | 1 + .../sidePane/editorOptions/OptionState.ts | 1 + .../editorOptions/codes/PluginsCode.ts | 2 + .../editorOptions/codes/SimplePluginCode.ts | 6 + .../lib/index.ts | 1 + .../lib/markdown/MarkdownPlugin.ts | 105 ++++++++++++++++++ .../lib/markdown/utils/setFormat.ts | 66 +++++++++++ 8 files changed, 184 insertions(+) create mode 100644 packages/roosterjs-content-model-plugins/lib/markdown/MarkdownPlugin.ts create mode 100644 packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index df358269189..1b1560a99fd 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -39,6 +39,7 @@ import { import { AutoFormatPlugin, EditPlugin, + MarkdownPlugin, PastePlugin, ShortcutPlugin, TableEditPlugin, @@ -434,6 +435,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { pluginList.shortcut && new ShortcutPlugin(), pluginList.tableEdit && new TableEditPlugin(), pluginList.watermark && new WatermarkPlugin(watermarkText), + pluginList.markdown && new MarkdownPlugin(), pluginList.emoji && createEmojiPlugin(), pluginList.pasteOption && createPasteOptionPlugin(), pluginList.sampleEntity && new SampleEntityPlugin(), diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 8cfea08f8bc..549560fa008 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -16,6 +16,7 @@ const initialState: OptionState = { emoji: true, pasteOption: true, sampleEntity: true, + markdown: true, // Legacy plugins contentEdit: false, diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts index 371ec790044..1f73d39f60a 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/OptionState.ts @@ -22,6 +22,7 @@ export interface NewPluginList { emoji: boolean; pasteOption: boolean; sampleEntity: boolean; + markdown: boolean; } export interface BuildInPluginList extends LegacyPluginList, NewPluginList {} diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts index 61a0953da1c..6337113785e 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts @@ -12,6 +12,7 @@ import { TableCellSelectionCode, TableEditPluginCode, ShortcutPluginCode, + MarkdownPluginCode, } from './SimplePluginCode'; export class PluginsCodeBase extends CodeElement { @@ -45,6 +46,7 @@ export class PluginsCode extends PluginsCodeBase { pluginList.tableEdit && new TableEditPluginCode(), pluginList.shortcut && new ShortcutPluginCode(), pluginList.watermark && new WatermarkCode(state.watermarkText), + pluginList.markdown && new MarkdownPluginCode(), ]); } } diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts index 5dc6d8d2f36..ae72c1010da 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts @@ -57,3 +57,9 @@ export class TableCellSelectionCode extends SimplePluginCode { super('TableCellSelection', 'roosterjs'); } } + +export class MarkdownPluginCode extends SimplePluginCode { + constructor() { + super('MarkdownPlugin'); + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/index.ts b/packages/roosterjs-content-model-plugins/lib/index.ts index fb189b8842b..e691f97a3e0 100644 --- a/packages/roosterjs-content-model-plugins/lib/index.ts +++ b/packages/roosterjs-content-model-plugins/lib/index.ts @@ -24,3 +24,4 @@ export { ShortcutKeyDefinition, ShortcutCommand } from './shortcut/ShortcutComma export { ContextMenuPluginBase, ContextMenuOptions } from './contextMenuBase/ContextMenuPluginBase'; export { WatermarkPlugin } from './watermark/WatermarkPlugin'; export { WatermarkFormat } from './watermark/WatermarkFormat'; +export { MarkdownPlugin, MarkdownOptions } from './markdown/MarkdownPlugin'; diff --git a/packages/roosterjs-content-model-plugins/lib/markdown/MarkdownPlugin.ts b/packages/roosterjs-content-model-plugins/lib/markdown/MarkdownPlugin.ts new file mode 100644 index 00000000000..affb3b12229 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/markdown/MarkdownPlugin.ts @@ -0,0 +1,105 @@ +import { setFormat } from './utils/setFormat'; +import type { + EditorPlugin, + IEditor, + KeyDownEvent, + PluginEvent, +} from 'roosterjs-content-model-types'; + +export interface MarkdownOptions { + strikethrough?: boolean; + bold?: boolean; + italic?: boolean; +} + +/** + * @internal + */ +const DefaultOptions: Required = { + strikethrough: true, + bold: true, + italic: true, +}; + +/** + * Markdown plugin handles markdown formatting, such as transforming * characters into bold text. + */ +export class MarkdownPlugin implements EditorPlugin { + private editor: IEditor | null = null; + + /** + * @param options An optional parameter that takes in an object of type MarkdownOptions, which includes the following properties: + * - strikethrough: If true text between ~ will receive strikethrough format. Defaults to true. + * - bold: If true text between * will receive bold format. Defaults to true. + * - italic: If true text between _ will receive italic format. Defaults to true. + */ + constructor(private options: MarkdownOptions = DefaultOptions) {} + + /** + * Get name of this plugin + */ + getName() { + return 'Markdown'; + } + + /** + * 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) { + if (this.editor) { + switch (event.eventType) { + case 'keyDown': + this.handleKeyDownEvent(this.editor, event); + break; + } + } + } + + private handleKeyDownEvent(editor: IEditor, event: KeyDownEvent) { + const rawEvent = event.rawEvent; + const { strikethrough, bold, italic } = this.options; + if (!rawEvent.defaultPrevented && !event.handledByEditFeature) { + if (rawEvent.shiftKey) { + switch (rawEvent.key) { + case '*': + if (bold) { + setFormat(editor, '*', { fontWeight: 'bold' }, rawEvent); + } + break; + case '~': + if (strikethrough) { + setFormat(editor, '~', { strikethrough: true }, rawEvent); + } + break; + case '_': + if (italic) { + setFormat(editor, '_', { italic: true }, rawEvent); + } + break; + } + } + } + } +} diff --git a/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts b/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts new file mode 100644 index 00000000000..284b478d0b8 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/lib/markdown/utils/setFormat.ts @@ -0,0 +1,66 @@ +import { createText } from 'roosterjs-content-model-dom'; +import { getSelectedSegmentsAndParagraphs } from 'roosterjs-content-model-core'; + +import { + ContentModelSegmentFormat, + ContentModelText, + IEditor, +} from 'roosterjs-content-model-types'; + +export function setFormat( + editor: IEditor, + character: string, + format: ContentModelSegmentFormat, + rawEvent: KeyboardEvent +) { + editor.formatContentModel(model => { + const selectedSegmentsAndParagraphs = getSelectedSegmentsAndParagraphs( + model, + false /*includeFormatHolder*/ + ); + if (selectedSegmentsAndParagraphs.length > 0 && selectedSegmentsAndParagraphs[0][1]) { + const marker = selectedSegmentsAndParagraphs[0][0]; + const paragraph = selectedSegmentsAndParagraphs[0][1]; + if (marker.segmentType == 'SelectionMarker') { + const markerIndex = paragraph.segments.indexOf(marker); + let previousCharacterSegment: ContentModelText | undefined = undefined; + let index = markerIndex; + if (markerIndex > 0) { + for (let i = markerIndex - 1; i >= 0; i--) { + const previousSegment = paragraph.segments[i]; + if ( + previousSegment && + previousSegment.segmentType == 'Text' && + previousSegment.text.indexOf(character) > -1 + ) { + previousCharacterSegment = previousSegment; + index = i; + break; + } + } + if (previousCharacterSegment && index < markerIndex) { + const textSegments = previousCharacterSegment.text.split(character); + previousCharacterSegment.text = textSegments[0]; + const formattedSegment = createText(textSegments[1], marker.format); + paragraph.segments.splice(index + 1, 0, formattedSegment); + + for (let i = index + 1; i <= markerIndex; i++) { + const segment = paragraph.segments[i]; + if (segment && segment.segmentType == 'Text') { + segment.format = { + ...segment.format, + ...format, + }; + } + } + + rawEvent.preventDefault(); + + return true; + } + } + } + } + return false; + }); +} From ca960e221c33247e43bbe8d1a9e3db41af11c3d7 Mon Sep 17 00:00:00 2001 From: Andres-CT98 <107568016+Andres-CT98@users.noreply.github.com> Date: Tue, 19 Mar 2024 14:11:03 -0600 Subject: [PATCH 18/73] Add tabs to demo site and PresetPlugin (#2511) * top right buttons * fix sidepane reload * tabs * remove icon table edit buttons * Preset Plugin base and presets * FormatStatePane add Image and table border * Add PresetPlugin and implement tabs on MainPane * fix correct export button * reorder buttons and tabs * cleanup * adapt for formatPainterPlugin button --- .../controlsV2/demoButtons/darkModeButton.ts | 5 + .../demoButtons/exportContentButton.ts | 5 + .../controlsV2/demoButtons/popoutButton.ts | 5 + .../demoButtons/tableEditButtons.ts | 20 +- .../controlsV2/demoButtons/zoomButton.ts | 6 + .../scripts/controlsV2/mainPane/MainPane.scss | 7 + demo/scripts/controlsV2/mainPane/MainPane.tsx | 77 +- .../ribbon/buttons/redoButton.ts | 5 + .../ribbon/buttons/undoButton.ts | 5 + demo/scripts/controlsV2/sidePane/SidePane.tsx | 1 + .../sidePane/formatState/FormatStatePane.tsx | 35 +- .../sidePane/presets/PresetPane.scss | 4 + .../sidePane/presets/PresetPane.tsx | 50 + .../sidePane/presets/PresetPlugin.ts | 38 + .../sidePane/presets/allPresets/allPresets.ts | 40 + .../presets/allPresets/imagePresets.ts | 87 ++ .../presets/allPresets/listPresets.ts | 999 ++++++++++++ .../presets/allPresets/paragraphPresets.ts | 200 +++ .../presets/allPresets/tablePresets.ts | 1354 +++++++++++++++++ .../presets/allPresets/textPresets.ts | 352 +++++ demo/scripts/controlsV2/tabs/getTabs.ts | 61 + .../{mainPane => tabs}/ribbonButtons.ts | 107 +- 22 files changed, 3428 insertions(+), 35 deletions(-) create mode 100644 demo/scripts/controlsV2/sidePane/presets/PresetPane.scss create mode 100644 demo/scripts/controlsV2/sidePane/presets/PresetPane.tsx create mode 100644 demo/scripts/controlsV2/sidePane/presets/PresetPlugin.ts create mode 100644 demo/scripts/controlsV2/sidePane/presets/allPresets/allPresets.ts create mode 100644 demo/scripts/controlsV2/sidePane/presets/allPresets/imagePresets.ts create mode 100644 demo/scripts/controlsV2/sidePane/presets/allPresets/listPresets.ts create mode 100644 demo/scripts/controlsV2/sidePane/presets/allPresets/paragraphPresets.ts create mode 100644 demo/scripts/controlsV2/sidePane/presets/allPresets/tablePresets.ts create mode 100644 demo/scripts/controlsV2/sidePane/presets/allPresets/textPresets.ts create mode 100644 demo/scripts/controlsV2/tabs/getTabs.ts rename demo/scripts/controlsV2/{mainPane => tabs}/ribbonButtons.ts (70%) diff --git a/demo/scripts/controlsV2/demoButtons/darkModeButton.ts b/demo/scripts/controlsV2/demoButtons/darkModeButton.ts index 32f773e8039..00d03c1a5d7 100644 --- a/demo/scripts/controlsV2/demoButtons/darkModeButton.ts +++ b/demo/scripts/controlsV2/demoButtons/darkModeButton.ts @@ -21,4 +21,9 @@ export const darkModeButton: RibbonButton = { MainPane.getInstance().toggleDarkMode(); return true; }, + commandBarProperties: { + buttonStyles: { + icon: { paddingBottom: '10px' }, + }, + }, }; diff --git a/demo/scripts/controlsV2/demoButtons/exportContentButton.ts b/demo/scripts/controlsV2/demoButtons/exportContentButton.ts index 44807386d04..8cf6371219a 100644 --- a/demo/scripts/controlsV2/demoButtons/exportContentButton.ts +++ b/demo/scripts/controlsV2/demoButtons/exportContentButton.ts @@ -19,4 +19,9 @@ export const exportContentButton: RibbonButton = { const html = exportContent(editor); win.document.write(editor.getTrustedHTMLHandler()(html)); }, + commandBarProperties: { + buttonStyles: { + icon: { paddingBottom: '10px' }, + }, + }, }; diff --git a/demo/scripts/controlsV2/demoButtons/popoutButton.ts b/demo/scripts/controlsV2/demoButtons/popoutButton.ts index 5814cdafd60..98b214ed413 100644 --- a/demo/scripts/controlsV2/demoButtons/popoutButton.ts +++ b/demo/scripts/controlsV2/demoButtons/popoutButton.ts @@ -17,4 +17,9 @@ export const popoutButton: RibbonButton = { onClick: _ => { MainPane.getInstance().popout(); }, + commandBarProperties: { + buttonStyles: { + icon: { paddingBottom: '10px' }, + }, + }, }; diff --git a/demo/scripts/controlsV2/demoButtons/tableEditButtons.ts b/demo/scripts/controlsV2/demoButtons/tableEditButtons.ts index fe0bc42df27..fc7c937416e 100644 --- a/demo/scripts/controlsV2/demoButtons/tableEditButtons.ts +++ b/demo/scripts/controlsV2/demoButtons/tableEditButtons.ts @@ -84,7 +84,7 @@ export const tableMergeButton: RibbonButton< 'ribbonButtonTableMerge' | TableEditMergeMenuItemStringKey > = { key: 'ribbonButtonTableMerge', - iconName: 'TableComputed', + iconName: '', unlocalizedText: 'Merge', isDisabled: formatState => !formatState.isInTable, dropDownMenu: { @@ -102,13 +102,16 @@ export const tableMergeButton: RibbonButton< editTable(editor, TableEditOperationMap[key]); } }, + commandBarProperties: { + iconOnly: false, + }, }; export const tableSplitButton: RibbonButton< 'ribbonButtonTableSplit' | TableEditSplitMenuItemStringKey > = { key: 'ribbonButtonTableSplit', - iconName: 'TableComputed', + iconName: '', unlocalizedText: 'Split', isDisabled: formatState => !formatState.isInTable, dropDownMenu: { @@ -122,13 +125,16 @@ export const tableSplitButton: RibbonButton< editTable(editor, TableEditOperationMap[key]); } }, + commandBarProperties: { + iconOnly: false, + }, }; export const tableAlignCellButton: RibbonButton< 'ribbonButtonTableAlignCell' | TableEditAlignMenuItemStringKey > = { key: 'ribbonButtonTableAlignCell', - iconName: 'TableComputed', + iconName: '', unlocalizedText: 'Align table cell', isDisabled: formatState => !formatState.isInTable, dropDownMenu: { @@ -147,13 +153,16 @@ export const tableAlignCellButton: RibbonButton< editTable(editor, TableEditOperationMap[key]); } }, + commandBarProperties: { + iconOnly: false, + }, }; export const tableAlignTableButton: RibbonButton< 'ribbonButtonTableAlignTable' | TableEditAlignTableMenuItemStringKey > = { key: 'ribbonButtonTableAlignTable', - iconName: 'TableComputed', + iconName: '', unlocalizedText: 'Align table', isDisabled: formatState => !formatState.isInTable, dropDownMenu: { @@ -168,4 +177,7 @@ export const tableAlignTableButton: RibbonButton< editTable(editor, TableEditOperationMap[key]); } }, + commandBarProperties: { + iconOnly: false, + }, }; diff --git a/demo/scripts/controlsV2/demoButtons/zoomButton.ts b/demo/scripts/controlsV2/demoButtons/zoomButton.ts index ecfa48d4c96..ceaf11bb688 100644 --- a/demo/scripts/controlsV2/demoButtons/zoomButton.ts +++ b/demo/scripts/controlsV2/demoButtons/zoomButton.ts @@ -41,4 +41,10 @@ export const zoomButton: RibbonButton = { editor.triggerEvent('zoomChanged', { newZoomScale: zoomScale }); }, + commandBarProperties: { + buttonStyles: { + icon: { paddingBottom: '10px' }, + menuIcon: { paddingBottom: '10px' }, + }, + }, }; diff --git a/demo/scripts/controlsV2/mainPane/MainPane.scss b/demo/scripts/controlsV2/mainPane/MainPane.scss index 1739df86547..88b2240b212 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.scss +++ b/demo/scripts/controlsV2/mainPane/MainPane.scss @@ -12,6 +12,13 @@ overflow-x: hidden; } +.menuTab { + border-radius: 30% 30% 0 0; + background-color: $primaryLighter; + color: white; + font-weight: bold; +} + .body { flex: 1 1 auto; position: relative; diff --git a/demo/scripts/controlsV2/mainPane/MainPane.tsx b/demo/scripts/controlsV2/mainPane/MainPane.tsx index df358269189..2cc9cd9a796 100644 --- a/demo/scripts/controlsV2/mainPane/MainPane.tsx +++ b/demo/scripts/controlsV2/mainPane/MainPane.tsx @@ -3,25 +3,31 @@ import * as ReactDOM from 'react-dom'; import SampleEntityPlugin from '../plugins/SampleEntityPlugin'; import { ApiPlaygroundPlugin } from '../sidePane/apiPlayground/ApiPlaygroundPlugin'; import { Border, ContentModelDocument, EditorOptions } from 'roosterjs-content-model-types'; -import { buttons, buttonsWithPopout } from './ribbonButtons'; import { Colors, EditorPlugin, IEditor, Snapshots } from 'roosterjs-content-model-types'; import { ContentModelPanePlugin } from '../sidePane/contentModel/ContentModelPanePlugin'; import { createEmojiPlugin } from '../roosterjsReact/emoji'; -import { createFormatPainterButton } from '../demoButtons/formatPainterButton'; import { createImageEditMenuProvider } from '../roosterjsReact/contextMenu/menus/createImageEditMenuProvider'; import { createLegacyPlugins } from '../plugins/createLegacyPlugins'; import { createListEditMenuProvider } from '../roosterjsReact/contextMenu/menus/createListEditMenuProvider'; import { createPasteOptionPlugin } from '../roosterjsReact/pasteOptions'; import { createRibbonPlugin, Ribbon, RibbonButton, RibbonPlugin } from '../roosterjsReact/ribbon'; +import { darkModeButton } from '../demoButtons/darkModeButton'; import { Editor } from 'roosterjs-content-model-core'; import { EditorAdapter } from 'roosterjs-editor-adapter'; import { EditorOptionsPlugin } from '../sidePane/editorOptions/EditorOptionsPlugin'; import { EventViewPlugin } from '../sidePane/eventViewer/EventViewPlugin'; +import { exportContentButton } from '../demoButtons/exportContentButton'; import { FormatPainterPlugin } from '../plugins/FormatPainterPlugin'; import { FormatStatePlugin } from '../sidePane/formatState/FormatStatePlugin'; +import { getButtons } from '../tabs/ribbonButtons'; import { getDarkColor } from 'roosterjs-color-utils'; +import { getPresetModelById } from '../sidePane/presets/allPresets/allPresets'; +import { getTabs, tabNames } from '../tabs/getTabs'; import { getTheme } from '../theme/themes'; import { OptionState } from '../sidePane/editorOptions/OptionState'; +import { popoutButton } from '../demoButtons/popoutButton'; +import { PresetPlugin } from '../sidePane/presets/PresetPlugin'; +import { redoButton } from '../roosterjsReact/ribbon/buttons/redoButton'; import { registerWindowForCss, unregisterWindowForCss } from '../../utils/cssMonitor'; import { Rooster } from '../roosterjsReact/rooster'; import { SidePane } from '../sidePane/SidePane'; @@ -30,8 +36,10 @@ import { SnapshotPlugin } from '../sidePane/snapshot/SnapshotPlugin'; import { ThemeProvider } from '@fluentui/react/lib/Theme'; import { TitleBar } from '../titleBar/TitleBar'; import { trustedHTMLHandler } from '../../utils/trustedHTMLHandler'; +import { undoButton } from '../roosterjsReact/ribbon/buttons/undoButton'; import { UpdateContentPlugin } from '../plugins/UpdateContentPlugin'; import { WindowProvider } from '@fluentui/react/lib/WindowProvider'; +import { zoomButton } from '../demoButtons/zoomButton'; import { createContextMenuPlugin, createTableEditMenuProvider, @@ -54,6 +62,7 @@ export interface MainPaneState { scale: number; isDarkMode: boolean; isRtl: boolean; + activeTab: tabNames; tableBorderFormat?: Border; editorCreator: (div: HTMLDivElement, options: EditorOptions) => IEditor; } @@ -73,12 +82,11 @@ export class MainPane extends React.Component<{}, MainPaneState> { private eventViewPlugin: EventViewPlugin; private apiPlaygroundPlugin: ApiPlaygroundPlugin; private contentModelPanePlugin: ContentModelPanePlugin; + private presetPlugin: PresetPlugin; private ribbonPlugin: RibbonPlugin; private snapshotPlugin: SnapshotPlugin; private formatPainterPlugin: FormatPainterPlugin; private snapshots: Snapshots; - private buttons: RibbonButton[]; - private buttonsWithPopout: RibbonButton[]; protected sidePane = React.createRef(); protected updateContentPlugin: UpdateContentPlugin; @@ -112,12 +120,10 @@ export class MainPane extends React.Component<{}, MainPaneState> { this.apiPlaygroundPlugin = new ApiPlaygroundPlugin(); this.snapshotPlugin = new SnapshotPlugin(this.snapshots); this.contentModelPanePlugin = new ContentModelPanePlugin(); + this.presetPlugin = new PresetPlugin(); this.ribbonPlugin = createRibbonPlugin(); this.formatPainterPlugin = new FormatPainterPlugin(); - const baseButtons = [createFormatPainterButton(this.formatPainterPlugin)]; - this.buttons = baseButtons.concat(buttons); - this.buttonsWithPopout = baseButtons.concat(buttonsWithPopout); this.state = { showSidePane: window.location.hash != '', popoutWindow: null, @@ -131,17 +137,17 @@ export class MainPane extends React.Component<{}, MainPaneState> { style: 'solid', color: '#ABABAB', }, + activeTab: 'all', }; } render() { + const theme = getTheme(this.state.isDarkMode); return ( - + {this.renderTitleBar()} - {!this.state.popoutWindow && this.renderRibbon(false /*isPopout*/)} + {!this.state.popoutWindow && this.renderTabs()} + {!this.state.popoutWindow && this.renderRibbon()}
{this.state.popoutWindow ? this.renderPopout() : this.renderMainPane()}
@@ -220,6 +226,16 @@ export class MainPane extends React.Component<{}, MainPaneState> { }); } + changeRibbon(id: tabNames): void { + this.setState({ + activeTab: id, + }); + } + + setPreset(preset: ContentModelDocument) { + this.model = preset; + } + setPageDirection(isRtl: boolean): void { this.setState({ isRtl: isRtl }); [window, this.state.popoutWindow].forEach(win => { @@ -233,10 +249,32 @@ export class MainPane extends React.Component<{}, MainPaneState> { return ; } - private renderRibbon(isPopout: boolean) { + private renderTabs() { + const tabs = getTabs(); + const topRightButtons: RibbonButton[] = [ + undoButton, + redoButton, + zoomButton, + darkModeButton, + exportContentButton, + ]; + this.state.popoutWindow ? null : topRightButtons.push(popoutButton); + + return ( +
+ + +
+ ); + } + private renderRibbon() { return ( @@ -271,6 +309,13 @@ export class MainPane extends React.Component<{}, MainPaneState> { } private renderEditor() { + // Set preset if found + const search = new URLSearchParams(document.location.search); + const hasPreset = search.get('preset'); + if (hasPreset) { + this.setPreset(getPresetModelById(hasPreset)); + } + const editorStyles = { transform: `scale(${this.state.scale})`, transformOrigin: this.state.isRtl ? 'right top' : 'left top', @@ -353,7 +398,8 @@ export class MainPane extends React.Component<{}, MainPaneState> {
- {this.renderRibbon(true /*isPopout*/)} + {this.renderTabs()} + {this.renderRibbon()}
{this.renderEditor()}
@@ -415,6 +461,7 @@ export class MainPane extends React.Component<{}, MainPaneState> { this.apiPlaygroundPlugin, this.snapshotPlugin, this.contentModelPanePlugin, + this.presetPlugin, ]; } diff --git a/demo/scripts/controlsV2/roosterjsReact/ribbon/buttons/redoButton.ts b/demo/scripts/controlsV2/roosterjsReact/ribbon/buttons/redoButton.ts index 9719c1bffc1..0f2a68cb78a 100644 --- a/demo/scripts/controlsV2/roosterjsReact/ribbon/buttons/redoButton.ts +++ b/demo/scripts/controlsV2/roosterjsReact/ribbon/buttons/redoButton.ts @@ -14,4 +14,9 @@ export const redoButton: RibbonButton = { onClick: editor => { redo(editor); }, + commandBarProperties: { + buttonStyles: { + icon: { paddingBottom: '10px' }, + }, + }, }; diff --git a/demo/scripts/controlsV2/roosterjsReact/ribbon/buttons/undoButton.ts b/demo/scripts/controlsV2/roosterjsReact/ribbon/buttons/undoButton.ts index 8febeb8afd4..8b59d345335 100644 --- a/demo/scripts/controlsV2/roosterjsReact/ribbon/buttons/undoButton.ts +++ b/demo/scripts/controlsV2/roosterjsReact/ribbon/buttons/undoButton.ts @@ -14,4 +14,9 @@ export const undoButton: RibbonButton = { onClick: editor => { undo(editor); }, + commandBarProperties: { + buttonStyles: { + icon: { paddingBottom: '10px' }, + }, + }, }; diff --git a/demo/scripts/controlsV2/sidePane/SidePane.tsx b/demo/scripts/controlsV2/sidePane/SidePane.tsx index 887a2b47b08..85f3b42f204 100644 --- a/demo/scripts/controlsV2/sidePane/SidePane.tsx +++ b/demo/scripts/controlsV2/sidePane/SidePane.tsx @@ -22,6 +22,7 @@ export class SidePane extends React.Component { }; window.addEventListener('hashchange', this.updateStateFromHash); + window.location.hash ? this.updateStateFromHash() : this.updateHash(); } componentDidMount() { diff --git a/demo/scripts/controlsV2/sidePane/formatState/FormatStatePane.tsx b/demo/scripts/controlsV2/sidePane/formatState/FormatStatePane.tsx index d9ef783395f..c501957cc86 100644 --- a/demo/scripts/controlsV2/sidePane/formatState/FormatStatePane.tsx +++ b/demo/scripts/controlsV2/sidePane/formatState/FormatStatePane.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; +import { MainPane } from '../../mainPane/MainPane'; import { SidePaneElementProps } from '../SidePaneElement'; import { ContentModelFormatState, EditorEnvironment, + ImageFormatState, TableMetadataFormat, } from 'roosterjs-content-model-types'; @@ -37,9 +39,13 @@ export default class FormatStatePane extends React.Component< render() { const { format, x, y } = this.state; const { isMac, isAndroid, isSafari, isMobileOrTablet } = this.props.env ?? {}; + const mpState = MainPane.getInstance(); const TableFormat = () => { const tableFormat = format.tableFormat; + const tableBorder = mpState.getTableBorder(); + const tableBorderFormat = + tableBorder.style + ' ' + tableBorder.width + ' ' + tableBorder.color; if (!tableFormat) { return <>; } @@ -47,9 +53,11 @@ export default class FormatStatePane extends React.Component< (key: keyof TableMetadataFormat, index: number, array) => { const rowStyle: React.CSSProperties = index == 0 - ? { borderTop: 'solid' } + ? { + borderTop: tableBorderFormat, + } : index == array.length - 1 - ? { borderBottom: 'solid' } + ? { borderBottom: tableBorderFormat } : {}; return ( @@ -61,9 +69,32 @@ export default class FormatStatePane extends React.Component< ); return tableFromatRows; }; + + const ImageFormat = () => { + const imageFormat = format.imageFormat; + if (!imageFormat) { + return <>; + } + const imageFormatRows = Object.keys(imageFormat).map( + (key: keyof ImageFormatState, index: number, array) => { + return ( + + {key} + {String(imageFormat[key])} + + ); + } + ); + return imageFormatRows; + }; + return format ? ( + + + + {ImageFormat()}
diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts index 61a0953da1c..4397b5de30e 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/PluginsCode.ts @@ -9,7 +9,6 @@ import { EditPluginCode, ImageEditCode, PastePluginCode, - TableCellSelectionCode, TableEditPluginCode, ShortcutPluginCode, } from './SimplePluginCode'; @@ -58,7 +57,6 @@ export class LegacyPluginCode extends PluginsCodeBase { pluginList.hyperlink && new HyperLinkCode(state.linkTitle), pluginList.imageEdit && new ImageEditCode(), pluginList.customReplace && new CustomReplaceCode(), - pluginList.tableCellSelection && new TableCellSelectionCode(), ]; super(plugins); diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts index 5dc6d8d2f36..b881594b79f 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/codes/SimplePluginCode.ts @@ -51,9 +51,3 @@ export class CustomReplaceCode extends SimplePluginCode { super('CustomReplace', 'roosterjs'); } } - -export class TableCellSelectionCode extends SimplePluginCode { - constructor() { - super('TableCellSelection', 'roosterjs'); - } -} diff --git a/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection/getDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection/getDOMSelection.ts index c2e0404f19a..63c77110276 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection/getDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/getDOMSelection/getDOMSelection.ts @@ -19,24 +19,13 @@ function getNewSelection(core: EditorCore): DOMSelection | null { const selection = core.logicalRoot.ownerDocument.defaultView?.getSelection(); const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; - return range && core.logicalRoot.contains(range.commonAncestorContainer) + return selection && range && core.logicalRoot.contains(range.commonAncestorContainer) ? { type: 'range', range, - isReverted: isSelectionReverted(selection), + isReverted: + selection.focusNode != range.endContainer || + selection.focusOffset != range.endOffset, } : null; } - -function isSelectionReverted(selection: Selection | null | undefined): boolean { - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - return ( - !range.collapsed && - selection.focusNode != range.endContainer && - selection.focusOffset != range.endOffset - ); - } - - return false; -} diff --git a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts index fba89dd2964..e8f73b807ad 100644 --- a/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts +++ b/packages/roosterjs-content-model-core/lib/coreApi/setDOMSelection/setDOMSelection.ts @@ -1,11 +1,16 @@ import { addRangeToSelection } from './addRangeToSelection'; import { ensureUniqueId } from '../setEditorStyle/ensureUniqueId'; import { isNodeOfType, toArray } from 'roosterjs-content-model-dom'; -import { parseTableCells } from '../../publicApi/domUtils/tableCellUtils'; +import { + findLastedCoInMergedCell, + findTableCellElement, + parseTableCells, +} from '../../publicApi/domUtils/tableCellUtils'; import type { + ParsedTable, SelectionChangedEvent, SetDOMSelection, - TableSelection, + TableCellCoordinate, } from 'roosterjs-content-model-types'; const DOM_SELECTION_CSS_KEY = '_DOMSelection'; @@ -49,11 +54,47 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC setRangeSelection(doc, image); break; case 'table': - const { table, firstColumn, firstRow } = selection; - const tableSelectors = buildTableSelectors( - ensureUniqueId(table, TABLE_ID), - selection - ); + const { table, firstColumn, firstRow, lastColumn, lastRow } = selection; + const parsedTable = parseTableCells(selection.table); + let firstCell = { + row: Math.min(firstRow, lastRow), + col: Math.min(firstColumn, lastColumn), + cell: null, + }; + let lastCell = { + row: Math.max(firstRow, lastRow), + col: Math.max(firstColumn, lastColumn), + }; + + firstCell = findTableCellElement(parsedTable, firstCell) || firstCell; + lastCell = findLastedCoInMergedCell(parsedTable, lastCell) || lastCell; + + if ( + isNaN(firstCell.row) || + isNaN(firstCell.col) || + isNaN(lastCell.row) || + isNaN(lastCell.col) + ) { + return; + } + + selection = { + type: 'table', + table, + firstRow: firstCell.row, + firstColumn: firstCell.col, + lastRow: lastCell.row, + lastColumn: lastCell.col, + }; + + const tableId = ensureUniqueId(table, TABLE_ID); + const tableSelectors = + firstCell.row == 0 && + firstCell.col == 0 && + lastCell.row == parsedTable.length - 1 && + lastCell.col == (parsedTable[lastCell.row]?.length ?? 0) - 1 + ? [`#${tableId}`, `#${tableId} *`] + : handleTableSelected(parsedTable, tableId, table, firstCell, lastCell); core.selection.selection = selection; core.api.setEditorStyle( @@ -64,7 +105,12 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC ); core.api.setEditorStyle(core, HIDE_CURSOR_CSS_KEY, CARET_CSS_RULE); - setRangeSelection(doc, table.rows[firstRow]?.cells[firstColumn]); + const nodeToSelect = firstCell.cell?.firstElementChild || firstCell.cell; + + if (nodeToSelect) { + setRangeSelection(doc, (nodeToSelect as HTMLElement) || undefined); + } + break; case 'range': addRangeToSelection(doc, selection.range, selection.isReverted); @@ -90,25 +136,13 @@ export const setDOMSelection: SetDOMSelection = (core, selection, skipSelectionC } }; -function buildTableSelectors(tableId: 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; - return isAllTableSelected - ? [`#${tableId}`, `#${tableId} *`] - : handleTableSelected(tableId, selection, cells); -} - function handleTableSelected( + parsedTable: ParsedTable, tableId: string, - selection: TableSelection, - cells: (HTMLTableCellElement | null)[][] + table: HTMLTableElement, + firstCell: TableCellCoordinate, + lastCell: TableCellCoordinate ) { - 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, @@ -132,7 +166,7 @@ function handleTableSelected( return result; }); - cells.forEach((row, rowIndex) => { + parsedTable.forEach((row, rowIndex) => { let tdCount = 0; //Get current TBODY/THEAD/TFOOT @@ -146,14 +180,14 @@ function handleTableSelected( for (let cellIndex = 0; cellIndex < row.length; cellIndex++) { const cell = row[cellIndex]; - if (cell) { + if (typeof cell == 'object') { tdCount++; if ( - rowIndex >= firstRow && - rowIndex <= lastRow && - cellIndex >= firstColumn && - cellIndex <= lastColumn + rowIndex >= firstCell.row && + rowIndex <= lastCell.row && + cellIndex >= firstCell.col && + cellIndex <= lastCell.col ) { const selector = `#${tableId}${middleElSelector} tr:nth-child(${currentRow})>${cell.tagName}:nth-child(${tdCount})`; diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts b/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts index f55468cc3e4..467daaa9b34 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/entity/entityDelimiterUtils.ts @@ -1,5 +1,6 @@ import { isCharacterValue } from '../../publicApi/domUtils/eventUtils'; import { iterateSelections } from '../../publicApi/selection/iterateSelections'; +import { normalizePos } from '../../publicApi/domUtils/normalizePos'; import { addDelimiters, createBr, @@ -132,14 +133,10 @@ function getFocusedElement( let node: Node | null = isReverted ? range.startContainer : range.endContainer; let offset = isReverted ? range.startOffset : range.endOffset; - while (node?.lastChild) { - if (offset == node.childNodes.length) { - node = node.lastChild; - offset = node.childNodes.length; - } else { - node = node.childNodes[offset]; - offset = 0; - } + if (node) { + const pos = normalizePos(node, offset); + node = pos.node; + offset = pos.offset; } if (!isNodeOfType(node, 'ELEMENT_NODE')) { diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts index f5c15052d74..726fa8d501a 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/selection/SelectionPlugin.ts @@ -1,5 +1,11 @@ +import { isCharacterValue, isModifierKey } from '../../publicApi/domUtils/eventUtils'; import { isElementOfType, isNodeOfType, toArray } from 'roosterjs-content-model-dom'; -import { isModifierKey } from '../../publicApi/domUtils/eventUtils'; +import { normalizePos } from '../../publicApi/domUtils/normalizePos'; +import { + findCoordinate, + findTableCellElement, + parseTableCells, +} from '../../publicApi/domUtils/tableCellUtils'; import type { DOMSelection, IEditor, @@ -7,10 +13,20 @@ import type { PluginWithState, SelectionPluginState, EditorOptions, + DOMHelper, + MouseUpEvent, + ParsedTable, + TableSelectionInfo, + TableCellCoordinate, } from 'roosterjs-content-model-types'; +const MouseLeftButton = 0; const MouseMiddleButton = 1; const MouseRightButton = 2; +const Up = 'ArrowUp'; +const Down = 'ArrowDown'; +const Left = 'ArrowLeft'; +const Right = 'ArrowRight'; class SelectionPlugin implements PluginWithState { private editor: IEditor | null = null; @@ -22,6 +38,7 @@ class SelectionPlugin implements PluginWithState { constructor(options: EditorOptions) { this.state = { selection: null, + tableSelection: null, imageSelectionBorderColor: options.imageSelectionBorderColor, }; } @@ -41,11 +58,15 @@ class SelectionPlugin implements PluginWithState { if (this.isSafari) { document.addEventListener('selectionchange', this.onSelectionChangeSafari); - this.disposer = this.editor.attachDomEvent({ focus: { beforeDispatch: this.onFocus } }); + this.disposer = this.editor.attachDomEvent({ + focus: { beforeDispatch: this.onFocus }, + drop: { beforeDispatch: this.onDrop }, + }); } else { this.disposer = this.editor.attachDomEvent({ focus: { beforeDispatch: this.onFocus }, blur: { beforeDispatch: this.onBlur }, + drop: { beforeDispatch: this.onDrop }, }); } } @@ -60,6 +81,7 @@ class SelectionPlugin implements PluginWithState { this.disposer = null; } + this.detachMouseEvent(); this.editor = null; } @@ -72,68 +94,318 @@ class SelectionPlugin implements PluginWithState { return; } - let image: HTMLImageElement | null; - let selection: DOMSelection | null; - switch (event.eventType) { - case 'mouseUp': - if ( - (image = this.getClickingImage(event.rawEvent)) && - image.isContentEditable && - event.rawEvent.button != MouseMiddleButton && - (event.rawEvent.button == - MouseRightButton /* it's not possible to drag using right click */ || - event.isClicking) - ) { - this.selectImage(this.editor, image); - } + case 'mouseDown': + this.onMouseDown(this.editor, event.rawEvent); break; - case 'mouseDown': - selection = this.editor.getDOMSelection(); - if ( - event.rawEvent.button === MouseRightButton && - (image = - this.getClickingImage(event.rawEvent) ?? - this.getContainedTargetImage(event.rawEvent, selection)) && - image.isContentEditable - ) { - this.selectImage(this.editor, image); - } else if ( - selection?.type == 'image' && - selection.image !== event.rawEvent.target - ) { - this.selectBeforeImage(this.editor, selection.image); - } + case 'mouseUp': + this.onMouseUp(event); break; case 'keyDown': - const rawEvent = event.rawEvent; - const key = rawEvent.key; - selection = this.editor.getDOMSelection(); - - if ( - !isModifierKey(rawEvent) && - !rawEvent.shiftKey && - selection?.type == 'image' && - selection.image.parentNode - ) { + this.onKeyDown(this.editor, event.rawEvent); + break; + + case 'contentChanged': + this.state.tableSelection = null; + break; + } + } + + private onMouseDown(editor: IEditor, rawEvent: MouseEvent) { + const selection = editor.getDOMSelection(); + let image: HTMLImageElement | null; + + // Image selection + if ( + rawEvent.button === MouseRightButton && + (image = + this.getClickingImage(rawEvent) ?? + this.getContainedTargetImage(rawEvent, selection)) && + image.isContentEditable + ) { + this.selectImage(image); + + return; + } else if (selection?.type == 'image' && selection.image !== rawEvent.target) { + this.selectBeforeImage(editor, selection.image); + + return; + } + + // Table selection + if (selection?.type == 'table' && rawEvent.button == MouseLeftButton) { + this.setDOMSelection(null /*domSelection*/, null /*tableSelection*/); + } + + let tableSelection: TableSelectionInfo | null; + const target = rawEvent.target as Node; + + if ( + target && + rawEvent.button == MouseLeftButton && + (tableSelection = this.parseTableSelection(target, target, editor.getDOMHelper())) + ) { + this.state.tableSelection = tableSelection; + + if (rawEvent.detail >= 3) { + const lastCo = findCoordinate( + tableSelection.parsedTable, + rawEvent.target as Node, + editor.getDOMHelper() + ); + + if (lastCo) { + // Triple click, select the current cell + tableSelection.lastCo = lastCo; + this.updateTableSelection(lastCo); + rawEvent.preventDefault(); + } + } + + this.state.mouseDisposer = editor.attachDomEvent({ + mousemove: { + beforeDispatch: this.onMouseMove, + }, + }); + } + } + + private onMouseMove = (event: Event) => { + if (this.editor && this.state.tableSelection) { + const hasTableSelection = !!this.state.tableSelection.lastCo; + const currentNode = event.target as Node; + const domHelper = this.editor.getDOMHelper(); + + const range = this.editor.getDocument().createRange(); + const startNode = this.state.tableSelection.startNode; + const isReverted = + currentNode.compareDocumentPosition(startNode) == Node.DOCUMENT_POSITION_FOLLOWING; + + if (isReverted) { + range.setStart(currentNode, 0); + range.setEnd( + startNode, + isNodeOfType(startNode, 'TEXT_NODE') + ? startNode.nodeValue?.length ?? 0 + : startNode.childNodes.length + ); + } else { + range.setStart(startNode, 0); + range.setEnd(currentNode, 0); + } + + // Use common container of the range to search a common table that covers both start and end node + const tableStart = range.commonAncestorContainer; + const newTableSelection = this.parseTableSelection(tableStart, startNode, domHelper); + + if (newTableSelection) { + const lastCo = findCoordinate( + newTableSelection.parsedTable, + currentNode, + domHelper + ); + + if (newTableSelection.table != this.state.tableSelection.table) { + // Move mouse into another table (nest table scenario) + this.state.tableSelection = newTableSelection; + this.state.tableSelection.lastCo = lastCo ?? undefined; + } + + const updated = lastCo && this.updateTableSelection(lastCo); + + if (hasTableSelection || updated) { + event.preventDefault(); + } + } else if (this.editor.getDOMSelection()?.type == 'table') { + // Move mouse out of table + this.setDOMSelection( + { + type: 'range', + range, + isReverted, + }, + this.state.tableSelection + ); + } + } + }; + + private onMouseUp(event: MouseUpEvent) { + let image: HTMLImageElement | null; + + if ( + (image = this.getClickingImage(event.rawEvent)) && + image.isContentEditable && + event.rawEvent.button != MouseMiddleButton && + (event.rawEvent.button == + MouseRightButton /* it's not possible to drag using right click */ || + event.isClicking) + ) { + this.selectImage(image); + } + + this.detachMouseEvent(); + } + + private onDrop = () => { + this.detachMouseEvent(); + }; + + private onKeyDown(editor: IEditor, rawEvent: KeyboardEvent) { + const key = rawEvent.key; + const selection = editor.getDOMSelection(); + const win = editor.getDocument().defaultView; + + switch (selection?.type) { + case 'image': + if (!isModifierKey(rawEvent) && !rawEvent.shiftKey && selection.image.parentNode) { if (key === 'Escape') { - this.selectBeforeImage(this.editor, selection.image); - event.rawEvent.stopPropagation(); + this.selectBeforeImage(editor, selection.image); + rawEvent.stopPropagation(); } else if (key !== 'Delete' && key !== 'Backspace') { - this.selectBeforeImage(this.editor, selection.image); + this.selectBeforeImage(editor, selection.image); + } + } + break; + + case 'range': + if (key == Up || key == Down || key == Left || key == Right) { + const start = selection.range.startContainer; + this.state.tableSelection = this.parseTableSelection( + start, + start, + editor.getDOMHelper() + ); + + if (this.state.tableSelection) { + win?.requestAnimationFrame(() => this.handleSelectionInTable(key)); } } break; + + case 'table': + if (this.state.tableSelection?.lastCo) { + const { shiftKey, key } = rawEvent; + + if (shiftKey && (key == Left || key == Right)) { + const isRtl = + win?.getComputedStyle(this.state.tableSelection.table).direction == + 'rtl'; + + this.updateTableSelectionFromKeyboard( + 0, + (key == Left ? -1 : 1) * (isRtl ? -1 : 1) + ); + rawEvent.preventDefault(); + } else if (shiftKey && (key == Up || key == Down)) { + this.updateTableSelectionFromKeyboard(key == Up ? -1 : 1, 0); + rawEvent.preventDefault(); + } else if (key != 'Shift' && !isCharacterValue(rawEvent)) { + if (key == Up || key == Down || key == Left || key == Right) { + this.setDOMSelection(null /*domSelection*/, this.state.tableSelection); + win?.requestAnimationFrame(() => this.handleSelectionInTable(key)); + } + } + } + break; + } + } + + private handleSelectionInTable(key: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight') { + if (!this.editor || !this.state.tableSelection) { + return; + } + + const selection = this.editor.getDOMSelection(); + const domHelper = this.editor.getDOMHelper(); + + if (selection?.type == 'range') { + const { + range: { collapsed, startContainer, endContainer, commonAncestorContainer }, + isReverted, + } = selection; + const start = isReverted ? endContainer : startContainer; + const end: Node | null = isReverted ? startContainer : endContainer; + const tableSel = this.parseTableSelection(commonAncestorContainer, start, domHelper); + + if (!tableSel) { + return; + } + + let lastCo = findCoordinate(tableSel?.parsedTable, end, domHelper); + const { parsedTable, firstCo: oldCo, table } = this.state.tableSelection; + + if (lastCo && tableSel.table == table && lastCo.col != oldCo.col) { + if (key == Up || key == Down) { + const change = key == Up ? -1 : 1; + const originalTd = findTableCellElement(parsedTable, oldCo)?.cell; + let td: HTMLTableCellElement | null = null; + + lastCo = { row: oldCo.row + change, col: oldCo.col }; + + while (lastCo.row >= 0 && lastCo.row < parsedTable.length) { + td = findTableCellElement(parsedTable, lastCo)?.cell || null; + + if (td == originalTd) { + lastCo.row += change; + } else { + break; + } + } + + if (collapsed && td) { + const { node, offset } = normalizePos( + td, + key == Up ? td.childNodes.length : 0 + ); + const range = this.editor.getDocument().createRange(); + + range.setStart(node, offset); + range.collapse(true /*toStart*/); + + this.setDOMSelection( + { + type: 'range', + range, + isReverted: false, + }, + null /*tableSelection*/ + ); + } + } else { + this.state.tableSelection = null; + } + } + + if (!collapsed && lastCo) { + this.state.tableSelection = tableSel; + this.updateTableSelection(lastCo); + } + } + } + + private updateTableSelectionFromKeyboard(rowChange: number, colChange: number) { + if (this.state.tableSelection?.lastCo && this.editor) { + const { lastCo, parsedTable } = this.state.tableSelection; + const row = lastCo.row + rowChange; + const col = lastCo.col + colChange; + + if (row >= 0 && row < parsedTable.length && col >= 0 && col < parsedTable[row].length) { + this.updateTableSelection({ row, col }); + } } } - private selectImage(editor: IEditor, image: HTMLImageElement) { - editor.setDOMSelection({ - type: 'image', - image: image, - }); + private selectImage(image: HTMLImageElement) { + this.setDOMSelection( + { + type: 'image', + image: image, + }, + null /*tableSelection*/ + ); } private selectBeforeImage(editor: IEditor, image: HTMLImageElement) { @@ -146,11 +418,14 @@ class SelectionPlugin implements PluginWithState { range.setStart(parent, index); range.collapse(); - editor.setDOMSelection({ - type: 'range', - range: range, - isReverted: false, - }); + this.setDOMSelection( + { + type: 'range', + range: range, + isReverted: false, + }, + null /*tableSelection*/ + ); } } @@ -162,8 +437,8 @@ class SelectionPlugin implements PluginWithState { : null; } - //MacOS will not create a mouseUp event if contextMenu event is not prevent defaulted. - //Make sure we capture image target even if image is wrapped + // MacOS will not create a mouseUp event if contextMenu event is not prevent defaulted. + // Make sure we capture image target even if image is wrapped private getContainedTargetImage = ( event: MouseEvent, previousSelection: DOMSelection | null @@ -185,7 +460,7 @@ class SelectionPlugin implements PluginWithState { private onFocus = () => { if (!this.state.skipReselectOnFocus && this.state.selection) { - this.editor?.setDOMSelection(this.state.selection); + this.setDOMSelection(this.state.selection, this.state.tableSelection); } if (this.state.selection?.type == 'range' && !this.isSafari) { @@ -211,6 +486,73 @@ class SelectionPlugin implements PluginWithState { } } }; + + private parseTableSelection( + tableStart: Node, + tdStart: Node, + domHelper: DOMHelper + ): TableSelectionInfo | null { + let table: HTMLTableElement | null; + let parsedTable: ParsedTable | null; + let firstCo: TableCellCoordinate | null; + + if ( + (table = domHelper.findClosestElementAncestor(tableStart, 'table')) && + (parsedTable = parseTableCells(table)) && + (firstCo = findCoordinate(parsedTable, tdStart, domHelper)) + ) { + return { table, parsedTable, firstCo, startNode: tdStart }; + } else { + return null; + } + } + + private updateTableSelection(lastCo: TableCellCoordinate) { + if (this.state.tableSelection && this.editor) { + const { + table, + firstCo, + parsedTable, + startNode, + lastCo: oldCo, + } = this.state.tableSelection; + + if (oldCo || firstCo.row != lastCo.row || firstCo.col != lastCo.col) { + this.state.tableSelection.lastCo = lastCo; + + this.setDOMSelection( + { + type: 'table', + table, + firstRow: firstCo.row, + firstColumn: firstCo.col, + lastRow: lastCo.row, + lastColumn: lastCo.col, + }, + { table, firstCo, lastCo, parsedTable, startNode } + ); + + return true; + } + } + + return false; + } + + private setDOMSelection( + selection: DOMSelection | null, + tableSelection: TableSelectionInfo | null + ) { + this.editor?.setDOMSelection(selection); + this.state.tableSelection = tableSelection; + } + + private detachMouseEvent() { + if (this.state.mouseDisposer) { + this.state.mouseDisposer(); + this.state.mouseDisposer = undefined; + } + } } /** diff --git a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/normalizePos.ts b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/normalizePos.ts new file mode 100644 index 00000000000..4e503c5478b --- /dev/null +++ b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/normalizePos.ts @@ -0,0 +1,25 @@ +import { isNodeOfType } from 'roosterjs-content-model-dom'; + +/** + * @internal + */ +export function normalizePos(node: Node, offset: number): { node: Node; offset: number } { + const len = isNodeOfType(node, 'TEXT_NODE') + ? node.nodeValue?.length ?? 0 + : node.childNodes.length; + offset = Math.max(Math.min(offset, len), 0); + + while (node?.lastChild) { + if (offset >= node.childNodes.length) { + node = node.lastChild; + offset = isNodeOfType(node, 'TEXT_NODE') + ? node.nodeValue?.length ?? 0 + : node.childNodes.length; + } else { + node = node.childNodes[offset]; + offset = 0; + } + } + + return { node, offset }; +} diff --git a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts index b6b727e0e5f..7fd6c67b9ce 100644 --- a/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts +++ b/packages/roosterjs-content-model-core/lib/publicApi/domUtils/tableCellUtils.ts @@ -1,14 +1,21 @@ import { toArray } from 'roosterjs-content-model-dom'; -import type { TableSelection } from 'roosterjs-content-model-types'; +import type { + DOMHelper, + ParsedTable, + TableCellCoordinate, + TableSelection, +} from 'roosterjs-content-model-types'; + +const TableCellSelector = 'TH,TD'; /** * 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)[][] { +export function parseTableCells(table: HTMLTableElement): ParsedTable { const trs = toArray(table.rows); - const cells: (HTMLTableCellElement | null)[][] = trs.map(row => []); + const cells: ParsedTable = trs.map(row => []); trs.forEach((tr, rowIndex) => { for (let sourceCol = 0, targetCol = 0; sourceCol < tr.cells.length; sourceCol++) { @@ -21,7 +28,13 @@ export function parseTableCells(table: HTMLTableElement): (HTMLTableCellElement for (let rowSpan = 0; rowSpan < td.rowSpan; rowSpan++) { if (cells[rowIndex + rowSpan]) { cells[rowIndex + rowSpan][targetCol] = - colSpan == 0 && rowSpan == 0 ? td : null; + colSpan == 0 + ? rowSpan == 0 + ? td + : 'spanTop' + : rowSpan == 0 + ? 'spanLeft' + : 'spanBoth'; } } } @@ -35,6 +48,116 @@ export function parseTableCells(table: HTMLTableElement): (HTMLTableCellElement return cells; } +/** + * @internal + */ +export interface TableCellCoordinateWithCell extends TableCellCoordinate { + cell: HTMLTableCellElement; +} + +/** + * @internal + * Try to find a TD/TH element from the given row and col number from the given parsed table + * @param parsedTable The parsed table + * @param row Row index + * @param col Column index + * @param findLast True to find last merged cell instead of the first cell + */ +export function findTableCellElement( + parsedTable: ParsedTable, + coordinate: TableCellCoordinate +): TableCellCoordinateWithCell | null { + let { row, col } = coordinate; + + while ( + row >= 0 && + col >= 0 && + row < parsedTable.length && + col < (parsedTable[row]?.length ?? 0) + ) { + const cell = parsedTable[row]?.[col]; + + if (!cell) { + break; + } else if (typeof cell == 'object') { + return { cell, row, col }; + } else if (cell == 'spanLeft' || cell == 'spanBoth') { + col--; + } else { + row--; + } + } + return null; +} + +/** + * @internal + * Try to find the last logic cell of a merged table cell + * @param parsedTable The parsed table + * @param row Row index + * @param col Column index + */ +export function findLastedCoInMergedCell( + parsedTable: ParsedTable, + coordinate: TableCellCoordinate +): TableCellCoordinate | null { + let { row, col } = coordinate; + + while ( + row >= 0 && + col >= 0 && + row < parsedTable.length && + col < (parsedTable[row]?.length ?? 0) + ) { + const right = parsedTable[row]?.[col + 1]; + const below = parsedTable[row + 1]?.[col]; + + if (right == 'spanLeft' || right == 'spanBoth') { + col++; + } else if (below == 'spanTop' || below == 'spanBoth') { + row++; + } else { + return { row, col }; + } + } + return null; +} + +/** + * @internal + * Find coordinate of a given element from a parsed table + */ +export function findCoordinate( + parsedTable: ParsedTable, + element: Node, + domHelper: DOMHelper +): TableCellCoordinate | null { + const td = domHelper.findClosestElementAncestor(element, TableCellSelector); + let result: TableCellCoordinate | null = null; + + // Try to do a fast check if both TD are in the given TABLE + if (td) { + parsedTable.some((row, rowIndex) => { + const colIndex = td ? row.indexOf(td as HTMLTableCellElement) : -1; + + return (result = colIndex >= 0 ? { row: rowIndex, col: colIndex } : null); + }); + } + + // For nested table scenario, try to find the outer TAble cells + if (!result) { + parsedTable.some((row, rowIndex) => { + const colIndex = row.findIndex( + cell => typeof cell == 'object' && cell.contains(element) + ); + + return (result = colIndex >= 0 ? { row: rowIndex, col: colIndex } : null); + }); + } + + return result; +} + /** * Create ranges from a table selection * @param selection The source table selection @@ -49,7 +172,7 @@ export function createTableRanges(selection: TableSelection): Range[] { for (let col = firstColumn; col <= lastColumn; col++) { const td = cells[row]?.[col]; - if (td) { + if (typeof td == 'object') { const range = table.ownerDocument.createRange(); range.selectNode(td); diff --git a/packages/roosterjs-content-model-core/lib/publicApi/selection/setSelection.ts b/packages/roosterjs-content-model-core/lib/publicApi/selection/setSelection.ts index e0e445fe92b..ba1b5de2f8f 100644 --- a/packages/roosterjs-content-model-core/lib/publicApi/selection/setSelection.ts +++ b/packages/roosterjs-content-model-core/lib/publicApi/selection/setSelection.ts @@ -5,6 +5,7 @@ import type { ContentModelSegment, ContentModelTable, Selectable, + TableCellCoordinate, } from 'roosterjs-content-model-types'; /** @@ -133,7 +134,7 @@ function setSelectionToTable( return isInSelection; } -function findCell(table: ContentModelTable, cell: Selectable | null): { row: number; col: number } { +function findCell(table: ContentModelTable, cell: Selectable | null): TableCellCoordinate { let col = -1; const row = cell ? table.rows.findIndex(row => (col = (row.cells as Selectable[]).indexOf(cell)) >= 0) diff --git a/packages/roosterjs-content-model-core/test/coreApi/getDOMSelection/getDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/getDOMSelection/getDOMSelectionTest.ts index e3575d2b4b0..b3841ece03c 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/getDOMSelection/getDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/getDOMSelection/getDOMSelectionTest.ts @@ -132,6 +132,40 @@ describe('getDOMSelection', () => { }); }); + it('no cached selection, range selection is in editor, isReverted - 2', () => { + const mockedElement = 'ELEMENT' as any; + const mockedElementOffset = 'MOCKED_ELEMENT_OFFSET' as any; + const secondaryMockedElement = 'ELEMENT_2' as any; + const mockedRange = { + commonAncestorContainer: mockedElement, + startContainer: mockedElement, + startOffset: mockedElementOffset, + endContainer: secondaryMockedElement, + endOffset: mockedElementOffset, + collapsed: false, + } as any; + const setBaseAndExtendSpy = jasmine.createSpy('setBaseAndExtendSpy'); + const mockedSelection = { + rangeCount: 1, + getRangeAt: () => mockedRange, + setBaseAndExtend: setBaseAndExtendSpy, + focusNode: mockedElement, + focusOffset: mockedElementOffset, + }; + + getSelectionSpy.and.returnValue(mockedSelection); + containsSpy.and.returnValue(true); + hasFocusSpy.and.returnValue(true); + + const result = getDOMSelection(core); + + expect(result).toEqual({ + type: 'range', + range: mockedRange, + isReverted: true, + }); + }); + it('has cached selection, editor is in shadowEdit', () => { const mockedSelection = 'SELECTION' as any; core.selection.selection = mockedSelection; diff --git a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts index 1ef705b02a8..73cd453126e 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setContentModel/setContentModelTest.ts @@ -159,6 +159,7 @@ describe('setContentModel', () => { core.selection = { selection: null, + tableSelection: null, }; setContentModel(core, mockedModel, { ignoreSelection: true, @@ -191,6 +192,7 @@ describe('setContentModel', () => { core.selection = { selection: null, + tableSelection: null, }; setContentModel(core, mockedModel, { ignoreSelection: true, @@ -219,6 +221,7 @@ describe('setContentModel', () => { core.selection = { selection: null, + tableSelection: null, }; setContentModel(core, mockedModel, { ignoreSelection: true, diff --git a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts index 530ed49ec5e..51f72c581a6 100644 --- a/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts +++ b/packages/roosterjs-content-model-core/test/coreApi/setDOMSelection/setDOMSelectionTest.ts @@ -10,6 +10,7 @@ describe('setDOMSelection', () => { let addRangeToSelectionSpy: jasmine.Spy; let createRangeSpy: jasmine.Spy; let setEditorStyleSpy: jasmine.Spy; + let containsSpy: jasmine.Spy; let doc: Document; let contentDiv: HTMLDivElement; let mockedRange = 'RANGE' as any; @@ -25,11 +26,12 @@ describe('setDOMSelection', () => { ); createRangeSpy = jasmine.createSpy('createRange'); setEditorStyleSpy = jasmine.createSpy('setEditorStyle'); + containsSpy = jasmine.createSpy('contains').and.returnValue(true); doc = { querySelectorAll: querySelectorAllSpy, createRange: createRangeSpy, - contains: () => true, + contains: containsSpy, } as any; contentDiv = { ownerDocument: doc, @@ -449,35 +451,16 @@ describe('setDOMSelection', () => { expect(core.selection).toEqual({ skipReselectOnFocus: undefined, - selection: mockedSelection, } as any); - expect(triggerEventSpy).toHaveBeenCalledWith( - core, - { - eventType: 'selectionChanged', - newSelection: mockedSelection, - }, - true - ); + expect(triggerEventSpy).not.toHaveBeenCalled(); expect(selectNodeSpy).not.toHaveBeenCalled(); expect(collapseSpy).not.toHaveBeenCalled(); expect(addRangeToSelectionSpy).not.toHaveBeenCalled(); - expect(mockedTable.id).toBe('table_0'); + expect(mockedTable.id).toBeUndefined(); - expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(2); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); - expect(setEditorStyleSpy).toHaveBeenCalledWith( - core, - '_DOMSelection', - 'background-color:#C6C6C6!important;', - [] - ); - expect(setEditorStyleSpy).toHaveBeenCalledWith( - core, - '_DOMSelectionHideCursor', - 'caret-color: transparent' - ); }); function runTest( @@ -587,6 +570,93 @@ describe('setDOMSelection', () => { ]); }); + it('Select TD after merged cell', () => { + const div = document.createElement('div'); + div.innerHTML = + '

'; + const table = div.firstChild as HTMLTableElement; + const innerDIV = div.querySelector('#div1'); + + runTest(table, 2, 0, 2, 0, [ + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2)', + '#table_0>TBODY> tr:nth-child(1)>TD:nth-child(2) *', + ]); + + expect(containsSpy).toHaveBeenCalledTimes(1); + expect(containsSpy).toHaveBeenCalledWith(innerDIV); + }); + + it('Select TD with double merged cell', () => { + const div = document.createElement('div'); + div.innerHTML = + '' + + '' + + '' + + '' + + '' + + '
'; + const table = div.firstChild as HTMLTableElement; + + const mockedSelection = { + type: 'table', + table: table, + firstColumn: 2, + firstRow: 1, + lastColumn: 1, + lastRow: 2, + } 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); + + const resultSelection = { + type: 'table', + table: table, + firstColumn: 0, + firstRow: 0, + lastColumn: 3, + lastRow: 3, + }; + + expect(core.selection).toEqual({ + skipReselectOnFocus: undefined, + selection: resultSelection, + } as any); + expect(triggerEventSpy).toHaveBeenCalledWith( + core, + { + eventType: 'selectionChanged', + newSelection: resultSelection, + }, + true + ); + expect(table.id).toBe('table_0'); + expect(setEditorStyleSpy).toHaveBeenCalledTimes(4); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelection', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith(core, '_DOMSelectionHideCursor', null); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelection', + 'background-color:#C6C6C6!important;', + ['#table_0', '#table_0 *'] + ); + expect(setEditorStyleSpy).toHaveBeenCalledWith( + core, + '_DOMSelectionHideCursor', + 'caret-color: transparent' + ); + }); + it('Select Table Cells THEAD, TBODY', () => { runTest(buildTable(true /* tbody */, true /* thead */), 1, 1, 2, 2, [ '#table_0>THEAD> tr:nth-child(2)>TD:nth-child(2)', diff --git a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts index 18d79dc59f4..68e83fe8f95 100644 --- a/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts +++ b/packages/roosterjs-content-model-core/test/corePlugin/selection/SelectionPluginTest.ts @@ -1,5 +1,7 @@ +import { createDOMHelper } from '../../../lib/editor/core/DOMHelperImpl'; import { createSelectionPlugin } from '../../../lib/corePlugin/selection/SelectionPlugin'; import { + DOMEventRecord, EditorPlugin, IEditor, PluginWithState, @@ -10,13 +12,9 @@ describe('SelectionPlugin', () => { it('init and dispose', () => { const plugin = createSelectionPlugin({}); const disposer = jasmine.createSpy('disposer'); - const appendChildSpy = jasmine.createSpy('appendChild'); const attachDomEvent = jasmine.createSpy('attachDomEvent').and.returnValue(disposer); const removeEventListenerSpy = jasmine.createSpy('removeEventListener'); const getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ - head: { - appendChild: appendChildSpy, - }, removeEventListener: removeEventListenerSpy, }); const state = plugin.getState(); @@ -31,6 +29,7 @@ describe('SelectionPlugin', () => { expect(state).toEqual({ selection: null, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(attachDomEvent).toHaveBeenCalled(); expect(removeEventListenerSpy).not.toHaveBeenCalled(); @@ -51,12 +50,8 @@ describe('SelectionPlugin', () => { const attachDomEvent = jasmine .createSpy('attachDomEvent') .and.returnValue(jasmine.createSpy('disposer')); - const appendChildSpy = jasmine.createSpy('appendChild'); const removeEventListenerSpy = jasmine.createSpy('removeEventListener'); const getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ - head: { - appendChild: appendChildSpy, - }, removeEventListener: removeEventListenerSpy, }); @@ -69,6 +64,7 @@ describe('SelectionPlugin', () => { expect(state).toEqual({ selection: null, imageSelectionBorderColor: 'red', + tableSelection: null, }); expect(attachDomEvent).toHaveBeenCalled(); @@ -83,7 +79,6 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { let eventMap: Record; let getElementAtCursorSpy: jasmine.Spy; let getDocumentSpy: jasmine.Spy; - let appendChildSpy: jasmine.Spy; let setDOMSelectionSpy: jasmine.Spy; let removeEventListenerSpy: jasmine.Spy; @@ -92,12 +87,8 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { beforeEach(() => { triggerEvent = jasmine.createSpy('triggerEvent'); getElementAtCursorSpy = jasmine.createSpy('getElementAtCursor'); - appendChildSpy = jasmine.createSpy('appendChild'); removeEventListenerSpy = jasmine.createSpy('removeEventListener'); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ - head: { - appendChild: appendChildSpy, - }, removeEventListener: removeEventListenerSpy, }); setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); @@ -134,6 +125,7 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { selection: mockedRange, imageSelectionBorderColor: undefined, skipReselectOnFocus: false, + tableSelection: null, }); }); @@ -149,6 +141,7 @@ describe('SelectionPlugin handle onFocus and onBlur event', () => { selection: mockedRange, imageSelectionBorderColor: undefined, skipReselectOnFocus: true, + tableSelection: null, }); }); }); @@ -167,9 +160,6 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy = jasmine.createSpy('createRange'); getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ createRange: createRangeSpy, - head: { - appendChild: () => {}, - }, }); editor = { @@ -187,7 +177,7 @@ describe('SelectionPlugin handle image selection', () => { it('No selection, mouse down to div', () => { const node = document.createElement('div'); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: node, @@ -217,7 +207,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); const node = document.createElement('div'); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: node, @@ -249,7 +239,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); const node = document.createElement('div'); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: node, @@ -268,7 +258,7 @@ describe('SelectionPlugin handle image selection', () => { image: mockedImage, }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: mockedImage, @@ -288,7 +278,7 @@ describe('SelectionPlugin handle image selection', () => { image: mockedImage, }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: mockedImage, @@ -303,7 +293,7 @@ describe('SelectionPlugin handle image selection', () => { const mockedImage = document.createElement('img'); mockedImage.contentEditable = 'true'; - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: mockedImage, @@ -317,7 +307,7 @@ describe('SelectionPlugin handle image selection', () => { it('Image selection, mouse down to div right click', () => { const node = document.createElement('div'); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseDown', rawEvent: { target: node, @@ -333,7 +323,7 @@ describe('SelectionPlugin handle image selection', () => { mockedImage.contentEditable = 'true'; - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseUp', isClicking: true, rawEvent: { @@ -353,7 +343,7 @@ describe('SelectionPlugin handle image selection', () => { mockedImage.contentEditable = 'false'; - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseUp', isClicking: true, rawEvent: { @@ -369,7 +359,7 @@ describe('SelectionPlugin handle image selection', () => { mockedImage.contentEditable = 'true'; - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'mouseUp', isClicking: false, rawEvent: { @@ -386,7 +376,7 @@ describe('SelectionPlugin handle image selection', () => { } as any; getDOMSelectionSpy.and.returnValue(null); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -402,7 +392,7 @@ describe('SelectionPlugin handle image selection', () => { type: 'range', }); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -434,7 +424,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -472,7 +462,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -511,7 +501,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -543,7 +533,7 @@ describe('SelectionPlugin handle image selection', () => { createRangeSpy.and.returnValue(mockedRange); - plugin.onPluginEvent({ + plugin.onPluginEvent!({ eventType: 'keyDown', rawEvent, }); @@ -553,6 +543,1029 @@ describe('SelectionPlugin handle image selection', () => { }); }); +describe('SelectionPlugin handle table selection', () => { + let plugin: PluginWithState; + let editor: IEditor; + let contentDiv: HTMLElement; + let getDOMSelectionSpy: jasmine.Spy; + let setDOMSelectionSpy: jasmine.Spy; + let getDocumentSpy: jasmine.Spy; + let createRangeSpy: jasmine.Spy; + let mouseDispatcher: Record; + let focusDispatcher: Record; + let focusDisposer: jasmine.Spy; + let mouseMoveDisposer: jasmine.Spy; + let requestAnimationFrameSpy: jasmine.Spy; + let getComputedStyleSpy: jasmine.Spy; + + beforeEach(() => { + contentDiv = document.createElement('div'); + getDOMSelectionSpy = jasmine.createSpy('getDOMSelection'); + setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + createRangeSpy = jasmine.createSpy('createRange'); + requestAnimationFrameSpy = jasmine.createSpy('requestAnimationFrame'); + getComputedStyleSpy = jasmine.createSpy('getComputedStyle'); + getDocumentSpy = jasmine.createSpy('getDocument').and.returnValue({ + createRange: createRangeSpy, + defaultView: { + requestAnimationFrame: requestAnimationFrameSpy, + getComputedStyle: getComputedStyleSpy, + }, + }); + focusDisposer = jasmine.createSpy('focus'); + mouseMoveDisposer = jasmine.createSpy('mouseMove'); + + const domHelper = createDOMHelper(contentDiv); + + editor = { + getDOMSelection: getDOMSelectionSpy, + setDOMSelection: setDOMSelectionSpy, + getDocument: getDocumentSpy, + getEnvironment: () => ({}), + getDOMHelper: () => domHelper, + attachDomEvent: (map: Record>) => { + if (map.mousemove) { + mouseDispatcher = map; + return mouseMoveDisposer; + } else { + focusDispatcher = map; + return focusDisposer; + } + }, + } as any; + plugin = createSelectionPlugin({}); + plugin.initialize(editor); + }); + + afterEach(() => { + focusDispatcher = undefined!; + mouseDispatcher = undefined!; + }); + + it('MouseDown - has tableSelection, clear it when left click', () => { + const state = plugin.getState(); + const mockedTableSelection = 'TableSelection' as any; + + state.tableSelection = mockedTableSelection; + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + + plugin.onPluginEvent!({ + eventType: 'mouseDown', + rawEvent: { + button: 2, + } as any, + }); + + expect(state).toEqual({ + selection: null, + tableSelection: mockedTableSelection, + imageSelectionBorderColor: undefined, + }); + + plugin.onPluginEvent!({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + } as any, + }); + + expect(state).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + expect(mouseDispatcher).toBeUndefined(); + }); + + it('MouseDown - save a table selection when left click', () => { + const state = plugin.getState(); + const table = document.createElement('table'); + const tr = document.createElement('tr'); + const td = document.createElement('td'); + const div = document.createElement('div'); + + tr.appendChild(td); + table.appendChild(tr); + contentDiv.appendChild(table); + contentDiv.appendChild(div); + + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + + plugin.onPluginEvent!({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + target: div, + } as any, + }); + + expect(state).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + + plugin.onPluginEvent!({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + target: td, + } as any, + }); + + expect(state).toEqual({ + selection: null, + tableSelection: { + table: table, + parsedTable: [[td]], + firstCo: { row: 0, col: 0 }, + startNode: td, + }, + mouseDisposer: mouseMoveDisposer, + imageSelectionBorderColor: undefined, + }); + expect(mouseDispatcher).toBeDefined(); + }); + + it('MouseDown - triple click', () => { + const state = plugin.getState(); + const table = document.createElement('table'); + const tr = document.createElement('tr'); + const td = document.createElement('td'); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + tr.appendChild(td); + table.appendChild(tr); + contentDiv.appendChild(table); + + plugin.onPluginEvent!({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + target: td, + detail: 3, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(state).toEqual({ + selection: null, + tableSelection: { + table: table, + parsedTable: [[td]], + firstCo: { row: 0, col: 0 }, + lastCo: { row: 0, col: 0 }, + startNode: td, + }, + mouseDisposer: mouseMoveDisposer, + imageSelectionBorderColor: undefined, + }); + expect(mouseDispatcher).toBeDefined(); + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table: table, + firstRow: 0, + firstColumn: 0, + lastRow: 0, + lastColumn: 0, + }); + }); + + it('MouseMove - in same table', () => { + const state = plugin.getState(); + const table = document.createElement('table'); + const tr = document.createElement('tr'); + const td1 = document.createElement('td'); + const td2 = document.createElement('td'); + const div = document.createElement('div'); + + td1.id = 'td1'; + td2.id = 'td2'; + + tr.appendChild(td1); + tr.appendChild(td2); + table.appendChild(tr); + contentDiv.appendChild(table); + contentDiv.appendChild(div); + + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + + plugin.onPluginEvent!({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + target: td1, + } as any, + }); + + expect(mouseDispatcher.mousemove).toBeDefined(); + expect(state.tableSelection).toEqual({ + table, + parsedTable: [[td1, td2]], + firstCo: { row: 0, col: 0 }, + startNode: td1, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const setStartSpy = jasmine.createSpy('setStart'); + const setEndSpy = jasmine.createSpy('setEnd'); + + createRangeSpy.and.returnValue({ + setStart: setStartSpy, + setEnd: setEndSpy, + commonAncestorContainer: table, + }); + + mouseDispatcher.mousemove.beforeDispatch!({ + target: td1, + preventDefault: preventDefaultSpy, + } as any); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(state.tableSelection).toEqual({ + table, + parsedTable: [[td1, td2]], + firstCo: { row: 0, col: 0 }, + startNode: td1, + }); + expect(setStartSpy).toHaveBeenCalledWith(td1, 0); + expect(setEndSpy).toHaveBeenCalledWith(td1, 0); + + mouseDispatcher.mousemove.beforeDispatch!({ + target: td2, + preventDefault: preventDefaultSpy, + } as any); + + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(2); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table: table, + firstRow: 0, + firstColumn: 0, + lastRow: 0, + lastColumn: 1, + }); + expect(state.tableSelection).toEqual({ + table, + parsedTable: [[td1, td2]], + firstCo: { row: 0, col: 0 }, + lastCo: { row: 0, col: 1 }, + startNode: td1, + }); + + mouseDispatcher.mousemove.beforeDispatch!({ + target: div, + preventDefault: preventDefaultSpy, + } as any); + + expect(preventDefaultSpy).toHaveBeenCalledTimes(2); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(2); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table: table, + firstRow: 0, + firstColumn: 0, + lastRow: 0, + lastColumn: 1, + }); + expect(state.tableSelection).toEqual({ + table, + parsedTable: [[td1, td2]], + firstCo: { row: 0, col: 0 }, + lastCo: { row: 0, col: 1 }, + startNode: td1, + }); + }); + + it('MouseMove - move to outer table', () => { + const state = plugin.getState(); + const table1 = document.createElement('table'); + const table2 = document.createElement('table'); + const tr1 = document.createElement('tr'); + const tr2 = document.createElement('tr'); + const td1 = document.createElement('td'); + const td2 = document.createElement('td'); + const div = document.createElement('div'); + + td1.id = 'td1'; + td2.id = 'td2'; + + tr1.appendChild(td1); + tr2.appendChild(td2); + table1.appendChild(tr1); + table2.appendChild(tr2); + + td1.appendChild(table2); + + contentDiv.appendChild(table1); + contentDiv.appendChild(div); + + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + + plugin.onPluginEvent!({ + eventType: 'mouseDown', + rawEvent: { + button: 0, + target: td2, + } as any, + }); + + expect(mouseDispatcher.mousemove).toBeDefined(); + expect(state.tableSelection).toEqual({ + table: table2, + parsedTable: [[td2]], + firstCo: { row: 0, col: 0 }, + startNode: td2, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith(null); + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + const setStartSpy = jasmine.createSpy('setStart'); + const setEndSpy = jasmine.createSpy('setEnd'); + + createRangeSpy.and.returnValue({ + setStart: setStartSpy, + setEnd: setEndSpy, + commonAncestorContainer: table1, + }); + + mouseDispatcher.mousemove.beforeDispatch!({ + target: td1, + preventDefault: preventDefaultSpy, + } as any); + + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(2); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table: table1, + firstRow: 0, + firstColumn: 0, + lastRow: 0, + lastColumn: 0, + }); + expect(state.tableSelection).toEqual({ + table: table1, + parsedTable: [[td1]], + firstCo: { row: 0, col: 0 }, + lastCo: { row: 0, col: 0 }, + startNode: td2, + }); + expect(setStartSpy).toHaveBeenCalledWith(td2, 0); + expect(setEndSpy).toHaveBeenCalledWith(td1, 0); + + createRangeSpy.and.returnValue({ + setStart: setStartSpy, + setEnd: setEndSpy, + commonAncestorContainer: table2, + }); + + mouseDispatcher.mousemove.beforeDispatch!({ + target: td2, + preventDefault: preventDefaultSpy, + } as any); + + expect(preventDefaultSpy).toHaveBeenCalledTimes(2); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(3); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table: table2, + firstRow: 0, + firstColumn: 0, + lastRow: 0, + lastColumn: 0, + }); + expect(state.tableSelection).toEqual({ + table: table2, + parsedTable: [[td2]], + firstCo: { row: 0, col: 0 }, + lastCo: { row: 0, col: 0 }, + startNode: td2, + }); + + const mockedRange = { + setStart: setStartSpy, + setEnd: setEndSpy, + commonAncestorContainer: contentDiv, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + mouseDispatcher.mousemove.beforeDispatch!({ + target: div, + preventDefault: preventDefaultSpy, + } as any); + + expect(preventDefaultSpy).toHaveBeenCalledTimes(2); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(4); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + expect(state.tableSelection).toEqual({ + table: table2, + parsedTable: [[td2]], + firstCo: { row: 0, col: 0 }, + lastCo: { row: 0, col: 0 }, + startNode: td2, + }); + }); + + it('OnDrop', () => { + expect(focusDispatcher.drop).toBeDefined(); + + const state = plugin.getState(); + const disposer = jasmine.createSpy('disposer'); + + state.mouseDisposer = disposer; + + focusDispatcher.drop.beforeDispatch!(null!); + + expect(disposer).toHaveBeenCalledTimes(1); + expect(state.mouseDisposer).toBeUndefined(); + + focusDispatcher.drop.beforeDispatch!(null!); + + expect(disposer).toHaveBeenCalledTimes(1); + expect(state.mouseDisposer).toBeUndefined(); + }); + + describe('OnKeyDown', () => { + let td1: HTMLTableCellElement; + let td2: HTMLTableCellElement; + let td3: HTMLTableCellElement; + let td4: HTMLTableCellElement; + let tr1: HTMLElement; + let tr2: HTMLElement; + let table: HTMLTableElement; + let div: HTMLElement; + + beforeEach(() => { + table = document.createElement('table'); + tr1 = document.createElement('tr'); + tr2 = document.createElement('tr'); + td1 = document.createElement('td'); + td2 = document.createElement('td'); + td3 = document.createElement('td'); + td4 = document.createElement('td'); + div = document.createElement('div'); + + td1.id = 'td1'; + td2.id = 'td2'; + td3.id = 'td3'; + td4.id = 'td4'; + + tr1.appendChild(td1); + tr1.appendChild(td2); + tr2.appendChild(td3); + tr2.appendChild(td4); + table.appendChild(tr1); + table.appendChild(tr2); + contentDiv.appendChild(table); + contentDiv.appendChild(div); + }); + + it('From Range, Press A', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { startContainer: td1, startOffset: 0, endContainer: td1, endOffset: 0 }, + isReverted: false, + }); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'a', + } as any, + }); + + expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('From Range, Press Right', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { startContainer: td1, startOffset: 0, endContainer: td1, endOffset: 0 }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowRight', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); + + it('From Range, Press Down', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td3, + startOffset: 0, + endContainer: td3, + endOffset: 0, + commonAncestorContainer: tr2, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + const setStartSpy = jasmine.createSpy('setStart'); + const collapseSpy = jasmine.createSpy('collapse'); + const mockedRange = { + setStart: setStartSpy, + collapse: collapseSpy, + } as any; + + createRangeSpy.and.returnValue(mockedRange); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowDown', + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: null, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + expect(setStartSpy).toHaveBeenCalledWith(td4, 0); + }); + + it('From Range, Press Shift+Up', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td4, + startOffset: 0, + endContainer: td4, + endOffset: 0, + commonAncestorContainer: tr2, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td3, + startOffset: 0, + endContainer: td4, + endOffset: 0, + commonAncestorContainer: tr2, + collapsed: false, + }, + isReverted: true, + }); + + func(); + }); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowUp', + shiftKey: true, + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 1, col: 1 }, + lastCo: { row: 0, col: 1 }, + startNode: td4, + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table, + firstRow: 1, + firstColumn: 1, + lastRow: 0, + lastColumn: 1, + }); + }); + + it('From Range, Press Shift+Down', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td3, + endOffset: 0, + commonAncestorContainer: table, + collapsed: false, + }, + isReverted: false, + }); + + func(); + }); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowDown', + shiftKey: true, + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, + startNode: td2, + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table, + firstRow: 0, + firstColumn: 1, + lastRow: 1, + lastColumn: 1, + }); + }); + + it('From Range, Press Shift+Down to ouside of table', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + }, + isReverted: false, + }); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: div, + endOffset: 0, + commonAncestorContainer: contentDiv, + collapsed: false, + }, + isReverted: false, + }); + + func(); + }); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowDown', + shiftKey: true, + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 0, col: 1 }, + startNode: td2, + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(0); + }); + + it('From Table, Press A', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + plugin.getState().tableSelection = { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, + startNode: td2, + }; + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'a', + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, + startNode: td2, + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('From Table, Press Left', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + plugin.getState().tableSelection = { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, + startNode: td2, + }; + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + requestAnimationFrameSpy.and.callFake((func: Function) => { + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { + startContainer: td2, + startOffset: 0, + endContainer: td2, + endOffset: 0, + commonAncestorContainer: tr1, + collapsed: true, + }, + isReverted: false, + }); + + func(); + }); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowLeft', + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, + startNode: td2, + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith(null); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('From Table, Press Shift+Left', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + + plugin.getState().tableSelection = { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 1 }, + startNode: td2, + }; + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + getComputedStyleSpy.and.returnValue({}); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowLeft', + shiftKey: true, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 0, col: 1 }, + lastCo: { row: 1, col: 0 }, + startNode: td2, + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table, + firstRow: 0, + firstColumn: 1, + lastRow: 1, + lastColumn: 0, + }); + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('From Table, Press Shift+Up', () => { + getDOMSelectionSpy.and.returnValue({ + type: 'table', + }); + + plugin.getState().tableSelection = { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 1, col: 0 }, + lastCo: { row: 1, col: 1 }, + startNode: td3, + }; + + const preventDefaultSpy = jasmine.createSpy('preventDefault'); + + getComputedStyleSpy.and.returnValue({}); + + plugin.onPluginEvent!({ + eventType: 'keyDown', + rawEvent: { + key: 'ArrowUp', + shiftKey: true, + preventDefault: preventDefaultSpy, + } as any, + }); + + expect(plugin.getState()).toEqual({ + selection: null, + tableSelection: { + table, + parsedTable: [ + [td1, td2], + [td3, td4], + ], + firstCo: { row: 1, col: 0 }, + lastCo: { row: 0, col: 1 }, + startNode: td3, + }, + imageSelectionBorderColor: undefined, + }); + expect(setDOMSelectionSpy).toHaveBeenCalledTimes(1); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'table', + table, + firstRow: 1, + firstColumn: 0, + lastRow: 0, + lastColumn: 1, + }); + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + }); +}); + describe('SelectionPlugin on Safari', () => { let disposer: jasmine.Spy; let appendChildSpy: jasmine.Spy; @@ -603,6 +1616,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: null, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(attachDomEvent).toHaveBeenCalled(); expect(addEventListenerSpy).toHaveBeenCalledWith('selectionchange', jasmine.anything()); @@ -637,6 +1651,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); @@ -664,6 +1679,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedNewSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); @@ -691,6 +1707,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); @@ -718,6 +1735,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(1); }); @@ -745,6 +1763,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); }); @@ -772,6 +1791,7 @@ describe('SelectionPlugin on Safari', () => { expect(state).toEqual({ selection: mockedOldSelection, imageSelectionBorderColor: undefined, + tableSelection: null, }); expect(getDOMSelectionSpy).toHaveBeenCalledTimes(0); }); diff --git a/packages/roosterjs-content-model-core/test/publicApi/domUtils/normalizePosTest.ts b/packages/roosterjs-content-model-core/test/publicApi/domUtils/normalizePosTest.ts new file mode 100644 index 00000000000..1eaa1cca34f --- /dev/null +++ b/packages/roosterjs-content-model-core/test/publicApi/domUtils/normalizePosTest.ts @@ -0,0 +1,102 @@ +import { normalizePos } from '../../../lib/publicApi/domUtils/normalizePos'; + +describe('normalizePos()', () => { + function runTest( + input: string, + getNode: (root: Node) => Node, + inputOffset: number, + expectNodeValue: string, + expectOffset: number + ) { + const div = document.createElement('div'); + document.body.appendChild(div); + + div.innerHTML = input; + const inputNode = getNode(div); + + const { node, offset } = normalizePos(inputNode, inputOffset); + + let value = node.nodeType == Node.TEXT_NODE ? node.nodeValue : node.textContent; + + expect(value).toBe(expectNodeValue, 'NodeValue'); + expect(offset).toBe(expectOffset, 'Offset'); + + document.body.removeChild(div); + } + + it('DIV - Begin', () => { + runTest( + 'test1
test2
test3', + () => document.getElementById('id1'), + 0, + 'test2', + 0 + ); + }); + it('DIV - With offset', () => { + runTest( + 'test1
test2
test3', + () => document.getElementById('id1'), + 1, + 'test2', + 5 + ); + }); + it('DIV - With offset out of range', () => { + runTest( + 'test1
test2
test3', + () => document.getElementById('id1'), + 2, + 'test2', + 5 + ); + }); + it('Text - Begin', () => { + runTest( + 'test1
test2
test3', + () => document.getElementById('id1').firstChild, + 0, + 'test2', + 0 + ); + }); + it('Text - End', () => { + runTest( + 'test1
test2
test3', + () => document.getElementById('id1').firstChild, + 5, + 'test2', + 5 + ); + }); + it('Text - With offset', () => { + runTest( + 'test1
test2
test3', + () => document.getElementById('id1').firstChild, + 2, + 'test2', + 2 + ); + }); + it('Text - With offset out of range', () => { + runTest( + 'test1
test2
test3', + () => document.getElementById('id1').firstChild, + 10, + 'test2', + 5 + ); + }); + it('VOID - Begin', () => { + runTest('test1test3', () => document.getElementById('id1'), 0, '', 0); + }); + it('VOID - End', () => { + runTest('test1test3', () => document.getElementById('id1'), 1, '', 0); + }); + it('VOID - With offset', () => { + runTest('test1test3', () => document.getElementById('id1'), 0, '', 0); + }); + it('VOID - With offset out of range', () => { + runTest('test1test3', () => document.getElementById('id1'), 2, '', 0); + }); +}); diff --git a/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts b/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts index ebf926eee41..7b3d5e4eff0 100644 --- a/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts +++ b/packages/roosterjs-content-model-core/test/publicApi/domUtils/tableCellUtilsTest.ts @@ -1,15 +1,23 @@ -import { createTableRanges, parseTableCells } from '../../../lib/publicApi/domUtils/tableCellUtils'; -import { DOMSelection } from 'roosterjs-content-model-types'; +import { createDOMHelper } from '../../../lib/editor/core/DOMHelperImpl'; +import { DOMHelper, DOMSelection, ParsedTable } from 'roosterjs-content-model-types'; +import { + TableCellCoordinateWithCell, + createTableRanges, + findCoordinate, + findLastedCoInMergedCell, + findTableCellElement, + parseTableCells, +} from '../../../lib/publicApi/domUtils/tableCellUtils'; describe('parseTableCells', () => { - function runTest(html: string, expectedResult: (string | null)[][]) { + function runTest(html: string, expectedResult: string[][]) { 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))); + const idResult = result.map(row => row.map(td => (typeof td == 'object' ? td.id : td))); expect(idResult).toEqual(expectedResult); } @@ -37,7 +45,7 @@ describe('parseTableCells', () => { '
', [ ['td1', 'td2'], - [null, 'td4'], + ['spanTop', 'td4'], ] ); }); @@ -46,7 +54,7 @@ describe('parseTableCells', () => { runTest( '
', [ - ['td1', null], + ['td1', 'spanLeft'], ['td3', 'td4'], ] ); @@ -54,8 +62,8 @@ describe('parseTableCells', () => { it('table with all merged cell', () => { runTest('
', [ - ['td1', null], - [null, null], + ['td1', 'spanLeft'], + ['spanTop', 'spanBoth'], ]); }); @@ -73,14 +81,232 @@ describe('parseTableCells', () => { runTest( '
', [ - ['td1', null, 'td3'], - ['td4', 'td5', null], - [null, 'td8', null], + ['td1', 'spanLeft', 'td3'], + ['td4', 'td5', 'spanTop'], + ['spanTop', 'td8', 'spanLeft'], ] ); }); }); +describe('findTableCellElement', () => { + const mockedTd1 = { id: 'TD1' } as any; + const mockedTd2 = { id: 'TD2' } as any; + const mockedTd3 = { id: 'TD3' } as any; + const mockedTd4 = { id: 'TD4' } as any; + const mockedTd5 = { id: 'TD5' } as any; + + function runTest( + parsedTable: ParsedTable, + row: number, + col: number, + expectedResult: TableCellCoordinateWithCell | null + ) { + const result = findTableCellElement(parsedTable, { row, col }); + + expect(result).toEqual(expectedResult); + } + + it('Null', () => { + runTest([], 0, 0, null); + }); + + it('Simple table', () => { + const parsedTable: ParsedTable = [ + [mockedTd1, mockedTd2], + [mockedTd3, mockedTd4], + ]; + runTest(parsedTable, 0, 0, { cell: mockedTd1, row: 0, col: 0 }); + runTest(parsedTable, 0, 1, { cell: mockedTd2, row: 0, col: 1 }); + runTest(parsedTable, 0, 2, null); + runTest(parsedTable, 1, 0, { cell: mockedTd3, row: 1, col: 0 }); + runTest(parsedTable, 2, 0, null); + runTest(parsedTable, 2, 2, null); + }); + + it('Complex table', () => { + const parsedTable: ParsedTable = [ + [mockedTd1, mockedTd2, 'spanLeft'], + ['spanTop', mockedTd3, mockedTd4], + [mockedTd5, 'spanLeft', 'spanTop'], + ]; + + runTest(parsedTable, 0, 0, { cell: mockedTd1, row: 0, col: 0 }); + runTest(parsedTable, 0, 1, { cell: mockedTd2, row: 0, col: 1 }); + runTest(parsedTable, 0, 2, { cell: mockedTd2, row: 0, col: 1 }); + runTest(parsedTable, 0, 3, null); + runTest(parsedTable, 1, 0, { cell: mockedTd1, row: 0, col: 0 }); + runTest(parsedTable, 1, 1, { cell: mockedTd3, row: 1, col: 1 }); + runTest(parsedTable, 1, 2, { cell: mockedTd4, row: 1, col: 2 }); + runTest(parsedTable, 1, 3, null); + runTest(parsedTable, 2, 0, { cell: mockedTd5, row: 2, col: 0 }); + runTest(parsedTable, 2, 1, { cell: mockedTd5, row: 2, col: 0 }); + runTest(parsedTable, 2, 2, { cell: mockedTd4, row: 1, col: 2 }); + runTest(parsedTable, 2, 3, null); + runTest(parsedTable, 3, 0, null); + runTest(parsedTable, 3, 1, null); + runTest(parsedTable, 3, 2, null); + runTest(parsedTable, 3, 3, null); + }); + + it('span both', () => { + const parsedTable: ParsedTable = [ + [mockedTd1, 'spanLeft'], + ['spanTop', 'spanBoth'], + ]; + + runTest(parsedTable, 0, 0, { cell: mockedTd1, row: 0, col: 0 }); + runTest(parsedTable, 0, 1, { cell: mockedTd1, row: 0, col: 0 }); + runTest(parsedTable, 0, 2, null); + runTest(parsedTable, 1, 0, { cell: mockedTd1, row: 0, col: 0 }); + runTest(parsedTable, 1, 1, { cell: mockedTd1, row: 0, col: 0 }); + runTest(parsedTable, 1, 2, null); + runTest(parsedTable, 2, 0, null); + runTest(parsedTable, 2, 1, null); + runTest(parsedTable, 2, 2, null); + }); +}); + +describe('findLastedCoInMergedCell', () => { + const mockedTd1 = { id: 'TD1' } as any; + const mockedTd2 = { id: 'TD2' } as any; + const mockedTd3 = { id: 'TD3' } as any; + const mockedTd4 = { id: 'TD4' } as any; + const mockedTd5 = { id: 'TD5' } as any; + + function runTest(parsedTable: ParsedTable, row: number, col: number, expectedResult: any) { + const result = findLastedCoInMergedCell(parsedTable, { row, col }); + + expect(result).toEqual(expectedResult); + } + + it('Null', () => { + runTest([], 0, 0, null); + }); + + it('Simple table', () => { + const parsedTable: ParsedTable = [ + [mockedTd1, mockedTd2], + [mockedTd3, mockedTd4], + ]; + runTest(parsedTable, 0, 0, { row: 0, col: 0 }); + runTest(parsedTable, 0, 1, { row: 0, col: 1 }); + runTest(parsedTable, 0, 2, null); + runTest(parsedTable, 1, 0, { row: 1, col: 0 }); + runTest(parsedTable, 2, 0, null); + runTest(parsedTable, 2, 2, null); + }); + + it('Complex table', () => { + const parsedTable: ParsedTable = [ + [mockedTd1, mockedTd2, 'spanLeft'], + ['spanTop', mockedTd3, mockedTd4], + [mockedTd5, 'spanLeft', 'spanTop'], + ]; + + runTest(parsedTable, 0, 0, { row: 1, col: 0 }); + runTest(parsedTable, 0, 1, { row: 0, col: 2 }); + runTest(parsedTable, 0, 2, { row: 0, col: 2 }); + runTest(parsedTable, 0, 3, null); + runTest(parsedTable, 1, 0, { row: 1, col: 0 }); + runTest(parsedTable, 1, 1, { row: 1, col: 1 }); + runTest(parsedTable, 1, 2, { row: 2, col: 2 }); + runTest(parsedTable, 1, 3, null); + runTest(parsedTable, 2, 0, { row: 2, col: 1 }); + runTest(parsedTable, 2, 1, { row: 2, col: 1 }); + runTest(parsedTable, 2, 2, { row: 2, col: 2 }); + runTest(parsedTable, 2, 3, null); + runTest(parsedTable, 3, 0, null); + runTest(parsedTable, 3, 1, null); + runTest(parsedTable, 3, 2, null); + runTest(parsedTable, 3, 3, null); + }); + + it('span both', () => { + const parsedTable: ParsedTable = [ + [mockedTd1, 'spanLeft'], + ['spanTop', 'spanBoth'], + ]; + + runTest(parsedTable, 0, 0, { row: 1, col: 1 }); + runTest(parsedTable, 0, 1, { row: 1, col: 1 }); + runTest(parsedTable, 0, 2, null); + runTest(parsedTable, 1, 0, { row: 1, col: 1 }); + runTest(parsedTable, 1, 1, { row: 1, col: 1 }); + runTest(parsedTable, 1, 2, null); + runTest(parsedTable, 2, 0, null); + runTest(parsedTable, 2, 1, null); + runTest(parsedTable, 2, 2, null); + }); +}); + +describe('findCoordinate', () => { + let domHelper: DOMHelper; + let root: HTMLElement; + + beforeEach(() => { + root = document.createElement('div'); + domHelper = createDOMHelper(root); + }); + + it('Empty table', () => { + const table: ParsedTable = []; + const text = document.createTextNode('test'); + + root.appendChild(text); + + const result = findCoordinate(table, text, domHelper); + + expect(result).toBeNull(); + }); + + it('Table contains node', () => { + const container = document.createElement('div') as any; + root.appendChild(container); + + const table: ParsedTable = [[container]]; + const text = document.createTextNode('test'); + + container.appendChild(text); + + const result = findCoordinate(table, text, domHelper); + + expect(result).toEqual({ row: 0, col: 0 }); + }); + + it('Table contains node indirectly', () => { + const container = document.createElement('div') as any; + root.appendChild(container); + + const table: ParsedTable = [[container]]; + const span = document.createElement('span'); + const text = document.createTextNode('test'); + + span.appendChild(text); + container.appendChild(span); + + const result = findCoordinate(table, text, domHelper); + + expect(result).toEqual({ row: 0, col: 0 }); + }); + + it('Table contains node on second row', () => { + const container = document.createElement('div') as any; + root.appendChild(container); + + const table: ParsedTable = [[], ['spanLeft', container]]; + const span = document.createElement('span'); + const text = document.createTextNode('test'); + + span.appendChild(text); + container.appendChild(span); + + const result = findCoordinate(table, text, domHelper); + + expect(result).toEqual({ row: 1, col: 1 }); + }); +}); + describe('createTableRanges', () => { function runTest( html: string, diff --git a/packages/roosterjs-content-model-types/lib/index.ts b/packages/roosterjs-content-model-types/lib/index.ts index e74dafa0d08..c5fd31168cb 100644 --- a/packages/roosterjs-content-model-types/lib/index.ts +++ b/packages/roosterjs-content-model-types/lib/index.ts @@ -232,7 +232,11 @@ export { CopyPastePluginState } from './pluginState/CopyPastePluginState'; export { DOMEventPluginState } from './pluginState/DOMEventPluginState'; export { LifecyclePluginState } from './pluginState/LifecyclePluginState'; export { EntityPluginState, KnownEntityItem } from './pluginState/EntityPluginState'; -export { SelectionPluginState } from './pluginState/SelectionPluginState'; +export { + SelectionPluginState, + TableSelectionInfo, + TableCellCoordinate, +} from './pluginState/SelectionPluginState'; export { UndoPluginState } from './pluginState/UndoPluginState'; export { PluginKey, @@ -293,6 +297,7 @@ export { export { NodeTypeMap } from './parameter/NodeTypeMap'; export { TypeOfBlockGroup } from './parameter/TypeOfBlockGroup'; export { OperationalBlocks } from './parameter/OperationalBlocks'; +export { ParsedTable, ParsedTableCell } from './parameter/ParsedTable'; export { BasePluginEvent, BasePluginDomEvent } from './event/BasePluginEvent'; export { BeforeCutCopyEvent } from './event/BeforeCutCopyEvent'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts index 0e09215976b..82b6443e17d 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts @@ -55,6 +55,18 @@ export interface DOMHelper { */ getDomStyle(style: T): CSSStyleDeclaration[T]; + /** + * Find closest element ancestor start from the given node which matches the given selector + * @param node Find ancestor start from this node + * @param selector The expected selector. If null, return the first HTML Element found from start node + * @returns An HTML element which matches the given selector. If the given start node matches the selector, + * returns the given node + */ + findClosestElementAncestor( + node: Node, + selector?: T + ): HTMLElementTagNameMap[T] | null; + /** * Find closest element ancestor start from the given node which matches the given selector * @param node Find ancestor start from this node diff --git a/packages/roosterjs-content-model-types/lib/parameter/ParsedTable.ts b/packages/roosterjs-content-model-types/lib/parameter/ParsedTable.ts new file mode 100644 index 00000000000..8cbad8b8151 --- /dev/null +++ b/packages/roosterjs-content-model-types/lib/parameter/ParsedTable.ts @@ -0,0 +1,9 @@ +/** + * Parse a table, this type represents a parsed table cell. It can be a cell element, or a string to indicate where it is spanned from + */ +export type ParsedTableCell = HTMLTableCellElement | 'spanLeft' | 'spanTop' | 'spanBoth'; + +/** + * Represents a parsed table with its table cells + */ +export type ParsedTable = ParsedTableCell[][]; diff --git a/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts index d37315b11f1..556f4ecccb3 100644 --- a/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts +++ b/packages/roosterjs-content-model-types/lib/pluginState/SelectionPluginState.ts @@ -1,5 +1,51 @@ +import type { ParsedTable } from '../parameter/ParsedTable'; import type { DOMSelection } from '../selection/DOMSelection'; +/** + * Logical coordinate of a table cell + */ +export interface TableCellCoordinate { + /** + * Row index + */ + row: number; + + /** + * Column index + */ + col: number; +} + +/** + * Table selection internal info for SelectionPlugin + */ +export interface TableSelectionInfo { + /** + * Selected table + */ + table: HTMLTableElement; + + /** + * Parsed table structure, cache this value to avoid calculating again while selecting table + */ + parsedTable: ParsedTable; + + /** + * The node where the focus is at when start selection + */ + startNode: Node; + + /** + * Coordinate for first selected table cell + */ + firstCo: TableCellCoordinate; + + /** + * Coordinate for last selected table cell + */ + lastCo?: TableCellCoordinate; +} + /** * The state object for SelectionPlugin */ @@ -9,6 +55,16 @@ export interface SelectionPluginState { */ selection: DOMSelection | null; + /** + * Table selection internal info for SelectionPlugin + */ + tableSelection: TableSelectionInfo | null; + + /** + * Disposer function for MouseMove event + */ + mouseDisposer?: () => void; + /** * When set to true, onFocus event will not trigger reselect cached range */ diff --git a/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts index 7d1a76bac2a..4fe6b77d499 100644 --- a/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts +++ b/packages/roosterjs-editor-adapter/lib/corePlugins/BridgePlugin.ts @@ -1,6 +1,7 @@ import { cacheGetEventData } from 'roosterjs-content-model-core'; import { createDarkColorHandler } from '../editor/DarkColorHandlerImpl'; import { createEditPlugin } from './EditPlugin'; +import { IgnoredPluginNames } from '../editor/IgnoredPluginNames'; import { newEventToOldEvent, oldEventToNewEvent } from '../editor/utils/eventConverter'; import type { EditorPlugin as LegacyEditorPlugin, @@ -71,7 +72,10 @@ export class BridgePlugin implements ContextMenuProvider { ) { const editPlugin = createEditPlugin(); - this.legacyPlugins = [editPlugin, ...legacyPlugins.filter(x => !!x)]; + this.legacyPlugins = [ + editPlugin, + ...legacyPlugins.filter(x => !!x && IgnoredPluginNames.indexOf(x.getName()) < 0), + ]; this.edit = editPlugin.getState(); this.contextMenuProviders = this.legacyPlugins.filter(isContextMenuProvider); this.checkExclusivelyHandling = this.legacyPlugins.some( diff --git a/packages/roosterjs-editor-adapter/lib/editor/IgnoredPluginNames.ts b/packages/roosterjs-editor-adapter/lib/editor/IgnoredPluginNames.ts new file mode 100644 index 00000000000..32b3b2b62d7 --- /dev/null +++ b/packages/roosterjs-editor-adapter/lib/editor/IgnoredPluginNames.ts @@ -0,0 +1,7 @@ +/** + * Name of plugins to be ignored. + * Plugins with these names will not be added into editor + */ +export const IgnoredPluginNames = [ + 'TableCellSelection', // Ignore TableCellSelection plugin since its functionality is already integrated into SelectionPlugin +]; diff --git a/packages/roosterjs-editor-adapter/lib/index.ts b/packages/roosterjs-editor-adapter/lib/index.ts index 89c5b8e99bc..3646c7e890c 100644 --- a/packages/roosterjs-editor-adapter/lib/index.ts +++ b/packages/roosterjs-editor-adapter/lib/index.ts @@ -2,3 +2,5 @@ export { EditorAdapterOptions } from './publicTypes/EditorAdapterOptions'; export { BeforePasteAdapterEvent } from './publicTypes/BeforePasteAdapterEvent'; export { EditorAdapter } from './editor/EditorAdapter'; + +export { IgnoredPluginNames } from './editor/IgnoredPluginNames'; diff --git a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts index a766c9f950f..482701720eb 100644 --- a/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts +++ b/packages/roosterjs-editor-adapter/test/corePlugins/BridgePluginTest.ts @@ -17,21 +17,32 @@ describe('BridgePlugin', () => { }); it('Ctor and init', () => { - const initializeSpy = jasmine.createSpy('initialize'); + const initializeSpy1 = jasmine.createSpy('initialize1'); + const initializeSpy2 = jasmine.createSpy('initialize2'); + const initializeSpy3 = jasmine.createSpy('initialize3'); const onPluginEventSpy1 = jasmine.createSpy('onPluginEvent1'); const onPluginEventSpy2 = jasmine.createSpy('onPluginEvent2'); + const onPluginEventSpy3 = jasmine.createSpy('onPluginEvent3'); const disposeSpy = jasmine.createSpy('dispose'); const queryElementsSpy = jasmine.createSpy('queryElement').and.returnValue([]); const mockedPlugin1 = { - initialize: initializeSpy, + initialize: initializeSpy1, onPluginEvent: onPluginEventSpy1, dispose: disposeSpy, + getName: () => '', } as any; const mockedPlugin2 = { - initialize: initializeSpy, + initialize: initializeSpy2, onPluginEvent: onPluginEventSpy2, dispose: disposeSpy, + getName: () => '', + } as any; + const mockedPlugin3 = { + initialize: initializeSpy3, + onPluginEvent: onPluginEventSpy3, + dispose: disposeSpy, + getName: () => 'TableCellSelection', } as any; const mockedEditor = { queryElements: queryElementsSpy, @@ -40,8 +51,11 @@ describe('BridgePlugin', () => { const plugin = new BridgePlugin.BridgePlugin(onInitializeSpy, [ mockedPlugin1, mockedPlugin2, + mockedPlugin3, ]); - expect(initializeSpy).not.toHaveBeenCalled(); + expect(initializeSpy1).not.toHaveBeenCalled(); + expect(initializeSpy2).not.toHaveBeenCalled(); + expect(initializeSpy3).not.toHaveBeenCalled(); expect(onPluginEventSpy1).not.toHaveBeenCalled(); expect(onPluginEventSpy2).not.toHaveBeenCalled(); expect(disposeSpy).not.toHaveBeenCalled(); @@ -76,9 +90,12 @@ describe('BridgePlugin', () => { contextMenuProviders: [], } as any); expect(createDarkColorHandlerSpy).toHaveBeenCalledWith(mockedInnerDarkColorHandler); - expect(initializeSpy).toHaveBeenCalledTimes(2); + expect(initializeSpy1).toHaveBeenCalledTimes(1); + expect(initializeSpy2).toHaveBeenCalledTimes(1); + expect(initializeSpy3).toHaveBeenCalledTimes(0); expect(disposeSpy).not.toHaveBeenCalled(); - expect(initializeSpy).toHaveBeenCalledWith(mockedEditor); + expect(initializeSpy1).toHaveBeenCalledWith(mockedEditor); + expect(initializeSpy2).toHaveBeenCalledWith(mockedEditor); expect(onPluginEventSpy1).not.toHaveBeenCalled(); expect(onPluginEventSpy2).not.toHaveBeenCalled(); @@ -111,11 +128,13 @@ describe('BridgePlugin', () => { initialize: initializeSpy, onPluginEvent: onPluginEventSpy1, dispose: disposeSpy, + getName: () => '', } as any; const mockedPlugin2 = { initialize: initializeSpy, onPluginEvent: onPluginEventSpy2, dispose: disposeSpy, + getName: () => '', } as any; const mockedEditor = { queryElements: queryElementsSpy, @@ -198,12 +217,14 @@ describe('BridgePlugin', () => { initialize: initializeSpy, onPluginEvent: onPluginEventSpy1, dispose: disposeSpy, + getName: () => '', } as any; const mockedPlugin2 = { initialize: initializeSpy, onPluginEvent: onPluginEventSpy2, willHandleEventExclusively: willHandleEventExclusivelySpy, dispose: disposeSpy, + getName: () => '', } as any; const mockedEditor = 'EDITOR' as any; const onInitializeSpy = jasmine.createSpy('onInitialize').and.returnValue(mockedEditor); @@ -246,11 +267,13 @@ describe('BridgePlugin', () => { initialize: initializeSpy, onPluginEvent: onPluginEventSpy1, dispose: disposeSpy, + getName: () => '', } as any; const mockedPlugin2 = { initialize: initializeSpy, onPluginEvent: onPluginEventSpy2, dispose: disposeSpy, + getName: () => '', } as any; const mockedEditor = 'EDITOR' as any; @@ -328,11 +351,13 @@ describe('BridgePlugin', () => { initialize: initializeSpy, onPluginEvent: onPluginEventSpy1, dispose: disposeSpy, + getName: () => '', } as any; const mockedPlugin2 = { initialize: initializeSpy, onPluginEvent: onPluginEventSpy2, dispose: disposeSpy, + getName: () => '', } as any; const mockedEditor = 'EDITOR' as any; @@ -391,12 +416,14 @@ describe('BridgePlugin', () => { onPluginEvent: onPluginEventSpy1, dispose: disposeSpy, getContextMenuItems: getContextMenuItemsSpy1, + getName: () => '', } as any; const mockedPlugin2 = { initialize: initializeSpy, onPluginEvent: onPluginEventSpy2, dispose: disposeSpy, getContextMenuItems: getContextMenuItemsSpy2, + getName: () => '', } as any; const mockedEditor = { queryElements: queryElementsSpy, From 1c50878fc529ae209bc6d81d1dec51d3a5ae58d1 Mon Sep 17 00:00:00 2001 From: florian-msft <87671048+florian-msft@users.noreply.github.com> Date: Wed, 20 Mar 2024 10:05:31 -0700 Subject: [PATCH 24/73] Add setLogicalRoot API (#2492) * Add setLogicalRoot API * Remove stray comment * Fix tests * Respond to PR comments * Move getPath from merge * Add tests and respond to PR comments * Fix deps * Move around some code * Changes * Whoops * Whoops * Allow undefined logicalRootPath --------- Co-authored-by: Jiuqing Song --- .../sidePane/snapshot/SnapshotPane.scss | 4 + .../sidePane/snapshot/SnapshotPane.tsx | 16 +- .../addUndoSnapshot/addUndoSnapshot.ts | 48 +++++- .../createSnapshotSelection.ts | 63 +------- .../lib/coreApi/addUndoSnapshot/getPath.ts | 63 ++++++++ .../lib/coreApi/coreApiMap.ts | 4 +- .../getPositionFromPath.ts | 29 ++++ .../restoreSnapshotLogicalRoot.ts | 17 ++ .../restoreSnapshotSelection.ts | 112 ++++++-------- .../restoreUndoSnapshot.ts | 4 +- .../coreApi/setLogicalRoot/setLogicalRoot.ts | 40 +++++ .../lib/editor/Editor.ts | 10 ++ .../addUndoSnapshot/addUndoSnapshotTest.ts | 92 ++++++++++- .../restoreSnapshotLogicalRootTest.ts | 93 +++++++++++ .../restoreUndoSnapshotTest.ts | 5 + .../setLogicalRoot/setLogicalRootTest.ts | 145 ++++++++++++++++++ .../lib/domUtils/entityUtils.ts | 11 ++ .../roosterjs-content-model-dom/lib/index.ts | 1 + .../lib/editor/EditorCore.ts | 14 ++ .../lib/editor/IEditor.ts | 7 + .../lib/enum/EntityOperation.ts | 7 + .../lib/event/LogicalRootChangedEvent.ts | 11 ++ .../lib/event/PluginEvent.ts | 8 +- .../lib/event/PluginEventType.ts | 6 + .../lib/index.ts | 2 + .../lib/parameter/Snapshot.ts | 5 + .../lib/editor/utils/eventConverter.ts | 26 ++-- 27 files changed, 695 insertions(+), 148 deletions(-) create mode 100644 packages/roosterjs-content-model-core/lib/coreApi/addUndoSnapshot/getPath.ts create mode 100644 packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/getPositionFromPath.ts create mode 100644 packages/roosterjs-content-model-core/lib/coreApi/restoreUndoSnapshot/restoreSnapshotLogicalRoot.ts create mode 100644 packages/roosterjs-content-model-core/lib/coreApi/setLogicalRoot/setLogicalRoot.ts create mode 100644 packages/roosterjs-content-model-core/test/coreApi/restoreUndoSnapshot/restoreSnapshotLogicalRootTest.ts create mode 100644 packages/roosterjs-content-model-core/test/coreApi/setLogicalRoot/setLogicalRootTest.ts create mode 100644 packages/roosterjs-content-model-types/lib/event/LogicalRootChangedEvent.ts diff --git a/demo/scripts/controlsV2/sidePane/snapshot/SnapshotPane.scss b/demo/scripts/controlsV2/sidePane/snapshot/SnapshotPane.scss index 7a97368159d..246c2c4142a 100644 --- a/demo/scripts/controlsV2/sidePane/snapshot/SnapshotPane.scss +++ b/demo/scripts/controlsV2/sidePane/snapshot/SnapshotPane.scss @@ -18,6 +18,10 @@ border-color: $primaryBorder; } +.input { + border-color: $primaryBorder; +} + .snapshotList { min-height: 100px; max-height: 200px; diff --git a/demo/scripts/controlsV2/sidePane/snapshot/SnapshotPane.tsx b/demo/scripts/controlsV2/sidePane/snapshot/SnapshotPane.tsx index 275d2fe6670..d503be95e52 100644 --- a/demo/scripts/controlsV2/sidePane/snapshot/SnapshotPane.tsx +++ b/demo/scripts/controlsV2/sidePane/snapshot/SnapshotPane.tsx @@ -20,6 +20,7 @@ export class SnapshotPane extends React.Component(); private isDarkColor = React.createRef(); private selection = React.createRef(); + private logicalRootPath = React.createRef(); constructor(props: SnapshotPaneProps) { super(props); @@ -50,6 +51,8 @@ export class SnapshotPane extends React.Component
Entity states: