diff --git a/demo/scripts/controls/ContentModelEditorMainPane.tsx b/demo/scripts/controls/ContentModelEditorMainPane.tsx index 9bbc2a0b77d..2117597746d 100644 --- a/demo/scripts/controls/ContentModelEditorMainPane.tsx +++ b/demo/scripts/controls/ContentModelEditorMainPane.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import ApiPlaygroundPlugin from './sidePane/apiPlayground/ApiPlaygroundPlugin'; +import ApiPlaygroundPlugin from './sidePane/contentModelApiPlayground/ApiPlaygroundPlugin'; import ContentModelEditorOptionsPlugin from './sidePane/editorOptions/ContentModelEditorOptionsPlugin'; import ContentModelFormatPainterPlugin from './contentModel/plugins/ContentModelFormatPainterPlugin'; import ContentModelFormatStatePlugin from './sidePane/formatState/ContentModelFormatStatePlugin'; diff --git a/demo/scripts/controls/contentModel/components/format/formatPart/FloatFormatRenderer.ts b/demo/scripts/controls/contentModel/components/format/formatPart/FloatFormatRenderer.ts new file mode 100644 index 00000000000..0b29f44b958 --- /dev/null +++ b/demo/scripts/controls/contentModel/components/format/formatPart/FloatFormatRenderer.ts @@ -0,0 +1,11 @@ +import { createTextFormatRenderer } from '../utils/createTextFormatRenderer'; +import { FloatFormat } from 'roosterjs-content-model-types'; +import { FormatRenderer } from '../utils/FormatRenderer'; + +export const FloatFormatRenderer: FormatRenderer = createTextFormatRenderer< + FloatFormat +>( + 'Float', + format => format.float, + (format, value) => (format.float = value) +); diff --git a/demo/scripts/controls/contentModel/components/model/ContentModelImageView.tsx b/demo/scripts/controls/contentModel/components/model/ContentModelImageView.tsx index e00fd88d057..948a6390a1a 100644 --- a/demo/scripts/controls/contentModel/components/model/ContentModelImageView.tsx +++ b/demo/scripts/controls/contentModel/components/model/ContentModelImageView.tsx @@ -3,6 +3,7 @@ import { ContentModelCodeView } from './ContentModelCodeView'; import { ContentModelImage, ContentModelImageFormat } from 'roosterjs-content-model-types'; import { ContentModelLinkView } from './ContentModelLinkView'; import { ContentModelView } from '../ContentModelView'; +import { FloatFormatRenderer } from '../format/formatPart/FloatFormatRenderer'; import { FormatRenderer } from '../format/utils/FormatRenderer'; import { FormatView } from '../format/FormatView'; import { IdFormatRenderer } from '../format/formatPart/IdFormatRenderer'; @@ -22,6 +23,7 @@ const ImageFormatRenderers: FormatRenderer[] = [ ...SizeFormatRenderers, MarginFormatRenderer, PaddingFormatRenderer, + FloatFormatRenderer, ]; export function ContentModelImageView(props: { image: ContentModelImage }) { diff --git a/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts b/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts index 22d7a9a5691..e9f2fee28a9 100644 --- a/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts +++ b/demo/scripts/controls/contentModel/plugins/ContentModelFormatPainterPlugin.ts @@ -2,7 +2,7 @@ import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { EditorPlugin, IEditor, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; import { applySegmentFormat, - getSegmentFormat, + getFormatState, IContentModelEditor, } from 'roosterjs-content-model-editor'; @@ -53,13 +53,13 @@ export default class ContentModelFormatPainterPlugin implements EditorPlugin { } } -function getFormatHolder(editor: IEditor): FormatPainterFormatHolder { +function getFormatHolder(editor: IContentModelEditor): FormatPainterFormatHolder { return editor.getCustomData('__FormatPainterFormat', () => { return {} as FormatPainterFormatHolder; }); } -function setFormatPainterCursor(editor: IEditor, isOn: boolean) { +function setFormatPainterCursor(editor: IContentModelEditor, isOn: boolean) { let styles = editor.getEditorDomAttribute('style') || ''; styles = styles.replace(CURSOR_REGEX, ''); @@ -69,3 +69,24 @@ function setFormatPainterCursor(editor: IEditor, isOn: boolean) { editor.setEditorDomAttribute('style', styles); } + +function getSegmentFormat(editor: IContentModelEditor): ContentModelSegmentFormat { + const formatState = getFormatState(editor); + + return { + backgroundColor: formatState.backgroundColor, + fontFamily: formatState.fontName, + fontSize: formatState.fontSize, + fontWeight: formatState.isBold ? 'bold' : 'normal', + italic: formatState.isItalic, + letterSpacing: formatState.letterSpacing, + strikethrough: formatState.isStrikeThrough, + superOrSubScriptSequence: formatState.isSubscript + ? 'sub' + : formatState.isSuperscript + ? 'super' + : '', + textColor: formatState.textColor, + underline: formatState.isUnderline, + }; +} diff --git a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx index 8313c34391f..a30c6ccba48 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx +++ b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbon.tsx @@ -37,7 +37,7 @@ import { removeLinkButton } from './removeLinkButton'; import { Ribbon, RibbonButton, RibbonPlugin } from 'roosterjs-react'; import { rtlButton } from './rtlButton'; import { setBulletedListStyleButton } from './setBulletedListStyleButton'; -import { setHeaderLevelButton } from './setHeaderLevelButton'; +import { setHeadingLevelButton } from './setHeadingLevelButton'; import { setNumberedListStyleButton } from './setNumberedListStyleButton'; import { setTableCellShadeButton } from './setTableCellShadeButton'; import { setTableHeaderButton } from './setTableHeaderButton'; @@ -84,7 +84,7 @@ const buttons = [ superscriptButton, subscriptButton, strikethroughButton, - setHeaderLevelButton, + setHeadingLevelButton, codeButton, ltrButton, rtlButton, diff --git a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts index c1d2ce4f978..dd36d92f4c8 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/ContentModelRibbonPlugin.ts @@ -1,13 +1,17 @@ -import { FormatState, PluginEvent, PluginEventType } from 'roosterjs-editor-types'; -import { getFormatState, IContentModelEditor } from 'roosterjs-content-model-editor'; import { getObjectKeys } from 'roosterjs-editor-dom'; import { LocalizedStrings, RibbonButton, RibbonPlugin, UIUtilities } from 'roosterjs-react'; +import { PluginEvent, PluginEventType } from 'roosterjs-editor-types'; +import { + ContentModelFormatState, + getFormatState, + IContentModelEditor, +} from 'roosterjs-content-model-editor'; export class ContentModelRibbonPlugin implements RibbonPlugin { private editor: IContentModelEditor | null = null; - private onFormatChanged: ((formatState: FormatState) => void) | null = null; + private onFormatChanged: ((formatState: ContentModelFormatState) => void) | null = null; private timer = 0; - private formatState: FormatState | null = null; + private formatState: ContentModelFormatState | null = null; private uiUtilities: UIUtilities | null = null; /** @@ -68,7 +72,7 @@ export class ContentModelRibbonPlugin implements RibbonPlugin { /** * Register a callback to be invoked when format state of editor is changed, returns a disposer function. */ - registerFormatChangedCallback(callback: (formatState: FormatState) => void) { + registerFormatChangedCallback(callback: (formatState: ContentModelFormatState) => void) { this.onFormatChanged = callback; return () => { diff --git a/demo/scripts/controls/ribbonButtons/contentModel/insertLinkButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/insertLinkButton.ts index 90f7ead23ed..e8f971511dc 100644 --- a/demo/scripts/controls/ribbonButtons/contentModel/insertLinkButton.ts +++ b/demo/scripts/controls/ribbonButtons/contentModel/insertLinkButton.ts @@ -1,5 +1,5 @@ -import showInputDialog from 'roosterjs-react/lib/inputDialog/utils/showInputDialog'; import { InsertLinkButtonStringKey, RibbonButton } from 'roosterjs-react'; +import { showInputDialog } from 'roosterjs-react/lib/inputDialog'; import { adjustLinkSelection, insertLink, diff --git a/demo/scripts/controls/ribbonButtons/contentModel/setHeaderLevelButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/setHeaderLevelButton.ts deleted file mode 100644 index d251e5d27f0..00000000000 --- a/demo/scripts/controls/ribbonButtons/contentModel/setHeaderLevelButton.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { isContentModelEditor, setHeaderLevel } from 'roosterjs-content-model-editor'; -import { - getButtons, - HeaderButtonStringKey, - KnownRibbonButtonKey, - RibbonButton, -} from 'roosterjs-react'; - -const originalHeadersButton: RibbonButton = getButtons([ - KnownRibbonButtonKey.Header, -])[0] as RibbonButton; -const keys: HeaderButtonStringKey[] = [ - 'buttonNameNoHeader', - 'buttonNameHeader1', - 'buttonNameHeader2', - 'buttonNameHeader3', - 'buttonNameHeader4', - 'buttonNameHeader5', - 'buttonNameHeader6', -]; - -export const setHeaderLevelButton: RibbonButton = { - dropDownMenu: { - ...originalHeadersButton.dropDownMenu, - }, - key: 'buttonNameHeader', - unlocalizedText: 'Header', - iconName: 'Header1', - onClick: (editor, key) => { - const headerLevel = keys.indexOf(key); - - if (isContentModelEditor(editor) && headerLevel >= 0) { - setHeaderLevel(editor, headerLevel as 0 | 1 | 2 | 3 | 4 | 5 | 6); - } - }, -}; diff --git a/demo/scripts/controls/ribbonButtons/contentModel/setHeadingLevelButton.ts b/demo/scripts/controls/ribbonButtons/contentModel/setHeadingLevelButton.ts new file mode 100644 index 00000000000..03c44f0dbb7 --- /dev/null +++ b/demo/scripts/controls/ribbonButtons/contentModel/setHeadingLevelButton.ts @@ -0,0 +1,36 @@ +import { isContentModelEditor, setHeadingLevel } from 'roosterjs-content-model-editor'; +import { + getButtons, + HeadingButtonStringKey, + KnownRibbonButtonKey, + RibbonButton, +} from 'roosterjs-react'; + +const originalHeadingButton: RibbonButton = getButtons([ + KnownRibbonButtonKey.Heading, +])[0] as RibbonButton; +const keys: HeadingButtonStringKey[] = [ + 'buttonNameNoHeading', + 'buttonNameHeading1', + 'buttonNameHeading2', + 'buttonNameHeading3', + 'buttonNameHeading4', + 'buttonNameHeading5', + 'buttonNameHeading6', +]; + +export const setHeadingLevelButton: RibbonButton = { + dropDownMenu: { + ...originalHeadingButton.dropDownMenu, + }, + key: 'buttonNameHeading', + unlocalizedText: 'Heading', + iconName: 'Header1', + onClick: (editor, key) => { + const headingLevel = keys.indexOf(key); + + if (isContentModelEditor(editor) && headingLevel >= 0) { + setHeadingLevel(editor, headingLevel as 0 | 1 | 2 | 3 | 4 | 5 | 6); + } + }, +}; diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPaneProps.ts b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPaneProps.ts new file mode 100644 index 00000000000..434149b960c --- /dev/null +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPaneProps.ts @@ -0,0 +1,10 @@ +import { IEditor, PluginEvent } from 'roosterjs-editor-types'; +import { SidePaneElementProps } from '../SidePaneElement'; + +export default interface ApiPaneProps extends SidePaneElementProps { + getEditor: () => IEditor; +} + +export interface ApiPlaygroundComponent { + onPluginEvent?: (e: PluginEvent) => void; +} diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPane.scss b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPane.scss new file mode 100644 index 00000000000..f9cce7e9015 --- /dev/null +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPane.scss @@ -0,0 +1,4 @@ +.header { + flex: 0 0 auto; + padding-bottom: 5px; +} diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPane.tsx b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPane.tsx new file mode 100644 index 00000000000..52c3bd3ee26 --- /dev/null +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPane.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import apiEntries, { ApiPlaygroundReactComponent } from './apiEntries'; +import ApiPaneProps from './ApiPaneProps'; +import { getObjectKeys } from 'roosterjs-editor-dom'; +import { PluginEvent } from 'roosterjs-editor-types'; +import { SidePaneElement } from '../SidePaneElement'; + +const styles = require('./ApiPlaygroundPane.scss'); + +export interface ApiPlaygroundPaneState { + current: string; +} + +export default class ApiPlaygroundPane extends React.Component + implements SidePaneElement { + private select = React.createRef(); + private pane = React.createRef(); + constructor(props: ApiPaneProps) { + super(props); + this.state = { current: 'empty' }; + } + + render() { + let componentClass = apiEntries[this.state.current].component; + let pane: JSX.Element = null; + if (componentClass) { + pane = React.createElement(componentClass, { ...this.props, ref: this.pane }); + } + + return ( + <> +
+

Select an API to try

+ + +
+ {pane} + + ); + } + + onPluginEvent(e: PluginEvent) { + if (this.pane.current && this.pane.current.onPluginEvent) { + this.pane.current.onPluginEvent(e); + } + } + + setHashPath(path: string[]) { + let paneName = path && getObjectKeys(apiEntries).indexOf(path[0]) >= 0 ? path[0] : null; + + if (paneName && paneName != this.state.current) { + this.setState({ + current: paneName, + }); + } else { + this.props.updateHash(null, [this.state.current]); + } + } + + private onChange = () => { + this.props.updateHash(null, [this.select.current.value]); + }; +} diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPlugin.ts b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPlugin.ts new file mode 100644 index 00000000000..2d503575e6f --- /dev/null +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/ApiPlaygroundPlugin.ts @@ -0,0 +1,27 @@ +import ApiPaneProps from './ApiPaneProps'; +import ApiPlaygroundPane from './ApiPlaygroundPane'; +import SidePanePluginImpl from '../SidePanePluginImpl'; +import { PluginEvent } from 'roosterjs-editor-types'; +import { SidePaneElementProps } from '../SidePaneElement'; + +export default class ApiPlaygroundPlugin extends SidePanePluginImpl< + ApiPlaygroundPane, + ApiPaneProps +> { + constructor() { + super(ApiPlaygroundPane, 'api', 'API Playground'); + } + + getComponentProps(base: SidePaneElementProps) { + return { + ...base, + getEditor: () => { + return this.editor; + }, + }; + } + + onPluginEvent(e: PluginEvent) { + this.getComponent(component => component.onPluginEvent(e)); + } +} diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/apiEntries.ts b/demo/scripts/controls/sidePane/contentModelApiPlayground/apiEntries.ts new file mode 100644 index 00000000000..4bd35162a73 --- /dev/null +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/apiEntries.ts @@ -0,0 +1,27 @@ +import * as React from 'react'; +import ApiPaneProps, { ApiPlaygroundComponent } from './ApiPaneProps'; +import InsertEntityPane from './insertEntity/InsertEntityPane'; + +export interface ApiPlaygroundReactComponent + extends React.Component, + ApiPlaygroundComponent {} + +interface ApiEntry { + name: string; + component?: { new (prpos: ApiPaneProps): ApiPlaygroundReactComponent }; +} + +const apiEntries: { [key: string]: ApiEntry } = { + empty: { + name: 'Please select', + }, + entity: { + name: 'Insert Entity', + component: InsertEntityPane, + }, + more: { + name: 'Coming soon...', + }, +}; + +export default apiEntries; diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.scss b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.scss new file mode 100644 index 00000000000..7ba4da96ccd --- /dev/null +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.scss @@ -0,0 +1,6 @@ +.textarea { + outline: none; + resize: none; + min-height: 40px; + width: 90%; +} diff --git a/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx new file mode 100644 index 00000000000..5fbebf4588d --- /dev/null +++ b/demo/scripts/controls/sidePane/contentModelApiPlayground/insertEntity/InsertEntityPane.tsx @@ -0,0 +1,177 @@ +import * as React from 'react'; +import ApiPaneProps from '../ApiPaneProps'; +import { Entity } from 'roosterjs-editor-types'; +import { getEntityFromElement, getEntitySelector } from 'roosterjs-editor-dom'; +import { trustedHTMLHandler } from '../../../../utils/trustedHTMLHandler'; +import { + IContentModelEditor, + insertEntity, + InsertEntityOptions, +} from 'roosterjs-content-model-editor'; + +const styles = require('./InsertEntityPane.scss'); + +interface InsertEntityPaneState { + entities: Entity[]; +} + +export default class InsertEntityPane extends React.Component { + private entityType = React.createRef(); + private html = React.createRef(); + private styleInline = React.createRef(); + private styleBlock = React.createRef(); + private focusAfterEntity = React.createRef(); + + private posFocus = React.createRef(); + private posTop = React.createRef(); + private posBottom = React.createRef(); + private posRegionRoot = React.createRef(); + + constructor(props: ApiPaneProps) { + super(props); + this.state = { + entities: [], + }; + } + + render() { + return ( + <> +
+ Type: +
+
+ HTML: +
+
+ Style: + + + + +
+
+ Position: +
+ + +
+ + +
+ + +
+ + +
+
+
+ + +
+
+ +
+
+
+ +
+
+ {this.state.entities.map(entity => ( + + ))} +
+ + ); + } + + private insertEntity = () => { + const entityType = this.entityType.current.value; + const node = document.createElement('span'); + node.innerHTML = trustedHTMLHandler(this.html.current.value); + const isBlock = this.styleBlock.current.checked; + const focusAfterEntity = this.focusAfterEntity.current.checked; + const insertAtTop = this.posTop.current.checked; + const insertAtBottom = this.posBottom.current.checked; + const insertAtRoot = this.posRegionRoot.current.checked; + + if (node) { + const editor = this.props.getEditor(); + + editor.addUndoSnapshot(() => { + const options: InsertEntityOptions = { + contentNode: node, + focusAfterEntity: focusAfterEntity, + }; + + if (isBlock) { + insertEntity( + editor as IContentModelEditor, + entityType, + true, + insertAtRoot + ? 'root' + : insertAtTop + ? 'begin' + : insertAtBottom + ? 'end' + : 'focus', + options + ); + } else { + insertEntity( + editor as IContentModelEditor, + entityType, + isBlock, + insertAtTop ? 'begin' : insertAtBottom ? 'end' : 'focus', + options + ); + } + }); + } + }; + + private onGetEntities = () => { + const selector = getEntitySelector(); + const nodes = this.props.getEditor().queryElements(selector); + const allEntities = nodes.map(node => getEntityFromElement(node)); + + this.setState({ + entities: allEntities.filter(e => !!e), + }); + }; +} + +function EntityButton({ entity }: { entity: Entity }) { + let background = ''; + const onMouseOver = React.useCallback(() => { + background = entity.wrapper.style.backgroundColor; + entity.wrapper.style.backgroundColor = 'blue'; + }, [entity]); + + const onMouseOut = React.useCallback(() => { + entity.wrapper.style.backgroundColor = background; + }, [entity]); + + return ( +
+ Type: {entity.type} +
+ Id: {entity.id} +
+ Readonly: {entity.isReadonly ? 'True' : 'False'} +
+
+ ); +} diff --git a/demo/scripts/controls/sidePane/formatState/FormatStatePane.tsx b/demo/scripts/controls/sidePane/formatState/FormatStatePane.tsx index b7964758f41..32b82e43621 100644 --- a/demo/scripts/controls/sidePane/formatState/FormatStatePane.tsx +++ b/demo/scripts/controls/sidePane/formatState/FormatStatePane.tsx @@ -83,8 +83,8 @@ export default class FormatStatePane extends React.Component< {this.renderSpan(format.tableHasHeader, 'Table Has Header')} {`Header ${format.headerLevel}`} + format.headingLevel == 0 && styles.inactive + }>{`Heading ${format.headingLevel}`} diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts index 3724793dfb7..b6e1c77bf07 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/createDomToModelContext.ts @@ -55,7 +55,6 @@ export function createDomToModelContext( defaultElementProcessors: defaultProcessorMap, defaultFormatParsers: defaultFormatParsers, - allowCacheElement: !options?.disableCacheElement, }; if (editorContext?.isRootRtl) { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts index a6d27bb0ab4..631dc2a3f51 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/context/defaultProcessors.ts @@ -45,6 +45,7 @@ export const defaultProcessorMap: ElementProcessorMap = { p: pProcessor, pre: formatContainerProcessor, s: knownElementProcessor, + section: knownElementProcessor, span: knownElementProcessor, strike: knownElementProcessor, strong: knownElementProcessor, diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts index 3ca9318e4ef..f3b8a842568 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/entityProcessor.ts @@ -23,22 +23,7 @@ export const entityProcessor: ElementProcessor = (group, element, c context, { segment: isBlockEntity ? 'empty' : undefined, paragraph: 'empty' }, () => { - const wrapperToUse = context.allowCacheElement - ? element - : (element.cloneNode(true /* deep */) as HTMLElement); - - if (!context.allowCacheElement) { - wrapperToUse.style.backgroundColor = element.style.backgroundColor || 'inherit'; - wrapperToUse.style.color = element.style.color || 'inherit'; - } - - const entityModel = createEntity( - wrapperToUse, - isReadonly, - context.segmentFormat, - id, - type - ); + const entityModel = createEntity(element, isReadonly, type, context.segmentFormat, id); // TODO: Need to handle selection for editable entity if (context.isInSelection) { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/headingProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/headingProcessor.ts index b097a834cea..2183c717f8c 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/headingProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/headingProcessor.ts @@ -20,8 +20,8 @@ export const headingProcessor: ElementProcessor = (group, el parseFormat(element, context.formatParsers.segmentOnBlock, segmentFormat, context); // These formats are already declared on heading element, no need to keep them in context. - // And we should not duplicate them in context, either. Because when we want to turn off header, - // inner text should not keep those text format from header. + // And we should not duplicate them in context, either. Because when we want to turn off heading, + // inner text should not keep those text format from heading. getObjectKeys(segmentFormat).forEach(key => { delete context.segmentFormat[key]; }); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts index 4000189240d..80711b09a80 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/domToModel/processors/tableProcessor.ts @@ -3,6 +3,7 @@ import { createTable } from '../../modelApi/creators/createTable'; import { createTableCell } from '../../modelApi/creators/createTableCell'; import { getBoundingClientRect } from '../utils/getBoundingClientRect'; import { parseFormat } from '../utils/parseFormat'; +import { safeInstanceOf } from 'roosterjs-editor-dom'; import { SelectionRangeTypes } from 'roosterjs-editor-types'; import { stackFormat } from '../utils/stackFormat'; import { @@ -74,7 +75,11 @@ export const tableProcessor: ElementProcessor = ( const tr = tableElement.rows[row]; const tableRow = table.rows[row]; - if (context.allowCacheElement) { + const tbody = tr.parentNode; + + if (safeInstanceOf(tbody, 'HTMLTableSectionElement')) { + parseFormat(tbody, context.formatParsers.tableRow, tableRow.format, context); + } else if (context.allowCacheElement) { tableRow.cachedElement = tr; } @@ -235,6 +240,17 @@ export const tableProcessor: ElementProcessor = ( ); } }); + + for (let col = 0; col < tableRow.cells.length; col++) { + if (!tableRow.cells[col]) { + tableRow.cells[col] = createTableCell( + false, + false, + false, + context.blockFormat + ); + } + } } table.widths = calcSizes(columnPositions); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/floatFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/floatFormatHandler.ts new file mode 100644 index 00000000000..538e174ab89 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/floatFormatHandler.ts @@ -0,0 +1,20 @@ +import { FloatFormat } from 'roosterjs-content-model-types'; +import { FormatHandler } from '../FormatHandler'; + +/** + * @internal + */ +export const floatFormatHandler: FormatHandler = { + parse: (format, element) => { + const float = element.style.float || element.getAttribute('align'); + + if (float) { + format.float = float; + } + }, + apply: (format, element) => { + if (format.float) { + element.style.float = format.float; + } + }, +}; diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/verticalAlignFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/verticalAlignFormatHandler.ts index a98131bb560..8a47f8455e4 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/verticalAlignFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/common/verticalAlignFormatHandler.ts @@ -22,6 +22,10 @@ export const verticalAlignFormatHandler: FormatHandler = { case 'bottom': format.verticalAlign = 'bottom'; break; + + case 'middle': + format.verticalAlign = 'middle'; + break; } }, apply: (format, element) => { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts index 9748ea9a197..010fb3b5dba 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/defaultFormatHandlers.ts @@ -6,6 +6,7 @@ import { boxShadowFormatHandler } from './common/boxShadowFormatHandler'; import { datasetFormatHandler } from './common/datasetFormatHandler'; import { directionFormatHandler } from './block/directionFormatHandler'; import { displayFormatHandler } from './block/displayFormatHandler'; +import { floatFormatHandler } from './common/floatFormatHandler'; import { fontFamilyFormatHandler } from './segment/fontFamilyFormatHandler'; import { fontSizeFormatHandler } from './segment/fontSizeFormatHandler'; import { FormatHandler } from './FormatHandler'; @@ -58,6 +59,7 @@ const defaultFormatHandlerMap: FormatHandlers = { dataset: datasetFormatHandler, direction: directionFormatHandler, display: displayFormatHandler, + float: floatFormatHandler, fontFamily: fontFamilyFormatHandler, fontSize: fontSizeFormatHandler, htmlAlign: htmlAlignFormatHandler, @@ -164,7 +166,18 @@ const defaultFormatKeysPerCategory: { ], tableBorder: ['borderBox', 'tableSpacing'], tableCellBorder: ['borderBox'], - image: ['id', 'size', 'margin', 'padding', 'borderBox', 'border', 'boxShadow', 'display'], + image: [ + 'id', + 'size', + 'margin', + 'padding', + 'borderBox', + 'border', + 'boxShadow', + 'display', + 'float', + 'verticalAlign', + ], link: [ 'link', 'textColor', diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts index 4b5405630a0..bccfa63fd0e 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/segment/fontSizeFormatHandler.ts @@ -1,6 +1,7 @@ import { FontSizeFormat } from 'roosterjs-content-model-types'; import { FormatHandler } from '../FormatHandler'; import { isSuperOrSubScript } from './superOrSubScriptFormatHandler'; +import { parseValueWithUnit } from '../utils/parseValueWithUnit'; /** * @internal @@ -13,7 +14,11 @@ export const fontSizeFormatHandler: FormatHandler = { // when font size is 'smaller' and the style is for superscript/subscript, // the font size will be handled by superOrSubScript handler if (fontSize && !isSuperOrSubScript(fontSize, verticalAlign) && fontSize != 'inherit') { - format.fontSize = fontSize; + if (element.style.fontSize) { + format.fontSize = normalizeFontSize(fontSize, context.segmentFormat.fontSize); + } else if (defaultStyle.fontSize) { + format.fontSize = fontSize; + } } }, apply: (format, element, context) => { @@ -22,3 +27,49 @@ export const fontSizeFormatHandler: FormatHandler = { } }, }; + +// https://developer.mozilla.org/en-US/docs/Web/CSS/font-size +const KnownFontSizes: Record = { + 'xx-small': '6.75pt', + 'x-small': '7.5pt', + small: '9.75pt', + medium: '12pt', + large: '13.5pt', + 'x-large': '18pt', + 'xx-large': '24pt', + 'xxx-large': '36pt', +}; + +function normalizeFontSize(fontSize: string, contextFont: string | undefined): string | undefined { + const knownFontSize = KnownFontSizes[fontSize]; + + if (knownFontSize) { + return knownFontSize; + } else if ( + fontSize == 'smaller' || + fontSize == 'larger' || + fontSize.endsWith('em') || + fontSize.endsWith('%') + ) { + if (!contextFont) { + return undefined; + } else { + const existingFontSize = parseValueWithUnit(contextFont, undefined /*element*/, 'px'); + + if (existingFontSize) { + switch (fontSize) { + case 'smaller': + return Math.round((existingFontSize * 500) / 6) / 100 + 'px'; + case 'larger': + return Math.round((existingFontSize * 600) / 5) / 100 + 'px'; + default: + return parseValueWithUnit(fontSize, existingFontSize, 'px') + 'px'; + } + } + } + } else if (fontSize == 'inherit' || fontSize == 'revert' || fontSize == 'unset') { + return undefined; + } else { + return fontSize; + } +} diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/defaultStyles.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/defaultStyles.ts index 71bffc02c5a..7eecb8fd775 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/defaultStyles.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/defaultStyles.ts @@ -161,7 +161,7 @@ export const defaultImplicitFormatMap: DefaultImplicitFormatMap = { }, h4: { fontWeight: 'bold', - fontSize: '1em', // Set this default value here to overwrite existing font size when change header level + fontSize: '1em', // Set this default value here to overwrite existing font size when change heading level }, h5: { fontWeight: 'bold', diff --git a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts index 72f61e137cb..ab12c0710ec 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/formatHandlers/utils/parseValueWithUnit.ts @@ -5,12 +5,12 @@ const MarginValueRegex = /(-?\d+(\.\d+)?)([a-z]+|%)/; /** * Parse unit value with its unit * @param value The source value to parse - * @param element The source element which has this unit value. + * @param currentSizePxOrElement The source element which has this unit value, or current font size (in px) from context. * @param resultUnit Unit for result, can be px or pt. @default px */ export function parseValueWithUnit( value: string = '', - element?: HTMLElement, + currentSizePxOrElement?: number | HTMLElement, resultUnit: 'px' | 'pt' = 'px' ): number { const match = MarginValueRegex.exec(value); @@ -28,13 +28,13 @@ export function parseValueWithUnit( result = ptToPx(num); break; case 'em': - result = element ? getFontSize(element) * num : 0; + result = getFontSize(currentSizePxOrElement) * num; break; case 'ex': - result = element ? (getFontSize(element) * num) / 2 : 0; + result = (getFontSize(currentSizePxOrElement) * num) / 2; break; case '%': - result = element ? (element.offsetWidth * num) / 100 : 0; + result = (getFontSize(currentSizePxOrElement) * num) / 100; break; default: // TODO: Support more unit if need @@ -49,12 +49,18 @@ export function parseValueWithUnit( return result; } -function getFontSize(element: HTMLElement) { - const styleInPt = getComputedStyle(element, 'font-size'); - const floatInPt = parseFloat(styleInPt); - const floatInPx = ptToPx(floatInPt); +function getFontSize(currentSizeOrElement?: number | HTMLElement): number { + if (typeof currentSizeOrElement === 'undefined') { + return 0; + } else if (typeof currentSizeOrElement === 'number') { + return currentSizeOrElement; + } else { + const styleInPt = getComputedStyle(currentSizeOrElement, 'font-size'); + const floatInPt = parseFloat(styleInPt); + const floatInPx = ptToPx(floatInPt); - return floatInPx; + return floatInPx; + } } function ptToPx(pt: number): number { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEntity.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEntity.ts index 45b9703b9c1..a02dbed9899 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEntity.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelApi/creators/createEntity.ts @@ -4,23 +4,21 @@ import { ContentModelEntity, ContentModelSegmentFormat } from 'roosterjs-content * Create a ContentModelEntity model * @param wrapper Wrapper element of this entity * @param isReadonly Whether this is a readonly entity - * @param segmentFormat Segment format of this entity - * @param id @optional Id of this entity * @param type @optional Type of this entity + * @param segmentFormat @optional Segment format of this entity + * @param id @optional Id of this entity */ export function createEntity( wrapper: HTMLElement, isReadonly: boolean, + type?: string, segmentFormat?: ContentModelSegmentFormat, - id?: string, - type?: string + id?: string ): ContentModelEntity { return { segmentType: 'Entity', blockType: 'Entity', - format: { - ...(segmentFormat || {}), - }, + format: { ...segmentFormat }, id, type, isReadonly, diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts index 133fffbf2cc..450ce903c76 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/contentModelToDom.ts @@ -33,7 +33,7 @@ export function contentModelToDom( doc: Document, root: Node, model: ContentModelDocument, - editorContext: EditorContext, + editorContext?: EditorContext, option?: ModelToDomOption ): SelectionRangeEx | null { const modelToDomContext = createModelToDomContext(editorContext, option); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleDivider.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleDivider.ts index 949edee3e47..1f0ad2817c7 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleDivider.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleDivider.ts @@ -16,14 +16,17 @@ export const handleDivider: ContentModelBlockHandler = ( context: ModelToDomContext, refNode: Node | null ) => { - let element = divider.cachedElement; + let element = context.allowCacheElement ? divider.cachedElement : undefined; if (element) { refNode = reuseCachedElement(parent, element, refNode); } else { element = doc.createElement(divider.tagName); - divider.cachedElement = element; + if (context.allowCacheElement) { + divider.cachedElement = element; + } + parent.insertBefore(element, refNode); applyFormat(element, context.formatAppliers.divider, divider.format, context); diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts index 363fc335901..82f419f3fb1 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleEntity.ts @@ -24,7 +24,15 @@ export const handleEntity: ContentModelBlockHandler = ( context: ModelToDomContext, refNode: Node | null ) => { - const { wrapper, id, type, isReadonly, format } = entityModel; + const { id, type, isReadonly, format } = entityModel; + let wrapper = entityModel.wrapper; + + if (!context.allowCacheElement) { + wrapper = wrapper.cloneNode(true /*deep*/) as HTMLElement; + wrapper.style.color = wrapper.style.color || 'inherit'; + wrapper.style.backgroundColor = wrapper.style.backgroundColor || 'inherit'; + } + const entity: Entity | null = id && type ? { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts index a8cd8726dee..035ca50891f 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleFormatContainer.ts @@ -19,7 +19,7 @@ export const handleFormatContainer: ContentModelBlockHandler { - let element = container.cachedElement; + let element = context.allowCacheElement ? container.cachedElement : undefined; if (element) { refNode = reuseCachedElement(parent, element, refNode); @@ -28,7 +28,10 @@ export const handleFormatContainer: ContentModelBlockHandler { diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts index 8714fec5f5a..2b3fbc13ed2 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleParagraph.ts @@ -21,7 +21,7 @@ export const handleParagraph: ContentModelBlockHandler = context: ModelToDomContext, refNode: Node | null ) => { - let container = paragraph.cachedElement; + let container = context.allowCacheElement ? paragraph.cachedElement : undefined; if (container) { refNode = reuseCachedElement(parent, container, refNode); @@ -102,7 +102,9 @@ export const handleParagraph: ContentModelBlockHandler = refNode = container.nextSibling; if (needParagraphWrapper) { - paragraph.cachedElement = container; + if (context.allowCacheElement) { + paragraph.cachedElement = container; + } } else { unwrap(container); } diff --git a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts index fc9cdcab121..b8a4e64658c 100644 --- a/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts +++ b/packages-content-model/roosterjs-content-model-dom/lib/modelToDom/handlers/handleTable.ts @@ -24,7 +24,7 @@ export const handleTable: ContentModelBlockHandler = ( return refNode; } - let tableNode = table.cachedElement; + let tableNode = context.allowCacheElement ? table.cachedElement : undefined; if (tableNode) { refNode = reuseCachedElement(parent, tableNode, refNode); @@ -33,7 +33,10 @@ export const handleTable: ContentModelBlockHandler = ( } else { tableNode = doc.createElement('table'); - table.cachedElement = tableNode; + if (context.allowCacheElement) { + table.cachedElement = tableNode; + } + parent.insertBefore(tableNode, refNode); applyFormat(tableNode, context.formatAppliers.block, table.format, context); @@ -55,12 +58,15 @@ export const handleTable: ContentModelBlockHandler = ( continue; } - const tr = tableRow.cachedElement || doc.createElement('tr'); + const tr = (context.allowCacheElement && tableRow.cachedElement) || doc.createElement('tr'); tbody.appendChild(tr); moveChildNodes(tr); if (!tableRow.cachedElement) { - tableRow.cachedElement = tr; + if (context.allowCacheElement) { + tableRow.cachedElement = tr; + } + applyFormat(tr, context.formatAppliers.tableRow, tableRow.format, context); } @@ -85,7 +91,9 @@ export const handleTable: ContentModelBlockHandler = ( } if (!cell.spanAbove && !cell.spanLeft) { - let td = cell.cachedElement || doc.createElement(cell.isHeader ? 'th' : 'td'); + let td = + (context.allowCacheElement && cell.cachedElement) || + doc.createElement(cell.isHeader ? 'th' : 'td'); tr.appendChild(td); @@ -120,7 +128,10 @@ export const handleTable: ContentModelBlockHandler = ( } if (!cell.cachedElement) { - cell.cachedElement = td; + if (context.allowCacheElement) { + cell.cachedElement = td; + } + applyFormat(td, context.formatAppliers.block, cell.format, context); applyFormat(td, context.formatAppliers.tableCell, cell.format, context); applyFormat(td, context.formatAppliers.tableCellBorder, cell.format, context); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts index 5570f2ece6b..e422c03519b 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/context/createDomToModelContextTest.ts @@ -40,7 +40,6 @@ describe('createDomToModelContext', () => { format: {}, tagName: '', }, - allowCacheElement: true, ...contextOptions, }); }); @@ -69,7 +68,6 @@ describe('createDomToModelContext', () => { format: {}, tagName: '', }, - allowCacheElement: true, ...contextOptions, }); }); @@ -98,7 +96,6 @@ describe('createDomToModelContext', () => { format: {}, tagName: '', }, - allowCacheElement: true, ...contextOptions, }); }); @@ -123,7 +120,6 @@ describe('createDomToModelContext', () => { format: {}, tagName: '', }, - allowCacheElement: true, ...contextOptions, rangeEx: selectionContext, }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts index ef82a06342e..72ee02b35a1 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/entityProcessorTest.ts @@ -197,69 +197,4 @@ describe('entityProcessor', () => { lineHeight: '20px', }); }); - - it('Block element entity, clone element', () => { - const group = createContentModelDocument(); - const div = document.createElement('div'); - - const clonedDiv = div.cloneNode(true /* deep */) as HTMLDivElement; - spyOn(Node.prototype, 'cloneNode').and.returnValue(clonedDiv); - context.allowCacheElement = false; - - commitEntity(div, 'entity', true, 'entity_1'); - - entityProcessor(group, div, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - - blocks: [ - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - id: 'entity_1', - type: 'entity', - isReadonly: true, - wrapper: clonedDiv, - }, - ], - }); - }); - - it('Inline element entity, clone entity element', () => { - const group = createContentModelDocument(); - const span = document.createElement('span'); - - const clonedSpan = span.cloneNode(true /* deep */) as HTMLDivElement; - spyOn(Node.prototype, 'cloneNode').and.returnValue(clonedSpan); - context.allowCacheElement = false; - - commitEntity(span, 'entity', true, 'entity_1'); - - entityProcessor(group, span, context); - - expect(group).toEqual({ - blockGroupType: 'Document', - - blocks: [ - { - blockType: 'Paragraph', - isImplicit: true, - segments: [ - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - id: 'entity_1', - type: 'entity', - isReadonly: true, - wrapper: clonedSpan, - }, - ], - format: {}, - }, - ], - }); - }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/headingProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/headingProcessorTest.ts index 84666a1d7bb..e1d156c062f 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/headingProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/headingProcessorTest.ts @@ -87,7 +87,7 @@ describe('headingProcessor', () => { }); }); - it('header with format from context', () => { + it('heading with format from context', () => { const group = createContentModelDocument(); const h1 = document.createElement('h1'); diff --git a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts index ce86a2d02f1..4b2cb122340 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/domToModel/processors/tableProcessorTest.ts @@ -23,7 +23,6 @@ describe('tableProcessor', () => { processorOverride: { child: childProcessor, }, - disableCacheElement: false, }); spyOn(getBoundingClientRect, 'getBoundingClientRect').and.returnValue(({ @@ -51,7 +50,6 @@ describe('tableProcessor', () => { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [ @@ -63,7 +61,6 @@ describe('tableProcessor', () => { blocks: [], format: {}, dataset: {}, - cachedElement: div.querySelector('#td1') as HTMLTableCellElement, }, ], }, @@ -71,7 +68,6 @@ describe('tableProcessor', () => { format: {}, widths: [100], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }; }); }); @@ -85,22 +81,15 @@ describe('tableProcessor', () => { const tdModel4 = createTableCell(1, 1, false); runTest(tableHTML, div => { - tdModel1.cachedElement = div.querySelector('#td1') as HTMLTableCellElement; - tdModel2.cachedElement = div.querySelector('#td2') as HTMLTableCellElement; - tdModel3.cachedElement = div.querySelector('#td3') as HTMLTableCellElement; - tdModel4.cachedElement = div.querySelector('#td4') as HTMLTableCellElement; - return { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel1, tdModel2], }, { - cachedElement: div.querySelector('#tr2') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel3, tdModel4], @@ -109,7 +98,6 @@ describe('tableProcessor', () => { format: {}, widths: [100, 100], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }; }); }); @@ -123,21 +111,15 @@ describe('tableProcessor', () => { const tdModel4 = createTableCell(2, 1, false); runTest(tableHTML, div => { - tdModel1.cachedElement = div.querySelector('#td1') as HTMLTableCellElement; - tdModel2.cachedElement = div.querySelector('#td2') as HTMLTableCellElement; - tdModel3.cachedElement = div.querySelector('#td3') as HTMLTableCellElement; - return { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel1, tdModel2], }, { - cachedElement: div.querySelector('#tr2') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel3, tdModel4], @@ -146,7 +128,6 @@ describe('tableProcessor', () => { format: {}, widths: [100, 100], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }; }); }); @@ -157,19 +138,16 @@ describe('tableProcessor', () => { runTest(tableHTML, div => { const tdModel1 = createTableCell(1, 1, false); - tdModel1.cachedElement = div.querySelector('#td1') as HTMLTableCellElement; return { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel1, createTableCell(2, 1, false)], }, { - cachedElement: div.querySelector('#tr2') as HTMLTableRowElement, format: {}, height: 0, cells: [createTableCell(1, 2, false), createTableCell(2, 2, false)], @@ -178,7 +156,6 @@ describe('tableProcessor', () => { format: {}, widths: [100, 0], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }; }); }); @@ -188,13 +165,10 @@ describe('tableProcessor', () => { const tdModel = createTableCell(1, 1, false); runTest(tableHTML, div => { - tdModel.cachedElement = div.querySelector('#td1') as HTMLTableCellElement; - return { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel], @@ -203,7 +177,6 @@ describe('tableProcessor', () => { format: {}, widths: [100], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }; }); @@ -217,14 +190,10 @@ describe('tableProcessor', () => { const tdModel2 = createTableCell(1, 1, false); runTest(tableHTML, div => { - tdModel1.cachedElement = div.querySelector('#td1') as HTMLTableCellElement; - tdModel2.cachedElement = div.querySelector('#td2') as HTMLTableCellElement; - return { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel1, tdModel2], @@ -233,7 +202,6 @@ describe('tableProcessor', () => { format: {}, widths: [100, 100], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }; }); @@ -247,13 +215,10 @@ describe('tableProcessor', () => { const tdModel2 = createTableCell(2, 1, false); runTest(tableHTML, div => { - tdModel1.cachedElement = div.querySelector('#td1') as HTMLTableCellElement; - return { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel1, tdModel2], @@ -262,7 +227,6 @@ describe('tableProcessor', () => { format: {}, widths: [100, 0], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }; }); @@ -297,10 +261,6 @@ describe('tableProcessor', () => { tdModel2.isSelected = true; tdModel4.isSelected = true; - tdModel1.cachedElement = div.querySelector('#td1') as HTMLTableCellElement; - tdModel2.cachedElement = div.querySelector('#td2') as HTMLTableCellElement; - tdModel3.cachedElement = div.querySelector('#td3') as HTMLTableCellElement; - tdModel4.cachedElement = div.querySelector('#td4') as HTMLTableCellElement; tableProcessor(doc, div.firstChild as HTMLTableElement, context); @@ -308,13 +268,11 @@ describe('tableProcessor', () => { blockType: 'Table', rows: [ { - cachedElement: div.querySelector('#tr1') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel1, tdModel2], }, { - cachedElement: div.querySelector('#tr2') as HTMLTableRowElement, format: {}, height: 200, cells: [tdModel3, tdModel4], @@ -323,7 +281,6 @@ describe('tableProcessor', () => { format: {}, widths: [100, 100], dataset: {}, - cachedElement: div.querySelector('.tb1') as HTMLTableElement, }); expect(childProcessor).toHaveBeenCalledTimes(4); @@ -336,8 +293,6 @@ describe('tableProcessor with format', () => { beforeEach(() => { context = createDomToModelContext(); - context.allowCacheElement = true; - spyOn(getBoundingClientRect, 'getBoundingClientRect').and.returnValue(({ width: 100, height: 200, @@ -386,7 +341,6 @@ describe('tableProcessor with format', () => { blockType: 'Table', rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -409,7 +363,6 @@ describe('tableProcessor with format', () => { format: {}, }, ], - cachedElement: td, spanLeft: false, spanAbove: false, isHeader: false, @@ -426,7 +379,6 @@ describe('tableProcessor with format', () => { format1: 'table', } as any, dataset: {}, - cachedElement: table, }, ], }); @@ -469,7 +421,6 @@ describe('tableProcessor with format', () => { format: {}, rows: [ { - cachedElement: mockedTr as any, format: {}, height: 100, cells: [ @@ -481,13 +432,11 @@ describe('tableProcessor with format', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: mockedTd, }, ], }, ], dataset: {}, - cachedElement: mockedTable, }, ], }); @@ -582,7 +531,6 @@ describe('tableProcessor', () => { processorOverride: { child: childProcessor, }, - disableCacheElement: false, }); spyOn(getBoundingClientRect, 'getBoundingClientRect').and.returnValue(({ @@ -681,7 +629,6 @@ describe('tableProcessor', () => { widths: [100], rows: [ { - cachedElement: mockedTr, format: {}, height: 200, cells: [ @@ -695,12 +642,10 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: mockedTd, }, ], }, ], - cachedElement: mockedTable, }, ], }); @@ -737,7 +682,6 @@ describe('tableProcessor', () => { dataset: {}, widths: [], rows: [], - cachedElement: mockedTable, }, ], }); @@ -767,7 +711,6 @@ describe('tableProcessor', () => { blockType: 'Table', rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -794,7 +737,6 @@ describe('tableProcessor', () => { ], }, ], - cachedElement: td, }, ], }, @@ -802,7 +744,6 @@ describe('tableProcessor', () => { format: {}, dataset: {}, widths: [100], - cachedElement: table, }, ], }); @@ -829,7 +770,6 @@ describe('tableProcessor', () => { }, dataset: {}, widths: [], - cachedElement: table, }, ], }); @@ -855,7 +795,6 @@ describe('tableProcessor', () => { blockType: 'Table', rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -867,7 +806,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, @@ -875,7 +813,6 @@ describe('tableProcessor', () => { format: {}, dataset: {}, widths: [100], - cachedElement: table, }, ], }); @@ -904,7 +841,6 @@ describe('tableProcessor', () => { blockType: 'Table', rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -921,7 +857,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, @@ -929,7 +864,6 @@ describe('tableProcessor', () => { format: {}, dataset: {}, widths: [100], - cachedElement: table, }, ], }); @@ -963,7 +897,7 @@ describe('tableProcessor', () => { { format: {}, height: 200, - cachedElement: tr, + cells: [ { blockGroupType: 'TableCell', @@ -989,7 +923,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, @@ -997,7 +930,6 @@ describe('tableProcessor', () => { format: {}, dataset: {}, widths: [100], - cachedElement: table, }, ], }); @@ -1028,7 +960,6 @@ describe('tableProcessor', () => { blockType: 'Table', rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -1055,7 +986,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, @@ -1063,7 +993,6 @@ describe('tableProcessor', () => { format: {}, dataset: {}, widths: [100], - cachedElement: table, }, ], }); @@ -1107,10 +1036,9 @@ describe('tableProcessor', () => { blockType: 'Table', widths: [100], dataset: {}, - cachedElement: table, + rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -1127,7 +1055,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, @@ -1172,10 +1099,9 @@ describe('tableProcessor', () => { blockType: 'Table', widths: [100], dataset: {}, - cachedElement: table, + rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -1187,7 +1113,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, @@ -1231,10 +1156,9 @@ describe('tableProcessor', () => { blockType: 'Table', widths: [100], dataset: {}, - cachedElement: table, + rows: [ { - cachedElement: tr, format: {}, height: 200, cells: [ @@ -1248,7 +1172,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, @@ -1291,10 +1214,63 @@ describe('tableProcessor', () => { blockType: 'Table', widths: [100], dataset: {}, - cachedElement: table, + + rows: [ + { + format: { + backgroundColor: 'red', + }, + height: 200, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [], + format: {}, + spanAbove: false, + spanLeft: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + }, + ], + }); + }); + + it('Respect background on tbody', () => { + const group = createContentModelDocument(); + const table = document.createElement('table'); + const tbody = document.createElement('tbody'); + const tr = document.createElement('tr'); + const td = document.createElement('td'); + + tbody.style.backgroundColor = 'red'; + + table.appendChild(tbody); + tbody.appendChild(tr); + tr.appendChild(td); + + childProcessor.and.callFake(() => { + expect(context.blockFormat).toEqual({}); + expect(context.segmentFormat).toEqual({}); + }); + + tableProcessor(group, table, context); + + expect(childProcessor).toHaveBeenCalledTimes(1); + expect(group).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + widths: [100], + dataset: {}, + rows: [ { - cachedElement: tr, format: { backgroundColor: 'red', }, @@ -1308,7 +1284,6 @@ describe('tableProcessor', () => { spanLeft: false, isHeader: false, dataset: {}, - cachedElement: td, }, ], }, diff --git a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts index 2e76b12a80a..a5693f7a613 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/endToEndTest.ts @@ -29,13 +29,7 @@ describe('End to end test for DOM => Model', () => { const div1 = document.createElement('div'); div1.innerHTML = html; - const model = domToContentModel( - div1, - { - disableCacheElement: true, - }, - context - ); + const model = domToContentModel(div1, undefined, context); expect(model).toEqual(expectedModel); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/floatFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/floatFormatHandlerTest.ts new file mode 100644 index 00000000000..1ea9466f24e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/floatFormatHandlerTest.ts @@ -0,0 +1,60 @@ +import { createDomToModelContext } from '../../../lib/domToModel/context/createDomToModelContext'; +import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; +import { DomToModelContext, FloatFormat, ModelToDomContext } from 'roosterjs-content-model-types'; +import { floatFormatHandler } from '../../../lib/formatHandlers/common/floatFormatHandler'; + +describe('floatFormatHandler.parse', () => { + let div: HTMLElement; + let format: FloatFormat; + let context: DomToModelContext; + + beforeEach(() => { + div = document.createElement('div'); + format = {}; + context = createDomToModelContext(); + }); + + it('No float', () => { + floatFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({}); + }); + + it('Float left', () => { + div.style.float = 'left'; + floatFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ + float: 'left', + }); + }); + + it('Float left from attribute', () => { + div.setAttribute('align', 'left'); + floatFormatHandler.parse(format, div, context, {}); + expect(format).toEqual({ + float: 'left', + }); + }); +}); + +describe('floatFormatHandler.apply', () => { + let div: HTMLElement; + let format: FloatFormat; + let context: ModelToDomContext; + + beforeEach(() => { + div = document.createElement('div'); + format = {}; + context = createModelToDomContext(); + }); + + it('No float', () => { + floatFormatHandler.apply(format, div, context); + expect(div.outerHTML).toBe('
'); + }); + + it('Float: left', () => { + format.float = 'left'; + floatFormatHandler.apply(format, div, context); + expect(div.outerHTML).toBe('
'); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/verticalAlignFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/verticalAlignFormatHandlerTest.ts index ab763132b97..d7d7f6ae6a5 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/verticalAlignFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/common/verticalAlignFormatHandlerTest.ts @@ -63,6 +63,7 @@ describe('verticalAlignFormatHandler.parse', () => { runTest('text-top', null, 'top'); runTest('text-bottom', null, 'top'); runTest('bottom', null, 'bottom'); + runTest('middle', null, 'middle'); }); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/fontSizeFormatHandlerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/fontSizeFormatHandlerTest.ts index 0ab9100592b..3c2b55781bd 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/fontSizeFormatHandlerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/segment/fontSizeFormatHandlerTest.ts @@ -59,6 +59,114 @@ describe('fontSizeFormatHandler.parse', () => { expect(format.fontSize).toBe('20px'); }); + + it('xx-small', () => { + div.style.fontSize = 'xx-small'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('6.75pt'); + }); + it('x-small', () => { + div.style.fontSize = 'x-small'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('7.5pt'); + }); + it('small', () => { + div.style.fontSize = 'small'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('9.75pt'); + }); + it('medium', () => { + div.style.fontSize = 'medium'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('12pt'); + }); + it('large', () => { + div.style.fontSize = 'large'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('13.5pt'); + }); + it('x-large', () => { + div.style.fontSize = 'x-large'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('18pt'); + }); + it('xx-large', () => { + div.style.fontSize = 'xx-large'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('24pt'); + }); + it('xxx-large', () => { + div.style.fontSize = 'xxx-large'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('36pt'); + }); + + it('smaller without context', () => { + div.style.fontSize = 'smaller'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe(undefined); + }); + + it('smaller with context', () => { + div.style.fontSize = 'smaller'; + context.segmentFormat.fontSize = '12pt'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('13.33px'); + }); + + it('larger without context', () => { + div.style.fontSize = 'larger'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe(undefined); + }); + + it('larger with context', () => { + div.style.fontSize = 'larger'; + context.segmentFormat.fontSize = '10pt'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('16px'); + }); + + it('em without context', () => { + div.style.fontSize = '2em'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe(undefined); + }); + + it('em with context', () => { + div.style.fontSize = '2em'; + context.segmentFormat.fontSize = '12pt'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('32px'); + }); + + it('% without context', () => { + div.style.fontSize = '50%'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe(undefined); + }); + it('% with context', () => { + div.style.fontSize = '50%'; + context.segmentFormat.fontSize = '12pt'; + fontSizeFormatHandler.parse(format, div, context, {}); + + expect(format.fontSize).toBe('8px'); + }); }); describe('fontSizeFormatHandler.apply', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts index 02f95a345d7..50fca53d4e1 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/formatHandlers/utils/parseValueWithUnitTest.ts @@ -1,7 +1,7 @@ import * as getComputedStyles from 'roosterjs-editor-dom/lib/utils/getComputedStyles'; import { parseValueWithUnit } from '../../../lib/formatHandlers/utils/parseValueWithUnit'; -describe('parseValueWithUnit', () => { +describe('parseValueWithUnit with element', () => { function runTest(unit: string, results: number[]) { const mockedElement = { offsetWidth: 1000, @@ -50,7 +50,64 @@ describe('parseValueWithUnit', () => { }); it('%', () => { - runTest('% ', [0, 10, 11, -11]); + runTest('% ', [0, 0.2, 0.22, -0.22]); + }); + + it('px to pt', () => { + const result = parseValueWithUnit('16px', undefined, 'pt'); + + expect(result).toBe(12); + }); + + it('pt to pt', () => { + const result = parseValueWithUnit('16pt', undefined, 'pt'); + + expect(result).toBe(16); + }); +}); + +describe('parseValueWithUnit with number', () => { + function runTest(unit: string, results: number[]) { + ['0', '1', '1.1', '-1.1'].forEach((value, i) => { + const input = value + unit; + const result = parseValueWithUnit(input, 20); + + expect(result).toBe(results[i], input); + }); + } + + it('empty', () => { + expect(parseValueWithUnit()).toBe(0); + expect(parseValueWithUnit('')).toBe(0); + expect(parseValueWithUnit('', {} as HTMLElement)).toBe(0); + }); + + it('px', () => { + runTest('px', [0, 1, 1.1, -1.1]); + }); + + it('pt', () => { + runTest('pt', [0, 1.333, 1.467, -1.467]); + }); + + it('em', () => { + runTest('em', [0, 20, 22, -22]); + }); + + it('ex', () => { + runTest('ex', [0, 10, 11, -11]); + }); + + it('no unit', () => { + runTest('', [0, 0, 0, 0]); + }); + + it('unknown unit', () => { + runTest('unknown', [0, 0, 0, 0]); + }); + + it('%', () => { + runTest('% ', [0, 0.2, 0.22, -0.22]); }); it('px to pt', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts index 261290abbed..b1d91fdcb3b 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelApi/creators/creatorsTest.ts @@ -432,7 +432,7 @@ describe('Creators', () => { const type = 'entity'; const isReadonly = true; const wrapper = document.createElement('div'); - const entityModel = createEntity(wrapper, isReadonly, undefined, id, type); + const entityModel = createEntity(wrapper, isReadonly, type, undefined, id); expect(entityModel).toEqual({ blockType: 'Entity', @@ -453,11 +453,11 @@ describe('Creators', () => { const entityModel = createEntity( wrapper, isReadonly, + type, { fontSize: '10pt', }, - id, - type + id ); expect(entityModel).toEqual({ diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts index 75edd6a57bb..08c8857d360 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleBlockGroupChildrenTest.ts @@ -20,11 +20,16 @@ describe('handleBlockGroupChildren', () => { beforeEach(() => { handleBlock = jasmine.createSpy('handleBlock').and.callFake(originalHandleBlock); - context = createModelToDomContext(undefined, { - modelHandlerOverride: { - block: handleBlock, + context = createModelToDomContext( + { + allowCacheElement: true, }, - }); + { + modelHandlerOverride: { + block: handleBlock, + }, + } + ); parent = document.createElement('div'); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts index b0f915ebb59..a9520509234 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleDividerTest.ts @@ -7,6 +7,7 @@ describe('handleDivider', () => { beforeEach(() => { context = createModelToDomContext(); + context.allowCacheElement = true; }); it('Simple HR', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts index cb33c058ace..6424fedd84e 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleEntityTest.ts @@ -7,7 +7,9 @@ describe('handleEntity', () => { let context: ModelToDomContext; beforeEach(() => { - context = createModelToDomContext(); + context = createModelToDomContext({ + allowCacheElement: true, + }); spyOn(addDelimiters, 'default').and.callThrough(); }); diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts index b65a115364d..f4c8c748d43 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleFormatContainerTest.ts @@ -1,14 +1,14 @@ -import { - ContentModelBlockGroup, - ContentModelHandler, - ModelToDomContext, -} from 'roosterjs-content-model-types'; import { createFormatContainer } from '../../../lib/modelApi/creators/createFormatContainer'; import { createModelToDomContext } from '../../../lib/modelToDom/context/createModelToDomContext'; import { createParagraph } from '../../../lib/modelApi/creators/createParagraph'; import { createText } from '../../../lib/modelApi/creators/createText'; import { handleBlockGroupChildren as originalHandleBlockGroupChildren } from '../../../lib/modelToDom/handlers/handleBlockGroupChildren'; import { handleFormatContainer } from '../../../lib/modelToDom/handlers/handleFormatContainer'; +import { + ContentModelBlockGroup, + ContentModelHandler, + ModelToDomContext, +} from 'roosterjs-content-model-types'; describe('handleFormatContainer', () => { let context: ModelToDomContext; @@ -16,11 +16,16 @@ describe('handleFormatContainer', () => { beforeEach(() => { handleBlockGroupChildren = jasmine.createSpy('handleBlockGroupChildren'); - context = createModelToDomContext(undefined, { - modelHandlerOverride: { - blockGroupChildren: handleBlockGroupChildren, + context = createModelToDomContext( + { + allowCacheElement: true, }, - }); + { + modelHandlerOverride: { + blockGroupChildren: handleBlockGroupChildren, + }, + } + ); }); it('Empty quote', () => { diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts index b775e17c1ea..32cbe0cb717 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleParagraphTest.ts @@ -20,11 +20,16 @@ describe('handleParagraph', () => { beforeEach(() => { parent = document.createElement('div'); handleSegment = jasmine.createSpy('handleSegment'); - context = createModelToDomContext(undefined, { - modelHandlerOverride: { - segment: handleSegment, + context = createModelToDomContext( + { + allowCacheElement: true, }, - }); + { + modelHandlerOverride: { + segment: handleSegment, + }, + } + ); }); function runTest( diff --git a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts index 975f716ef5d..08642467bf4 100644 --- a/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts +++ b/packages-content-model/roosterjs-content-model-dom/test/modelToDom/handlers/handleTableTest.ts @@ -11,7 +11,7 @@ describe('handleTable', () => { beforeEach(() => { spyOn(handleBlock, 'handleBlock'); - context = createModelToDomContext(); + context = createModelToDomContext({ allowCacheElement: true }); context.darkColorHandler = new DarkColorHandlerImpl(null!, s => 'darkMock: ' + s); }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/domToModel/processors/reducedModelChildProcessor.ts b/packages-content-model/roosterjs-content-model-editor/lib/domToModel/processors/reducedModelChildProcessor.ts deleted file mode 100644 index 3eb7dd78cd9..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/domToModel/processors/reducedModelChildProcessor.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { contains, getTagOfNode } from 'roosterjs-editor-dom'; -import { ContentModelBlockGroup, DomToModelContext } from 'roosterjs-content-model-types'; -import { getSelectionRootNode } from '../../modelApi/selection/getSelectionRootNode'; -import { - getRegularSelectionOffsets, - handleRegularSelection, - processChildNode, -} from 'roosterjs-content-model-dom'; - -/** - * @internal - */ -export interface FormatStateContext extends DomToModelContext { - /** - * An optional stack of parent elements to process. When provided, the child nodes of current parent element will be ignored, - * but use the top element in this stack instead in childProcessor. - */ - nodeStack?: Node[]; -} - -/** - * @internal - * In order to get format, we can still use the regular child processor. However, to improve performance, we don't need to create - * content model for the whole doc, instead we only need to traverse the tree path that can arrive current selected node. - * This "reduced" child processor will first create a node stack that stores DOM node from root to current common ancestor node of selection, - * then use this stack as a faked DOM tree to create a reduced content model which we can use to retrieve format state - */ -export function reducedModelChildProcessor( - group: ContentModelBlockGroup, - parent: ParentNode, - context: FormatStateContext -) { - const selectionRootNode = getSelectionRootNode(context.rangeEx); - - if (selectionRootNode) { - if (!context.nodeStack) { - context.nodeStack = createNodeStack(parent, selectionRootNode); - } - - const stackChild = context.nodeStack.pop(); - - if (stackChild) { - const [nodeStartOffset, nodeEndOffset] = getRegularSelectionOffsets(context, parent); - - // If selection is not on this node, skip getting node index to save some time since we don't need it here - const index = - nodeStartOffset >= 0 || nodeEndOffset >= 0 ? getChildIndex(parent, stackChild) : -1; - - if (index >= 0) { - handleRegularSelection(index, context, group, nodeStartOffset, nodeEndOffset); - } - - processChildNode(group, stackChild, context); - - if (index >= 0) { - handleRegularSelection(index + 1, context, group, nodeStartOffset, nodeEndOffset); - } - } else { - // No child node from node stack, that means we have reached the deepest node of selection. - // Now we can use default child processor to perform full sub tree scanning for content model, - // So that all selected node will be included. - context.defaultElementProcessors.child(group, parent, context); - } - } -} - -function createNodeStack(root: Node, startNode: Node): Node[] { - const result: Node[] = []; - let node: Node | null = startNode; - - while (node && contains(root, node)) { - if (getTagOfNode(node) == 'TABLE') { - // For table, we can't do a reduced model creation since we need to handle their cells and indexes, - // so clean up whatever we already have, and just put table into the stack - result.splice(0, result.length, node); - } else { - result.push(node); - } - - node = node.parentNode; - } - - return result; -} - -function getChildIndex(parent: ParentNode, stackChild: Node) { - let index = 0; - let child = parent.firstChild; - - while (child && child != stackChild) { - index++; - child = child.nextSibling; - } - return index; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts index 630a3a7cf4e..7384aaf62ba 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/ContentModelEditor.ts @@ -2,6 +2,7 @@ import { ContentModelEditorCore } from '../publicTypes/ContentModelEditorCore'; import { ContentModelEditorOptions, IContentModelEditor } from '../publicTypes/IContentModelEditor'; import { createContentModelEditorCore } from './createContentModelEditorCore'; import { EditorBase } from 'roosterjs-editor-core'; +import { SelectionRangeEx } from 'roosterjs-editor-types'; import { ContentModelDocument, ContentModelSegmentFormat, @@ -29,10 +30,13 @@ export default class ContentModelEditor * Create Content Model from DOM tree in this editor * @param option The option to customize the behavior of DOM to Content Model conversion */ - createContentModel(option?: DomToModelOption): ContentModelDocument { + createContentModel( + option?: DomToModelOption, + selectionOverride?: SelectionRangeEx + ): ContentModelDocument { const core = this.getCore(); - return core.api.createContentModel(core, option); + return core.api.createContentModel(core, option, selectionOverride); } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts index e21d011cca7..b95b71a5288 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createContentModel.ts @@ -1,6 +1,7 @@ import { cloneModel } from '../../modelApi/common/cloneModel'; import { domToContentModel } from 'roosterjs-content-model-dom'; import { DomToModelOption } from 'roosterjs-content-model-types'; +import { SelectionRangeEx } from 'roosterjs-editor-types'; import { tablePreProcessor } from '../../domToModel/processors/tablePreProcessor'; import { ContentModelEditorCore, @@ -12,20 +13,21 @@ import { * Create Content Model from DOM tree in this editor * @param option The option to customize the behavior of DOM to Content Model conversion */ -export const createContentModel: CreateContentModel = (core, option) => { - let cachedModel = core.cachedModel; +export const createContentModel: CreateContentModel = (core, option, selectionOverride) => { + let cachedModel = selectionOverride ? null : core.cachedModel; if (cachedModel && core.lifecycle.shadowEditFragment) { // When in shadow edit, use a cloned model so we won't pollute the cached one - cachedModel = cloneModel(cachedModel); + cachedModel = cloneModel(cachedModel, { includeCachedElement: true }); } - return cachedModel || internalCreateContentModel(core, option); + return cachedModel || internalCreateContentModel(core, option, selectionOverride); }; function internalCreateContentModel( core: ContentModelEditorCore, - option: DomToModelOption | undefined + option: DomToModelOption | undefined, + selectionOverride?: SelectionRangeEx ) { const context: DomToModelOption = { ...core.defaultDomToModelOptions, @@ -42,6 +44,6 @@ function internalCreateContentModel( core.contentDiv, context, core.api.createEditorContext(core), - core.api.getSelectionRangeEx(core) + selectionOverride || core.api.getSelectionRangeEx(core) ); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createEditorContext.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createEditorContext.ts index 33af8997f97..69006c7e55d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createEditorContext.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/coreApi/createEditorContext.ts @@ -13,6 +13,7 @@ export const createEditorContext: CreateEditorContext = core => { defaultFormat: defaultFormat, darkColorHandler: darkColorHandler, addDelimiterForEntity: addDelimiterForEntity, + allowCacheElement: true, }; checkRootRtl(contentDiv, context); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts index 4a83e2813ec..aa8bcaca30c 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/corePlugins/ContentModelCopyPastePlugin.ts @@ -1,8 +1,9 @@ import paste from '../../publicApi/utils/paste'; import { cloneModel } from '../../modelApi/common/cloneModel'; -import { contentModelToDom } from 'roosterjs-content-model-dom'; +import { contentModelToDom, normalizeContentModel } from 'roosterjs-content-model-dom'; +import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; -import { getOnDeleteEntityCallback } from '../utils/handleKeyboardEventCommon'; +import { formatWithContentModel } from '../../publicApi/utils/formatWithContentModel'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { iterateSelections } from '../../modelApi/selection/iterateSelections'; import type { @@ -19,7 +20,6 @@ import { createRange, extractClipboardItems, toArray, - Browser, wrap, safeInstanceOf, } from 'roosterjs-editor-dom'; @@ -92,9 +92,7 @@ export default class ContentModelCopyPastePlugin implements PluginWithState { cleanUpAndRestoreSelection(tempDiv); editor.focus(); - if (selectionAfterPaste) { - this.editor?.select(selectionAfterPaste); - } + editor.select(selection); + if (isCut) { - editor.addUndoSnapshot(() => { - deleteSelection( - model, - getOnDeleteEntityCallback(editor as IContentModelEditor) - ); - this.editor?.setContentModel(model); - }, ChangeSource.Cut); + formatWithContentModel( + editor as IContentModelEditor, + 'cut', + (model, context) => { + if ( + deleteSelection(model, [], context).deleteResult == + DeleteResult.Range + ) { + normalizeContentModel(model); + } + + return true; + }, + { + changeSource: ChangeSource.Cut, + } + ); } }); } @@ -177,7 +181,6 @@ export default class ContentModelCopyPastePlugin implements PluginWithState { if (!editor.isDisposed()) { - removeContentForAndroid(editor); paste(editor, clipboardData); } }); @@ -218,16 +221,11 @@ function cleanUpAndRestoreSelection(tempDiv: HTMLDivElement) { tempDiv.style.display = 'none'; moveChildNodes(tempDiv); } + function isClipboardEvent(event: Event): event is ClipboardEvent { return !!(event as ClipboardEvent).clipboardData; } -function removeContentForAndroid(editor: IContentModelEditor) { - if (Browser.isAndroid) { - const model = editor.createContentModel(); - deleteSelection(model, getOnDeleteEntityCallback(editor)); - editor.setContentModel(model); - } -} + function selectionExToRange( selection: SelectionRangeEx | null, tempDiv: HTMLDivElement diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts index 11c4c051776..5f7e3e54ede 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/ContentModelEditPlugin.ts @@ -3,13 +3,11 @@ import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; import { formatWithContentModel } from '../../publicApi/utils/formatWithContentModel'; -import { getOnDeleteEntityCallback } from '../utils/handleKeyboardEventCommon'; import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { isNodeOfType, normalizeContentModel } from 'roosterjs-content-model-dom'; import { EditorPlugin, - EntityOperationEvent, IEditor, Keys, NodePosition, @@ -38,7 +36,6 @@ const ProcessKey = 'Process'; */ export default class ContentModelEditPlugin implements EditorPlugin { private editor: IContentModelEditor | null = null; - private triggeredEntityEvents: EntityOperationEvent[] = []; private hasDefaultFormat = false; /** @@ -82,10 +79,6 @@ export default class ContentModelEditPlugin implements EditorPlugin { onPluginEvent(event: PluginEvent) { if (this.editor) { switch (event.eventType) { - case PluginEventType.EntityOperation: - this.handleEntityOperationEvent(this.editor, event); - break; - case PluginEventType.KeyDown: this.handleKeyDownEvent(this.editor, event); break; @@ -99,15 +92,6 @@ export default class ContentModelEditPlugin implements EditorPlugin { } } - private handleEntityOperationEvent(editor: IContentModelEditor, event: EntityOperationEvent) { - if (event.rawEvent?.type == 'keydown') { - // If we see an entity operation event triggered from keydown event, it means the event can be triggered from original - // EntityFeatures or EntityPlugin, so we don't need to trigger the same event again from ContentModel. - // TODO: This is a temporary solution. Once Content Model can fully replace Entity Features, we can remove this. - this.triggeredEntityEvents.push(event); - } - } - private handleKeyDownEvent(editor: IContentModelEditor, event: PluginKeyDownEvent) { const rawEvent = event.rawEvent; const which = rawEvent.which; @@ -125,7 +109,7 @@ export default class ContentModelEditPlugin implements EditorPlugin { rangeEx.type == SelectionRangeTypes.Normal ? rangeEx.ranges[0] : null; if (this.shouldDeleteWithContentModel(range, rawEvent)) { - handleKeyDownEvent(editor, rawEvent, this.triggeredEntityEvents); + handleKeyDownEvent(editor, rawEvent); } else { editor.cacheContentModel(null); } @@ -144,10 +128,6 @@ export default class ContentModelEditPlugin implements EditorPlugin { break; } } - - if (this.triggeredEntityEvents.length > 0) { - this.triggeredEntityEvents = []; - } } private tryApplyDefaultFormat(editor: IContentModelEditor) { @@ -166,15 +146,8 @@ export default class ContentModelEditPlugin implements EditorPlugin { } } - formatWithContentModel(editor, 'input', model => { - const result = deleteSelection( - model, - getOnDeleteEntityCallback( - editor, - undefined /*rawEvent*/, - this.triggeredEntityEvents - ) - ); + formatWithContentModel(editor, 'input', (model, context) => { + const result = deleteSelection(model, [], context); if (result.deleteResult == DeleteResult.Range) { normalizeContentModel(model); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts index 7360aed2410..a86c294362d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/plugins/PastePlugin/ContentModelPastePlugin.ts @@ -1,6 +1,7 @@ import addParser from './utils/addParser'; import ContentModelBeforePasteEvent from '../../../publicTypes/event/ContentModelBeforePasteEvent'; -import { getPasteSource } from 'roosterjs-editor-dom'; +import { chainSanitizerCallback, getPasteSource } from 'roosterjs-editor-dom'; +import { ContentModelBlockFormat, FormatParser } from 'roosterjs-content-model-types'; import { IContentModelEditor } from '../../../publicTypes/IContentModelEditor'; import { parseDeprecatedColor } from './utils/deprecatedColorParser'; import { parseLink } from './utils/linkParser'; @@ -10,6 +11,7 @@ import { processPastedContentFromWordDesktop } from './WordDesktop/processPasted import { processPastedContentWacComponents } from './WacComponents/processPastedContentWacComponents'; import { EditorPlugin, + HtmlSanitizerOptions, IEditor, KnownPasteSourceType, PasteType, @@ -106,7 +108,32 @@ export default class ContentModelPastePlugin implements EditorPlugin { addParser(ev.domToModelOption, 'link', parseLink); parseDeprecatedColor(ev.sanitizingOption); + sanitizeBlockStyles(ev.sanitizingOption); + + if (event.pasteType === PasteType.MergeFormat) { + addParser(ev.domToModelOption, 'block', blockElementParser); + addParser(ev.domToModelOption, 'listLevel', blockElementParser); + } event.sanitizingOption.unknownTagReplacement = this.unknownTagReplacement; } } + +/** + * For block elements that have background color style, remove the background color when user selects the merge current format + * paste option + */ +const blockElementParser: FormatParser = ( + format: ContentModelBlockFormat, + element: HTMLElement +) => { + if (element.style.backgroundColor) { + delete format.backgroundColor; + } +}; + +function sanitizeBlockStyles(sanitizingOption: Required) { + chainSanitizerCallback(sanitizingOption.cssStyleCallbacks, 'display', (value: string) => { + return value != 'flex'; // return whether we keep the style + }); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts index 1782497f58c..c6e81e6238a 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/editor/utils/handleKeyboardEventCommon.ts @@ -1,41 +1,9 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; -import { DeleteResult, OnDeleteEntity } from '../../modelApi/edit/utils/DeleteSelectionStep'; -import { EntityOperationEvent, PluginEventType } from 'roosterjs-editor-types'; +import { DeleteResult } from '../../modelApi/edit/utils/DeleteSelectionStep'; +import { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { normalizeContentModel } from 'roosterjs-content-model-dom'; - -/** - * @internal - */ -export function getOnDeleteEntityCallback( - editor: IContentModelEditor, - rawEvent?: KeyboardEvent, - triggeredEntityEvents: EntityOperationEvent[] = [] -): OnDeleteEntity { - return (entity, operation) => { - if (entity.id && entity.type) { - // Only trigger entity operation event when the same event was not triggered before. - // TODO: This is a temporary solution as the event deletion is handled by both original EntityPlugin/EntityFeatures and ContentModel. - // Later when Content Model can fully replace Content Edit Features, we can remove this check. - if (!triggeredEntityEvents.some(x => x.entity.wrapper == entity.wrapper)) { - editor.triggerPluginEvent(PluginEventType.EntityOperation, { - entity: { - id: entity.id, - isReadonly: entity.isReadonly, - type: entity.type, - wrapper: entity.wrapper, - }, - operation, - rawEvent: rawEvent, - }); - } - } - - // If entity is still in editor and default behavior of event is prevented, that means plugin wants to keep this entity - // Return true to tell caller we should keep it. - return !!rawEvent?.defaultPrevented && editor.contains(entity.wrapper); - }; -} +import { PluginEventType } from 'roosterjs-editor-types'; /** * @internal @@ -45,8 +13,11 @@ export function handleKeyboardEventResult( editor: IContentModelEditor, model: ContentModelDocument, rawEvent: KeyboardEvent, - result: DeleteResult + result: DeleteResult, + context: FormatWithContentModelContext ): boolean { + context.skipUndoSnapshot = true; + switch (result) { case DeleteResult.NotDeleted: // We have not delete anything, we will let browser handle this event, so clear cached model if any since the content will be changed by browser @@ -66,7 +37,7 @@ export function handleKeyboardEventResult( if (result == DeleteResult.Range) { // A range is about to be deleted, so add an undo snapshot immediately - editor.addUndoSnapshot(); + context.skipUndoSnapshot = false; } // Trigger an event to let plugins know the content is about to be changed by Content Model keyboard editing. diff --git a/packages-content-model/roosterjs-content-model-editor/lib/index.ts b/packages-content-model/roosterjs-content-model-editor/lib/index.ts index b125c62979b..67531e9a789 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/index.ts @@ -16,6 +16,16 @@ export { export { IContentModelEditor, ContentModelEditorOptions } from './publicTypes/IContentModelEditor'; export { InsertPoint } from './publicTypes/selection/InsertPoint'; export { TableSelectionContext } from './publicTypes/selection/TableSelectionContext'; +export { + DeletedEntity, + FormatWithContentModelContext, + FormatWithContentModelOptions, + ContentModelFormatter, +} from './publicTypes/parameter/FormatWithContentModelContext'; +export { + InsertEntityOptions, + InsertEntityPosition, +} from './publicTypes/parameter/InsertEntityOptions'; export { default as insertTable } from './publicApi/table/insertTable'; export { default as formatTable } from './publicApi/table/formatTable'; @@ -46,14 +56,13 @@ export { default as getSelectedSegments } from './publicApi/selection/getSelecte export { default as setIndentation } from './publicApi/block/setIndentation'; export { default as setAlignment } from './publicApi/block/setAlignment'; export { default as setDirection } from './publicApi/block/setDirection'; -export { default as setHeaderLevel } from './publicApi/block/setHeaderLevel'; +export { default as setHeadingLevel } from './publicApi/block/setHeadingLevel'; export { default as toggleBlockQuote } from './publicApi/block/toggleBlockQuote'; export { default as setSpacing } from './publicApi/block/setSpacing'; export { default as setImageBorder } from './publicApi/image/setImageBorder'; export { default as setImageBoxShadow } from './publicApi/image/setImageBoxShadow'; export { default as changeImage } from './publicApi/image/changeImage'; export { default as getFormatState } from './publicApi/format/getFormatState'; -export { default as getSegmentFormat } from './publicApi/format/getSegmentFormat'; export { default as applyPendingFormat } from './publicApi/format/applyPendingFormat'; export { default as clearFormat } from './publicApi/format/clearFormat'; export { default as insertLink } from './publicApi/link/insertLink'; @@ -64,6 +73,8 @@ export { default as adjustImageSelection } from './publicApi/image/adjustImageSe export { default as setParagraphMargin } from './publicApi/block/setParagraphMargin'; export { default as toggleCode } from './publicApi/segment/toggleCode'; export { default as paste } from './publicApi/utils/paste'; +export { default as insertEntity } from './publicApi/entity/insertEntity'; +export { formatWithContentModel } from './publicApi/utils/formatWithContentModel'; export { default as ContentModelEditor } from './editor/ContentModelEditor'; export { default as isContentModelEditor } from './editor/isContentModelEditor'; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts index 378166ddde4..9c8a0fb3012 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/cloneModel.ts @@ -29,9 +29,26 @@ import type { /** * @internal + * Options for cloneModel API */ -export function cloneModel(model: ContentModelDocument): ContentModelDocument { - const newModel: ContentModelDocument = cloneBlockGroupBase(model); +export interface CloneModelOptions { + /** + * When pass false or not passed, the cloned model will not have cached element even they exist in original model. + * For entity and general model, a cloned wrapper element will be added into cloned model. So that the cloned model will be fully disconnected from the original one + * When pass true, cloned model will have the same cached element and element wrapper with the original model + * @default true + */ + includeCachedElement?: boolean; +} + +/** + * @internal + */ +export function cloneModel( + model: ContentModelDocument, + options?: CloneModelOptions +): ContentModelDocument { + const newModel: ContentModelDocument = cloneBlockGroupBase(model, options || {}); if (model.format) { newModel.format = Object.assign({}, model.format); @@ -40,37 +57,40 @@ export function cloneModel(model: ContentModelDocument): ContentModelDocument { return newModel; } -function cloneBlock(block: ContentModelBlock): ContentModelBlock { +function cloneBlock(block: ContentModelBlock, options: CloneModelOptions): ContentModelBlock { switch (block.blockType) { case 'BlockGroup': switch (block.blockGroupType) { case 'FormatContainer': - return cloneFormatContainer(block); + return cloneFormatContainer(block, options); case 'General': - return cloneGeneralBlock(block); + return cloneGeneralBlock(block, options); case 'ListItem': - return cloneListItem(block); + return cloneListItem(block, options); } break; case 'Divider': - return cloneDivider(block); + return cloneDivider(block, options); case 'Entity': - return cloneEntity(block); + return cloneEntity(block, options); case 'Paragraph': - return cloneParagraph(block); + return cloneParagraph(block, options); case 'Table': - return cloneTable(block); + return cloneTable(block, options); } } -function cloneSegment(segment: ContentModelSegment): ContentModelSegment { +function cloneSegment( + segment: ContentModelSegment, + options: CloneModelOptions +): ContentModelSegment { switch (segment.segmentType) { case 'Br': return cloneSegmentBase(segment); case 'Entity': - return cloneEntity(segment); + return cloneEntity(segment, options); case 'General': - return cloneGeneralSegment(segment); + return cloneGeneralSegment(segment, options); case 'Image': return cloneImage(segment); case 'SelectionMarker': @@ -108,13 +128,14 @@ function cloneBlockBase( } function cloneBlockGroupBase( - group: ContentModelBlockGroupBase + group: ContentModelBlockGroupBase, + options: CloneModelOptions ): ContentModelBlockGroupBase { const { blockGroupType, blocks } = group; return { blockGroupType: blockGroupType, - blocks: blocks.map(cloneBlock), + blocks: blocks.map(block => cloneBlock(block, options)), }; } @@ -141,24 +162,34 @@ function cloneSegmentBase( return newSegment; } -function cloneEntity(entity: ContentModelEntity): ContentModelEntity { +function cloneEntity(entity: ContentModelEntity, options: CloneModelOptions): ContentModelEntity { const { wrapper, isReadonly, type, id } = entity; return Object.assign( - { wrapper, isReadonly, type, id }, + { + wrapper: options.includeCachedElement + ? wrapper + : (wrapper.cloneNode(true /*deep*/) as HTMLElement), + isReadonly, + type, + id, + }, cloneBlockBase(entity), cloneSegmentBase(entity) ); } -function cloneParagraph(paragraph: ContentModelParagraph): ContentModelParagraph { +function cloneParagraph( + paragraph: ContentModelParagraph, + options: CloneModelOptions +): ContentModelParagraph { const { cachedElement, segments, isImplicit, decorator, segmentFormat } = paragraph; const newParagraph: ContentModelParagraph = Object.assign( { - cachedElement, + cachedElement: options.includeCachedElement ? cachedElement : undefined, isImplicit, - segments: segments.map(cloneSegment), + segments: segments.map(segment => cloneSegment(segment, options)), segmentFormat: segmentFormat ? { ...segmentFormat } : undefined, }, cloneBlockBase(paragraph), @@ -177,50 +208,65 @@ function cloneParagraph(paragraph: ContentModelParagraph): ContentModelParagraph return newParagraph; } -function cloneTable(table: ContentModelTable): ContentModelTable { +function cloneTable(table: ContentModelTable, options: CloneModelOptions): ContentModelTable { const { cachedElement, widths, rows } = table; return Object.assign( { - cachedElement, + cachedElement: options.includeCachedElement ? cachedElement : undefined, widths: Array.from(widths), - rows: rows.map(cloneTableRow), + rows: rows.map(row => cloneTableRow(row, options)), }, cloneBlockBase(table), cloneModelWithDataset(table) ); } -function cloneTableRow(row: ContentModelTableRow): ContentModelTableRow { +function cloneTableRow( + row: ContentModelTableRow, + options: CloneModelOptions +): ContentModelTableRow { const { height, cells, cachedElement } = row; return Object.assign( { height, - cachedElement, - cells: cells.map(cloneTableCell), + cachedElement: options.includeCachedElement ? cachedElement : undefined, + cells: cells.map(cell => cloneTableCell(cell, options)), }, cloneModelWithFormat(row) ); } -function cloneTableCell(cell: ContentModelTableCell): ContentModelTableCell { +function cloneTableCell( + cell: ContentModelTableCell, + options: CloneModelOptions +): ContentModelTableCell { const { cachedElement, isSelected, spanAbove, spanLeft, isHeader } = cell; return Object.assign( - { cachedElement, isSelected, spanAbove, spanLeft, isHeader }, - cloneBlockGroupBase(cell), + { + cachedElement: options.includeCachedElement ? cachedElement : undefined, + isSelected, + spanAbove, + spanLeft, + isHeader, + }, + cloneBlockGroupBase(cell, options), cloneModelWithFormat(cell), cloneModelWithDataset(cell) ); } -function cloneFormatContainer(container: ContentModelFormatContainer): ContentModelFormatContainer { +function cloneFormatContainer( + container: ContentModelFormatContainer, + options: CloneModelOptions +): ContentModelFormatContainer { const { tagName, cachedElement } = container; const newContainer: ContentModelFormatContainer = Object.assign( - { tagName, cachedElement }, + { tagName, cachedElement: options.includeCachedElement ? cachedElement : undefined }, cloneBlockBase(container), - cloneBlockGroupBase(container) + cloneBlockGroupBase(container, options) ); if (container.zeroFontSize) { @@ -230,7 +276,10 @@ function cloneFormatContainer(container: ContentModelFormatContainer): ContentMo return newContainer; } -function cloneListItem(item: ContentModelListItem): ContentModelListItem { +function cloneListItem( + item: ContentModelListItem, + options: CloneModelOptions +): ContentModelListItem { const { formatHolder, levels } = item; return Object.assign( @@ -239,7 +288,7 @@ function cloneListItem(item: ContentModelListItem): ContentModelListItem { levels: levels.map(cloneListLevel), }, cloneBlockBase(item), - cloneBlockGroupBase(item) + cloneBlockGroupBase(item, options) ); } @@ -248,16 +297,37 @@ function cloneListLevel(level: ContentModelListLevel): ContentModelListLevel { return Object.assign({ listType }, cloneModelWithFormat(level), cloneModelWithDataset(level)); } -function cloneDivider(divider: ContentModelDivider): ContentModelDivider { +function cloneDivider( + divider: ContentModelDivider, + options: CloneModelOptions +): ContentModelDivider { const { tagName, isSelected, cachedElement } = divider; - return Object.assign({ isSelected, tagName, cachedElement }, cloneBlockBase(divider)); + return Object.assign( + { + isSelected, + tagName, + cachedElement: options.includeCachedElement ? cachedElement : undefined, + }, + cloneBlockBase(divider) + ); } -function cloneGeneralBlock(general: ContentModelGeneralBlock): ContentModelGeneralBlock { +function cloneGeneralBlock( + general: ContentModelGeneralBlock, + options: CloneModelOptions +): ContentModelGeneralBlock { const { element } = general; - return Object.assign({ element }, cloneBlockBase(general), cloneBlockGroupBase(general)); + return Object.assign( + { + element: options.includeCachedElement + ? element + : (element.cloneNode(true /*deep*/) as HTMLElement), + }, + cloneBlockBase(general), + cloneBlockGroupBase(general, options) + ); } function cloneSelectionMarker(marker: ContentModelSelectionMarker): ContentModelSelectionMarker { @@ -274,8 +344,11 @@ function cloneImage(image: ContentModelImage): ContentModelImage { ); } -function cloneGeneralSegment(general: ContentModelGeneralSegment): ContentModelGeneralSegment { - return Object.assign(cloneGeneralBlock(general), cloneSegmentBase(general)); +function cloneGeneralSegment( + general: ContentModelGeneralSegment, + options: CloneModelOptions +): ContentModelGeneralSegment { + return Object.assign(cloneGeneralBlock(general, options), cloneSegmentBase(general)); } function cloneText(textSegment: ContentModelText): ContentModelText { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts index 4196d976ea3..0e28c4c3847 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/mergeModel.ts @@ -1,11 +1,11 @@ import { addSegment } from 'roosterjs-content-model-dom'; import { applyTableFormat } from '../table/applyTableFormat'; import { deleteSelection } from '../edit/deleteSelection'; +import { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; import { getClosestAncestorBlockGroupIndex } from './getClosestAncestorBlockGroupIndex'; import { getObjectKeys } from 'roosterjs-editor-dom'; import { InsertPoint } from '../../publicTypes/selection/InsertPoint'; import { normalizeTable } from '../table/normalizeTable'; -import { OnDeleteEntity } from '../edit/utils/DeleteSelectionStep'; import { createListItem, createParagraph, @@ -62,11 +62,11 @@ export interface MergeModelOption { export function mergeModel( target: ContentModelDocument, source: ContentModelDocument, - onDeleteEntity: OnDeleteEntity, + context?: FormatWithContentModelContext, options?: MergeModelOption ) { const insertPosition = - options?.insertPosition ?? deleteSelection(target, onDeleteEntity).insertPoint; + options?.insertPosition ?? deleteSelection(target, [], context).insertPoint; if (insertPosition) { if (options?.mergeFormat && options.mergeFormat != 'none') { @@ -287,7 +287,8 @@ function splitParagraph(markerPosition: InsertPoint, newParaFormat: ContentModel function insertBlock(markerPosition: InsertPoint, block: ContentModelBlock) { const { path } = markerPosition; - const newPara = splitParagraph(markerPosition, block.format); + const newParaFormat = block.blockType !== 'Paragraph' ? {} : block.format; + const newPara = splitParagraph(markerPosition, newParaFormat); const blockIndex = path[0].blocks.indexOf(newPara); if (blockIndex >= 0) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts index 803c123478f..560dc4fe087 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/common/retrieveModelFormatState.ts @@ -136,6 +136,7 @@ function retrieveSegmentFormat( mergeValue(result, 'isStrikeThrough', mergedFormat.strikethrough, isFirst); mergeValue(result, 'isSuperscript', superOrSubscript == 'super', isFirst); mergeValue(result, 'isSubscript', superOrSubscript == 'sub', isFirst); + mergeValue(result, 'letterSpacing', mergedFormat.letterSpacing, isFirst); mergeValue(result, 'fontName', mergedFormat.fontFamily, isFirst); mergeValue(result, 'fontSize', mergedFormat.fontSize, isFirst); @@ -151,12 +152,13 @@ function retrieveParagraphFormat( paragraph: ContentModelParagraph, isFirst: boolean ) { - const headerLevel = parseInt((paragraph.decorator?.tagName || '').substring(1)); - const validHeaderLevel = headerLevel >= 1 && headerLevel <= 6 ? headerLevel : undefined; + const headingLevel = parseInt((paragraph.decorator?.tagName || '').substring(1)); + const validHeadingLevel = headingLevel >= 1 && headingLevel <= 6 ? headingLevel : undefined; mergeValue(result, 'marginBottom', paragraph.format.marginBottom, isFirst); mergeValue(result, 'marginTop', paragraph.format.marginTop, isFirst); - mergeValue(result, 'headerLevel', validHeaderLevel, isFirst); + mergeValue(result, 'headingLevel', validHeadingLevel, isFirst); + mergeValue(result, 'headerLevel', validHeadingLevel, isFirst); mergeValue(result, 'textAlign', paragraph.format.textAlign, isFirst); mergeValue(result, 'direction', paragraph.format.direction, isFirst); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSelection.ts index 4819e2f4d46..26d4b9a9236 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSelection.ts @@ -1,12 +1,12 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; import { deleteExpandedSelection } from './utils/deleteExpandedSelection'; +import { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; import { DeleteResult, DeleteSelectionContext, DeleteSelectionResult, DeleteSelectionStep, ValidDeleteSelectionContext, - OnDeleteEntity, } from './utils/DeleteSelectionStep'; /** @@ -14,10 +14,10 @@ import { */ export function deleteSelection( model: ContentModelDocument, - onDeleteEntity: OnDeleteEntity, - additionalSteps: (DeleteSelectionStep | null)[] = [] + additionalSteps: (DeleteSelectionStep | null)[] = [], + formatContext?: FormatWithContentModelContext ): DeleteSelectionResult { - const context = deleteExpandedSelection(model, onDeleteEntity); + const context = deleteExpandedSelection(model, formatContext); additionalSteps.forEach(step => { if ( @@ -25,7 +25,7 @@ export function deleteSelection( isValidDeleteSelectionContext(context) && context.deleteResult == DeleteResult.NotDeleted ) { - step(context, onDeleteEntity); + step(context); } }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts index 2586e2b7282..ecc9a18fac8 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore.ts @@ -4,7 +4,7 @@ import { deleteSegment } from '../utils/deleteSegment'; /** * @internal */ -export const deleteAllSegmentBefore: DeleteSelectionStep = (context, onDeleteEntity) => { +export const deleteAllSegmentBefore: DeleteSelectionStep = context => { const { paragraph, marker } = context.insertPoint; const index = paragraph.segments.indexOf(marker); @@ -13,7 +13,7 @@ export const deleteAllSegmentBefore: DeleteSelectionStep = (context, onDeleteEnt segment.isSelected = true; - if (deleteSegment(paragraph, segment, onDeleteEntity)) { + if (deleteSegment(paragraph, segment, context.formatContext)) { context.deleteResult = DeleteResult.Range; } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteCollapsedSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteCollapsedSelection.ts index 8f4c08a329b..3512044aeab 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteCollapsedSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/deleteSteps/deleteCollapsedSelection.ts @@ -7,7 +7,7 @@ import { deleteSegment } from '../utils/deleteSegment'; import { setParagraphNotImplicit } from 'roosterjs-content-model-dom'; function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteSelectionStep { - return (context, onDeleteEntity) => { + return context => { const isForward = direction == 'forward'; const { paragraph, marker, path, tableContext } = context.insertPoint; const segments = paragraph.segments; @@ -19,7 +19,7 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS let blockToDelete: BlockAndPath | null; if (segmentToDelete) { - if (deleteSegment(paragraph, segmentToDelete, onDeleteEntity, direction)) { + if (deleteSegment(paragraph, segmentToDelete, context.formatContext, direction)) { context.deleteResult = DeleteResult.SingleChar; // It is possible that we have deleted everything from this paragraph, so we need to mark it as not implicit @@ -32,7 +32,7 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS if (block.blockType == 'Paragraph') { if (siblingSegment) { // When selection is under general segment, need to check if it has a sibling sibling, and delete from it - if (deleteSegment(block, siblingSegment, onDeleteEntity, direction)) { + if (deleteSegment(block, siblingSegment, context.formatContext, direction)) { context.deleteResult = DeleteResult.Range; } } else { @@ -58,8 +58,8 @@ function getDeleteCollapsedSelection(direction: 'forward' | 'backward'): DeleteS deleteBlock( path[0].blocks, block, - onDeleteEntity, undefined /*replacement*/, + context.formatContext, direction ) ) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/DeleteSelectionStep.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/DeleteSelectionStep.ts index f004d28586f..64bd1300ab3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/DeleteSelectionStep.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/DeleteSelectionStep.ts @@ -1,8 +1,7 @@ -import { ContentModelEntity, ContentModelParagraph } from 'roosterjs-content-model-types'; -import { EntityOperation } from 'roosterjs-editor-types'; +import { ContentModelParagraph } from 'roosterjs-content-model-types'; +import { FormatWithContentModelContext } from '../../../publicTypes/parameter/FormatWithContentModelContext'; import { InsertPoint } from '../../../publicTypes/selection/InsertPoint'; import { TableSelectionContext } from '../../../publicTypes/selection/TableSelectionContext'; -import type { CompatibleEntityOperation } from 'roosterjs-editor-types/lib/compatibleTypes'; /** * @internal @@ -28,6 +27,7 @@ export interface DeleteSelectionResult { export interface DeleteSelectionContext extends DeleteSelectionResult { lastParagraph?: ContentModelParagraph; lastTableContext?: TableSelectionContext; + formatContext?: FormatWithContentModelContext; } /** @@ -39,27 +39,5 @@ export interface ValidDeleteSelectionContext extends DeleteSelectionContext { /** * @internal - * A callback for deleteSelection API to decide how to handle an entity - * @param entity The entity to delete - * @param operation The operation of entity - * @returns True means we want to keep this entity, so deleteSelection() will not remove it. Otherwise false, - * the entity will be removed from Content Model */ -export type OnDeleteEntity = ( - entity: ContentModelEntity, - operation: - | EntityOperation.RemoveFromStart - | EntityOperation.RemoveFromEnd - | EntityOperation.Overwrite - | CompatibleEntityOperation.RemoveFromStart - | CompatibleEntityOperation.RemoveFromEnd - | CompatibleEntityOperation.Overwrite -) => boolean; - -/** - * @internal - */ -export type DeleteSelectionStep = ( - context: ValidDeleteSelectionContext, - onDeleteEntity: OnDeleteEntity -) => void; +export type DeleteSelectionStep = (context: ValidDeleteSelectionContext) => void; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteBlock.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteBlock.ts index 87ca5cd7aa4..fdc5f6ce501 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteBlock.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteBlock.ts @@ -1,6 +1,6 @@ import { ContentModelBlock } from 'roosterjs-content-model-types'; import { EntityOperation } from 'roosterjs-editor-types'; -import { OnDeleteEntity } from './DeleteSelectionStep'; +import { FormatWithContentModelContext } from '../../../publicTypes/parameter/FormatWithContentModelContext'; /** * @internal @@ -8,8 +8,8 @@ import { OnDeleteEntity } from './DeleteSelectionStep'; export function deleteBlock( blocks: ContentModelBlock[], blockToDelete: ContentModelBlock, - onDeleteEntity: OnDeleteEntity, replacement?: ContentModelBlock, + context?: FormatWithContentModelContext, direction?: 'forward' | 'backward' ): boolean { const index = blocks.indexOf(blockToDelete); @@ -29,8 +29,12 @@ export function deleteBlock( ? EntityOperation.RemoveFromEnd : undefined; - if (operation !== undefined && !onDeleteEntity(blockToDelete, operation)) { + if (operation !== undefined) { replacement ? blocks.splice(index, 1, replacement) : blocks.splice(index, 1); + context?.deletedEntities.push({ + entity: blockToDelete, + operation, + }); } return true; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts index 23ca5f22065..9b4998b6aba 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteExpandedSelection.ts @@ -1,8 +1,9 @@ import { ContentModelDocument } from 'roosterjs-content-model-types'; import { createInsertPoint } from '../utils/createInsertPoint'; import { deleteBlock } from '../utils/deleteBlock'; -import { DeleteResult, DeleteSelectionContext, OnDeleteEntity } from '../utils/DeleteSelectionStep'; +import { DeleteResult, DeleteSelectionContext } from '../utils/DeleteSelectionStep'; import { deleteSegment } from '../utils/deleteSegment'; +import { FormatWithContentModelContext } from '../../../publicTypes/parameter/FormatWithContentModelContext'; import { iterateSelections, IterateSelectionsOption } from '../../selection/iterateSelections'; import { createBr, @@ -24,11 +25,12 @@ const DeleteSelectionIteratingOptions: IterateSelectionsOption = { */ export function deleteExpandedSelection( model: ContentModelDocument, - onDeleteEntity: OnDeleteEntity + formatContext?: FormatWithContentModelContext ): DeleteSelectionContext { const context: DeleteSelectionContext = { deleteResult: DeleteResult.NotDeleted, insertPoint: null, + formatContext, }; iterateSelections( @@ -70,7 +72,7 @@ export function deleteExpandedSelection( path, tableContext ); - } else if (deleteSegment(block, segment, onDeleteEntity)) { + } else if (deleteSegment(block, segment, context.formatContext)) { context.deleteResult = DeleteResult.Range; } }); @@ -86,7 +88,7 @@ export function deleteExpandedSelection( // Delete a whole block (divider, table, ...) const blocks = path[0].blocks; - if (deleteBlock(blocks, block, onDeleteEntity, paragraph)) { + if (deleteBlock(blocks, block, paragraph, context.formatContext)) { context.deleteResult = DeleteResult.Range; } } else if (tableContext) { diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts index a0b014f0d5b..35f33db56ba 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/edit/utils/deleteSegment.ts @@ -1,9 +1,9 @@ import { ContentModelParagraph, ContentModelSegment } from 'roosterjs-content-model-types'; import { deleteSingleChar } from './deleteSingleChar'; import { EntityOperation } from 'roosterjs-editor-types'; +import { FormatWithContentModelContext } from '../../../publicTypes/parameter/FormatWithContentModelContext'; import { isWhiteSpacePreserved, normalizeSingleSegment } from 'roosterjs-content-model-dom'; import { normalizeText } from '../../../domUtils/stringUtil'; -import { OnDeleteEntity } from './DeleteSelectionStep'; /** * @internal @@ -11,7 +11,7 @@ import { OnDeleteEntity } from './DeleteSelectionStep'; export function deleteSegment( paragraph: ContentModelParagraph, segmentToDelete: ContentModelSegment, - onDeleteEntity: OnDeleteEntity, + context?: FormatWithContentModelContext, direction?: 'forward' | 'backward' ): boolean { const segments = paragraph.segments; @@ -39,8 +39,12 @@ export function deleteSegment( : isBackward ? EntityOperation.RemoveFromEnd : undefined; - if (operation !== undefined && !onDeleteEntity(segmentToDelete, operation)) { + if (operation !== undefined) { segments.splice(index, 1); + context?.deletedEntities.push({ + entity: segmentToDelete, + operation, + }); } return true; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts new file mode 100644 index 00000000000..fe5b5a6f0ec --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/entity/insertEntityModel.ts @@ -0,0 +1,107 @@ +import { + createBr, + createParagraph, + createSelectionMarker, + normalizeContentModel, +} from 'roosterjs-content-model-dom'; +import { DeleteResult, DeleteSelectionResult } from '../edit/utils/DeleteSelectionStep'; +import { deleteSelection } from '../edit/deleteSelection'; +import { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; +import { getClosestAncestorBlockGroupIndex } from '../common/getClosestAncestorBlockGroupIndex'; +import { InsertEntityPosition } from '../../publicTypes/parameter/InsertEntityOptions'; +import { setSelection } from '../selection/setSelection'; +import { + ContentModelBlock, + ContentModelBlockGroup, + ContentModelDocument, + ContentModelEntity, + ContentModelParagraph, +} from 'roosterjs-content-model-types'; + +/** + * @internal + */ +export function insertEntityModel( + model: ContentModelDocument, + entityModel: ContentModelEntity, + position: InsertEntityPosition, + isBlock: boolean, + focusAfterEntity?: boolean, + context?: FormatWithContentModelContext +) { + let blockParent: ContentModelBlockGroup | undefined; + let blockIndex = -1; + let deleteResult: DeleteSelectionResult; + + if (position == 'begin' || position == 'end') { + blockParent = model; + blockIndex = position == 'begin' ? 0 : model.blocks.length; + } else if ((deleteResult = deleteSelection(model, [], context)).insertPoint) { + const { marker, paragraph, path } = deleteResult.insertPoint; + + if (deleteResult.deleteResult == DeleteResult.Range) { + normalizeContentModel(model); + } + + if (!isBlock) { + const index = paragraph.segments.indexOf(marker); + + if (index >= 0) { + paragraph.segments.splice(focusAfterEntity ? index : index + 1, 0, entityModel); + } + } else { + const pathIndex = + position == 'root' + ? getClosestAncestorBlockGroupIndex(path, ['TableCell', 'Document']) + : 0; + blockParent = path[pathIndex]; + const child = path[pathIndex - 1]; + const directChild: ContentModelBlock = + child?.blockGroupType == 'FormatContainer' || + child?.blockGroupType == 'General' || + child?.blockGroupType == 'ListItem' + ? child + : paragraph; + const childIndex = blockParent.blocks.indexOf(directChild); + blockIndex = childIndex >= 0 ? childIndex + 1 : -1; + } + } + + if (blockIndex >= 0 && blockParent) { + const blocksToInsert: ContentModelBlock[] = []; + let nextParagraph: ContentModelParagraph | undefined; + + if (isBlock) { + const nextBlock = blockParent.blocks[blockIndex]; + + blocksToInsert.push(entityModel); + + if (nextBlock?.blockType == 'Paragraph') { + nextParagraph = nextBlock; + } else if (!nextBlock || nextBlock.blockType == 'Entity' || focusAfterEntity) { + nextParagraph = createParagraph(false /*isImplicit*/, {}, model.format); + nextParagraph.segments.push(createBr(model.format)); + blocksToInsert.push(nextParagraph); + } + } else { + nextParagraph = createParagraph( + false /*isImplicit*/, + undefined /*format*/, + model.format + ); + + nextParagraph.segments.push(entityModel); + blocksToInsert.push(nextParagraph); + } + + blockParent.blocks.splice(blockIndex, 0, ...blocksToInsert); + + if (focusAfterEntity && nextParagraph) { + const marker = createSelectionMarker(nextParagraph.segments[0]?.format || model.format); + const segments = nextParagraph.segments; + + isBlock ? segments.unshift(marker) : segments.push(marker); + setSelection(model, marker, marker); + } + } +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts index 2cbab5bd76e..ef737bdab62 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/selection/collectSelections.ts @@ -115,10 +115,10 @@ export function getOperationalBlocks( */ export function getFirstSelectedTable( model: ContentModelDocument -): [ContentModelTable | undefined, ContentModelBlockGroup | undefined] { +): [ContentModelTable | undefined, ContentModelBlockGroup[]] { const selections = collectSelections(model, { includeListFormatHolder: 'never' }); let table: ContentModelTable | undefined; - let parent: ContentModelBlockGroup | undefined; + let resultPath: ContentModelBlockGroup[] = []; removeUnmeaningfulSelections(selections); @@ -126,15 +126,20 @@ export function getFirstSelectedTable( if (!table) { if (block?.blockType == 'Table') { table = block; - parent = path[0]; + resultPath = [...path]; } else if (tableContext?.table) { table = tableContext.table; - parent = path.filter(group => group.blocks.indexOf(tableContext.table) >= 0)[0]; + + const parent = path.filter( + group => group.blocks.indexOf(tableContext.table) >= 0 + )[0]; + const index = path.indexOf(parent); + resultPath = index >= 0 ? path.slice(index) : []; } } }); - return [table, parent]; + return [table, resultPath]; } /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/ensureFocusableParagraphForTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/ensureFocusableParagraphForTable.ts new file mode 100644 index 00000000000..81e72372f23 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/modelApi/table/ensureFocusableParagraphForTable.ts @@ -0,0 +1,74 @@ +import { createBr, createParagraph } from 'roosterjs-content-model-dom'; +import { + ContentModelBlock, + ContentModelBlockGroup, + ContentModelDocument, + ContentModelParagraph, + ContentModelTable, +} from 'roosterjs-content-model-types'; + +/** + * @internal + * After edit table, it maybe in a abnormal state, e.g. selected table cell is removed, or all rows are removed causes no place to put cursor. + * We need to make sure table is in normal state, and there is a place to put cursor. + * @returns a new paragraph that can but put focus in, or undefined if not needed + */ +export function ensureFocusableParagraphForTable( + model: ContentModelDocument, + path: ContentModelBlockGroup[], + table: ContentModelTable +): ContentModelParagraph | undefined { + let paragraph: ContentModelParagraph | undefined; + const firstCell = table.rows.filter(row => row.cells.length > 0)[0]?.cells[0]; + + if (firstCell) { + // When there is a valid cell to put focus, use it + paragraph = firstCell.blocks.filter( + (block): block is ContentModelParagraph => block.blockType == 'Paragraph' + )[0]; + + if (!paragraph) { + // If there is not a paragraph under this cell, create one + paragraph = createEmptyParagraph(model); + firstCell.blocks.push(paragraph); + } + } else { + // No table cell at all, which means the whole table is deleted. So we need to remove it from content model. + let block: ContentModelBlock = table; + let parent: ContentModelBlockGroup | undefined; + paragraph = createEmptyParagraph(model); + + // If the table is the only block of its parent and parent is a FormatContainer, remove the parent as well. + // We need to do this in a loop in case there are multiple layer of FormatContainer that match this case + while ((parent = path.shift())) { + const index = parent.blocks.indexOf(block) ?? -1; + + if (parent && index >= 0) { + parent.blocks.splice(index, 1, paragraph); + } + + if ( + parent.blockGroupType == 'FormatContainer' && + parent.blocks.length == 1 && + parent.blocks[0] == paragraph + ) { + // If the new paragraph is the only child of parent format container, unwrap parent as well + block = parent; + } else { + // Otherwise, just stop here and keep processing the new paragraph + break; + } + } + } + + return paragraph; +} + +function createEmptyParagraph(model: ContentModelDocument) { + const newPara = createParagraph(false /*isImplicit*/, undefined /*blockFormat*/, model.format); + const br = createBr(model.format); + + newPara.segments.push(br); + + return newPara; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeaderLevel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts similarity index 56% rename from packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeaderLevel.ts rename to packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts index 3cb2e57f4ef..cbb797b2107 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeaderLevel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/block/setHeadingLevel.ts @@ -6,29 +6,29 @@ import { ContentModelSegmentFormat, } from 'roosterjs-content-model-types'; -type HeaderLevelTags = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; +type HeadingLevelTags = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; /** - * Set header level of selected paragraphs - * @param editor The editor to set header level to - * @param headerLevel Level of header, from 1 to 6. Set to 0 means set it back to a regular paragraph + * Set heading level of selected paragraphs + * @param editor The editor to set heading level to + * @param headingLevel Level of heading, from 1 to 6. Set to 0 means set it back to a regular paragraph */ -export default function setHeaderLevel( +export default function setHeadingLevel( editor: IContentModelEditor, - headerLevel: 0 | 1 | 2 | 3 | 4 | 5 | 6 + headingLevel: 0 | 1 | 2 | 3 | 4 | 5 | 6 ) { - formatParagraphWithContentModel(editor, 'setHeaderLevel', para => { + formatParagraphWithContentModel(editor, 'setHeadingLevel', para => { const tagName = - headerLevel > 0 - ? (('h' + headerLevel) as HeaderLevelTags | null) - : getExistingHeaderHeaderTag(para.decorator); - const headerStyle = + headingLevel > 0 + ? (('h' + headingLevel) as HeadingLevelTags | null) + : getExistingHeadingTag(para.decorator); + const headingStyle = (tagName && (defaultImplicitFormatMap[tagName] as ContentModelSegmentFormat)) || {}; - if (headerLevel > 0) { + if (headingLevel > 0) { para.decorator = { tagName: tagName!, - format: { ...headerStyle }, + format: { ...headingStyle }, }; // Remove existing formats since tags have default font size and weight @@ -42,11 +42,11 @@ export default function setHeaderLevel( }); } -function getExistingHeaderHeaderTag( +function getExistingHeadingTag( decorator?: ContentModelParagraphDecorator -): HeaderLevelTags | null { +): HeadingLevelTags | null { const tag = decorator?.tagName || ''; const level = parseInt(tag.substring(1)); - return level >= 1 && level <= 6 ? (tag as HeaderLevelTags) : null; + return level >= 1 && level <= 6 ? (tag as HeadingLevelTags) : null; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/handleKeyDownEvent.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/handleKeyDownEvent.ts index fd3a4262b1b..6b477dd2e42 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/handleKeyDownEvent.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/editing/handleKeyDownEvent.ts @@ -1,12 +1,11 @@ import { Browser } from 'roosterjs-editor-dom'; -import { ChangeSource, EntityOperationEvent, Keys } from 'roosterjs-editor-types'; +import { ChangeSource, Keys } from 'roosterjs-editor-types'; import { deleteAllSegmentBefore } from '../../modelApi/edit/deleteSteps/deleteAllSegmentBefore'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; import { DeleteSelectionStep } from '../../modelApi/edit/utils/DeleteSelectionStep'; import { formatWithContentModel } from '../utils/formatWithContentModel'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { - getOnDeleteEntityCallback, handleKeyboardEventResult, shouldDeleteAllSegmentsBefore, shouldDeleteWord, @@ -25,27 +24,19 @@ import { * Handle KeyDown event * Currently only DELETE and BACKSPACE keys are supported */ -export default function handleKeyDownEvent( - editor: IContentModelEditor, - rawEvent: KeyboardEvent, - triggeredEntityEvents: EntityOperationEvent[] -) { +export default function handleKeyDownEvent(editor: IContentModelEditor, rawEvent: KeyboardEvent) { const which = rawEvent.which; formatWithContentModel( editor, which == Keys.DELETE ? 'handleDeleteKey' : 'handleBackspaceKey', - model => { - const result = deleteSelection( - model, - getOnDeleteEntityCallback(editor, rawEvent, triggeredEntityEvents), - getDeleteSteps(rawEvent) - ).deleteResult; + (model, context) => { + const result = deleteSelection(model, getDeleteSteps(rawEvent), context).deleteResult; - return handleKeyboardEventResult(editor, model, rawEvent, result); + return handleKeyboardEventResult(editor, model, rawEvent, result, context); }, { - skipUndoSnapshot: true, // No need to add undo snapshot for each key down event. We will trigger a ContentChanged event and let UndoPlugin decide when to add undo snapshot + rawEvent, changeSource: ChangeSource.Keyboard, getChangeData: () => which, } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts new file mode 100644 index 00000000000..7fafcddff5e --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/entity/insertEntity.ts @@ -0,0 +1,105 @@ +import { ChangeSource, Entity, SelectionRangeEx } from 'roosterjs-editor-types'; +import { commitEntity, getEntityFromElement } from 'roosterjs-editor-dom'; +import { createEntity } from 'roosterjs-content-model-dom'; +import { formatWithContentModel } from '../utils/formatWithContentModel'; +import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; +import { insertEntityModel } from '../../modelApi/entity/insertEntityModel'; +import { + InsertEntityOptions, + InsertEntityPosition, +} from '../../publicTypes/parameter/InsertEntityOptions'; + +const BlockEntityTag = 'div'; +const InlineEntityTag = 'span'; + +/** + * Insert an entity into editor + * @param editor The Content Model editor + * @param type Type of entity + * @param isBlock True to insert a block entity, false to insert an inline entity + * @param position Position of the entity to insert. It can be + * Value of InsertEntityPosition: see InsertEntityPosition + * selectionRangeEx: Use this range instead of current focus position to insert. After insert, focus will be moved to + * the beginning of this range (when focusAfterEntity is not set to true) or after the new entity (when focusAfterEntity is set to true) + * @param options Move options to insert. See InsertEntityOptions + */ +export default function insertEntity( + editor: IContentModelEditor, + type: string, + isBlock: boolean, + position: 'focus' | 'begin' | 'end' | SelectionRangeEx, + options?: InsertEntityOptions +): Entity | null; + +/** + * Insert a block entity into editor + * @param editor The Content Model editor + * @param type Type of entity + * @param isBlock Must be true for a block entity + * @param position Position of the entity to insert. It can be + * Value of InsertEntityPosition: see InsertEntityPosition + * selectionRangeEx: Use this range instead of current focus position to insert. After insert, focus will be moved to + * the beginning of this range (when focusAfterEntity is not set to true) or after the new entity (when focusAfterEntity is set to true) + * @param options Move options to insert. See InsertEntityOptions + */ +export default function insertEntity( + editor: IContentModelEditor, + type: string, + isBlock: true, + position: InsertEntityPosition | SelectionRangeEx, + options?: InsertEntityOptions +): Entity | null; + +export default function insertEntity( + editor: IContentModelEditor, + type: string, + isBlock: boolean, + position?: InsertEntityPosition | SelectionRangeEx, + options?: InsertEntityOptions +): Entity | null { + const { contentNode, focusAfterEntity, wrapperDisplay, skipUndoSnapshot } = options || {}; + const wrapper = editor.getDocument().createElement(isBlock ? BlockEntityTag : InlineEntityTag); + const display = wrapperDisplay ?? (isBlock ? undefined : 'inline-block'); + + wrapper.style.setProperty('display', display || null); + + if (contentNode) { + wrapper.appendChild(contentNode); + } + + commitEntity(wrapper, type, true /*isReadonly*/); + + const entityModel = createEntity(wrapper, true /*isReadonly*/, type); + + formatWithContentModel( + editor, + 'insertEntity', + (model, context) => { + insertEntityModel( + model, + entityModel, + typeof position == 'string' ? position : 'focus', + isBlock, + isBlock ? focusAfterEntity : true, + context + ); + + context.skipUndoSnapshot = skipUndoSnapshot; + + return true; + }, + { + selectionOverride: typeof position === 'object' ? position : undefined, + } + ); + + if (editor.isDarkMode()) { + editor.transformToDarkColor(wrapper); + } + + const newEntity = getEntityFromElement(wrapper); + + editor.triggerContentChangedEvent(ChangeSource.InsertEntity, newEntity); + + return newEntity; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts index ad7c79eb773..50293fb72a6 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/applyPendingFormat.ts @@ -22,58 +22,49 @@ export default function applyPendingFormat(editor: IContentModelEditor, data: st if (format) { let isChanged = false; - formatWithContentModel( - editor, - 'applyPendingFormat', - model => { - iterateSelections([model], (_, __, block, segments) => { - if ( - block?.blockType == 'Paragraph' && - segments?.length == 1 && - segments[0].segmentType == 'SelectionMarker' - ) { - const marker = segments[0]; - const index = block.segments.indexOf(marker); - const previousSegment = block.segments[index - 1]; + formatWithContentModel(editor, 'applyPendingFormat', (model, context) => { + iterateSelections([model], (_, __, block, segments) => { + if ( + block?.blockType == 'Paragraph' && + segments?.length == 1 && + segments[0].segmentType == 'SelectionMarker' + ) { + const marker = segments[0]; + const index = block.segments.indexOf(marker); + const previousSegment = block.segments[index - 1]; - if (previousSegment?.segmentType == 'Text') { - const text = previousSegment.text; - const subStr = text.substr(-data.length, data.length); + if (previousSegment?.segmentType == 'Text') { + const text = previousSegment.text; + const subStr = text.substr(-data.length, data.length); - // For space, there can be (space) or   ( ), we treat them as the same - if ( - subStr == data || - (data == ANSI_SPACE && subStr == NON_BREAK_SPACE) - ) { - marker.format = { ...format }; - previousSegment.text = text.substring(0, text.length - data.length); + // For space, there can be (space) or   ( ), we treat them as the same + if (subStr == data || (data == ANSI_SPACE && subStr == NON_BREAK_SPACE)) { + marker.format = { ...format }; + previousSegment.text = text.substring(0, text.length - data.length); - const newText = createText( - data == ANSI_SPACE ? NON_BREAK_SPACE : data, - { - ...previousSegment.format, - ...format, - } - ); + const newText = createText( + data == ANSI_SPACE ? NON_BREAK_SPACE : data, + { + ...previousSegment.format, + ...format, + } + ); - block.segments.splice(index, 0, newText); - setParagraphNotImplicit(block); - isChanged = true; - } + block.segments.splice(index, 0, newText); + setParagraphNotImplicit(block); + isChanged = true; } } - return true; - }); - - if (isChanged) { - normalizeContentModel(model); } + return true; + }); - return isChanged; - }, - { - skipUndoSnapshot: true, + if (isChanged) { + normalizeContentModel(model); + context.skipUndoSnapshot = true; } - ); + + return isChanged; + }); } } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts index b77a4007a2e..42dbb47f7e3 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getFormatState.ts @@ -1,35 +1,122 @@ -import { FormatState } from 'roosterjs-editor-types'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; +import { contains, getTagOfNode } from 'roosterjs-editor-dom'; +import { ContentModelBlockGroup, DomToModelContext } from 'roosterjs-content-model-types'; +import { ContentModelFormatState } from '../../publicTypes/format/formatState/ContentModelFormatState'; import { getPendingFormat } from '../../modelApi/format/pendingFormat'; +import { getSelectionRootNode } from '../../modelApi/selection/getSelectionRootNode'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { retrieveModelFormatState } from '../../modelApi/common/retrieveModelFormatState'; +import { + getRegularSelectionOffsets, + handleRegularSelection, + processChildNode, +} from 'roosterjs-content-model-dom'; /** * Get current format state * @param editor The editor to get format from */ -export default function getFormatState(editor: IContentModelEditor): FormatState { - let result: FormatState = { +export default function getFormatState(editor: IContentModelEditor): ContentModelFormatState { + const pendingFormat = getPendingFormat(editor); + const model = editor.createContentModel({ + processorOverride: { + child: reducedModelChildProcessor, + }, + }); + const result: ContentModelFormatState = { ...editor.getUndoState(), - isDarkMode: editor.isDarkMode(), zoomScale: editor.getZoomScale(), }; - formatWithContentModel( - editor, - 'getFormatState', - model => { - const pendingFormat = getPendingFormat(editor); + retrieveModelFormatState(model, pendingFormat, result); - retrieveModelFormatState(model, pendingFormat, result); + return result; +} - return false; - }, - { - useReducedModel: true, +/** + * @internal + */ +interface FormatStateContext extends DomToModelContext { + /** + * An optional stack of parent elements to process. When provided, the child nodes of current parent element will be ignored, + * but use the top element in this stack instead in childProcessor. + */ + nodeStack?: Node[]; +} + +/** + * @internal + * Export for test only + * In order to get format, we can still use the regular child processor. However, to improve performance, we don't need to create + * content model for the whole doc, instead we only need to traverse the tree path that can arrive current selected node. + * This "reduced" child processor will first create a node stack that stores DOM node from root to current common ancestor node of selection, + * then use this stack as a faked DOM tree to create a reduced content model which we can use to retrieve format state + */ +export function reducedModelChildProcessor( + group: ContentModelBlockGroup, + parent: ParentNode, + context: FormatStateContext +) { + const selectionRootNode = getSelectionRootNode(context.rangeEx); + + if (selectionRootNode) { + if (!context.nodeStack) { + context.nodeStack = createNodeStack(parent, selectionRootNode); } - ); + + const stackChild = context.nodeStack.pop(); + + if (stackChild) { + const [nodeStartOffset, nodeEndOffset] = getRegularSelectionOffsets(context, parent); + + // If selection is not on this node, skip getting node index to save some time since we don't need it here + const index = + nodeStartOffset >= 0 || nodeEndOffset >= 0 ? getChildIndex(parent, stackChild) : -1; + + if (index >= 0) { + handleRegularSelection(index, context, group, nodeStartOffset, nodeEndOffset); + } + + processChildNode(group, stackChild, context); + + if (index >= 0) { + handleRegularSelection(index + 1, context, group, nodeStartOffset, nodeEndOffset); + } + } else { + // No child node from node stack, that means we have reached the deepest node of selection. + // Now we can use default child processor to perform full sub tree scanning for content model, + // So that all selected node will be included. + context.defaultElementProcessors.child(group, parent, context); + } + } +} + +function createNodeStack(root: Node, startNode: Node): Node[] { + const result: Node[] = []; + let node: Node | null = startNode; + + while (node && contains(root, node)) { + if (getTagOfNode(node) == 'TABLE') { + // For table, we can't do a reduced model creation since we need to handle their cells and indexes, + // so clean up whatever we already have, and just put table into the stack + result.splice(0, result.length, node); + } else { + result.push(node); + } + + node = node.parentNode; + } return result; } + +function getChildIndex(parent: ParentNode, stackChild: Node) { + let index = 0; + let child = parent.firstChild; + + while (child && child != stackChild) { + index++; + child = child.nextSibling; + } + return index; +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getSegmentFormat.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getSegmentFormat.ts deleted file mode 100644 index a11c42ae891..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/format/getSegmentFormat.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ContentModelSegmentFormat } from 'roosterjs-content-model-types'; -import { formatWithContentModel } from '../utils/formatWithContentModel'; -import { getPendingFormat } from '../../modelApi/format/pendingFormat'; -import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import { iterateSelections } from '../../modelApi/selection/iterateSelections'; - -/** - * Get current segment format. This is usually used by format painter - * @param editor The editor to get format from - */ -export default function getSegmentFormat( - editor: IContentModelEditor -): ContentModelSegmentFormat | null { - let result = getPendingFormat(editor); - - if (!result) { - formatWithContentModel( - editor, - 'getSegmentFormat', - model => { - iterateSelections( - [model], - (path, tableContext, block, segments) => { - result = segments?.[0]?.format || null; - return true; - }, - { - includeListFormatHolder: 'never', - } - ); - - return false; - }, - { - useReducedModel: true, - } - ); - } - - return result; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts index fefa1833923..b3a47c880df 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/image/insertImage.ts @@ -1,6 +1,5 @@ import { addSegment, createContentModelDocument, createImage } from 'roosterjs-content-model-dom'; import { formatWithContentModel } from '../utils/formatWithContentModel'; -import { getOnDeleteEntityCallback } from '../../editor/utils/handleKeyboardEventCommon'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { mergeModel } from '../../modelApi/common/mergeModel'; import { readFile } from 'roosterjs-editor-dom'; @@ -23,12 +22,12 @@ export default function insertImage(editor: IContentModelEditor, imageFileOrSrc: } function insertImageWithSrc(editor: IContentModelEditor, src: string) { - formatWithContentModel(editor, 'insertImage', model => { + formatWithContentModel(editor, 'insertImage', (model, context) => { const image = createImage(src, { backgroundColor: '' }); const doc = createContentModelDocument(); addSegment(doc, image); - mergeModel(model, doc, getOnDeleteEntityCallback(editor), { + mergeModel(model, doc, context, { mergeFormat: 'mergeAll', }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts index d2dc3b872b3..fd61d92252f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/link/insertLink.ts @@ -2,7 +2,6 @@ import getSelectedSegments from '../selection/getSelectedSegments'; import { ChangeSource } from 'roosterjs-editor-types'; import { ContentModelLink } from 'roosterjs-content-model-types'; import { formatWithContentModel } from '../utils/formatWithContentModel'; -import { getOnDeleteEntityCallback } from '../../editor/utils/handleKeyboardEventCommon'; import { getPendingFormat } from '../../modelApi/format/pendingFormat'; import { HtmlSanitizer, matchLink } from 'roosterjs-editor-dom'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -60,7 +59,7 @@ export default function insertLink( formatWithContentModel( editor, 'insertLink', - model => { + (model, context) => { const segments = getSelectedSegments(model, false /*includingFormatHolder*/); const originalText = segments .map(x => (x.segmentType == 'Text' ? x.text : '')) @@ -95,7 +94,7 @@ export default function insertLink( links.push(segment.link); } - mergeModel(model, doc, getOnDeleteEntityCallback(editor), { + mergeModel(model, doc, context, { mergeFormat: 'mergeAll', }); } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts index aab8911c72c..f6c2e5fa93f 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/editTable.ts @@ -5,6 +5,7 @@ import { applyTableFormat } from '../../modelApi/table/applyTableFormat'; import { deleteTable } from '../../modelApi/table/deleteTable'; import { deleteTableColumn } from '../../modelApi/table/deleteTableColumn'; import { deleteTableRow } from '../../modelApi/table/deleteTableRow'; +import { ensureFocusableParagraphForTable } from '../../modelApi/table/ensureFocusableParagraphForTable'; import { formatWithContentModel } from '../utils/formatWithContentModel'; import { getFirstSelectedTable } from '../../modelApi/selection/collectSelections'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; @@ -19,14 +20,6 @@ import { splitTableCellHorizontally } from '../../modelApi/table/splitTableCellH import { splitTableCellVertically } from '../../modelApi/table/splitTableCellVertically'; import { TableOperation } from 'roosterjs-editor-types'; import { - ContentModelBlockGroup, - ContentModelDocument, - ContentModelParagraph, - ContentModelTable, -} from 'roosterjs-content-model-types'; -import { - createBr, - createParagraph, createSelectionMarker, hasMetadata, setParagraphNotImplicit, @@ -39,7 +32,7 @@ import { */ export default function editTable(editor: IContentModelEditor, operation: TableOperation) { formatWithContentModel(editor, 'editTable', model => { - const [tableModel, parent] = getFirstSelectedTable(model); + const [tableModel, path] = getFirstSelectedTable(model); if (tableModel) { switch (operation) { @@ -104,7 +97,17 @@ export default function editTable(editor: IContentModelEditor, operation: TableO break; } - ensureTableSelection(model, parent, tableModel); + if (!hasSelectionInBlock(tableModel)) { + const paragraph = ensureFocusableParagraphForTable(model, path, tableModel); + + if (paragraph) { + const marker = createSelectionMarker(model.format); + + paragraph.segments.unshift(marker); + setParagraphNotImplicit(paragraph); + setSelection(model, marker); + } + } normalizeTable(tableModel); @@ -118,49 +121,3 @@ export default function editTable(editor: IContentModelEditor, operation: TableO } }); } - -function ensureTableSelection( - model: ContentModelDocument, - parent: ContentModelBlockGroup | undefined, - table: ContentModelTable -) { - if (!hasSelectionInBlock(table)) { - let paragraph: ContentModelParagraph | undefined; - const firstCell = table.rows.filter(row => row.cells.length > 0)[0]?.cells[0]; - - if (firstCell) { - paragraph = firstCell.blocks.filter( - (block): block is ContentModelParagraph => block.blockType == 'Paragraph' - )[0]; - - if (!paragraph) { - paragraph = createEmptyParagraph(model); - firstCell.blocks.push(paragraph); - } - } else if (parent) { - const index = parent.blocks.indexOf(table); - - if (index >= 0) { - paragraph = createEmptyParagraph(model); - parent.blocks.splice(index, 1, paragraph); - } - } - - if (paragraph) { - const marker = createSelectionMarker(model.format); - - paragraph.segments.unshift(marker); - setParagraphNotImplicit(paragraph); - setSelection(model, marker); - } - } -} - -function createEmptyParagraph(model: ContentModelDocument) { - const newPara = createParagraph(false /*isImplicit*/, undefined /*blockFormat*/, model.format); - const br = createBr(model.format); - - newPara.segments.push(br); - - return newPara; -} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts index 6536ed69b23..530d2a46785 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/table/insertTable.ts @@ -3,7 +3,6 @@ import { createContentModelDocument, createSelectionMarker } from 'roosterjs-con import { createTableStructure } from '../../modelApi/table/createTableStructure'; import { deleteSelection } from '../../modelApi/edit/deleteSelection'; import { formatWithContentModel } from '../utils/formatWithContentModel'; -import { getOnDeleteEntityCallback } from '../../editor/utils/handleKeyboardEventCommon'; import { getPendingFormat } from '../../modelApi/format/pendingFormat'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { mergeModel } from '../../modelApi/common/mergeModel'; @@ -26,9 +25,8 @@ export default function insertTable( rows: number, format?: Partial ) { - formatWithContentModel(editor, 'insertTable', model => { - const onDeleteEntity = getOnDeleteEntityCallback(editor); - const insertPosition = deleteSelection(model, onDeleteEntity).insertPoint; + formatWithContentModel(editor, 'insertTable', (model, context) => { + const insertPosition = deleteSelection(model, [], context).insertPoint; if (insertPosition) { const doc = createContentModelDocument(); @@ -38,7 +36,7 @@ export default function insertTable( // Assign default vertical align format = format || { verticalAlign: 'top' }; applyTableFormat(table, format); - mergeModel(model, doc, onDeleteEntity, { + mergeModel(model, doc, context, { insertPosition, mergeFormat: 'mergeAll', }); diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts index 8791c50ef3b..1d3d9ec6a4d 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/formatWithContentModel.ts @@ -1,79 +1,49 @@ -import { ChangeSource } from 'roosterjs-editor-types'; -import { - ContentModelDocument, - DomToModelOption, - OnNodeCreated, -} from 'roosterjs-content-model-types'; +import { ChangeSource, PluginEventType } from 'roosterjs-editor-types'; import { getPendingFormat, setPendingFormat } from '../../modelApi/format/pendingFormat'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; -import { reducedModelChildProcessor } from '../../domToModel/processors/reducedModelChildProcessor'; - -/** - * @internal - */ -export interface FormatWithContentModelOptions { - /** - * When set to true, it will only create Content Model for selected content - */ - useReducedModel?: boolean; - - /** - * When set to true, if there is pending format, it will be preserved after this format operation is done - */ - preservePendingFormat?: boolean; - - /** - * When pass true, skip adding undo snapshot when write Content Model back to DOM - */ - skipUndoSnapshot?: boolean; - - /** - * Change source used for triggering a ContentChanged event. @default ChangeSource.Format. - */ - changeSource?: string; - - /** - * An optional callback that will be called when a DOM node is created - * @param modelElement The related Content Model element - * @param node The node created for this model element - */ - onNodeCreated?: OnNodeCreated; - - /** - * Optional callback to get an object used for change data in ContentChangedEvent - */ - getChangeData?: () => any; -} +import { + ContentModelFormatter, + FormatWithContentModelContext, + FormatWithContentModelOptions, +} from '../../publicTypes/parameter/FormatWithContentModelContext'; /** - * @internal + * The general API to do format change with Content Model + * It will grab a Content Model for current editor content, and invoke a callback function + * to do format change. Then according to the return value, write back the modified content model into editor. + * If there is cached model, it will be used and updated. + * @param editor Content Model editor + * @param apiName Name of the format API + * @param formatter Formatter function, see ContentModelFormatter + * @param options More options, see FormatWithContentModelOptions */ export function formatWithContentModel( editor: IContentModelEditor, apiName: string, - callback: (model: ContentModelDocument) => boolean, + formatter: ContentModelFormatter, options?: FormatWithContentModelOptions ) { const { - useReducedModel, onNodeCreated, preservePendingFormat, getChangeData, - skipUndoSnapshot, changeSource, + rawEvent, + selectionOverride, } = options || {}; - const domToModelOption: DomToModelOption | undefined = useReducedModel - ? { - processorOverride: { - child: reducedModelChildProcessor, - }, - } - : undefined; - const model = editor.createContentModel(domToModelOption); - if (callback(model)) { + editor.focus(); + + const model = editor.createContentModel(undefined /*option*/, selectionOverride); + const context: FormatWithContentModelContext = { + deletedEntities: [], + rawEvent, + }; + + if (formatter(model, context)) { const callback = () => { - editor.focus(); + handleDeletedEntities(editor, context); + if (model) { editor.setContentModel(model, { onNodeCreated }); } @@ -90,11 +60,11 @@ export function formatWithContentModel( return getChangeData?.(); }; - if (skipUndoSnapshot) { - callback(); + if (context.skipUndoSnapshot) { + const contentChangedEventData = callback(); if (changeSource) { - editor.triggerContentChangedEvent(changeSource, getChangeData?.()); + editor.triggerContentChangedEvent(changeSource, contentChangedEventData); } } else { editor.addUndoSnapshot( @@ -110,3 +80,23 @@ export function formatWithContentModel( editor.cacheContentModel?.(model); } } + +function handleDeletedEntities( + editor: IContentModelEditor, + context: FormatWithContentModelContext +) { + context.deletedEntities.forEach(({ entity, operation }) => { + if (entity.id && entity.type) { + editor.triggerPluginEvent(PluginEventType.EntityOperation, { + entity: { + id: entity.id, + isReadonly: entity.isReadonly, + type: entity.type, + wrapper: entity.wrapper, + }, + operation, + rawEvent: context.rawEvent, + }); + } + }); +} diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts index e7201027454..0196320f264 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicApi/utils/paste.ts @@ -1,10 +1,10 @@ -import { ContentModelBlockFormat, FormatParser } from 'roosterjs-content-model-types'; import { domToContentModel } from 'roosterjs-content-model-dom'; import { formatWithContentModel } from './formatWithContentModel'; -import { getOnDeleteEntityCallback } from '../../editor/utils/handleKeyboardEventCommon'; +import { FormatWithContentModelContext } from '../../publicTypes/parameter/FormatWithContentModelContext'; import { IContentModelEditor } from '../../publicTypes/IContentModelEditor'; import { mergeModel } from '../../modelApi/common/mergeModel'; import { NodePosition } from 'roosterjs-editor-types'; +import { ContentModelDocument } from 'roosterjs-content-model-types'; import ContentModelBeforePasteEvent, { ContentModelBeforePasteEventData, } from '../../publicTypes/event/ContentModelBeforePasteEvent'; @@ -53,7 +53,11 @@ export default function paste( getPasteType(pasteAsText, applyCurrentFormat, pasteAsImage) ); - const { pluginEvent, fragment } = triggerPluginEventAndCreatePasteFragment( + const { + domToModelOption, + fragment, + customizedMerge, + } = triggerPluginEventAndCreatePasteFragment( editor, clipboardData, null /* position */, @@ -62,39 +66,14 @@ export default function paste( eventData ); - const pasteModel = domToContentModel(fragment, { - ...pluginEvent.domToModelOption, - disableCacheElement: true, - additionalFormatParsers: { - ...pluginEvent.domToModelOption.additionalFormatParsers, - block: [ - ...(pluginEvent.domToModelOption.additionalFormatParsers?.block || []), - ...(applyCurrentFormat ? [blockElementParser] : []), - ], - listLevel: [ - ...(pluginEvent.domToModelOption.additionalFormatParsers?.listLevel || []), - ...(applyCurrentFormat ? [blockElementParser] : []), - ], - }, - }); + const pasteModel = domToContentModel(fragment, domToModelOption); if (pasteModel) { formatWithContentModel( editor, 'Paste', - model => { - if (pluginEvent.customizedMerge) { - pluginEvent.customizedMerge(model, pasteModel); - } else { - mergeModel(model, pasteModel, getOnDeleteEntityCallback(editor), { - mergeFormat: applyCurrentFormat ? 'keepSourceEmphasisFormat' : 'none', - mergeTable: - pasteModel.blocks.length === 1 && - pasteModel.blocks[0].blockType === 'Table', - }); - } - return true; - }, + (model, context) => + mergePasteContent(model, context, pasteModel, applyCurrentFormat, customizedMerge), { changeSource: ChangeSource.Paste, getChangeData: () => clipboardData, @@ -103,6 +82,45 @@ export default function paste( } } +/** + * @internal + * Export only for unit test + */ +export function mergePasteContent( + model: ContentModelDocument, + context: FormatWithContentModelContext, + pasteModel: ContentModelDocument, + applyCurrentFormat: boolean, + customizedMerge: + | undefined + | ((source: ContentModelDocument, target: ContentModelDocument) => void) +): boolean { + if (customizedMerge) { + customizedMerge(model, pasteModel); + } else { + mergeModel(model, pasteModel, context, { + mergeFormat: applyCurrentFormat ? 'keepSourceEmphasisFormat' : 'none', + mergeTable: shouldMergeTable(pasteModel), + }); + } + return true; +} + +function shouldMergeTable(pasteModel: ContentModelDocument): boolean | undefined { + // If model contains a table and a paragraph element after the table with a single BR segment, remove the Paragraph after the table + if ( + pasteModel.blocks.length == 2 && + pasteModel.blocks[0].blockType === 'Table' && + pasteModel.blocks[1].blockType === 'Paragraph' && + pasteModel.blocks[1].segments.length === 1 && + pasteModel.blocks[1].segments[0].segmentType === 'Br' + ) { + pasteModel.blocks.splice(1); + } + // Only merge table when the document contain a single table. + return pasteModel.blocks.length === 1 && pasteModel.blocks[0].blockType === 'Table'; +} + function createBeforePasteEventData( editor: IContentModelEditor, clipboardData: ClipboardData, @@ -121,7 +139,7 @@ function createBeforePasteEventData( htmlAfter: '', htmlAttributes: {}, domToModelOption: {}, - pasteType: pasteType, + pasteType, }; } @@ -136,7 +154,7 @@ function triggerPluginEventAndCreatePasteFragment( pasteAsText: boolean, pasteAsImage: boolean, eventData: ContentModelBeforePasteEventData -): { pluginEvent: ContentModelBeforePasteEvent; fragment: DocumentFragment } { +): ContentModelBeforePasteEventData { const event = { eventType: PluginEventType.BeforePaste, ...eventData, @@ -151,7 +169,7 @@ function triggerPluginEventAndCreatePasteFragment( : undefined; // Step 2: Retrieve Metadata from Html and the Html that was copied. - retrieveMetadataFromClipboard(doc, event, editor.getTrustedHTMLHandler()); + retrieveMetadataFromClipboard(doc, event, trustedHTMLHandler); // Step 3: Fill the BeforePasteEvent object, especially the fragment for paste if ((pasteAsImage && imageDataUri) || (!pasteAsText && !text && imageDataUri)) { @@ -164,29 +182,18 @@ function triggerPluginEventAndCreatePasteFragment( handleTextPaste(text, position, fragment); } - // Step 4: Trigger BeforePasteEvent so that plugins can do proper change before paste - const pluginEvent = editor.triggerPluginEvent( - PluginEventType.BeforePaste, - eventData, - true /* broadcast */ - ) as ContentModelBeforePasteEvent; + let pluginEvent: ContentModelBeforePasteEvent = event; + // Step 4: Trigger BeforePasteEvent so that plugins can do proper change before paste, when the type of paste is different than Plain Text + if (event.pasteType !== PasteType.AsPlainText) { + pluginEvent = editor.triggerPluginEvent( + PluginEventType.BeforePaste, + event, + true /* broadcast */ + ) as ContentModelBeforePasteEvent; + } // Step 5. Sanitize the fragment before paste to make sure the content is safe sanitizePasteContent(event, position); - return { fragment, pluginEvent }; + return pluginEvent; } - -/** - * For block elements that have background color style, remove the background color when user selects the merge current format - * paste option - */ -const blockElementParser: FormatParser = ( - format: ContentModelBlockFormat, - element: HTMLElement -) => { - if (element.style.backgroundColor) { - element.style.backgroundColor = ''; - delete format.backgroundColor; - } -}; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts index a0e48c67474..01a8bedd271 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/ContentModelEditorCore.ts @@ -20,7 +20,8 @@ export type CreateEditorContext = (core: ContentModelEditorCore) => EditorContex */ export type CreateContentModel = ( core: ContentModelEditorCore, - option?: DomToModelOption + option?: DomToModelOption, + selectionOverride?: SelectionRangeEx ) => ContentModelDocument; /** diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts index e1e5968ce59..e62c05de571 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/IContentModelEditor.ts @@ -1,10 +1,10 @@ +import { EditorOptions, IEditor, SelectionRangeEx } from 'roosterjs-editor-types'; import { ContentModelDocument, ContentModelSegmentFormat, DomToModelOption, ModelToDomOption, } from 'roosterjs-content-model-types'; -import { EditorOptions, IEditor } from 'roosterjs-editor-types'; /** * An interface of editor with Content Model support. @@ -16,8 +16,12 @@ export interface IContentModelEditor extends IEditor { * @param rootNode Optional start node. If provided, Content Model will be created from this node (including itself), * otherwise it will create Content Model for the whole content in editor. * @param option The options to customize the behavior of DOM to Content Model conversion + * @param selectionOverride When specified, use this selection to override existing selection inside editor */ - createContentModel(option?: DomToModelOption): ContentModelDocument; + createContentModel( + option?: DomToModelOption, + selectionOverride?: SelectionRangeEx + ): ContentModelDocument; /** * Set content with content model diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/format/formatState/ContentModelFormatState.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/format/formatState/ContentModelFormatState.ts index 14248e6ce83..82eb6de52f7 100644 --- a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/format/formatState/ContentModelFormatState.ts +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/format/formatState/ContentModelFormatState.ts @@ -9,4 +9,9 @@ export interface ContentModelFormatState extends FormatState { * Format of image, if there is table at cursor position */ imageFormat?: ImageFormatState; + + /** + * Letter spacing + */ + letterSpacing?: string; } diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts new file mode 100644 index 00000000000..da2f5627b76 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/FormatWithContentModelContext.ts @@ -0,0 +1,91 @@ +import { EntityOperation, SelectionRangeEx } from 'roosterjs-editor-types'; +import { + ContentModelDocument, + ContentModelEntity, + OnNodeCreated, +} from 'roosterjs-content-model-types'; +import type { CompatibleEntityOperation } from 'roosterjs-editor-types/lib/compatibleTypes'; + +/** + * Represents an entity that is deleted by a specified entity operation + */ +export interface DeletedEntity { + entity: ContentModelEntity; + operation: + | EntityOperation.RemoveFromStart + | EntityOperation.RemoveFromEnd + | EntityOperation.Overwrite + | CompatibleEntityOperation.RemoveFromStart + | CompatibleEntityOperation.RemoveFromEnd + | CompatibleEntityOperation.Overwrite; +} + +/** + * Context object for API formatWithContentModel + */ +export interface FormatWithContentModelContext { + /** + * Entities got deleted during formatting. Need to be set by the formatter function + */ + readonly deletedEntities: DeletedEntity[]; + + /** + * Raw Event that triggers this format call + */ + readonly rawEvent?: Event; + + /** + * @optional + * When pass true, skip adding undo snapshot when write Content Model back to DOM. + * Need to be set by the formatter function + */ + skipUndoSnapshot?: boolean; +} + +/** + * Options for API formatWithContentModel + */ +export interface FormatWithContentModelOptions { + /** + * When set to true, if there is pending format, it will be preserved after this format operation is done + */ + preservePendingFormat?: boolean; + + /** + * Raw event object that triggers this call + */ + rawEvent?: Event; + + /** + * Change source used for triggering a ContentChanged event. @default ChangeSource.Format. + */ + changeSource?: string; + + /** + * An optional callback that will be called when a DOM node is created + * @param modelElement The related Content Model element + * @param node The node created for this model element + */ + onNodeCreated?: OnNodeCreated; + + /** + * Optional callback to get an object used for change data in ContentChangedEvent + */ + getChangeData?: () => any; + + /** + * When specified, use this selection range to override current selection inside editor + */ + selectionOverride?: SelectionRangeEx; +} + +/** + * Type of formatter used for format Content Model. + * @param model The source Content Model to format + * @param context A context object used for pass in and out more parameters + * @returns True means the model is changed and need to write back to editor, otherwise false + */ +export type ContentModelFormatter = ( + model: ContentModelDocument, + context: FormatWithContentModelContext +) => boolean; diff --git a/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/InsertEntityOptions.ts b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/InsertEntityOptions.ts new file mode 100644 index 00000000000..bac29b89210 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/lib/publicTypes/parameter/InsertEntityOptions.ts @@ -0,0 +1,33 @@ +/** + * Options for insertEntity API + */ +export interface InsertEntityOptions { + /** + * Content node of the entity. If not passed, an empty entity will be created + */ + contentNode?: Node; + + /** + * Whether move focus after entity after insert + */ + focusAfterEntity?: boolean; + + /** + * "Display" value of the entity wrapper. By default, block entity will have no display, inline entity will have display: inline-block + */ + wrapperDisplay?: 'inline' | 'block' | 'none' | 'inline-block'; + + /** + * Whether skip adding an undo snapshot around + */ + skipUndoSnapshot?: boolean; +} + +/** + * Define the position of the entity to insert. It can be: + * "focus": insert at current focus. If insert a block entity, it will be inserted under the paragraph where focus is + * "begin": insert at beginning of content. When insert an inline entity, it will be wrapped with a paragraph. + * "end": insert at end of content. When insert an inline entity, it will be wrapped with a paragraph. + * "root": insert at the root level of current region + */ +export type InsertEntityPosition = 'focus' | 'begin' | 'end' | 'root'; diff --git a/packages-content-model/roosterjs-content-model-editor/test/domToModel/processors/reducedModelChildProcessorTest.ts b/packages-content-model/roosterjs-content-model-editor/test/domToModel/processors/reducedModelChildProcessorTest.ts deleted file mode 100644 index b51c67cb269..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/test/domToModel/processors/reducedModelChildProcessorTest.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { createContentModelDocument, createDomToModelContext } from 'roosterjs-content-model-dom'; -import { DomToModelContext } from 'roosterjs-content-model-types'; -import { reducedModelChildProcessor } from '../../../lib/domToModel/processors/reducedModelChildProcessor'; -import { SelectionRangeTypes } from 'roosterjs-editor-types'; - -describe('reducedModelChildProcessor', () => { - let context: DomToModelContext; - - beforeEach(() => { - context = createDomToModelContext(undefined, { - disableCacheElement: true, - processorOverride: { - child: reducedModelChildProcessor, - }, - }); - }); - - it('Empty DOM', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [], - }); - }); - - it('Single child node, with selected Node in context', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - const span = document.createElement('span'); - - div.appendChild(span); - span.textContent = 'test'; - context.rangeEx = { - type: SelectionRangeTypes.Normal, - ranges: [ - { - commonAncestorContainer: span, - } as any, - ], - areAllCollapsed: false, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'Text', - text: 'test', - format: {}, - }, - ], - }, - ], - }); - }); - - it('Multiple child nodes, with selected Node in context', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - const span1 = document.createElement('span'); - const span2 = document.createElement('span'); - const span3 = document.createElement('span'); - - div.appendChild(span1); - div.appendChild(span2); - div.appendChild(span3); - span1.textContent = 'test1'; - span2.textContent = 'test2'; - span3.textContent = 'test3'; - context.rangeEx = { - type: SelectionRangeTypes.Normal, - ranges: [ - { - commonAncestorContainer: span2, - } as any, - ], - areAllCollapsed: false, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - format: {}, - isImplicit: true, - segments: [ - { - segmentType: 'Text', - text: 'test2', - format: {}, - }, - ], - }, - ], - }); - }); - - it('Multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - const span1 = document.createElement('span'); - const span2 = document.createElement('span'); - const span3 = document.createElement('span'); - - div.appendChild(span1); - div.appendChild(span2); - div.appendChild(span3); - span1.textContent = 'test1'; - span2.innerHTML = '
line1
line2
'; - span3.textContent = 'test3'; - context.rangeEx = { - type: SelectionRangeTypes.Normal, - ranges: [ - { - commonAncestorContainer: span2, - } as any, - ], - areAllCollapsed: false, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'line1', - format: {}, - }, - ], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - isImplicit: true, - }, - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'line2', - format: {}, - }, - ], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - isImplicit: true, - }, - ], - }); - }); - - it('Multiple layer with multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { - const doc = createContentModelDocument(); - const div1 = document.createElement('div'); - const div2 = document.createElement('div'); - const div3 = document.createElement('div'); - const span1 = document.createElement('span'); - const span2 = document.createElement('span'); - const span3 = document.createElement('span'); - - div3.appendChild(span1); - div3.appendChild(span2); - div3.appendChild(span3); - div1.appendChild(div2); - div2.appendChild(div3); - span1.textContent = 'test1'; - span2.innerHTML = '
line1
line2
'; - span3.textContent = 'test3'; - - context.rangeEx = { - type: SelectionRangeTypes.Normal, - ranges: [ - { - commonAncestorContainer: span2, - } as any, - ], - areAllCollapsed: false, - }; - - reducedModelChildProcessor(doc, div1, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { blockType: 'Paragraph', segments: [], format: {} }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [{ segmentType: 'Text', text: 'line1', format: {} }], - format: {}, - }, - { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, - { - blockType: 'Paragraph', - segments: [{ segmentType: 'Text', text: 'line2', format: {} }], - format: {}, - }, - { - blockType: 'Paragraph', - segments: [], - format: {}, - isImplicit: true, - }, - { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, - { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, - ], - }); - }); - - it('With table, need to do format for all table cells', () => { - const doc = createContentModelDocument(); - const div = document.createElement('div'); - div.innerHTML = - 'aa
test1test2
bb'; - context.rangeEx = { - type: SelectionRangeTypes.Normal, - ranges: [ - { - commonAncestorContainer: div.querySelector('#selection') as HTMLElement, - } as any, - ], - areAllCollapsed: false, - }; - - reducedModelChildProcessor(doc, div, context); - - expect(doc).toEqual({ - blockGroupType: 'Document', - blocks: [ - { - blockType: 'Table', - rows: [ - { - format: {}, - height: 0, - cells: [ - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test1', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - { - blockGroupType: 'TableCell', - blocks: [ - { - blockType: 'Paragraph', - segments: [ - { - segmentType: 'Text', - text: 'test2', - format: {}, - }, - ], - format: {}, - isImplicit: true, - }, - ], - format: {}, - spanLeft: false, - spanAbove: false, - isHeader: false, - dataset: {}, - }, - ], - }, - ], - format: {}, - widths: [], - dataset: {}, - }, - ], - }); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts index 407cd547685..ef735e679a4 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createContentModelTest.ts @@ -42,7 +42,7 @@ describe('createContentModel', () => { }); it('Reuse model, no cache, no shadow edit', () => { - const option: DomToModelOption = { disableCacheElement: false }; + const option: DomToModelOption = {}; core.cachedModel = undefined; @@ -53,7 +53,6 @@ describe('createContentModel', () => { expect(domToContentModelSpy).toHaveBeenCalledWith( mockedDiv, { - disableCacheElement: false, processorOverride: { table: tablePreProcessor, }, @@ -81,7 +80,9 @@ describe('createContentModel', () => { const model = createContentModel(core, option); - expect(cloneModelSpy).toHaveBeenCalledWith(mockedCachedMode); + expect(cloneModelSpy).toHaveBeenCalledWith(mockedCachedMode, { + includeCachedElement: true, + }); expect(createEditorContext).not.toHaveBeenCalled(); expect(getSelectionRangeEx).not.toHaveBeenCalled(); expect(domToContentModelSpy).not.toHaveBeenCalled(); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createEditorContextTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createEditorContextTest.ts index 659c83de790..f0b6708b0a3 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createEditorContextTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/coreApi/createEditorContextTest.ts @@ -36,6 +36,7 @@ describe('createEditorContext', () => { darkColorHandler, defaultFormat, addDelimiterForEntity, + allowCacheElement: true, }); }); }); @@ -87,6 +88,7 @@ describe('createEditorContext - checkZoomScale', () => { darkColorHandler, addDelimiterForEntity, zoomScale: 1, + allowCacheElement: true, }); }); @@ -104,6 +106,7 @@ describe('createEditorContext - checkZoomScale', () => { darkColorHandler, addDelimiterForEntity, zoomScale: 2, + allowCacheElement: true, }); }); @@ -121,6 +124,7 @@ describe('createEditorContext - checkZoomScale', () => { darkColorHandler, addDelimiterForEntity, zoomScale: 0.5, + allowCacheElement: true, }); }); }); @@ -170,6 +174,7 @@ describe('createEditorContext - checkRootDir', () => { defaultFormat, darkColorHandler, addDelimiterForEntity, + allowCacheElement: true, }); }); @@ -186,6 +191,7 @@ describe('createEditorContext - checkRootDir', () => { darkColorHandler, addDelimiterForEntity, isRootRtl: true, + allowCacheElement: true, }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts index 7c0cd70f0c5..639319f96b4 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelCopyPastePluginTest.ts @@ -1,9 +1,12 @@ import * as cloneModelFile from '../../../lib/modelApi/common/cloneModel'; import * as contentModelToDomFile from 'roosterjs-content-model-dom/lib/modelToDom/contentModelToDom'; +import * as createRangeF from 'roosterjs-editor-dom/lib/selection/createRange'; import * as deleteSelectionsFile from '../../../lib/modelApi/edit/deleteSelection'; import * as extractClipboardItemsFile from 'roosterjs-editor-dom/lib/clipboard/extractClipboardItems'; import * as iterateSelectionsFile from '../../../lib/modelApi/selection/iterateSelections'; +import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import * as PasteFile from '../../../lib/publicApi/utils/paste'; +import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import ContentModelCopyPastePlugin, { onNodeCreated, @@ -177,10 +180,7 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - { - isDarkMode: false, - darkColorHandler: darkColorHandler, - }, + undefined, { onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); @@ -202,7 +202,7 @@ describe('ContentModelCopyPastePlugin |', () => { it('Selection not Collapsed and table selection', () => { // Arrange const table = document.createElement('table'); - table.id = 'image'; + table.id = 'table'; // Arrange selectionRangeExValue = { type: SelectionRangeTypes.TableSelection, @@ -212,6 +212,7 @@ describe('ContentModelCopyPastePlugin |', () => { table, }; + spyOn(createRangeF, 'default').and.callThrough(); spyOn(deleteSelectionsFile, 'deleteSelection'); spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { const container = document.createElement('div'); @@ -231,16 +232,14 @@ describe('ContentModelCopyPastePlugin |', () => { domEvents.copy?.({}); // Assert + expect(createRangeF.default).toHaveBeenCalledWith(table.parentElement); expect(getSelectionRangeEx).toHaveBeenCalled(); expect(deleteSelectionsFile.deleteSelection).not.toHaveBeenCalled(); expect(contentModelToDomFile.contentModelToDom).toHaveBeenCalledWith( document, div, pasteModelValue, - { - isDarkMode: false, - darkColorHandler: darkColorHandler, - }, + undefined, { onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); @@ -270,6 +269,7 @@ describe('ContentModelCopyPastePlugin |', () => { image, }; + spyOn(createRangeF, 'default').and.callThrough(); spyOn(deleteSelectionsFile, 'deleteSelection'); spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { div.appendChild(image); @@ -286,16 +286,14 @@ describe('ContentModelCopyPastePlugin |', () => { domEvents.copy?.({}); // Assert + expect(createRangeF.default).toHaveBeenCalledWith(image); expect(getSelectionRangeEx).toHaveBeenCalled(); expect(deleteSelectionsFile.deleteSelection).not.toHaveBeenCalled(); expect(contentModelToDomFile.contentModelToDom).toHaveBeenCalledWith( document, div, pasteModelValue, - { - isDarkMode: false, - darkColorHandler: darkColorHandler, - }, + undefined, { onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); @@ -380,10 +378,7 @@ describe('ContentModelCopyPastePlugin |', () => { document, div, pasteModelValue, - { - isDarkMode: false, - darkColorHandler: darkColorHandler, - }, + undefined, { onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); @@ -398,13 +393,15 @@ describe('ContentModelCopyPastePlugin |', () => { // On Cut Spy expect(undoSnapShotSpy).toHaveBeenCalled(); - expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, undefined); + expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { + onNodeCreated: undefined, + }); }); it('Selection not Collapsed and table selection', () => { // Arrange const table = document.createElement('table'); - table.id = 'image'; + table.id = 'table'; selectionRangeExValue = { type: SelectionRangeTypes.TableSelection, ranges: [new Range()], @@ -413,7 +410,11 @@ describe('ContentModelCopyPastePlugin |', () => { table, }; - spyOn(deleteSelectionsFile, 'deleteSelection'); + spyOn(createRangeF, 'default').and.callThrough(); + spyOn(deleteSelectionsFile, 'deleteSelection').and.returnValue({ + deleteResult: DeleteResult.Range, + insertPoint: null!, + }); spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { const container = document.createElement('div'); container.append(table); @@ -422,6 +423,7 @@ describe('ContentModelCopyPastePlugin |', () => { return selectionRangeExValue; }); spyOn(iterateSelectionsFile, 'iterateSelections').and.returnValue(undefined); + spyOn(normalizeContentModel, 'normalizeContentModel'); triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); @@ -432,15 +434,13 @@ describe('ContentModelCopyPastePlugin |', () => { domEvents.cut?.({}); // Assert + expect(createRangeF.default).toHaveBeenCalledWith(table.parentElement); expect(getSelectionRangeEx).toHaveBeenCalled(); expect(contentModelToDomFile.contentModelToDom).toHaveBeenCalledWith( document, div, pasteModelValue, - { - isDarkMode: false, - darkColorHandler: darkColorHandler, - }, + undefined, { onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); @@ -457,7 +457,10 @@ describe('ContentModelCopyPastePlugin |', () => { // On Cut Spy expect(undoSnapShotSpy).toHaveBeenCalled(); expect(deleteSelectionsFile.deleteSelection).toHaveBeenCalled(); - expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, undefined); + expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { + onNodeCreated: undefined, + }); + expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(modelValue); }); it('Selection not Collapsed and image selection', () => { @@ -471,12 +474,17 @@ describe('ContentModelCopyPastePlugin |', () => { image, }; - spyOn(deleteSelectionsFile, 'deleteSelection'); + spyOn(createRangeF, 'default').and.callThrough(); + spyOn(deleteSelectionsFile, 'deleteSelection').and.returnValue({ + deleteResult: DeleteResult.Range, + insertPoint: null!, + }); spyOn(contentModelToDomFile, 'contentModelToDom').and.callFake(() => { div.appendChild(image); return selectionRangeExValue; }); spyOn(iterateSelectionsFile, 'iterateSelections').and.returnValue(undefined); + spyOn(normalizeContentModel, 'normalizeContentModel'); triggerPluginEventSpy.and.callThrough(); focusSpy.and.callThrough(); @@ -487,15 +495,13 @@ describe('ContentModelCopyPastePlugin |', () => { domEvents.cut?.({}); // Assert + expect(createRangeF.default).toHaveBeenCalledWith(image); expect(getSelectionRangeEx).toHaveBeenCalled(); expect(contentModelToDomFile.contentModelToDom).toHaveBeenCalledWith( document, div, pasteModelValue, - { - isDarkMode: false, - darkColorHandler: darkColorHandler, - }, + undefined, { onNodeCreated } ); expect(createContentModelSpy).toHaveBeenCalled(); @@ -511,7 +517,10 @@ describe('ContentModelCopyPastePlugin |', () => { // On Cut Spy expect(undoSnapShotSpy).toHaveBeenCalled(); expect(deleteSelectionsFile.deleteSelection).toHaveBeenCalled(); - expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, undefined); + expect(setContentModelSpy).toHaveBeenCalledWith(modelValue, { + onNodeCreated: undefined, + }); + expect(normalizeContentModel.normalizeContentModel).toHaveBeenCalledWith(modelValue); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts index 758a81aefa7..10a9e296223 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelEditPluginTest.ts @@ -50,7 +50,7 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent, []); + expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); expect(cacheContentModel).not.toHaveBeenCalled(); }); @@ -65,7 +65,7 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent, []); + expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); expect(cacheContentModel).not.toHaveBeenCalled(); }); @@ -118,20 +118,9 @@ describe('ContentModelEditPlugin', () => { rawEvent: { which: Keys.DELETE } as any, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith( - editor, - { which: Keys.DELETE } as any, - [ - { - eventType: PluginEventType.EntityOperation, - operation: EntityOperation.Overwrite, - rawEvent: { - type: 'keydown', - } as any, - entity: wrapper, - }, - ] - ); + expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, { + which: Keys.DELETE, + } as any); plugin.onPluginEvent({ eventType: PluginEventType.KeyDown, @@ -139,11 +128,9 @@ describe('ContentModelEditPlugin', () => { }); expect(handleKeyDownEventSpy).toHaveBeenCalledTimes(2); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith( - editor, - { which: Keys.DELETE } as any, - [] - ); + expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, { + which: Keys.DELETE, + } as any); expect(cacheContentModel).not.toHaveBeenCalled(); }); @@ -230,7 +217,7 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent, []); + expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); expect(cacheContentModel).not.toHaveBeenCalled(); }); @@ -251,7 +238,7 @@ describe('ContentModelEditPlugin', () => { rawEvent, }); - expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent, []); + expect(handleKeyDownEventSpy).toHaveBeenCalledWith(editor, rawEvent); expect(cacheContentModel).not.toHaveBeenCalled(); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts index 403ac5d358e..d25c9c4bff7 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelFormatPluginTest.ts @@ -40,6 +40,7 @@ describe('ContentModelFormatPlugin', () => { const setContentModel = jasmine.createSpy('setContentModel'); const editor = ({ + focus: jasmine.createSpy('focus'), createContentModel: () => model, setContentModel, isInIME: () => false, @@ -104,6 +105,7 @@ describe('ContentModelFormatPlugin', () => { addSegment(model, marker); const editor = ({ + focus: jasmine.createSpy('focus'), createContentModel: () => model, setContentModel, isInIME: () => false, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts index db69a5b2e82..d6ab2e4b8bf 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/ContentModelPastePluginTest.ts @@ -1,16 +1,29 @@ import * as addParser from '../../../lib/editor/plugins/PastePlugin/utils/addParser'; +import * as chainSanitizerCallbackFile from 'roosterjs-editor-dom/lib/htmlSanitizer/chainSanitizerCallback'; +import * as ExcelFile from '../../../lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel'; import * as getPasteSource from 'roosterjs-editor-dom/lib/pasteSourceValidations/getPasteSource'; +import * as PowerPointFile from '../../../lib/editor/plugins/PastePlugin/PowerPoint/processPastedContentFromPowerPoint'; +import * as setProcessor from '../../../lib/editor/plugins/PastePlugin/utils/setProcessor'; +import * as WacFile from '../../../lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents'; import * as WordDesktopFile from '../../../lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop'; import ContentModelBeforePasteEvent from '../../../lib/publicTypes/event/ContentModelBeforePasteEvent'; import ContentModelPastePlugin from '../../../lib/editor/plugins/PastePlugin/ContentModelPastePlugin'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { KnownPasteSourceType, PluginEventType } from 'roosterjs-editor-types'; +import { KnownPasteSourceType, PasteType, PluginEventType } from 'roosterjs-editor-types'; + +const trustedHTMLHandler = 'mock'; +const GOOGLE_SHEET_NODE_NAME = 'google-sheets-html-origin'; describe('Paste', () => { let editor: IContentModelEditor; beforeEach(() => { - editor = ({} as any) as IContentModelEditor; + editor = ({ + getTrustedHTMLHandler: () => trustedHTMLHandler, + } as any) as IContentModelEditor; + spyOn(addParser, 'default').and.callThrough(); + spyOn(chainSanitizerCallbackFile, 'default').and.callThrough(); + spyOn(setProcessor, 'setProcessor').and.callThrough(); }); let event: ContentModelBeforePasteEvent = ({ @@ -57,6 +70,11 @@ describe('Paste', () => { preserveHtmlComments: false, unknownTagReplacement: null, }, + pasteType: PasteType.Normal, + clipboardData: { + html: '', + }, + fragment: document.createDocumentFragment(), }); }); @@ -71,18 +89,131 @@ describe('Paste', () => { expect(event.domToModelOption.processorOverride?.element).toBe( WordDesktopFile.wordDesktopElementProcessor ); + expect(addParser.default).toHaveBeenCalledTimes(4); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(5); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(1); + }); + + it('Excel | merge format', () => { + spyOn(getPasteSource, 'default').and.returnValue(KnownPasteSourceType.ExcelDesktop); + spyOn(ExcelFile, 'processPastedContentFromExcel').and.callThrough(); + + (event).pasteType = PasteType.MergeFormat; + plugin.initialize(editor); + plugin.onPluginEvent(event); + + expect(ExcelFile.processPastedContentFromExcel).toHaveBeenCalledWith( + event, + trustedHTMLHandler + ); + expect(addParser.default).toHaveBeenCalledTimes(4); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + }); + + it('Excel | image', () => { + spyOn(getPasteSource, 'default').and.returnValue(KnownPasteSourceType.ExcelDesktop); + spyOn(ExcelFile, 'processPastedContentFromExcel').and.callThrough(); + + (event).pasteType = PasteType.AsImage; + plugin.initialize(editor); + plugin.onPluginEvent(event); + + expect(ExcelFile.processPastedContentFromExcel).not.toHaveBeenCalledWith( + event, + trustedHTMLHandler + ); + expect(addParser.default).toHaveBeenCalledTimes(1); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); + }); + + it('Excel', () => { + spyOn(getPasteSource, 'default').and.returnValue(KnownPasteSourceType.ExcelDesktop); + spyOn(ExcelFile, 'processPastedContentFromExcel').and.callThrough(); + + plugin.initialize(editor); + plugin.onPluginEvent(event); + + expect(ExcelFile.processPastedContentFromExcel).toHaveBeenCalledWith( + event, + trustedHTMLHandler + ); + expect(addParser.default).toHaveBeenCalledTimes(2); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + }); + + it('Excel Online', () => { + spyOn(getPasteSource, 'default').and.returnValue(KnownPasteSourceType.ExcelOnline); + spyOn(ExcelFile, 'processPastedContentFromExcel').and.callThrough(); + + plugin.initialize(editor); + plugin.onPluginEvent(event); + + expect(ExcelFile.processPastedContentFromExcel).toHaveBeenCalledWith( + event, + trustedHTMLHandler + ); + expect(addParser.default).toHaveBeenCalledTimes(2); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + }); + + it('Power Point', () => { + spyOn(getPasteSource, 'default').and.returnValue( + KnownPasteSourceType.PowerPointDesktop + ); + spyOn(PowerPointFile, 'processPastedContentFromPowerPoint').and.callThrough(); + + plugin.initialize(editor); + plugin.onPluginEvent(event); + + expect(PowerPointFile.processPastedContentFromPowerPoint).toHaveBeenCalledWith( + event, + trustedHTMLHandler + ); + expect(addParser.default).toHaveBeenCalledTimes(1); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + }); + + it('Wac', () => { + spyOn(getPasteSource, 'default').and.returnValue(KnownPasteSourceType.WacComponents); + spyOn(WacFile, 'processPastedContentWacComponents').and.callThrough(); + + plugin.initialize(editor); + plugin.onPluginEvent(event); + + expect(WacFile.processPastedContentWacComponents).toHaveBeenCalledWith(event); + expect(addParser.default).toHaveBeenCalledTimes(5); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(4); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); }); it('Default', () => { spyOn(getPasteSource, 'default').and.returnValue(KnownPasteSourceType.Default); - spyOn(WordDesktopFile, 'processPastedContentFromWordDesktop'); - spyOn(addParser, 'default').and.callThrough(); plugin.initialize(editor); plugin.onPluginEvent(event); - expect(WordDesktopFile.processPastedContentFromWordDesktop).not.toHaveBeenCalled(); - expect(event.domToModelOption.processorOverride?.element).toBeUndefined(); + expect(addParser.default).toHaveBeenCalledTimes(1); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + }); + + it('Google Sheets', () => { + spyOn(getPasteSource, 'default').and.returnValue(KnownPasteSourceType.GoogleSheets); + + plugin.initialize(editor); + plugin.onPluginEvent(event); + + expect(addParser.default).toHaveBeenCalledTimes(1); + expect(setProcessor.setProcessor).toHaveBeenCalledTimes(0); + expect(chainSanitizerCallbackFile.default).toHaveBeenCalledTimes(3); + expect( + event.sanitizingOption.additionalTagReplacements[GOOGLE_SHEET_NODE_NAME] + ).toEqual('*'); }); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts index f449f977639..f6e10ff5bf2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/e2e/cmPasteFromExcelTest.ts @@ -18,9 +18,6 @@ export function initEditor(id: string) { let options: ContentModelEditorOptions = { plugins: [new ContentModelPastePlugin()], experimentalFeatures: [ExperimentalFeatures.ContentModelPaste], - defaultDomToModelOptions: { - disableCacheElement: true, - }, }; let editor = new ContentModelEditor(node as HTMLDivElement, options); @@ -80,7 +77,6 @@ describe(ID, () => { processorOverride: { table: tableProcessor, }, - disableCacheElement: true, }); expect(model).toEqual({ diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromExcelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromExcelTest.ts index e0cc430d002..bc27ff924c2 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromExcelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromExcelTest.ts @@ -24,7 +24,6 @@ describe('processPastedContentFromExcelTest', () => { const model = domToContentModel(fragment, { ...event.domToModelOption, - disableCacheElement: true, }); if (expectedModel) { expect(model).toEqual(expectedModel); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts index 5053a5dba1a..0a94771ef6e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWacTest.ts @@ -126,7 +126,6 @@ describe('wordOnlineHandler', () => { const model = domToContentModel(fragment, { ...event.domToModelOption, - disableCacheElement: true, }); if (expectedModel) { expect(model).toEqual(expectedModel); @@ -1347,6 +1346,7 @@ describe('wordOnlineHandler', () => { borderRight: Browser.isFirefox ? 'medium none' : '', borderBottom: Browser.isFirefox ? 'medium none' : '', borderLeft: Browser.isFirefox ? 'medium none' : '', + verticalAlign: 'top', }, dataset: {}, alt: @@ -1768,6 +1768,7 @@ describe('wordOnlineHandler', () => { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + verticalAlign: 'middle', }, spanLeft: false, spanAbove: false, @@ -1879,6 +1880,7 @@ describe('wordOnlineHandler', () => { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + verticalAlign: 'middle', }, spanLeft: false, spanAbove: false, @@ -1995,6 +1997,7 @@ describe('wordOnlineHandler', () => { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + verticalAlign: 'middle', }, spanLeft: false, spanAbove: false, @@ -2020,6 +2023,7 @@ describe('wordOnlineHandler', () => { paddingRight: '0px', paddingBottom: '0px', paddingLeft: '0px', + verticalAlign: 'middle', }, spanLeft: true, spanAbove: false, diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWordDesktopTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWordDesktopTest.ts index b8f53cbf6b3..ff88b07d850 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWordDesktopTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/plugins/paste/processPastedContentFromWordDesktopTest.ts @@ -27,7 +27,6 @@ describe('processPastedContentFromWordDesktopTest', () => { const model = domToContentModel(fragment, { ...event.domToModelOption, - disableCacheElement: true, }); if (expectedModel) { expect(model).toEqual(expectedModel); diff --git a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts index df5d1296372..613500dc825 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/editor/utils/handleKeyboardEventCommonTest.ts @@ -1,133 +1,14 @@ import * as normalizeContentModel from 'roosterjs-content-model-dom/lib/modelApi/common/normalizeContentModel'; import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; -import { EntityOperation, PluginEventType } from 'roosterjs-editor-types'; +import { FormatWithContentModelContext } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { PluginEventType } from 'roosterjs-editor-types'; import { - getOnDeleteEntityCallback, handleKeyboardEventResult, shouldDeleteAllSegmentsBefore, shouldDeleteWord, } from '../../../lib/editor/utils/handleKeyboardEventCommon'; -describe('getOnDeleteEntityCallback', () => { - let mockedEditor: IContentModelEditor; - let mockedEvent: KeyboardEvent; - let triggerPluginEvent: jasmine.Spy; - let contains: jasmine.Spy; - - beforeEach(() => { - triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); - contains = jasmine.createSpy('contains').and.returnValue(true); - - mockedEditor = ({ - triggerPluginEvent, - contains, - } as any) as IContentModelEditor; - mockedEvent = ({ - defaultPrevented: false, - } as any) as KeyboardEvent; - }); - - it('Entity without id', () => { - const func = getOnDeleteEntityCallback(mockedEditor, mockedEvent, []); - - const result = func( - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - isReadonly: true, - wrapper: {} as any, - }, - EntityOperation.RemoveFromStart - ); - - expect(result).toBeFalse(); - expect(triggerPluginEvent).not.toHaveBeenCalled(); - }); - - it('Entity with id and type', () => { - const func = getOnDeleteEntityCallback(mockedEditor, mockedEvent, []); - - const result = func( - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - isReadonly: true, - wrapper: {} as any, - id: '1', - type: '2', - }, - EntityOperation.RemoveFromStart - ); - - expect(result).toBeFalse(); - expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.EntityOperation, { - entity: { - id: '1', - type: '2', - isReadonly: true, - wrapper: {} as any, - }, - operation: EntityOperation.RemoveFromStart, - rawEvent: mockedEvent, - }); - }); - - it('Entity with id and type and change defaultPrevented', () => { - triggerPluginEvent.and.callFake((_1: any, param: any) => { - param.rawEvent.defaultPrevented = true; - }); - - const func = getOnDeleteEntityCallback(mockedEditor, mockedEvent, []); - - const result = func( - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - isReadonly: true, - wrapper: {} as any, - id: '1', - type: '2', - }, - EntityOperation.RemoveFromStart - ); - - expect(result).toBeTrue(); - expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.EntityOperation, { - entity: { - id: '1', - type: '2', - isReadonly: true, - wrapper: {} as any, - }, - operation: EntityOperation.RemoveFromStart, - rawEvent: mockedEvent, - }); - }); - - it('Call with triggeredEntityEvents', () => { - const wrapper = 'WRAPPER'; - const entity = { - wrapper, - } as any; - const func = getOnDeleteEntityCallback(mockedEditor, mockedEvent, [ - { - eventType: PluginEventType.EntityOperation, - operation: EntityOperation.Overwrite, - entity, - }, - ]); - - const result = func({ wrapper } as any, EntityOperation.Overwrite); - - expect(result).toBeFalse(); - expect(triggerPluginEvent).not.toHaveBeenCalled(); - }); -}); - describe('handleKeyboardEventResult', () => { let mockedEditor: IContentModelEditor; let mockedEvent: KeyboardEvent; @@ -161,12 +42,13 @@ describe('handleKeyboardEventResult', () => { const mockedModel = 'MODEL' as any; const which = 'WHICH' as any; (mockedEvent).which = which; - + const context: FormatWithContentModelContext = { deletedEntities: [] }; const result = handleKeyboardEventResult( mockedEditor, mockedModel, mockedEvent, - DeleteResult.SingleChar + DeleteResult.SingleChar, + context ); expect(result).toBeTrue(); @@ -177,17 +59,18 @@ describe('handleKeyboardEventResult', () => { expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.BeforeKeyboardEditing, { rawEvent: mockedEvent, }); - expect(addUndoSnapshot).not.toHaveBeenCalled(); + expect(context.skipUndoSnapshot).toBeTrue(); }); it('DeleteResult.NotDeleted', () => { const mockedModel = 'MODEL' as any; - + const context: FormatWithContentModelContext = { deletedEntities: [] }; const result = handleKeyboardEventResult( mockedEditor, mockedModel, mockedEvent, - DeleteResult.NotDeleted + DeleteResult.NotDeleted, + context ); expect(result).toBeFalse(); @@ -196,17 +79,18 @@ describe('handleKeyboardEventResult', () => { expect(normalizeContentModel.normalizeContentModel).not.toHaveBeenCalled(); expect(cacheContentModel).toHaveBeenCalledWith(null); expect(triggerPluginEvent).not.toHaveBeenCalled(); - expect(addUndoSnapshot).not.toHaveBeenCalled(); + expect(context.skipUndoSnapshot).toBeTrue(); }); it('DeleteResult.Range', () => { const mockedModel = 'MODEL' as any; - + const context: FormatWithContentModelContext = { deletedEntities: [] }; const result = handleKeyboardEventResult( mockedEditor, mockedModel, mockedEvent, - DeleteResult.Range + DeleteResult.Range, + context ); expect(result).toBeTrue(); @@ -217,17 +101,18 @@ describe('handleKeyboardEventResult', () => { expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.BeforeKeyboardEditing, { rawEvent: mockedEvent, }); - expect(addUndoSnapshot).toHaveBeenCalled(); + expect(context.skipUndoSnapshot).toBeFalse(); }); it('DeleteResult.NothingToDelete', () => { const mockedModel = 'MODEL' as any; - + const context: FormatWithContentModelContext = { deletedEntities: [] }; const result = handleKeyboardEventResult( mockedEditor, mockedModel, mockedEvent, - DeleteResult.NothingToDelete + DeleteResult.NothingToDelete, + context ); expect(result).toBeFalse(); @@ -236,7 +121,7 @@ describe('handleKeyboardEventResult', () => { expect(normalizeContentModel.normalizeContentModel).not.toHaveBeenCalled(); expect(cacheContentModel).not.toHaveBeenCalled(); expect(triggerPluginEvent).not.toHaveBeenCalled(); - expect(addUndoSnapshot).not.toHaveBeenCalled(); + expect(context.skipUndoSnapshot).toBeTrue(); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/cloneModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/cloneModelTest.ts index c866275e0b7..4299f581179 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/cloneModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/cloneModelTest.ts @@ -2,7 +2,7 @@ import { cloneModel } from '../../../lib/modelApi/common/cloneModel'; import { ContentModelDocument } from 'roosterjs-content-model-types'; describe('cloneModel', () => { - function compareObjects(o1: any, o2: any) { + function compareObjects(o1: any, o2: any, allowCache: boolean, path: string = '/') { expect(typeof o2).toBe(typeof o1); if (typeof o1 == 'boolean' || typeof o1 == 'number' || typeof o1 == 'string') { @@ -12,23 +12,41 @@ describe('cloneModel', () => { } else if (typeof o1 == 'object') { if (Array.isArray(o1)) { expect(Array.isArray(o2)).toBeTrue(); - expect(o2).not.toBe(o1); - expect(o2.length).toBe(o1.length); + expect(o2).not.toBe(o1, path); + expect(o2.length).toBe(o1.length, path); for (let i = 0; i < o1.length; i++) { - compareObjects(o1[i], o2[i]); + compareObjects(o1[i], o2[i], allowCache, path + `[${i}]/`); } } else if (o1 instanceof Node) { - expect(o2).toBe(o1); + expect(o2).toBe(o1, path); } else if (o1 === null) { - expect(o2).toBeNull(); + expect(o2).toBeNull(path); } else { - expect(o2).not.toBe(o1); + expect(o2).not.toBe(o1, path); const keys = new Set([...Object.keys(o1), ...Object.keys(o2)]); keys.forEach(key => { - compareObjects(o1[key], o2[key]); + if (allowCache) { + compareObjects(o1[key], o2[key], allowCache, path + key + '/'); + } else { + switch (key) { + case 'cachedElement': + expect(o2[key]).toBeUndefined(path); + break; + + case 'wrapper': + case 'element': + expect(o2[key]).not.toBe(o1[key], path); + expect(o2[key]).toEqual(o1[key], path); + break; + + default: + compareObjects(o1[key], o2[key], allowCache, path + key + '/'); + break; + } + } }); } } else { @@ -37,9 +55,11 @@ describe('cloneModel', () => { } function runTest(model: ContentModelDocument) { - const clone = cloneModel(model); + const cloneWithCache = cloneModel(model, { includeCachedElement: true }); + const cloneWithoutCache = cloneModel(model); - compareObjects(model, clone); + compareObjects(model, cloneWithCache, true); + compareObjects(model, cloneWithoutCache, false); } it('Empty model', () => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts index 766928e3335..3e43abcc795 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/mergeModelTest.ts @@ -5,6 +5,7 @@ import { mergeModel } from '../../../lib/modelApi/common/mergeModel'; import { createContentModelDocument, createDivider, + createEntity, createListItem, createListLevel, createParagraph, @@ -14,10 +15,6 @@ import { createText, } from 'roosterjs-content-model-dom'; -function onDeleteEntityMock() { - return false; -} - describe('mergeModel', () => { it('empty to single selection', () => { const majorModel = createContentModelDocument(); @@ -28,7 +25,7 @@ describe('mergeModel', () => { para.segments.push(marker); majorModel.blocks.push(para); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -68,7 +65,7 @@ describe('mergeModel', () => { para2.segments.push(text1, text2); sourceModel.blocks.push(para2); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -118,7 +115,7 @@ describe('mergeModel', () => { majorModel.blocks.push(para1); sourceModel.blocks.push(para2); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -197,7 +194,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newPara1); sourceModel.blocks.push(newPara2); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -292,7 +289,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newPara2); sourceModel.blocks.push(newPara3); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -439,7 +436,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newList1); sourceModel.blocks.push(newList2); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -605,7 +602,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newList1); sourceModel.blocks.push(newList2); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -793,7 +790,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newTable1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -894,7 +891,7 @@ describe('mergeModel', () => { spyOn(applyTableFormat, 'applyTableFormat'); spyOn(normalizeTable, 'normalizeTable'); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(normalizeTable.normalizeTable).not.toHaveBeenCalled(); expect(majorModel).toEqual({ @@ -1011,9 +1008,14 @@ describe('mergeModel', () => { spyOn(applyTableFormat, 'applyTableFormat'); spyOn(normalizeTable, 'normalizeTable'); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeTable: true, - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeTable: true, + } + ); expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); expect(majorModel).toEqual({ @@ -1148,9 +1150,14 @@ describe('mergeModel', () => { spyOn(applyTableFormat, 'applyTableFormat'); spyOn(normalizeTable, 'normalizeTable'); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeTable: true, - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeTable: true, + } + ); expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); expect(majorModel).toEqual({ @@ -1274,9 +1281,14 @@ describe('mergeModel', () => { spyOn(applyTableFormat, 'applyTableFormat'); spyOn(normalizeTable, 'normalizeTable'); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeTable: true, - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeTable: true, + } + ); expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); expect(majorModel).toEqual({ @@ -1383,13 +1395,18 @@ describe('mergeModel', () => { newPara.segments.push(newText); sourceModel.blocks.push(newPara); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - insertPosition: { - marker: marker2, - paragraph: para1, - path: [majorModel], - }, - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + insertPosition: { + marker: marker2, + paragraph: para1, + path: [majorModel], + }, + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1461,9 +1478,14 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeFormat: 'mergeAll', - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeFormat: 'mergeAll', + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1518,9 +1540,14 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeFormat: 'keepSourceEmphasisFormat', - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeFormat: 'keepSourceEmphasisFormat', + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1581,9 +1608,14 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeFormat: 'keepSourceEmphasisFormat', - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeFormat: 'keepSourceEmphasisFormat', + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1671,9 +1703,14 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeFormat: 'keepSourceEmphasisFormat', - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeFormat: 'keepSourceEmphasisFormat', + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1745,7 +1782,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(divider); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1812,7 +1849,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(newPara1); sourceModel.blocks.push(newPara2); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel, { deletedEntities: [] }); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1909,9 +1946,14 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeFormat: 'keepSourceEmphasisFormat', - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeFormat: 'keepSourceEmphasisFormat', + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -1985,9 +2027,14 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeFormat: 'mergeAll', - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeFormat: 'mergeAll', + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -2164,9 +2211,14 @@ describe('mergeModel', () => { para1.segments.push(marker); majorModel.blocks.push(para1); - mergeModel(majorModel, sourceModel, onDeleteEntityMock, { - mergeFormat: 'mergeAll', - }); + mergeModel( + majorModel, + sourceModel, + { deletedEntities: [] }, + { + mergeFormat: 'mergeAll', + } + ); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -2334,7 +2386,7 @@ describe('mergeModel', () => { sourceModel.blocks.push(heading); - mergeModel(majorModel, sourceModel, onDeleteEntityMock); + mergeModel(majorModel, sourceModel); expect(majorModel).toEqual({ blockGroupType: 'Document', @@ -2367,4 +2419,393 @@ describe('mergeModel', () => { ], }); }); + + it('Merge Table with styles into paragraph, paragraph after table should not inherit styles from table', () => { + const majorModel = createContentModelDocument(); + const para1 = createParagraph(false, undefined, { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(text1, marker, text2); + majorModel.blocks.push(para1); + + const sourceModel: ContentModelDocument = createContentModelDocument(); + const newPara1 = createParagraph(); + const newText1 = createText('newText1'); + const newCell1 = createTableCell(false, false); + const newTable1 = createTable(1, { + textAlign: 'start', + whiteSpace: 'normal', + borderTop: '1px solid black', + borderRight: '1px solid black', + borderBottom: '1px solid black', + borderLeft: '1px solid black', + backgroundColor: 'rgb(255, 255, 255)', + useBorderBox: true, + borderCollapse: true, + }); + + newPara1.segments.push(newText1); + newCell1.blocks.push(newPara1); + newTable1.rows[0].cells.push(newCell1); + sourceModel.blocks.push(newTable1); + + mergeModel(majorModel, sourceModel); + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test1', format: {} }], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'newText1', + format: {}, + }, + ], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { + textAlign: 'start', + whiteSpace: 'normal', + borderTop: '1px solid black', + borderRight: '1px solid black', + borderBottom: '1px solid black', + borderLeft: '1px solid black', + backgroundColor: 'rgb(255, 255, 255)', + useBorderBox: true, + borderCollapse: true, + }, + widths: [], + dataset: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Text', text: 'test2', format: {} }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + ], + }); + }); + + it('Merge Divider with styles into paragraph, paragraph after table should not inherit styles from Divider', () => { + const majorModel = createContentModelDocument(); + const para1 = createParagraph(false, undefined, { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(text1, marker, text2); + majorModel.blocks.push(para1); + + const sourceModel: ContentModelDocument = createContentModelDocument(); + const newDiv = createDivider('div', { + textAlign: 'start', + whiteSpace: 'normal', + borderTop: '1px solid black', + borderRight: '1px solid black', + borderBottom: '1px solid black', + borderLeft: '1px solid black', + backgroundColor: 'rgb(255, 255, 255)', + }); + + sourceModel.blocks.push(newDiv); + mergeModel(majorModel, sourceModel); + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test1', format: {} }], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + { + blockType: 'Divider', + tagName: 'div', + format: { + textAlign: 'start', + whiteSpace: 'normal', + borderTop: '1px solid black', + borderRight: '1px solid black', + borderBottom: '1px solid black', + borderLeft: '1px solid black', + backgroundColor: 'rgb(255, 255, 255)', + }, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Text', text: 'test2', format: {} }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + ], + }); + }); + + it('Merge ListItem with styles into paragraph, paragraph after table should not inherit styles from ListItem', () => { + const majorModel = createContentModelDocument(); + const para1 = createParagraph(false, undefined, { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(text1, marker, text2); + majorModel.blocks.push(para1); + + const sourceModel: ContentModelDocument = createContentModelDocument(); + const newList = createListItem([ + createListLevel('OL', { + marginBottom: '100px', + }), + ]); + const para2 = createParagraph(false, undefined, { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }); + const text3 = createText('test1'); + newList.blocks.push(para2); + para2.segments.push(text3); + + sourceModel.blocks.push(newList); + mergeModel(majorModel, sourceModel); + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test1', format: {} }], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + { + blockType: 'BlockGroup', + blockGroupType: 'ListItem', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test1', format: {} }], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + ], + levels: [ + { + listType: 'OL', + format: { marginBottom: '100px' }, + dataset: {}, + }, + ], + formatHolder: { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + format: {}, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Text', text: 'test2', format: {} }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + ], + }); + }); + + it('Merge Entity with styles into paragraph, paragraph after table should not inherit styles from Entity', () => { + const majorModel = createContentModelDocument(); + const para1 = createParagraph(false, undefined, { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }); + const marker = createSelectionMarker(); + const text1 = createText('test1'); + const text2 = createText('test2'); + + para1.segments.push(text1, marker, text2); + majorModel.blocks.push(para1); + + const sourceModel: ContentModelDocument = createContentModelDocument(); + const newEntity = createEntity(document.createElement('div'), false, undefined, { + fontFamily: 'Corbel', + fontSize: '20px', + backgroundColor: 'blue', + textColor: 'aliceblue', + italic: true, + }); + + sourceModel.blocks.push(newEntity); + mergeModel(majorModel, sourceModel); + + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'test1', format: {} }], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + { + segmentType: 'Entity', + blockType: 'Entity', + format: { + fontFamily: 'Corbel', + fontSize: '20px', + backgroundColor: 'blue', + textColor: 'aliceblue', + italic: true, + }, + id: undefined, + type: undefined, + isReadonly: false, + wrapper: newEntity.wrapper, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Text', text: 'test2', format: {} }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Arial', + fontSize: '15px', + backgroundColor: 'red', + textColor: 'blue', + italic: false, + }, + }, + ], + }); + }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts index 2ad038367d5..9e700715aaa 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/common/retrieveModelFormatStateTest.ts @@ -139,7 +139,7 @@ describe('retrieveModelFormatState', () => { }); }); - it('Single selection with header', () => { + it('Single selection with heading', () => { const model = createContentModelDocument(); const result: ContentModelFormatState = {}; const para = createParagraph(false, undefined, undefined, { @@ -157,6 +157,7 @@ describe('retrieveModelFormatState', () => { expect(result).toEqual({ ...baseFormatResult, + headingLevel: 1, headerLevel: 1, isBlockQuote: false, isCodeInline: false, @@ -275,7 +276,7 @@ describe('retrieveModelFormatState', () => { }); }); - it('With table header', () => { + it('With table heading', () => { const model = createContentModelDocument(); const result: ContentModelFormatState = {}; const table = createTable(1); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts index 4fe35c07587..d85e654052e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/edit/deleteSelectionTest.ts @@ -1,4 +1,5 @@ import { ContentModelSelectionMarker } from 'roosterjs-content-model-types'; +import { DeletedEntity } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; import { DeleteResult } from '../../../lib/modelApi/edit/utils/DeleteSelectionStep'; import { deleteSelection } from '../../../lib/modelApi/edit/deleteSelection'; import { EntityOperation } from 'roosterjs-editor-types'; @@ -27,10 +28,6 @@ import { forwardDeleteCollapsedSelection, } from '../../../lib/modelApi/edit/deleteSteps/deleteCollapsedSelection'; -function onDeleteEntityMock() { - return false; -} - describe('deleteSelection - selectionOnly', () => { it('empty selection', () => { const model = createContentModelDocument(); @@ -38,7 +35,7 @@ describe('deleteSelection - selectionOnly', () => { model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(model).toEqual({ blockGroupType: 'Document', @@ -63,7 +60,7 @@ describe('deleteSelection - selectionOnly', () => { para.segments.push(marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.NotDeleted); expect(result.insertPoint).toEqual({ @@ -103,7 +100,7 @@ describe('deleteSelection - selectionOnly', () => { para.segments.push(text); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -152,7 +149,7 @@ describe('deleteSelection - selectionOnly', () => { model.blocks.push(para1); model.blocks.push(para2); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -200,7 +197,7 @@ describe('deleteSelection - selectionOnly', () => { divider.isSelected = true; model.blocks.push(divider); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -254,7 +251,7 @@ describe('deleteSelection - selectionOnly', () => { divider2.isSelected = true; model.blocks.push(para1, divider1, divider2, para2); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -324,7 +321,7 @@ describe('deleteSelection - selectionOnly', () => { table.rows[0].cells.push(cell1, cell2); model.blocks.push(table); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -425,7 +422,7 @@ describe('deleteSelection - selectionOnly', () => { table.rows[0].cells.push(cell); model.blocks.push(table); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -477,7 +474,7 @@ describe('deleteSelection - selectionOnly', () => { entity.isSelected = true; - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -525,12 +522,12 @@ describe('deleteSelection - selectionOnly', () => { const model = createContentModelDocument(); const wrapper = 'WRAPPER' as any; const entity = createEntity(wrapper, true); + const deletedEntities: DeletedEntity[] = []; model.blocks.push(entity); entity.isSelected = true; - const onDeleteEntity = jasmine.createSpy('onDeleteEntity').and.returnValue(false); - const result = deleteSelection(model, onDeleteEntity, []); + const result = deleteSelection(model, [], { deletedEntities }); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -573,7 +570,7 @@ describe('deleteSelection - selectionOnly', () => { ], }); - expect(onDeleteEntity).toHaveBeenCalledWith(entity, EntityOperation.Overwrite); + expect(deletedEntities).toEqual([{ entity, operation: EntityOperation.Overwrite }]); }); it('Entity selection, callback returns true', () => { @@ -584,8 +581,8 @@ describe('deleteSelection - selectionOnly', () => { entity.isSelected = true; - const onDeleteEntity = jasmine.createSpy('onDeleteEntity').and.returnValue(true); - const result = deleteSelection(model, onDeleteEntity, []); + const deletedEntities: DeletedEntity[] = []; + const result = deleteSelection(model, [], { deletedEntities }); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -614,19 +611,21 @@ describe('deleteSelection - selectionOnly', () => { blockGroupType: 'Document', blocks: [ { - blockType: 'Entity', - segmentType: 'Entity', - wrapper: wrapper, + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], format: {}, - isReadonly: true, - id: undefined, - type: undefined, - isSelected: true, + isImplicit: false, }, ], }); - expect(onDeleteEntity).toHaveBeenCalledWith(entity, EntityOperation.Overwrite); + expect(deletedEntities).toEqual([{ entity, operation: EntityOperation.Overwrite }]); }); it('delete with default format', () => { @@ -638,7 +637,7 @@ describe('deleteSelection - selectionOnly', () => { divider.isSelected = true; model.blocks.push(divider); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: { fontSize: '10pt' }, @@ -681,7 +680,7 @@ describe('deleteSelection - selectionOnly', () => { general.isSelected = true; model.blocks.push(general); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: {}, @@ -723,7 +722,7 @@ describe('deleteSelection - selectionOnly', () => { general.isSelected = true; model.blocks.push(divider, general); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: {}, @@ -771,7 +770,7 @@ describe('deleteSelection - selectionOnly', () => { para.segments.push(general); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: {}, @@ -813,7 +812,7 @@ describe('deleteSelection - selectionOnly', () => { para.segments.push(general, text); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: {}, @@ -854,7 +853,7 @@ describe('deleteSelection - selectionOnly', () => { para.segments.push(text, image); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: {}, @@ -894,7 +893,7 @@ describe('deleteSelection - selectionOnly', () => { para.segments.push(text); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: {}, @@ -934,7 +933,7 @@ describe('deleteSelection - selectionOnly', () => { divider.isSelected = true; model.blocks.push(divider); - const result = deleteSelection(model, onDeleteEntityMock); + const result = deleteSelection(model); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: { fontFamily: 'Arial' }, @@ -978,9 +977,7 @@ describe('deleteSelection - forward', () => { model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(model).toEqual({ blockGroupType: 'Document', @@ -1005,9 +1002,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.NothingToDelete); expect(result.insertPoint).toEqual({ @@ -1047,9 +1042,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, segment); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); expect(result.insertPoint).toEqual({ @@ -1097,9 +1090,7 @@ describe('deleteSelection - forward', () => { para2.segments.push(text2); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1157,9 +1148,7 @@ describe('deleteSelection - forward', () => { para2.segments.push(text); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1213,9 +1202,7 @@ describe('deleteSelection - forward', () => { para2.segments.push(text); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); expect(result.insertPoint).toEqual({ @@ -1274,9 +1261,7 @@ describe('deleteSelection - forward', () => { para2.segments.push(marker2, text2); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1331,9 +1316,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, image); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); expect(result.insertPoint).toEqual({ @@ -1375,9 +1358,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, br); model.blocks.push(para, table); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1418,9 +1399,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, br); model.blocks.push(para, divider); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1462,9 +1441,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, br); model.blocks.push(para, entity); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1506,8 +1483,10 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, br); model.blocks.push(para, entity); - const onDeleteEntity = jasmine.createSpy('onDeleteEntity').and.returnValue(false); - const result = deleteSelection(model, onDeleteEntity, [forwardDeleteCollapsedSelection]); + const deletedEntities: DeletedEntity[] = []; + const result = deleteSelection(model, [forwardDeleteCollapsedSelection], { + deletedEntities, + }); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1536,7 +1515,7 @@ describe('deleteSelection - forward', () => { }, ], }); - expect(onDeleteEntity).toHaveBeenCalledWith(entity, EntityOperation.RemoveFromStart); + expect(deletedEntities).toEqual([{ entity, operation: EntityOperation.RemoveFromStart }]); }); it('Single selection marker before entity, with callback returns true', () => { @@ -1550,8 +1529,10 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, br); model.blocks.push(para, entity); - const onDeleteEntity = jasmine.createSpy('onDeleteEntity').and.returnValue(true); - const result = deleteSelection(model, onDeleteEntity, [forwardDeleteCollapsedSelection]); + const deletedEntities: DeletedEntity[] = []; + const result = deleteSelection(model, [forwardDeleteCollapsedSelection], { + deletedEntities, + }); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1578,18 +1559,9 @@ describe('deleteSelection - forward', () => { }, ], }, - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - wrapper: wrapper, - isReadonly: true, - id: undefined, - type: undefined, - }, ], }); - expect(onDeleteEntity).toHaveBeenCalledWith(entity, EntityOperation.RemoveFromStart); + expect(deletedEntities).toEqual([{ entity, operation: EntityOperation.RemoveFromStart }]); }); it('Single selection marker before list item', () => { @@ -1606,9 +1578,7 @@ describe('deleteSelection - forward', () => { listItem.blocks.push(para2); model.blocks.push(para1, listItem); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1676,9 +1646,7 @@ describe('deleteSelection - forward', () => { quote.blocks.push(para2); model.blocks.push(para1, quote); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1741,9 +1709,7 @@ describe('deleteSelection - forward', () => { quote.blocks.push(para1); model.blocks.push(quote, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1808,9 +1774,7 @@ describe('deleteSelection - forward', () => { listItem.blocks.push(para2); model.blocks.push(quote, listItem); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1882,9 +1846,7 @@ describe('deleteSelection - forward', () => { para.segments.push(text1, text2); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1938,9 +1900,7 @@ describe('deleteSelection - forward', () => { model.blocks.push(para1); model.blocks.push(para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -1988,9 +1948,7 @@ describe('deleteSelection - forward', () => { divider.isSelected = true; model.blocks.push(divider); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -2044,9 +2002,7 @@ describe('deleteSelection - forward', () => { divider2.isSelected = true; model.blocks.push(para1, divider1, divider2, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -2116,9 +2072,7 @@ describe('deleteSelection - forward', () => { table.rows[0].cells.push(cell1, cell2); model.blocks.push(table); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -2219,9 +2173,7 @@ describe('deleteSelection - forward', () => { table.rows[0].cells.push(cell); model.blocks.push(table); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -2274,9 +2226,7 @@ describe('deleteSelection - forward', () => { divider.isSelected = true; model.blocks.push(divider); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: { fontSize: '10pt' }, @@ -2324,9 +2274,7 @@ describe('deleteSelection - forward', () => { parentParagraph.segments.push(general); model.blocks.push(parentParagraph); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.NothingToDelete); @@ -2377,9 +2325,7 @@ describe('deleteSelection - forward', () => { parentParagraph.segments.push(general, text); model.blocks.push(parentParagraph); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -2431,9 +2377,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, text); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -2477,9 +2421,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, text); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -2525,9 +2467,7 @@ describe('deleteSelection - forward', () => { para.segments.push(text1, marker, text2); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -2576,9 +2516,7 @@ describe('deleteSelection - forward', () => { para.segments.push(text1, marker, text2); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - forwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [forwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -2623,7 +2561,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, text1, text2, text3); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [forwardDeleteWordSelection]); + const result = deleteSelection(model, [forwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -2666,7 +2604,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, text1); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [forwardDeleteWordSelection]); + const result = deleteSelection(model, [forwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -2709,7 +2647,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, text1); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [forwardDeleteWordSelection]); + const result = deleteSelection(model, [forwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -2752,7 +2690,7 @@ describe('deleteSelection - forward', () => { para.segments.push(marker, text1); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [forwardDeleteWordSelection]); + const result = deleteSelection(model, [forwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -2794,9 +2732,7 @@ describe('deleteSelection - backward', () => { model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(model).toEqual({ blockGroupType: 'Document', @@ -2821,9 +2757,7 @@ describe('deleteSelection - backward', () => { para.segments.push(marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.NothingToDelete); expect(result.insertPoint).toEqual({ @@ -2863,9 +2797,7 @@ describe('deleteSelection - backward', () => { para.segments.push(segment, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); expect(result.insertPoint).toEqual({ @@ -2913,9 +2845,7 @@ describe('deleteSelection - backward', () => { para2.segments.push(marker, text2); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -2973,9 +2903,7 @@ describe('deleteSelection - backward', () => { para2.segments.push(marker, text); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3029,9 +2957,7 @@ describe('deleteSelection - backward', () => { para2.segments.push(marker, text); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3089,9 +3015,7 @@ describe('deleteSelection - backward', () => { para2.segments.push(marker2, text2); model.blocks.push(para1, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3146,9 +3070,7 @@ describe('deleteSelection - backward', () => { para.segments.push(image, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); expect(result.insertPoint).toEqual({ @@ -3190,9 +3112,7 @@ describe('deleteSelection - backward', () => { para.segments.push(marker, br); model.blocks.push(table, para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3233,9 +3153,7 @@ describe('deleteSelection - backward', () => { para.segments.push(marker, br); model.blocks.push(divider, para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3277,9 +3195,7 @@ describe('deleteSelection - backward', () => { para.segments.push(marker, br); model.blocks.push(entity, para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3321,8 +3237,10 @@ describe('deleteSelection - backward', () => { para.segments.push(marker, br); model.blocks.push(entity, para); - const onDeleteEntity = jasmine.createSpy('onDeleteEntity').and.returnValue(false); - const result = deleteSelection(model, onDeleteEntity, [backwardDeleteCollapsedSelection]); + const deletedEntities: DeletedEntity[] = []; + const result = deleteSelection(model, [backwardDeleteCollapsedSelection], { + deletedEntities, + }); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3351,7 +3269,7 @@ describe('deleteSelection - backward', () => { }, ], }); - expect(onDeleteEntity).toHaveBeenCalledWith(entity, EntityOperation.RemoveFromEnd); + expect(deletedEntities).toEqual([{ entity, operation: EntityOperation.RemoveFromEnd }]); }); it('Single selection marker after entity, with callback returns true', () => { @@ -3365,8 +3283,10 @@ describe('deleteSelection - backward', () => { para.segments.push(marker, br); model.blocks.push(entity, para); - const onDeleteEntity = jasmine.createSpy('onDeleteEntity').and.returnValue(true); - const result = deleteSelection(model, onDeleteEntity, [backwardDeleteCollapsedSelection]); + const deletedEntities: DeletedEntity[] = []; + const result = deleteSelection(model, [backwardDeleteCollapsedSelection], { + deletedEntities, + }); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3382,15 +3302,6 @@ describe('deleteSelection - backward', () => { expect(model).toEqual({ blockGroupType: 'Document', blocks: [ - { - blockType: 'Entity', - segmentType: 'Entity', - format: {}, - wrapper: wrapper, - isReadonly: true, - id: undefined, - type: undefined, - }, { blockType: 'Paragraph', format: {}, @@ -3404,7 +3315,7 @@ describe('deleteSelection - backward', () => { }, ], }); - expect(onDeleteEntity).toHaveBeenCalledWith(entity, EntityOperation.RemoveFromEnd); + expect(deletedEntities).toEqual([{ entity, operation: EntityOperation.RemoveFromEnd }]); }); it('Single selection marker after list item', () => { @@ -3421,9 +3332,7 @@ describe('deleteSelection - backward', () => { listItem.blocks.push(para2); model.blocks.push(listItem, para1); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3491,9 +3400,7 @@ describe('deleteSelection - backward', () => { quote.blocks.push(para2); model.blocks.push(quote, para1); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3556,9 +3463,7 @@ describe('deleteSelection - backward', () => { quote.blocks.push(para1); model.blocks.push(para2, quote); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3623,9 +3528,7 @@ describe('deleteSelection - backward', () => { listItem.blocks.push(para2); model.blocks.push(listItem, quote); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3697,9 +3600,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, text2); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3753,9 +3654,7 @@ describe('deleteSelection - backward', () => { model.blocks.push(para1); model.blocks.push(para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3803,9 +3702,7 @@ describe('deleteSelection - backward', () => { divider.isSelected = true; model.blocks.push(divider); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3859,9 +3756,7 @@ describe('deleteSelection - backward', () => { divider2.isSelected = true; model.blocks.push(para1, divider1, divider2, para2); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -3931,9 +3826,7 @@ describe('deleteSelection - backward', () => { table.rows[0].cells.push(cell1, cell2); model.blocks.push(table); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -4034,9 +3927,7 @@ describe('deleteSelection - backward', () => { table.rows[0].cells.push(cell); model.blocks.push(table); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); expect(result.insertPoint).toEqual({ @@ -4089,9 +3980,7 @@ describe('deleteSelection - backward', () => { divider.isSelected = true; model.blocks.push(divider); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); const marker: ContentModelSelectionMarker = { segmentType: 'SelectionMarker', format: { fontSize: '10pt' }, @@ -4139,9 +4028,7 @@ describe('deleteSelection - backward', () => { parentParagraph.segments.push(general); model.blocks.push(parentParagraph); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.NothingToDelete); @@ -4192,9 +4079,7 @@ describe('deleteSelection - backward', () => { parentParagraph.segments.push(text, general); model.blocks.push(parentParagraph); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -4246,9 +4131,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -4292,9 +4175,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -4340,9 +4221,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, text2, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -4391,9 +4270,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, text2, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); @@ -4438,7 +4315,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, text2, text3, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [backwardDeleteWordSelection]); + const result = deleteSelection(model, [backwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -4486,7 +4363,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [backwardDeleteWordSelection]); + const result = deleteSelection(model, [backwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -4529,7 +4406,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [backwardDeleteWordSelection]); + const result = deleteSelection(model, [backwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -4572,7 +4449,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [backwardDeleteWordSelection]); + const result = deleteSelection(model, [backwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -4617,7 +4494,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, text2, marker, text3); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [backwardDeleteWordSelection]); + const result = deleteSelection(model, [backwardDeleteWordSelection]); expect(result.deleteResult).toBe(DeleteResult.Range); @@ -4661,9 +4538,7 @@ describe('deleteSelection - backward', () => { para.segments.push(text1, marker); model.blocks.push(para); - const result = deleteSelection(model, onDeleteEntityMock, [ - backwardDeleteCollapsedSelection, - ]); + const result = deleteSelection(model, [backwardDeleteCollapsedSelection]); expect(result.deleteResult).toBe(DeleteResult.SingleChar); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/entity/insertEntityModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/entity/insertEntityModelTest.ts new file mode 100644 index 00000000000..a6ea985581d --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/entity/insertEntityModelTest.ts @@ -0,0 +1,2181 @@ +import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { insertEntityModel } from '../../../lib/modelApi/entity/insertEntityModel'; +import { InsertEntityPosition } from '../../../lib/publicTypes/parameter/InsertEntityOptions'; +import { + createBr, + createContentModelDocument, + createDivider, + createEntity, + createParagraph, + createSelectionMarker, + createText, +} from 'roosterjs-content-model-dom'; + +const Entity = 'Entity' as any; + +function runTestGlobal( + model: ContentModelDocument, + pos: InsertEntityPosition, + expectedResult: ContentModelDocument, + isBlock: boolean, + focusAfterEntity: boolean +) { + insertEntityModel(model, Entity, pos, isBlock, focusAfterEntity); + + expect(model).toEqual(expectedResult, pos); +} + +describe('insertEntityModel, block element, not focus after entity', () => { + const marker = createSelectionMarker(); + + function runTest( + createModel: () => ContentModelDocument, + topResult: ContentModelDocument, + bottomResult: ContentModelDocument, + focusResult: ContentModelDocument, + rootResult: ContentModelDocument + ) { + runTestGlobal(createModel(), 'begin', topResult, true, false); + runTestGlobal(createModel(), 'end', bottomResult, true, false); + runTestGlobal(createModel(), 'focus', focusResult, true, false); + runTestGlobal(createModel(), 'root', rootResult, true, false); + } + + it('no selection', () => { + const br = createBr(); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + } + ); + }); + + it('collapsed selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + const br = createBr(); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + } + ); + }); + + it('Expanded selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + const br = createBr(); + + txt2.isSelected = true; + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(txt1, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + } + ); + }); + + it('Before another paragraph', () => { + const br = createBr(); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, para2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + } + ); + }); + + it('Before another divider', () => { + const divider = createDivider('hr'); + const br = createBr(); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, divider); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + divider, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + Entity, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + Entity, + divider, + ], + } + ); + }); + + it('Before another entity', () => { + const entity2 = createEntity({} as any, true); + const br = createBr(); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, entity2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + entity2, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + format: {}, + }, + entity2, + ], + } + ); + }); + + it('With default format', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + const format = { + fontSize: '10px', + }; + const br = createBr(format); + + runTest( + () => { + const model = createContentModelDocument(format); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + segmentFormat: format, + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + segmentFormat: format, + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [br], + segmentFormat: format, + format: {}, + }, + ], + format, + } + ); + }); +}); + +describe('insertEntityModel, block element, focus after entity', () => { + const br = createBr(); + const marker = createSelectionMarker(); + + function runTest( + createModel: () => ContentModelDocument, + topResult: ContentModelDocument, + bottomResult: ContentModelDocument, + focusResult: ContentModelDocument, + rootResult: ContentModelDocument + ) { + runTestGlobal(createModel(), 'begin', topResult, true, true); + runTestGlobal(createModel(), 'end', bottomResult, true, true); + runTestGlobal(createModel(), 'focus', focusResult, true, true); + runTestGlobal(createModel(), 'root', rootResult, true, true); + } + + it('no selection', () => { + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + } + ); + }); + + it('collapsed selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker, txt1, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + } + ); + }); + + it('Expanded selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + txt2.isSelected = true; + + para.segments.push(txt1, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker, txt1, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + } + ); + }); + + it('Before another paragraph', () => { + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, para2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + ], + } + ); + }); + + it('Before another divider', () => { + const divider = createDivider('hr'); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, divider); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + divider, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + divider, + ], + } + ); + }); + + it('Before another entity', () => { + const entity2 = createEntity({} as any, true); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, entity2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + entity2, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker, br], + format: {}, + }, + entity2, + ], + } + ); + }); + + it('With default format', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + const format = { + fontSize: '10px', + }; + const br = createBr(format); + const marker2 = createSelectionMarker(format); + + runTest( + () => { + const model = createContentModelDocument(format); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + Entity, + { + blockType: 'Paragraph', + segments: [marker, txt1, txt2], + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker2, br], + segmentFormat: format, + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker2, br], + segmentFormat: format, + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + Entity, + { + blockType: 'Paragraph', + segments: [marker2, br], + segmentFormat: format, + format: {}, + }, + ], + format, + } + ); + }); +}); + +describe('insertEntityModel, inline element, not focus after entity', () => { + const marker = createSelectionMarker(); + + function runTest( + createModel: () => ContentModelDocument, + topResult: ContentModelDocument, + bottomResult: ContentModelDocument, + focusResult: ContentModelDocument, + rootResult: ContentModelDocument + ) { + runTestGlobal(createModel(), 'begin', topResult, false, false); + runTestGlobal(createModel(), 'end', bottomResult, false, false); + runTestGlobal(createModel(), 'focus', focusResult, false, false); + runTestGlobal(createModel(), 'root', rootResult, false, false); + } + + it('no selection', () => { + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + } + ); + }); + + it('collapsed selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, Entity, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, Entity, txt2], + format: {}, + }, + ], + } + ); + }); + + it('Expanded selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + txt2.isSelected = true; + + para.segments.push(txt1, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, Entity], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, Entity], + format: {}, + }, + ], + } + ); + }); + + it('Before another paragraph', () => { + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, para2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + } + ); + }); + + it('Before another divider', () => { + const divider = createDivider('hr'); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, divider); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + divider, + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, Entity], + format: {}, + }, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, Entity], + format: {}, + }, + divider, + ], + } + ); + }); + + it('Before another entity', () => { + const entity2 = createEntity({} as any, true); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, entity2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker], + format: {}, + }, + entity2, + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, Entity], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [marker, Entity], + format: {}, + }, + entity2, + ], + } + ); + }); + + it('With default format', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + const format = { + fontSize: '10px', + }; + + runTest( + () => { + const model = createContentModelDocument(format); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + segmentFormat: format, + }, + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, txt2], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity], + format: {}, + segmentFormat: format, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, Entity, txt2], + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, marker, Entity, txt2], + format: {}, + }, + ], + format, + } + ); + }); +}); + +describe('insertEntityModel, inline element, focus after entity', () => { + const marker = createSelectionMarker(); + + function runTest( + createModel: () => ContentModelDocument, + topResult: ContentModelDocument, + bottomResult: ContentModelDocument, + focusResult: ContentModelDocument, + rootResult: ContentModelDocument + ) { + runTestGlobal(createModel(), 'begin', topResult, false, true); + runTestGlobal(createModel(), 'end', bottomResult, false, true); + runTestGlobal(createModel(), 'focus', focusResult, false, true); + runTestGlobal(createModel(), 'root', rootResult, false, true); + } + + it('no selection', () => { + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + } + ); + }); + + it('collapsed selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, Entity, marker, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, Entity, marker, txt2], + format: {}, + }, + ], + } + ); + }); + + it('Expanded selection', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + + runTest( + () => { + const model = createContentModelDocument(); + const para = createParagraph(); + + txt2.isSelected = true; + + para.segments.push(txt1, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, Entity, marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, Entity, marker], + format: {}, + }, + ], + } + ); + }); + + it('Before another paragraph', () => { + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + const para2 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, para2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + ], + } + ); + }); + + it('Before another divider', () => { + const divider = createDivider('hr'); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, divider); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + divider, + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + divider, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + divider, + ], + } + ); + }); + + it('Before another entity', () => { + const entity2 = createEntity({} as any, true); + + runTest( + () => { + const model = createContentModelDocument(); + const para1 = createParagraph(); + + para1.segments.push(marker); + model.blocks.push(para1, entity2); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + entity2, + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + entity2, + ], + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker], + format: {}, + }, + entity2, + ], + } + ); + }); + + it('With default format', () => { + const txt1 = createText('test1'); + const txt2 = createText('test2'); + const format = { + fontSize: '10px', + }; + const marker2 = createSelectionMarker(format); + + runTest( + () => { + const model = createContentModelDocument(format); + const para = createParagraph(); + + para.segments.push(txt1, marker, txt2); + model.blocks.push(para); + + return model; + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [Entity, marker2], + format: {}, + segmentFormat: format, + }, + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, txt2], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [Entity, marker2], + format: {}, + segmentFormat: format, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, Entity, marker, txt2], + format: {}, + }, + ], + format, + }, + { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [txt1, Entity, marker, txt2], + format: {}, + }, + ], + format, + } + ); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts index 1e88ac3f0f4..cb7806b1f0d 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/selection/collectSelectionsTest.ts @@ -374,7 +374,7 @@ describe('getSelectedParagraphs', () => { describe('getFirstSelectedTable', () => { function runTest( selections: SelectionInfo[], - expectedResult: [ContentModelTable | undefined, ContentModelBlockGroup | undefined] + expectedResult: [ContentModelTable | undefined, ContentModelBlockGroup[]] ) { spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { selections.forEach(({ path, tableContext, block, segments }) => { @@ -390,7 +390,7 @@ describe('getFirstSelectedTable', () => { } it('Empty selection', () => { - runTest([], [undefined, undefined]); + runTest([], [undefined, []]); }); it('Single table selection in context', () => { @@ -408,7 +408,7 @@ describe('getFirstSelectedTable', () => { }, }, ], - [table, undefined] + [table, []] ); }); @@ -422,7 +422,7 @@ describe('getFirstSelectedTable', () => { block: table, }, ], - [table, undefined] + [table, []] ); }); @@ -443,7 +443,7 @@ describe('getFirstSelectedTable', () => { }, }, ], - [table1, undefined] + [table1, []] ); }); @@ -462,7 +462,7 @@ describe('getFirstSelectedTable', () => { block: table2, }, ], - [table1, undefined] + [table1, []] ); }); @@ -491,7 +491,7 @@ describe('getFirstSelectedTable', () => { }, }, ], - [table1, undefined] + [table1, []] ); }); @@ -510,7 +510,7 @@ describe('getFirstSelectedTable', () => { block: table1, }, ], - [table1, undefined] + [table1, []] ); }); @@ -526,7 +526,7 @@ describe('getFirstSelectedTable', () => { const result = getFirstSelectedTable(doc); - expect(result).toEqual([table1, doc]); + expect(result).toEqual([table1, [doc]]); }); it('With parent, things under table is selected', () => { @@ -545,7 +545,30 @@ describe('getFirstSelectedTable', () => { const result = getFirstSelectedTable(doc); - expect(result).toEqual([table1, doc]); + expect(result).toEqual([table1, [doc]]); + }); + + it('With deep parent, deep things under table is selected', () => { + const table1 = createTable(1); + const cell = createTableCell(); + const doc = createContentModelDocument(); + + const para = createParagraph(); + const marker = createSelectionMarker(); + + const container1 = createFormatContainer('div'); + const container2 = createFormatContainer('div'); + + para.segments.push(marker); + container2.blocks.push(para); + cell.blocks.push(container2); + table1.rows[0].cells.push(cell); + container1.blocks.push(table1); + doc.blocks.push(container1); + + const result = getFirstSelectedTable(doc); + + expect(result).toEqual([table1, [container1, doc]]); }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/ensureFocusableParagraphForTableTest.ts b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/ensureFocusableParagraphForTableTest.ts new file mode 100644 index 00000000000..4b242677c2b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/modelApi/table/ensureFocusableParagraphForTableTest.ts @@ -0,0 +1,222 @@ +import { ContentModelParagraph } from 'roosterjs-content-model-types'; +import { ensureFocusableParagraphForTable } from '../../../lib/modelApi/table/ensureFocusableParagraphForTable'; +import { + createContentModelDocument, + createDivider, + createFormatContainer, + createParagraph, + createTable, + createTableCell, +} from 'roosterjs-content-model-dom'; + +describe('ensureFocusableParagraphForTable', () => { + it('Table has cell with paragraph', () => { + const table = createTable(1); + const cell = createTableCell(); + const paragraph = createParagraph(); + const model = createContentModelDocument(); + + cell.blocks.push(paragraph); + table.rows[0].cells.push(cell); + model.blocks.push(table); + + const result = ensureFocusableParagraphForTable(model, [model], table); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [paragraph], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + }); + expect(result).toBe(paragraph); + }); + + it('Table has cell without paragraph', () => { + const table = createTable(1); + const cell = createTableCell(); + const divider = createDivider('div'); + const model = createContentModelDocument(); + + cell.blocks.push(divider); + table.rows[0].cells.push(cell); + model.blocks.push(table); + + const result = ensureFocusableParagraphForTable(model, [model], table); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + divider, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + }); + expect(result).toEqual({ + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }); + }); + + it('Table has no cell', () => { + const table = createTable(0); + const model = createContentModelDocument(); + + model.blocks.push(table); + + const result = ensureFocusableParagraphForTable(model, [model], table); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + }); + expect(result).toBe(model.blocks[0] as ContentModelParagraph); + }); + + it('Table has no cell, parent is format container', () => { + const table = createTable(0); + const container = createFormatContainer('div'); + const model = createContentModelDocument(); + + container.blocks.push(table); + model.blocks.push(container); + + const result = ensureFocusableParagraphForTable(model, [container, model], table); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + }); + expect(result).toBe(model.blocks[0] as ContentModelParagraph); + }); + + it('Table has no cell, parent is format container, and has other block', () => { + const table = createTable(0); + const paragraph = createParagraph(); + const container = createFormatContainer('div'); + const model = createContentModelDocument(); + + container.blocks.push(paragraph); + container.blocks.push(table); + model.blocks.push(container); + + const result = ensureFocusableParagraphForTable(model, [container, model], table); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'BlockGroup', + blockGroupType: 'FormatContainer', + tagName: 'div', + blocks: [ + { blockType: 'Paragraph', segments: [], format: {} }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + format: {}, + }, + ], + }); + expect(result).toBe(container.blocks[1] as ContentModelParagraph); + }); + + it('Table has no cell, parent is format container in another container', () => { + const table = createTable(0); + const container1 = createFormatContainer('div'); + const container2 = createFormatContainer('div'); + const para1 = createParagraph(); + const para2 = createParagraph(); + const model = createContentModelDocument(); + + container1.blocks.push(table); + container2.blocks.push(container1); + model.blocks.push(para1); + model.blocks.push(container2); + model.blocks.push(para2); + + const result = ensureFocusableParagraphForTable( + model, + [container1, container2, model], + table + ); + + expect(model).toEqual({ + blockGroupType: 'Document', + blocks: [ + { blockType: 'Paragraph', segments: [], format: {} }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + { blockType: 'Paragraph', segments: [], format: {} }, + ], + }); + expect(result).toBe(model.blocks[1] as ContentModelParagraph); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setHeaderLevelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setHeadingLevelTest.ts similarity index 96% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setHeaderLevelTest.ts rename to packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setHeadingLevelTest.ts index e5f743c22ac..2d818e50f9f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setHeaderLevelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setHeadingLevelTest.ts @@ -1,16 +1,16 @@ -import setHeaderLevel from '../../../lib/publicApi/block/setHeaderLevel'; +import setHeadingLevel from '../../../lib/publicApi/block/setHeadingLevel'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { paragraphTestCommon } from './paragraphTestCommon'; -describe('setHeaderLevel to 1', () => { +describe('setHeadingLevel to 1', () => { function runTest( model: ContentModelDocument, result: ContentModelDocument, calledTimes: number ) { paragraphTestCommon( - 'setHeaderLevel', - editor => setHeaderLevel(editor, 1), + 'setHeadingLevel', + editor => setHeadingLevel(editor, 1), model, result, calledTimes @@ -171,7 +171,7 @@ describe('setHeaderLevel to 1', () => { ); }); - it('With existing header', () => { + it('With existing heading', () => { runTest( { blockGroupType: 'Document', @@ -229,15 +229,15 @@ describe('setHeaderLevel to 1', () => { }); }); -describe('setHeaderLevel to 0', () => { +describe('setHeadingLevel to 0', () => { function runTest( model: ContentModelDocument, result: ContentModelDocument, calledTimes: number ) { paragraphTestCommon( - 'setHeaderLevel', - editor => setHeaderLevel(editor, 0), + 'setHeadingLevel', + editor => setHeadingLevel(editor, 0), model, result, calledTimes @@ -344,7 +344,7 @@ describe('setHeaderLevel to 0', () => { ); }); - it('With existing header', () => { + it('With existing heading', () => { runTest( { blockGroupType: 'Document', diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts index 488d932812d..d8dda555f8e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/setIndentationTest.ts @@ -10,6 +10,7 @@ describe('setIndentation', () => { beforeEach(() => { editor = ({ createContentModel: () => fakeModel, + focus: jasmine.createSpy('focus'), } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts index 3dfd07d7878..e1516101856 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/block/toggleBlockQuoteTest.ts @@ -9,6 +9,7 @@ describe('toggleBlockQuote', () => { beforeEach(() => { editor = ({ + focus: jasmine.createSpy('focus'), createContentModel: () => fakeModel, } as any) as IContentModelEditor; }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts index 1d1dbd81a8c..42b6e0b051f 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/editingTestCommon.ts @@ -13,6 +13,7 @@ export function editingTestCommon( spyOn(pendingFormat, 'setPendingFormat'); spyOn(pendingFormat, 'getPendingFormat').and.returnValue(null); + const triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); const triggerContentChangedEvent = jasmine.createSpy('triggerContentChangedEvent'); const addUndoSnapshot = jasmine @@ -29,9 +30,11 @@ export function editingTestCommon( }); const editor = ({ createContentModel: () => model, + cacheContentModel: jasmine.createSpy('cacheContentModel'), addUndoSnapshot, focus: jasmine.createSpy(), setContentModel, + triggerPluginEvent, isDisposed: () => false, getFocusedPosition: () => null as NodePosition, triggerContentChangedEvent, diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts index f92a61cc037..eef61461c7b 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/editing/handleKeyDownEventTest.ts @@ -6,6 +6,7 @@ import { ChangeSource, Keys } from 'roosterjs-editor-types'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { deleteAllSegmentBefore } from '../../../lib/modelApi/edit/deleteSteps/deleteAllSegmentBefore'; import { editingTestCommon } from './editingTestCommon'; +import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { backwardDeleteWordSelection, forwardDeleteWordSelection, @@ -21,17 +22,8 @@ import { describe('handleKeyDownEvent', () => { let deleteSelectionSpy: jasmine.Spy; - let mockedCallback = 'CALLBACK' as any; - let handleKeyboardEventResultSpy: jasmine.Spy; beforeEach(() => { - handleKeyboardEventResultSpy = spyOn( - handleKeyboardEventResult, - 'handleKeyboardEventResult' - ); - spyOn(handleKeyboardEventResult, 'getOnDeleteEntityCallback').and.returnValue( - mockedCallback - ); deleteSelectionSpy = spyOn(deleteSelection, 'deleteSelection'); }); @@ -46,13 +38,12 @@ describe('handleKeyDownEvent', () => { deleteSelectionSpy.and.returnValue({ deleteResult: expectedDelete, }); - handleKeyboardEventResultSpy.and.returnValue( - expectedDelete == DeleteResult.Range || expectedDelete == DeleteResult.SingleChar - ); - const mockedEvent = { + const preventDefault = jasmine.createSpy('preventDefault'); + const mockedEvent = ({ which: key, - } as KeyboardEvent; + preventDefault, + } as any) as KeyboardEvent; let editor: any; @@ -60,25 +51,18 @@ describe('handleKeyDownEvent', () => { 'handleBackspaceKey', newEditor => { editor = newEditor; - handleKeyDownEvent(editor, mockedEvent, []); + handleKeyDownEvent(editor, mockedEvent); }, input, expectedResult, calledTimes ); - expect(handleKeyboardEventResult.getOnDeleteEntityCallback).toHaveBeenCalledWith( - editor, - mockedEvent, - [] - ); - expect(deleteSelectionSpy).toHaveBeenCalledWith(input, mockedCallback, expectedSteps); - expect(handleKeyboardEventResult.handleKeyboardEventResult).toHaveBeenCalledWith( - editor, - input, - mockedEvent, - expectedDelete - ); + expect(deleteSelectionSpy).toHaveBeenCalledWith(input, expectedSteps, { + deletedEntities: [], + rawEvent: mockedEvent, + skipUndoSnapshot: true, + }); } it('Empty model, forward', () => { @@ -377,38 +361,40 @@ describe('handleKeyDownEvent', () => { it('Check parameter of formatWithContentModel, forward', () => { const spy = spyOn(formatWithContentModel, 'formatWithContentModel'); + const addUndoSnapshot = jasmine.createSpy('addUndoSnapshot'); - const editor = 'EDITOR' as any; + const editor = ({ + addUndoSnapshot, + } as any) as IContentModelEditor; const which = Keys.DELETE; const event = { which, } as any; - const triggeredEvents = 'EVENTS' as any; - handleKeyDownEvent(editor, event, triggeredEvents); + handleKeyDownEvent(editor, event); expect(spy.calls.argsFor(0)[0]).toBe(editor); expect(spy.calls.argsFor(0)[1]).toBe('handleDeleteKey'); - expect(spy.calls.argsFor(0)[3]?.skipUndoSnapshot).toBe(true); + expect(addUndoSnapshot).not.toHaveBeenCalled(); expect(spy.calls.argsFor(0)[3]?.changeSource).toBe(ChangeSource.Keyboard); expect(spy.calls.argsFor(0)[3]?.getChangeData?.()).toBe(which); }); it('Check parameter of formatWithContentModel, backward', () => { const spy = spyOn(formatWithContentModel, 'formatWithContentModel'); + const preventDefault = jasmine.createSpy('preventDefault'); const editor = 'EDITOR' as any; const which = Keys.BACKSPACE; const event = { which, + preventDefault, } as any; - const triggeredEvents = 'EVENTS' as any; - handleKeyDownEvent(editor, event, triggeredEvents); + handleKeyDownEvent(editor, event); expect(spy.calls.argsFor(0)[0]).toBe(editor); expect(spy.calls.argsFor(0)[1]).toBe('handleBackspaceKey'); - expect(spy.calls.argsFor(0)[3]?.skipUndoSnapshot).toBe(true); expect(spy.calls.argsFor(0)[3]?.changeSource).toBe(ChangeSource.Keyboard); expect(spy.calls.argsFor(0)[3]?.getChangeData?.()).toBe(which); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts new file mode 100644 index 00000000000..1699a15787b --- /dev/null +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/entity/insertEntityTest.ts @@ -0,0 +1,232 @@ +import * as commitEntity from 'roosterjs-editor-dom/lib/entity/commitEntity'; +import * as formatWithContentModel from '../../../lib/publicApi/utils/formatWithContentModel'; +import * as getEntityFromElement from 'roosterjs-editor-dom/lib/entity/getEntityFromElement'; +import * as insertEntityModel from '../../../lib/modelApi/entity/insertEntityModel'; +import insertEntity from '../../../lib/publicApi/entity/insertEntity'; +import { ChangeSource } from 'roosterjs-editor-types'; +import { FormatWithContentModelContext } from '../../../lib/publicTypes/parameter/FormatWithContentModelContext'; +import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; + +describe('insertEntity', () => { + let editor: IContentModelEditor; + let context: FormatWithContentModelContext; + let wrapper: HTMLElement; + const model = 'MockedModel' as any; + const newEntity = 'MockedEntity' as any; + + let formatWithContentModelSpy: jasmine.Spy; + let getEntityFromElementSpy: jasmine.Spy; + let triggerContentChangedEventSpy: jasmine.Spy; + let getDocumentSpy: jasmine.Spy; + let createElementSpy: jasmine.Spy; + let commitEntitySpy: jasmine.Spy; + let setPropertySpy: jasmine.Spy; + let appendChildSpy: jasmine.Spy; + let insertEntityModelSpy: jasmine.Spy; + let isDarkModeSpy: jasmine.Spy; + let transformToDarkColorSpy: jasmine.Spy; + + const type = 'Entity'; + const apiName = 'insertEntity'; + + beforeEach(() => { + context = { + deletedEntities: [], + }; + + setPropertySpy = jasmine.createSpy('setPropertySpy'); + appendChildSpy = jasmine.createSpy('appendChildSpy'); + insertEntityModelSpy = spyOn(insertEntityModel, 'insertEntityModel'); + isDarkModeSpy = jasmine.createSpy('isDarkMode'); + transformToDarkColorSpy = jasmine.createSpy('transformToDarkColor'); + + wrapper = { + style: { + setProperty: setPropertySpy, + }, + appendChild: appendChildSpy, + } as any; + + formatWithContentModelSpy = spyOn( + formatWithContentModel, + 'formatWithContentModel' + ).and.callFake((editor, apiName, formatter, options) => { + formatter(model, context); + }); + getEntityFromElementSpy = spyOn(getEntityFromElement, 'default').and.returnValue(newEntity); + commitEntitySpy = spyOn(commitEntity, 'default'); + triggerContentChangedEventSpy = jasmine.createSpy('triggerContentChangedEventSpy'); + createElementSpy = jasmine.createSpy('createElementSpy').and.returnValue(wrapper); + getDocumentSpy = jasmine.createSpy('getDocumentSpy').and.returnValue({ + createElement: createElementSpy, + }); + + editor = { + triggerContentChangedEvent: triggerContentChangedEventSpy, + getDocument: getDocumentSpy, + isDarkMode: isDarkModeSpy, + transformToDarkColor: transformToDarkColorSpy, + } as any; + }); + + it('insert inline entity to top', () => { + const entity = insertEntity(editor, type, false, 'begin'); + + expect(createElementSpy).toHaveBeenCalledWith('span'); + expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block'); + expect(appendChildSpy).not.toHaveBeenCalled(); + expect(commitEntitySpy).toHaveBeenCalledWith(wrapper, type, true); + expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); + expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); + expect(formatWithContentModelSpy.calls.argsFor(0)[3]).toEqual({ + selectionOverride: undefined, + }); + expect(insertEntityModelSpy).toHaveBeenCalledWith( + model, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + id: undefined, + type: type, + isReadonly: true, + wrapper: wrapper, + }, + 'begin', + false, + true, + context + ); + expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); + expect(triggerContentChangedEventSpy).toHaveBeenCalledWith( + ChangeSource.InsertEntity, + newEntity + ); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + + expect(entity).toBe(newEntity); + }); + + it('block inline entity to root', () => { + const entity = insertEntity(editor, type, true, 'root'); + + expect(createElementSpy).toHaveBeenCalledWith('div'); + expect(setPropertySpy).toHaveBeenCalledWith('display', null); + expect(appendChildSpy).not.toHaveBeenCalled(); + expect(commitEntitySpy).toHaveBeenCalledWith(wrapper, type, true); + expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); + expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); + expect(formatWithContentModelSpy.calls.argsFor(0)[3]).toEqual({ + selectionOverride: undefined, + }); + expect(insertEntityModelSpy).toHaveBeenCalledWith( + model, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + id: undefined, + type: type, + isReadonly: true, + wrapper: wrapper, + }, + 'root', + true, + undefined, + context + ); + expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); + expect(triggerContentChangedEventSpy).toHaveBeenCalledWith( + ChangeSource.InsertEntity, + newEntity + ); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + + expect(entity).toBe(newEntity); + }); + + it('block inline entity with more options', () => { + const range = { range: 'RangeEx' } as any; + const contentNode = 'ContentNode' as any; + const entity = insertEntity(editor, type, true, range, { + contentNode: contentNode, + focusAfterEntity: true, + skipUndoSnapshot: true, + wrapperDisplay: 'none', + }); + + expect(createElementSpy).toHaveBeenCalledWith('div'); + expect(setPropertySpy).toHaveBeenCalledWith('display', 'none'); + expect(appendChildSpy).toHaveBeenCalledWith(contentNode); + expect(commitEntitySpy).toHaveBeenCalledWith(wrapper, type, true); + expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); + expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); + expect(formatWithContentModelSpy.calls.argsFor(0)[3]).toEqual({ + selectionOverride: range, + }); + expect(insertEntityModelSpy).toHaveBeenCalledWith( + model, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + id: undefined, + type: type, + isReadonly: true, + wrapper: wrapper, + }, + 'focus', + true, + true, + context + ); + expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); + expect(triggerContentChangedEventSpy).toHaveBeenCalledWith( + ChangeSource.InsertEntity, + newEntity + ); + expect(transformToDarkColorSpy).not.toHaveBeenCalled(); + + expect(entity).toBe(newEntity); + }); + + it('In dark mode', () => { + isDarkModeSpy.and.returnValue(true); + + const entity = insertEntity(editor, type, false, 'begin'); + + expect(createElementSpy).toHaveBeenCalledWith('span'); + expect(setPropertySpy).toHaveBeenCalledWith('display', 'inline-block'); + expect(appendChildSpy).not.toHaveBeenCalled(); + expect(commitEntitySpy).toHaveBeenCalledWith(wrapper, type, true); + expect(formatWithContentModelSpy.calls.argsFor(0)[0]).toBe(editor); + expect(formatWithContentModelSpy.calls.argsFor(0)[1]).toBe(apiName); + expect(formatWithContentModelSpy.calls.argsFor(0)[3]).toEqual({ + selectionOverride: undefined, + }); + expect(insertEntityModelSpy).toHaveBeenCalledWith( + model, + { + segmentType: 'Entity', + blockType: 'Entity', + format: {}, + id: undefined, + type: type, + isReadonly: true, + wrapper: wrapper, + }, + 'begin', + false, + true, + context + ); + expect(getEntityFromElementSpy).toHaveBeenCalledWith(wrapper); + expect(triggerContentChangedEventSpy).toHaveBeenCalledWith( + ChangeSource.InsertEntity, + newEntity + ); + expect(transformToDarkColorSpy).toHaveBeenCalled(); + + expect(entity).toBe(newEntity); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts index 2a50eab85bc..40e2e030e61 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/applyPendingFormatTest.ts @@ -47,7 +47,9 @@ describe('applyPendingFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('applyPendingFormat'); - callback(model); + callback(model, { + deletedEntities: [], + }); } ); spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -116,7 +118,7 @@ describe('applyPendingFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('applyPendingFormat'); - callback(model); + callback(model, { deletedEntities: [] }); } ); spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -176,7 +178,7 @@ describe('applyPendingFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('applyPendingFormat'); - callback(model); + callback(model, { deletedEntities: [] }); } ); spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -234,7 +236,7 @@ describe('applyPendingFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('applyPendingFormat'); - callback(model); + callback(model, { deletedEntities: [] }); } ); spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { @@ -279,7 +281,9 @@ describe('applyPendingFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('applyPendingFormat'); - callback(model); + callback(model, { + deletedEntities: [], + }); } ); spyOn(iterateSelections, 'iterateSelections').and.callFake((_, callback) => { diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts index f9f3f7ba53e..65a87687c0e 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/clearFormatTest.ts @@ -13,7 +13,7 @@ describe('clearFormat', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (_, apiName, callback) => { expect(apiName).toEqual('clearFormat'); - callback(model); + callback(model, { deletedEntities: [] }); } ); spyOn(clearModelFormat, 'clearModelFormat'); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts index 648e903b823..bb228f4fae5 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getFormatStateTest.ts @@ -1,6 +1,9 @@ import * as getPendingFormat from '../../../lib/modelApi/format/pendingFormat'; import * as retrieveModelFormatState from '../../../lib/modelApi/common/retrieveModelFormatState'; -import getFormatState from '../../../lib/publicApi/format/getFormatState'; +import getFormatState, { + reducedModelChildProcessor, +} from '../../../lib/publicApi/format/getFormatState'; +import { DomToModelContext } from 'roosterjs-content-model-types'; import { FormatState, SelectionRangeTypes } from 'roosterjs-editor-types'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; import { @@ -180,3 +183,318 @@ describe('getFormatState', () => { ); }); }); +describe('reducedModelChildProcessor', () => { + let context: DomToModelContext; + + beforeEach(() => { + context = createDomToModelContext(undefined, { + processorOverride: { + child: reducedModelChildProcessor, + }, + }); + }); + + it('Empty DOM', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [], + }); + }); + + it('Single child node, with selected Node in context', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + const span = document.createElement('span'); + + div.appendChild(span); + span.textContent = 'test'; + context.rangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + { + commonAncestorContainer: span, + } as any, + ], + areAllCollapsed: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'test', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Multiple child nodes, with selected Node in context', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + const span3 = document.createElement('span'); + + div.appendChild(span1); + div.appendChild(span2); + div.appendChild(span3); + span1.textContent = 'test1'; + span2.textContent = 'test2'; + span3.textContent = 'test3'; + context.rangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + { + commonAncestorContainer: span2, + } as any, + ], + areAllCollapsed: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + format: {}, + isImplicit: true, + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + }, + ], + }); + }); + + it('Multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + const span3 = document.createElement('span'); + + div.appendChild(span1); + div.appendChild(span2); + div.appendChild(span3); + span1.textContent = 'test1'; + span2.innerHTML = '
line1
line2
'; + span3.textContent = 'test3'; + context.rangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + { + commonAncestorContainer: span2, + } as any, + ], + areAllCollapsed: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'line1', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }, + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'line2', + format: {}, + }, + ], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }, + ], + }); + }); + + it('Multiple layer with multiple child nodes, with selected Node in context, with more child nodes under selected node', () => { + const doc = createContentModelDocument(); + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + const div3 = document.createElement('div'); + const span1 = document.createElement('span'); + const span2 = document.createElement('span'); + const span3 = document.createElement('span'); + + div3.appendChild(span1); + div3.appendChild(span2); + div3.appendChild(span3); + div1.appendChild(div2); + div2.appendChild(div3); + span1.textContent = 'test1'; + span2.innerHTML = '
line1
line2
'; + span3.textContent = 'test3'; + + context.rangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + { + commonAncestorContainer: span2, + } as any, + ], + areAllCollapsed: false, + }; + + reducedModelChildProcessor(doc, div1, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { blockType: 'Paragraph', segments: [], format: {} }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'line1', format: {} }], + format: {}, + }, + { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Text', text: 'line2', format: {} }], + format: {}, + }, + { + blockType: 'Paragraph', + segments: [], + format: {}, + isImplicit: true, + }, + { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, + { blockType: 'Paragraph', segments: [], format: {}, isImplicit: true }, + ], + }); + }); + + it('With table, need to do format for all table cells', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + div.innerHTML = + 'aa
test1test2
bb'; + context.rangeEx = { + type: SelectionRangeTypes.Normal, + ranges: [ + { + commonAncestorContainer: div.querySelector('#selection') as HTMLElement, + } as any, + ], + areAllCollapsed: false, + }; + + reducedModelChildProcessor(doc, div, context); + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test1', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'test2', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }, + ], + }); + }); +}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getSegmentFormatTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getSegmentFormatTest.ts deleted file mode 100644 index e0f923216c3..00000000000 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/format/getSegmentFormatTest.ts +++ /dev/null @@ -1,94 +0,0 @@ -import * as getPendingFormat from '../../../lib/modelApi/format/pendingFormat'; -import getSegmentFormat from '../../../lib/publicApi/format/getSegmentFormat'; -import { ContentModelSegmentFormat, DomToModelOption } from 'roosterjs-content-model-types'; -import { createRange } from 'roosterjs-editor-dom'; -import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; -import { PositionType, SelectionRangeTypes } from 'roosterjs-editor-types'; -import { - createContentModelDocument, - createDomToModelContext, - normalizeContentModel, -} from 'roosterjs-content-model-dom'; - -const selectedNodeId = 'Selected'; - -describe('getSegmentFormat', () => { - function runTest( - html: string, - pendingFormat: ContentModelSegmentFormat | null, - expectedFormat: ContentModelSegmentFormat | null - ) { - spyOn(getPendingFormat, 'getPendingFormat').and.returnValue(pendingFormat); - - const editor = ({ - getUndoState: () => ({ - canUndo: false, - canRedo: false, - }), - isDarkMode: () => false, - getZoomScale: () => 1, - createContentModel: (options: DomToModelOption) => { - const model = createContentModelDocument(); - const editorDiv = document.createElement('div'); - - editorDiv.innerHTML = html; - - const selectedNode = editorDiv.querySelector('#' + selectedNodeId); - const range = - selectedNode && - createRange(selectedNode, PositionType.Begin, selectedNode, PositionType.End); - const context = createDomToModelContext( - undefined, - options, - range - ? { - type: SelectionRangeTypes.Normal, - ranges: [range], - areAllCollapsed: range.collapsed, - } - : undefined - ); - - context.elementProcessors.child(model, editorDiv, context); - - normalizeContentModel(model); - - return model; - }, - } as any) as IContentModelEditor; - const result = getSegmentFormat(editor); - - expect(result).toEqual(expectedFormat); - } - - it('Empty model', () => { - runTest('', null, null); - }); - - it('Single node', () => { - runTest(`test`, null, { - fontSize: '10px', - textColor: 'red', - }); - }); - - it('Multiple node', () => { - runTest( - `
test1
test2
test3
`, - null, - { fontSize: '10px', textColor: 'red' } - ); - }); - - it('Multiple node, has child under selection', () => { - runTest( - `
test1
line1
line2
test3
`, - null, - { fontSize: '10px', textColor: 'red' } - ); - }); - - it('Has pending format', () => { - runTest('', { fontSize: '10px', textColor: 'red' }, { fontSize: '10px', textColor: 'red' }); - }); -}); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts index 006537762d4..b78705d7134 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStartNumberTest.ts @@ -11,7 +11,7 @@ describe('setListStartNumber', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (editor, apiName, callback) => { expect(apiName).toBe('setListStartNumber'); - const result = callback(input); + const result = callback(input, { deletedEntities: [] }); expect(result).toBe(expectedResult); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts index 74ec67b7252..2212976a091 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/list/setListStyleTest.ts @@ -12,7 +12,7 @@ describe('setListStyle', () => { spyOn(formatWithContentModel, 'formatWithContentModel').and.callFake( (editor, apiName, callback) => { expect(apiName).toBe('setListStyle'); - const result = callback(input); + const result = callback(input, { deletedEntities: [] }); expect(result).toBe(expectedResult); } diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleCode.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleCodeTest.ts similarity index 100% rename from packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleCode.ts rename to packages-content-model/roosterjs-content-model-editor/test/publicApi/segment/toggleCodeTest.ts diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts index 765394800e3..ee12942f250 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/formatWithContentModelTest.ts @@ -1,5 +1,5 @@ import * as pendingFormat from '../../../lib/modelApi/format/pendingFormat'; -import { ChangeSource } from 'roosterjs-editor-types'; +import { ChangeSource, EntityOperation, PluginEventType } from 'roosterjs-editor-types'; import { ContentModelDocument } from 'roosterjs-content-model-types'; import { formatWithContentModel } from '../../../lib/publicApi/utils/formatWithContentModel'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; @@ -14,6 +14,7 @@ describe('formatWithContentModel', () => { let cacheContentModel: jasmine.Spy; let getFocusedPosition: jasmine.Spy; let triggerContentChangedEvent: jasmine.Spy; + let triggerPluginEvent: jasmine.Spy; const apiName = 'mockedApi'; const mockedPos = 'POS' as any; @@ -28,6 +29,7 @@ describe('formatWithContentModel', () => { cacheContentModel = jasmine.createSpy('cacheContentModel'); getFocusedPosition = jasmine.createSpy('getFocusedPosition').and.returnValue(mockedPos); triggerContentChangedEvent = jasmine.createSpy('triggerContentChangedEvent'); + triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); editor = ({ focus, @@ -37,6 +39,7 @@ describe('formatWithContentModel', () => { cacheContentModel, getFocusedPosition, triggerContentChangedEvent, + triggerPluginEvent, } as any) as IContentModelEditor; }); @@ -45,11 +48,14 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback); - expect(callback).toHaveBeenCalledWith(mockedModel); + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, + }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).not.toHaveBeenCalled(); expect(setContentModel).not.toHaveBeenCalled(); - expect(focus).not.toHaveBeenCalled(); + expect(focus).toHaveBeenCalled(); }); it('Callback return true', () => { @@ -57,7 +63,10 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback); - expect(callback).toHaveBeenCalledWith(mockedModel); + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, + }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(1); expect(addUndoSnapshot.calls.argsFor(0)[1]).toBe(ChangeSource.Format); @@ -81,7 +90,10 @@ describe('formatWithContentModel', () => { preservePendingFormat: true, }); - expect(callback).toHaveBeenCalledWith(mockedModel); + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, + }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalledTimes(1); expect(addUndoSnapshot.calls.argsFor(0)[1]).toBe(ChangeSource.Format); @@ -98,17 +110,22 @@ describe('formatWithContentModel', () => { }); it('Skip undo snapshot', () => { - const callback = jasmine.createSpy('callback').and.returnValue(true); + const callback = jasmine.createSpy('callback').and.callFake((model, context) => { + context.skipUndoSnapshot = true; + return true; + }); const mockedFormat = 'FORMAT' as any; spyOn(pendingFormat, 'getPendingFormat').and.returnValue(mockedFormat); spyOn(pendingFormat, 'setPendingFormat'); - formatWithContentModel(editor, apiName, callback, { + formatWithContentModel(editor, apiName, callback); + + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, skipUndoSnapshot: true, }); - - expect(callback).toHaveBeenCalledWith(mockedModel); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).not.toHaveBeenCalled(); }); @@ -118,22 +135,30 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback, { changeSource: 'TEST' }); - expect(callback).toHaveBeenCalledWith(mockedModel); + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, + }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalled(); expect(addUndoSnapshot.calls.argsFor(0)[1]).toBe('TEST'); }); it('Customize change source and skip undo snapshot', () => { - const callback = jasmine.createSpy('callback').and.returnValue(true); - + const callback = jasmine.createSpy('callback').and.callFake((model, context) => { + context.skipUndoSnapshot = true; + return true; + }); formatWithContentModel(editor, apiName, callback, { changeSource: 'TEST', - skipUndoSnapshot: true, getChangeData: () => 'DATA', }); - expect(callback).toHaveBeenCalledWith(mockedModel); + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, + skipUndoSnapshot: true, + }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).not.toHaveBeenCalled(); expect(triggerContentChangedEvent).toHaveBeenCalledWith('TEST', 'DATA'); @@ -145,7 +170,10 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback, { onNodeCreated: onNodeCreated }); - expect(callback).toHaveBeenCalledWith(mockedModel); + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, + }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(addUndoSnapshot).toHaveBeenCalled(); expect(setContentModel).toHaveBeenCalledWith(mockedModel, { onNodeCreated }); @@ -158,7 +186,10 @@ describe('formatWithContentModel', () => { formatWithContentModel(editor, apiName, callback, { getChangeData }); - expect(callback).toHaveBeenCalledWith(mockedModel); + expect(callback).toHaveBeenCalledWith(mockedModel, { + deletedEntities: [], + rawEvent: undefined, + }); expect(createContentModel).toHaveBeenCalledTimes(1); expect(setContentModel).toHaveBeenCalledWith(mockedModel, { onNodeCreated: undefined }); expect(addUndoSnapshot).toHaveBeenCalled(); @@ -169,4 +200,53 @@ describe('formatWithContentModel', () => { expect(getChangeData).toHaveBeenCalled(); expect(result).toBe(mockedData); }); + + it('Has entity got deleted', () => { + const entity1 = { id: 'E1', type: 'E', wrapper: {}, isReadonly: true } as any; + const entity2 = { id: 'E2', type: 'E', wrapper: {}, isReadonly: true } as any; + const rawEvent = 'RawEvent' as any; + + formatWithContentModel( + editor, + apiName, + (model, context) => { + context.deletedEntities.push( + { + entity: entity1, + operation: EntityOperation.RemoveFromStart, + }, + { + entity: entity2, + operation: EntityOperation.RemoveFromEnd, + } + ); + return true; + }, + { + rawEvent: rawEvent, + } + ); + + expect(triggerPluginEvent).toHaveBeenCalledTimes(2); + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + entity: entity1, + operation: EntityOperation.RemoveFromStart, + rawEvent: rawEvent, + }); + expect(triggerPluginEvent).toHaveBeenCalledWith(PluginEventType.EntityOperation, { + entity: entity2, + operation: EntityOperation.RemoveFromEnd, + rawEvent: rawEvent, + }); + }); + + it('With selectionOverride', () => { + const range = 'MockedRangeEx' as any; + + formatWithContentModel(editor, apiName, () => true, { + selectionOverride: range, + }); + + expect(createContentModel).toHaveBeenCalledWith(undefined, range); + }); }); diff --git a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts index 885815ae29f..c9c03cce21c 100644 --- a/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts +++ b/packages-content-model/roosterjs-content-model-editor/test/publicApi/utils/pasteTest.ts @@ -1,9 +1,28 @@ +import * as addParserF from '../../../lib/editor/plugins/PastePlugin/utils/addParser'; import * as domToContentModel from 'roosterjs-content-model-dom/lib/domToModel/domToContentModel'; +import * as ExcelF from '../../../lib/editor/plugins/PastePlugin/Excel/processPastedContentFromExcel'; +import * as getPasteSourceF from 'roosterjs-editor-dom/lib/pasteSourceValidations/getPasteSource'; import * as mergeModelFile from '../../../lib/modelApi/common/mergeModel'; -import paste from '../../../lib/publicApi/utils/paste'; -import { ClipboardData, PasteType } from 'roosterjs-editor-types'; +import * as pasteF from '../../../lib/publicApi/utils/paste'; +import * as PPT from '../../../lib/editor/plugins/PastePlugin/PowerPoint/processPastedContentFromPowerPoint'; +import * as setProcessorF from '../../../lib/editor/plugins/PastePlugin/utils/setProcessor'; +import * as WacComponents from '../../../lib/editor/plugins/PastePlugin/WacComponents/processPastedContentWacComponents'; +import * as WordDesktopFile from '../../../lib/editor/plugins/PastePlugin/WordDesktop/processPastedContentFromWordDesktop'; +import ContentModelEditor from '../../../lib/editor/ContentModelEditor'; +import ContentModelPastePlugin from '../../../lib/editor/plugins/PastePlugin/ContentModelPastePlugin'; import { ContentModelDocument } from 'roosterjs-content-model-types'; +import { createContentModelDocument } from 'roosterjs-content-model-dom'; import { IContentModelEditor } from '../../../lib/publicTypes/IContentModelEditor'; +import { + BeforePasteEvent, + ClipboardData, + KnownPasteSourceType, + PasteType, + PluginEvent, + PluginEventType, +} from 'roosterjs-editor-types'; + +let clipboardData: ClipboardData; describe('Paste ', () => { let editor: IContentModelEditor; @@ -25,18 +44,16 @@ describe('Paste ', () => { let div: HTMLDivElement; - const clipboardData: ClipboardData = { - types: ['image/png', 'text/html'], - text: '', - image: null!, - rawHtml: '\r\nteststringteststring\r\n', - customValues: {}, - imageDataUri: null!, - }; - beforeEach(() => { spyOn(domToContentModel, 'domToContentModel').and.callThrough(); - + clipboardData = { + types: ['image/png', 'text/html'], + text: '', + image: null!, + rawHtml: '\r\nteststringteststring\r\n', + customValues: {}, + imageDataUri: null!, + }; div = document.createElement('div'); document.body.appendChild(div); mockedModel = ({} as any) as ContentModelDocument; @@ -98,7 +115,7 @@ describe('Paste ', () => { }); it('Execute', () => { - paste(editor, clipboardData, false, false, false); + pasteF.default(editor, clipboardData, false, false, false); expect(setContentModel).toHaveBeenCalled(); expect(focus).toHaveBeenCalled(); @@ -111,4 +128,424 @@ describe('Paste ', () => { expect(mockedModel).toEqual(mockedMergeModel); expect(clipboardData).toEqual(undoSnapshotResult); }); + + it('Execute | As plain text', () => { + pasteF.default(editor, clipboardData, true /* asPlainText */, false, false); + + expect(setContentModel).toHaveBeenCalled(); + expect(focus).toHaveBeenCalled(); + expect(addUndoSnapshot).toHaveBeenCalled(); + expect(getFocusedPosition).not.toHaveBeenCalled(); + expect(getContent).toHaveBeenCalled(); + expect(triggerPluginEvent).not.toHaveBeenCalled(); + expect(getDocument).toHaveBeenCalled(); + expect(getTrustedHTMLHandler).toHaveBeenCalled(); + expect(mockedModel).toEqual(mockedMergeModel); + expect(clipboardData).toEqual(undoSnapshotResult); + }); +}); + +describe('paste with content model & paste plugin', () => { + let editor: ContentModelEditor | undefined; + let div: HTMLDivElement | undefined; + + beforeEach(() => { + div = document.createElement('div'); + document.body.appendChild(div); + editor = new ContentModelEditor(div, { + plugins: [new ContentModelPastePlugin()], + }); + spyOn(addParserF, 'default').and.callThrough(); + spyOn(setProcessorF, 'setProcessor').and.callThrough(); + clipboardData = { + types: ['image/png', 'text/html'], + text: '', + image: null!, + rawHtml: '\r\nteststringteststring\r\n', + customValues: {}, + imageDataUri: null!, + }; + }); + + afterEach(() => { + editor?.dispose(); + editor = undefined; + div?.remove(); + div = undefined; + }); + + it('Word Desktop', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.WordDesktop); + spyOn(WordDesktopFile, 'processPastedContentFromWordDesktop').and.callThrough(); + + pasteF.default(editor!, clipboardData); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(1); + expect(addParserF.default).toHaveBeenCalledTimes(4); + expect(WordDesktopFile.processPastedContentFromWordDesktop).toHaveBeenCalledTimes(1); + }); + + it('Word Online', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.WacComponents); + spyOn(WacComponents, 'processPastedContentWacComponents').and.callThrough(); + + pasteF.default(editor!, clipboardData); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(4); + expect(addParserF.default).toHaveBeenCalledTimes(5); + expect(WacComponents.processPastedContentWacComponents).toHaveBeenCalledTimes(1); + }); + + it('Excel Online', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.ExcelOnline); + spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); + + pasteF.default(editor!, clipboardData); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(2); + expect(ExcelF.processPastedContentFromExcel).toHaveBeenCalledTimes(1); + }); + + it('Excel Desktop', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.ExcelDesktop); + spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); + + pasteF.default(editor!, clipboardData); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(2); + expect(ExcelF.processPastedContentFromExcel).toHaveBeenCalledTimes(1); + }); + + it('PowerPoint', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.PowerPointDesktop); + spyOn(PPT, 'processPastedContentFromPowerPoint').and.callThrough(); + + pasteF.default(editor!, clipboardData); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(1); + expect(PPT.processPastedContentFromPowerPoint).toHaveBeenCalledTimes(1); + }); + + // Plain Text + it('Word Desktop | Plain Text', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.WordDesktop); + spyOn(WordDesktopFile, 'processPastedContentFromWordDesktop').and.callThrough(); + + pasteF.default(editor!, clipboardData, true /* pasteAsText */); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(0); + expect(WordDesktopFile.processPastedContentFromWordDesktop).toHaveBeenCalledTimes(0); + }); + + it('Word Online | Plain Text', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.WacComponents); + spyOn(WacComponents, 'processPastedContentWacComponents').and.callThrough(); + + pasteF.default(editor!, clipboardData, true /* pasteAsText */); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(0); + expect(WacComponents.processPastedContentWacComponents).toHaveBeenCalledTimes(0); + }); + + it('Excel Online | Plain Text', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.ExcelOnline); + spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); + + pasteF.default(editor!, clipboardData, true /* pasteAsText */); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(0); + expect(ExcelF.processPastedContentFromExcel).toHaveBeenCalledTimes(0); + }); + + it('Excel Desktop | Plain Text', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.ExcelDesktop); + spyOn(ExcelF, 'processPastedContentFromExcel').and.callThrough(); + + pasteF.default(editor!, clipboardData, true /* pasteAsText */); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(0); + expect(ExcelF.processPastedContentFromExcel).toHaveBeenCalledTimes(0); + }); + + it('PowerPoint | Plain Text', () => { + spyOn(getPasteSourceF, 'default').and.returnValue(KnownPasteSourceType.PowerPointDesktop); + spyOn(PPT, 'processPastedContentFromPowerPoint').and.callThrough(); + + pasteF.default(editor!, clipboardData, true /* pasteAsText */); + + expect(setProcessorF.setProcessor).toHaveBeenCalledTimes(0); + expect(addParserF.default).toHaveBeenCalledTimes(0); + expect(PPT.processPastedContentFromPowerPoint).toHaveBeenCalledTimes(0); + }); + + it('Verify the event data is not lost', () => { + clipboardData = { + types: ['image/png', 'text/plain', 'text/html'], + text: 'Flight\tDescription\r\n', + image: {}, + files: [], + rawHtml: + '\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n \r\n \r\n \r\n\r\n \r\n \r\n\r\n \r\n
FlightDescription
\r\n\r\n\r\n\r\n\r\n', + customValues: {}, + pasteNativeEvent: true, + imageDataUri: '', + }; + + let eventChecker: BeforePasteEvent = {}; + editor = new ContentModelEditor(div!, { + plugins: [ + { + initialize: () => {}, + dispose: () => {}, + getName: () => 'test', + onPluginEvent(event: PluginEvent) { + if (event.eventType === PluginEventType.BeforePaste) { + eventChecker = event; + } + }, + }, + ], + }); + + pasteF.default(editor!, clipboardData); + + expect(eventChecker?.clipboardData).toEqual(clipboardData); + expect(eventChecker?.htmlBefore).toBeTruthy(); + expect(eventChecker?.htmlAfter).toBeTruthy(); + expect(eventChecker?.pasteType).toEqual(0); + }); +}); + +describe('mergePasteContent', () => { + it('merge table', () => { + // A doc with only one table in content + const pasteModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 0, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'FromPaste', + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { useBorderBox: true, borderCollapse: true }, + widths: [], + dataset: { + editingInfo: '', + }, + }, + ], + }; + + // A doc with a table, and selection marker inside of it. + const sourceModel: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + { segmentType: 'Br', format: {} }, + ], + format: {}, + isImplicit: true, + }, + ], + format: {}, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { useBorderBox: true, borderCollapse: true }, + widths: [120, 120], + dataset: { + editingInfo: '', + }, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + format: {}, + }; + + spyOn(mergeModelFile, 'mergeModel').and.callThrough(); + + pasteF.mergePasteContent( + sourceModel, + { deletedEntities: [] }, + pasteModel, + false /* applyCurrentFormat */, + undefined /* customizedMerge */ + ); + + expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( + sourceModel, + pasteModel, + { deletedEntities: [] }, + { + mergeFormat: 'none', + mergeTable: true, + } + ); + expect(sourceModel).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Table', + rows: [ + { + height: 22, + format: {}, + cells: [ + { + blockGroupType: 'TableCell', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Text', + text: 'FromPaste', + format: {}, + }, + { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }, + ], + format: {}, + isImplicit: true, + }, + ], + format: { + useBorderBox: true, + borderTop: '1px solid #ABABAB', + borderRight: '1px solid #ABABAB', + borderBottom: '1px solid #ABABAB', + borderLeft: '1px solid #ABABAB', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: { useBorderBox: true, borderCollapse: true }, + widths: [120, 120], + dataset: { + editingInfo: + '{"topBorderColor":"#ABABAB","bottomBorderColor":"#ABABAB","verticalBorderColor":"#ABABAB","hasHeaderRow":false,"hasFirstColumn":false,"hasBandedRows":false,"hasBandedColumns":false,"bgColorEven":null,"bgColorOdd":"#ABABAB20","headerRowColor":"#ABABAB","tableBorderFormat":0,"verticalAlign":null}', + }, + }, + { + blockType: 'Paragraph', + segments: [{ segmentType: 'Br', format: {} }], + format: {}, + }, + ], + format: {}, + }); + }); + + it('customized merge', () => { + const pasteModel: ContentModelDocument = createContentModelDocument(); + const sourceModel: ContentModelDocument = createContentModelDocument(); + + const customizedMerge = jasmine.createSpy('customizedMerge'); + + spyOn(mergeModelFile, 'mergeModel').and.callThrough(); + + pasteF.mergePasteContent( + sourceModel, + { deletedEntities: [] }, + pasteModel, + false /* applyCurrentFormat */, + customizedMerge /* customizedMerge */ + ); + + expect(mergeModelFile.mergeModel).not.toHaveBeenCalled(); + expect(customizedMerge).toHaveBeenCalledWith(sourceModel, pasteModel); + }); + + it('Apply current format', () => { + const pasteModel: ContentModelDocument = createContentModelDocument(); + const sourceModel: ContentModelDocument = createContentModelDocument(); + + spyOn(mergeModelFile, 'mergeModel').and.callThrough(); + + pasteF.mergePasteContent( + sourceModel, + { deletedEntities: [] }, + pasteModel, + true /* applyCurrentFormat */, + undefined /* customizedMerge */ + ); + + expect(mergeModelFile.mergeModel).toHaveBeenCalledWith( + sourceModel, + pasteModel, + { deletedEntities: [] }, + { + mergeFormat: 'keepSourceEmphasisFormat', + mergeTable: false, + } + ); + }); }); diff --git a/packages-content-model/roosterjs-content-model-types/lib/block/ContentModelParagraph.ts b/packages-content-model/roosterjs-content-model-types/lib/block/ContentModelParagraph.ts index 8966f66b649..8eb809ee954 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/block/ContentModelParagraph.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/block/ContentModelParagraph.ts @@ -21,7 +21,7 @@ export interface ContentModelParagraph segmentFormat?: ContentModelSegmentFormat; /** - * Header info for this paragraph if it is a header + * Decorator info for this paragraph, used by heading and P tags */ decorator?: ContentModelParagraphDecorator; diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts index c5934c0e6a5..303965d7a31 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelFormatContext.ts @@ -44,12 +44,6 @@ export interface DomToModelFormatContext { * Context of list that is currently processing */ listFormat: DomToModelListFormat; - - /** - * Whether put the source element into Content Model when possible. - * When pass true, this cached element will be used to create DOM tree back when convert Content Model to DOM - */ - allowCacheElement?: boolean; } /** diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts index 9a40f7e7906..7814b6f8ad4 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/DomToModelOption.ts @@ -28,10 +28,4 @@ export interface DomToModelOption { * Provide additional format parsers for each format type */ additionalFormatParsers?: Partial; - - /** - * Whether put the source element into Content Model when possible. - * When pass true, this cached element will be used to create DOM tree back when convert Content Model to DOM - */ - disableCacheElement?: boolean; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts b/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts index b6411e231d2..09999c9b121 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/context/EditorContext.ts @@ -34,4 +34,10 @@ export interface EditorContext { * Whether the content is in Right-to-left from root level */ isRootRtl?: boolean; + + /** + * Whether put the source element into Content Model when possible. + * When pass true, this cached element will be used to create DOM tree back when convert Content Model to DOM + */ + allowCacheElement?: boolean; } diff --git a/packages-content-model/roosterjs-content-model-types/lib/decorator/ContentModelParagraphDecorator.ts b/packages-content-model/roosterjs-content-model-types/lib/decorator/ContentModelParagraphDecorator.ts index b90583ceef5..1f58635f03c 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/decorator/ContentModelParagraphDecorator.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/decorator/ContentModelParagraphDecorator.ts @@ -3,8 +3,8 @@ import { ContentModelWithFormat } from '../format/ContentModelWithFormat'; /** * Represent decorator for a paragraph in Content Model - * A decorator of paragraph can represent a header, or a P tag that act likes a paragraph but with some extra format info - * since header is also a kind of paragraph, with some extra information + * A decorator of paragraph can represent a heading, or a P tag that act likes a paragraph but with some extra format info + * since heading is also a kind of paragraph, with some extra information */ export interface ContentModelParagraphDecorator extends ContentModelWithFormat { diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelImageFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelImageFormat.ts index 5693df671ae..f344c54931b 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelImageFormat.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/ContentModelImageFormat.ts @@ -2,10 +2,12 @@ import { BorderFormat } from './formatParts/BorderFormat'; import { BoxShadowFormat } from './formatParts/BoxShadowFormat'; import { ContentModelSegmentFormat } from './ContentModelSegmentFormat'; import { DisplayFormat } from './formatParts/DisplayFormat'; +import { FloatFormat } from './formatParts/FloatFormat'; import { IdFormat } from './formatParts/IdFormat'; import { MarginFormat } from './formatParts/MarginFormat'; import { PaddingFormat } from './formatParts/PaddingFormat'; import { SizeFormat } from './formatParts/SizeFormat'; +import { VerticalAlignFormat } from './formatParts/VerticalAlignFormat'; /** * The format object for an image in Content Model @@ -17,4 +19,6 @@ export type ContentModelImageFormat = ContentModelSegmentFormat & PaddingFormat & BorderFormat & BoxShadowFormat & - DisplayFormat; + DisplayFormat & + FloatFormat & + VerticalAlignFormat; diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts b/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts index 483fe79386d..8936c886c75 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/format/FormatHandlerTypeMap.ts @@ -6,6 +6,7 @@ import { BoxShadowFormat } from './formatParts/BoxShadowFormat'; import { DatasetFormat } from './metadata/DatasetFormat'; import { DirectionFormat } from './formatParts/DirectionFormat'; import { DisplayFormat } from './formatParts/DisplayFormat'; +import { FloatFormat } from './formatParts/FloatFormat'; import { FontFamilyFormat } from './formatParts/FontFamilyFormat'; import { FontSizeFormat } from './formatParts/FontSizeFormat'; import { HtmlAlignFormat } from './formatParts/HtmlAlignFormat'; @@ -74,6 +75,11 @@ export interface FormatHandlerTypeMap { */ display: DisplayFormat; + /** + * Format for FloatFormat + */ + float: FloatFormat; + /** * Format for FontFamilyFormat */ diff --git a/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/FloatFormat.ts b/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/FloatFormat.ts new file mode 100644 index 00000000000..ce59dc748c3 --- /dev/null +++ b/packages-content-model/roosterjs-content-model-types/lib/format/formatParts/FloatFormat.ts @@ -0,0 +1,9 @@ +/** + * Format of float + */ +export type FloatFormat = { + /** + * Float style + */ + float?: string; +}; diff --git a/packages-content-model/roosterjs-content-model-types/lib/index.ts b/packages-content-model/roosterjs-content-model-types/lib/index.ts index 51657498387..22e154bf4a5 100644 --- a/packages-content-model/roosterjs-content-model-types/lib/index.ts +++ b/packages-content-model/roosterjs-content-model-types/lib/index.ts @@ -45,6 +45,7 @@ export { SizeFormat } from './format/formatParts/SizeFormat'; export { BoxShadowFormat } from './format/formatParts/BoxShadowFormat'; export { ListThreadFormat } from './format/formatParts/ListThreadFormat'; export { ListStylePositionFormat } from './format/formatParts/ListStylePositionFormat'; +export { FloatFormat } from './format/formatParts/FloatFormat'; export { DatasetFormat } from './format/metadata/DatasetFormat'; export { TableMetadataFormat } from './format/metadata/TableMetadataFormat'; diff --git a/packages-ui/roosterjs-react/lib/index.ts b/packages-ui/roosterjs-react/lib/index.ts index af9e5f7ddd9..57d3e4a8ce2 100644 --- a/packages-ui/roosterjs-react/lib/index.ts +++ b/packages-ui/roosterjs-react/lib/index.ts @@ -5,3 +5,4 @@ export * from './contextMenu/index'; export * from './pasteOptions/index'; export * from './colorPicker/index'; export * from './emoji/index'; +export * from './inputDialog/index'; diff --git a/packages-ui/roosterjs-react/lib/inputDialog/index.ts b/packages-ui/roosterjs-react/lib/inputDialog/index.ts new file mode 100644 index 00000000000..d64da44e8d3 --- /dev/null +++ b/packages-ui/roosterjs-react/lib/inputDialog/index.ts @@ -0,0 +1,2 @@ +export { default as showInputDialog } from './utils/showInputDialog'; +export { default as DialogItem } from './type/DialogItem'; diff --git a/packages-ui/roosterjs-react/lib/inputDialog/type/DialogItem.ts b/packages-ui/roosterjs-react/lib/inputDialog/type/DialogItem.ts index 0c93299ee47..b01318be33d 100644 --- a/packages-ui/roosterjs-react/lib/inputDialog/type/DialogItem.ts +++ b/packages-ui/roosterjs-react/lib/inputDialog/type/DialogItem.ts @@ -1,9 +1,24 @@ /** - * @internal + * Item of input dialog */ export default interface DialogItem { + /** + * Localized string key of the input item name + */ labelKey: Strings | null; + + /** + * Unlocalized string for the label text. This will be used when a valid localized string is not found using the given string key + */ unlocalizedLabel: string | null; + + /** + * Initial value of this item + */ initValue: string; + + /** + * Whether focus should be put into this item automatically + */ autoFocus?: boolean; } diff --git a/packages-ui/roosterjs-react/lib/inputDialog/utils/showInputDialog.tsx b/packages-ui/roosterjs-react/lib/inputDialog/utils/showInputDialog.tsx index 2b11eac8047..6a7ea3cee78 100644 --- a/packages-ui/roosterjs-react/lib/inputDialog/utils/showInputDialog.tsx +++ b/packages-ui/roosterjs-react/lib/inputDialog/utils/showInputDialog.tsx @@ -10,7 +10,13 @@ import { } from '../../common/index'; /** - * @internal + * Show a dialog with input items + * @param uiUtilities UI utilities to help render the dialog + * @param dialogTitleKey Localized string key for title of this dialog + * @param unlocalizedTitle Unlocalized title string of this dialog. It will be used if a valid localized string is not found using dialogTitleKey + * @param items Input items in this dialog + * @param strings Localized strings + * @param onChange An optional callback that will be invoked when input item value is changed */ export default function showInputDialog( uiUtilities: UIUtilities, diff --git a/packages-ui/roosterjs-react/lib/ribbon/component/buttons/header.ts b/packages-ui/roosterjs-react/lib/ribbon/component/buttons/header.ts deleted file mode 100644 index 896abba0621..00000000000 --- a/packages-ui/roosterjs-react/lib/ribbon/component/buttons/header.ts +++ /dev/null @@ -1,42 +0,0 @@ -import RibbonButton from '../../type/RibbonButton'; -import { getObjectKeys } from 'roosterjs-editor-dom'; -import { HeaderButtonStringKey } from '../../type/RibbonButtonStringKeys'; -import { toggleHeader } from 'roosterjs-editor-api'; - -const headers: Partial> = { - buttonNameHeader1: 'Header 1', - buttonNameHeader2: 'Header 2', - buttonNameHeader3: 'Header 3', - buttonNameHeader4: 'Header 4', - buttonNameHeader5: 'Header 5', - buttonNameHeader6: 'Header 6', - '-': '-', - buttonNameNoHeader: 'No header', -}; - -/** - * @internal - * "Header" button on the format ribbon - */ -export const header: RibbonButton = { - key: 'buttonNameHeader', - unlocalizedText: 'Header', - iconName: 'Header1', - dropDownMenu: { - items: headers, - getSelectedItemKey: formatState => { - return (formatState.headerLevel ?? 0) > 0 - ? 'header' + formatState.headerLevel - : 'noHeader'; - }, - }, - onClick: (editor, key) => { - const index = getObjectKeys(headers).indexOf(key) + 1; - - if (index > 6) { - toggleHeader(editor, 0); - } else if (index > 0) { - toggleHeader(editor, index); - } - }, -}; diff --git a/packages-ui/roosterjs-react/lib/ribbon/component/buttons/heading.ts b/packages-ui/roosterjs-react/lib/ribbon/component/buttons/heading.ts new file mode 100644 index 00000000000..e9b51df3b61 --- /dev/null +++ b/packages-ui/roosterjs-react/lib/ribbon/component/buttons/heading.ts @@ -0,0 +1,42 @@ +import RibbonButton from '../../type/RibbonButton'; +import { getObjectKeys } from 'roosterjs-editor-dom'; +import { HeadingButtonStringKey } from '../../type/RibbonButtonStringKeys'; +import { setHeadingLevel } from 'roosterjs-editor-api'; + +const headings: Partial> = { + buttonNameHeading1: 'Heading 1', + buttonNameHeading2: 'Heading 2', + buttonNameHeading3: 'Heading 3', + buttonNameHeading4: 'Heading 4', + buttonNameHeading5: 'Heading 5', + buttonNameHeading6: 'Heading 6', + '-': '-', + buttonNameNoHeading: 'No heading', +}; + +/** + * @internal + * "Heading" button on the format ribbon + */ +export const heading: RibbonButton = { + key: 'buttonNameHeading', + unlocalizedText: 'Heading', + iconName: 'Header1', + dropDownMenu: { + items: headings, + getSelectedItemKey: formatState => { + return (formatState.headingLevel ?? 0) > 0 + ? 'heading' + formatState.headingLevel + : 'noHeading'; + }, + }, + onClick: (editor, key) => { + const index = getObjectKeys(headings).indexOf(key) + 1; + + if (index > 6) { + setHeadingLevel(editor, 0); + } else if (index > 0) { + setHeadingLevel(editor, index); + } + }, +}; diff --git a/packages-ui/roosterjs-react/lib/ribbon/component/getButtons.ts b/packages-ui/roosterjs-react/lib/ribbon/component/getButtons.ts index 487b6905b5f..ecf922228a4 100644 --- a/packages-ui/roosterjs-react/lib/ribbon/component/getButtons.ts +++ b/packages-ui/roosterjs-react/lib/ribbon/component/getButtons.ts @@ -12,7 +12,7 @@ import { decreaseFontSize } from './buttons/decreaseFontSize'; import { decreaseIndent } from './buttons/decreaseIndent'; import { font } from './buttons/font'; import { fontSize } from './buttons/fontSize'; -import { header } from './buttons/header'; +import { heading } from './buttons/heading'; import { increaseFontSize } from './buttons/increaseFontSize'; import { increaseIndent } from './buttons/increaseIndent'; import { insertImage } from './buttons/insertImage'; @@ -58,7 +58,8 @@ const KnownRibbonButtons = getTagOfNode(cell) == 'TH') : undefined; + const headingLevel = (headingTag && parseInt(headingTag[1])) || 0; return { isBullet: listTag == 'UL', isNumbering: listTag == 'OL', isMultilineSelection: multiline, - headerLevel: (headerTag && parseInt(headerTag[1])) || 0, + headingLevel: headingLevel, + headerLevel: headingLevel, canUnlink: !!editor.queryElements('a[href]', QueryScope.OnSelection)[0], canAddImageAltText: !!editor.queryElements('img', QueryScope.OnSelection)[0], isBlockQuote: !!editor.queryElements('blockquote', QueryScope.OnSelection)[0], @@ -56,7 +58,7 @@ export function getElementBasedFormatState( isCodeBlock: !!editor.queryElements('pre>code', QueryScope.OnSelection)[0], isInTable: !!table, tableFormat: tableFormat || {}, - tableHasHeader: hasHeader, + tableHasHeader: hasTableHeader, canMergeTableCell: canMergeTableCell(editor), }; } @@ -67,7 +69,7 @@ export function getElementBasedFormatState( * bold, italic, underline, font name, font size, etc. * @param editor The editor instance * @param event (Optional) The plugin event, it stores the event cached data for looking up. - * In this function the event cache is used to get list state and header level. If not passed, + * In this function the event cache is used to get list state and heading level. If not passed, * it will query the node within selection to get the info * @returns The format state at cursor */ diff --git a/packages/roosterjs-editor-api/lib/format/toggleHeader.ts b/packages/roosterjs-editor-api/lib/format/setHeadingLevel.ts similarity index 74% rename from packages/roosterjs-editor-api/lib/format/toggleHeader.ts rename to packages/roosterjs-editor-api/lib/format/setHeadingLevel.ts index eb7070bb73d..174278ee849 100644 --- a/packages/roosterjs-editor-api/lib/format/toggleHeader.ts +++ b/packages/roosterjs-editor-api/lib/format/setHeadingLevel.ts @@ -1,50 +1,56 @@ -import formatUndoSnapshot from '../utils/formatUndoSnapshot'; -import { DocumentCommand, IEditor, QueryScope } from 'roosterjs-editor-types'; -import { HtmlSanitizer, moveChildNodes } from 'roosterjs-editor-dom'; - -/** - * Toggle header at selection - * @param editor The editor instance - * @param level The header level, can be a number from 0 to 6, in which 1 ~ 6 refers to - * the HTML header element <H1> to <H6>, 0 means no header - * if passed in param is outside the range, will be rounded to nearest number in the range - */ -export default function toggleHeader(editor: IEditor, level: number) { - level = Math.min(Math.max(Math.round(level), 0), 6); - - formatUndoSnapshot( - editor, - () => { - editor.focus(); - - let wrapped = false; - editor.queryElements('H1,H2,H3,H4,H5,H6', QueryScope.OnSelection, header => { - if (!wrapped) { - editor.getDocument().execCommand(DocumentCommand.FormatBlock, false, '
'); - wrapped = true; - } - - const div = editor.getDocument().createElement('div'); - moveChildNodes(div, header); - editor.replaceNode(header, div); - }); - - if (level > 0) { - let traverser = editor.getSelectionTraverser(); - let blockElement = traverser?.currentBlockElement; - let sanitizer = new HtmlSanitizer({ - cssStyleCallbacks: { - 'font-size': () => false, - }, - }); - while (blockElement) { - let element = blockElement.collapseToSingleElement(); - sanitizer.sanitize(element); - blockElement = traverser?.getNextBlockElement(); - } - editor.getDocument().execCommand(DocumentCommand.FormatBlock, false, ``); - } - }, - 'toggleHeader' - ); -} +import formatUndoSnapshot from '../utils/formatUndoSnapshot'; +import { DocumentCommand, IEditor, QueryScope } from 'roosterjs-editor-types'; +import { HtmlSanitizer, moveChildNodes } from 'roosterjs-editor-dom'; + +/** + * Set heading level at selection + * @param editor The editor instance + * @param level The heading level, can be a number from 0 to 6, in which 1 ~ 6 refers to + * the HTML heading element <H1> to <H6>, 0 means no heading + * if passed in param is outside the range, will be rounded to nearest number in the range + */ +export default function setHeadingLevel(editor: IEditor, level: number) { + level = Math.min(Math.max(Math.round(level), 0), 6); + + formatUndoSnapshot( + editor, + () => { + editor.focus(); + + let wrapped = false; + editor.queryElements('H1,H2,H3,H4,H5,H6', QueryScope.OnSelection, heading => { + if (!wrapped) { + editor.getDocument().execCommand(DocumentCommand.FormatBlock, false, '
'); + wrapped = true; + } + + const div = editor.getDocument().createElement('div'); + moveChildNodes(div, heading); + editor.replaceNode(heading, div); + }); + + if (level > 0) { + let traverser = editor.getSelectionTraverser(); + let blockElement = traverser?.currentBlockElement; + let sanitizer = new HtmlSanitizer({ + cssStyleCallbacks: { + 'font-size': () => false, + }, + }); + while (blockElement) { + let element = blockElement.collapseToSingleElement(); + sanitizer.sanitize(element); + blockElement = traverser?.getNextBlockElement(); + } + editor.getDocument().execCommand(DocumentCommand.FormatBlock, false, ``); + } + }, + 'toggleHeader' + ); +} + +/** + * @deprecated Use setHeadingLevel instead + * Keep this for compatibility only, will be removed in next major release + */ +export const toggleHeader = setHeadingLevel; diff --git a/packages/roosterjs-editor-api/lib/index.ts b/packages/roosterjs-editor-api/lib/index.ts index 5c39f16f71d..cec4c5e78be 100644 --- a/packages/roosterjs-editor-api/lib/index.ts +++ b/packages/roosterjs-editor-api/lib/index.ts @@ -31,7 +31,7 @@ export { default as toggleStrikethrough } from './format/toggleStrikethrough'; export { default as toggleSubscript } from './format/toggleSubscript'; export { default as toggleSuperscript } from './format/toggleSuperscript'; export { default as toggleUnderline } from './format/toggleUnderline'; -export { default as toggleHeader } from './format/toggleHeader'; +export { default as setHeadingLevel, toggleHeader } from './format/setHeadingLevel'; export { default as applyCellShading } from './table/applyCellShading'; export { default as toggleListType } from './utils/toggleListType'; diff --git a/packages/roosterjs-editor-core/lib/coreApi/createPasteFragment.ts b/packages/roosterjs-editor-core/lib/coreApi/createPasteFragment.ts index e687aa251ef..7a1b98e8bc3 100644 --- a/packages/roosterjs-editor-core/lib/coreApi/createPasteFragment.ts +++ b/packages/roosterjs-editor-core/lib/coreApi/createPasteFragment.ts @@ -125,8 +125,10 @@ function createFragmentFromClipboardData( handleTextPaste(text, position, fragment); } - // Step 4: Trigger BeforePasteEvent so that plugins can do proper change before paste - core.api.triggerEvent(core, event, true /*broadcast*/); + // Step 4: Trigger BeforePasteEvent so that plugins can do proper change before paste, when the type of paste is different than Plain Text + if (event.pasteType !== PasteType.AsPlainText) { + core.api.triggerEvent(core, event, true /*broadcast*/); + } // Step 5. Sanitize the fragment before paste to make sure the content is safe sanitizePasteContent(event, position); diff --git a/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts b/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts index f5f964b74a6..2c1fa9da916 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/DOMEventPlugin.ts @@ -84,7 +84,8 @@ export default class DOMEventPlugin implements PluginWithState { + const dragEvent = e as DragEvent; + const element = this.editor?.getElementAtCursor('*', dragEvent.target as Node); + + if (element && !element.isContentEditable) { + dragEvent.preventDefault(); + } + }; private onDrop = () => { this.editor?.runAsync(editor => { editor.addUndoSnapshot(() => {}, ChangeSource.Drop); diff --git a/packages/roosterjs-editor-core/lib/corePlugins/EntityPlugin.ts b/packages/roosterjs-editor-core/lib/corePlugins/EntityPlugin.ts index 3453ed07081..434edcdcfa5 100644 --- a/packages/roosterjs-editor-core/lib/corePlugins/EntityPlugin.ts +++ b/packages/roosterjs-editor-core/lib/corePlugins/EntityPlugin.ts @@ -64,7 +64,6 @@ const REMOVE_ENTITY_OPERATIONS: (EntityOperation | CompatibleEntityOperation)[] export default class EntityPlugin implements PluginWithState { private editor: IEditor | null = null; private state: EntityPluginState; - private disposer: (() => void) | null = null; /** * Construct a new instance of EntityPlugin @@ -88,15 +87,12 @@ export default class EntityPlugin implements PluginWithState */ initialize(editor: IEditor) { this.editor = editor; - this.disposer = this.editor.addDomEventHandler('dragstart', this.onDragStart); } /** * Dispose this plugin */ dispose() { - this.disposer?.(); - this.disposer = null; this.editor = null; this.state.entityMap = {}; } @@ -277,18 +273,6 @@ export default class EntityPlugin implements PluginWithState }); } - private onDragStart = (e: Event) => { - const dragEvent = e as DragEvent; - const entityWrapper = this.editor?.getElementAtCursor( - getEntitySelector(), - dragEvent.target as Node - ); - - if (entityWrapper && getEntityFromElement(entityWrapper)?.isReadonly) { - dragEvent.preventDefault(); - } - }; - private checkRemoveEntityForRange(event: Event) { const editableEntityElements: HTMLElement[] = []; const selector = getEntitySelector(); diff --git a/packages/roosterjs-editor-core/test/coreApi/createPasteFragmentTest.ts b/packages/roosterjs-editor-core/test/coreApi/createPasteFragmentTest.ts index 8cf5f6fb80c..60df016dbb3 100644 --- a/packages/roosterjs-editor-core/test/coreApi/createPasteFragmentTest.ts +++ b/packages/roosterjs-editor-core/test/coreApi/createPasteFragmentTest.ts @@ -480,6 +480,27 @@ describe('createPasteFragment', () => { expect(html.trim()).toBe('teststringteststring'); expect(clipboardData.htmlFirstLevelChildTags).toEqual(['', 'IMG', '']); }); + + it('Skip triggerEvent on Plain text paste', () => { + const triggerEvent = jasmine.createSpy(); + const core = createEditorCore(div, { + coreApiOverride: { + triggerEvent, + }, + }); + + const clipboardData: ClipboardData = { + types: ['image/png', 'text/html'], + text: '', + image: null, + rawHtml: '\r\nteststringteststring\r\n', + customValues: {}, + imageDataUri: null, + }; + createPasteFragment(core, clipboardData, null, true /* plainText */, false, false); + + expect(triggerEvent).not.toHaveBeenCalled(); + }); }); function getHTML(fragment: DocumentFragment) { diff --git a/packages/roosterjs-editor-core/test/corePlugins/entityPluginTest.ts b/packages/roosterjs-editor-core/test/corePlugins/entityPluginTest.ts index 63bab07d794..219f1eaa072 100644 --- a/packages/roosterjs-editor-core/test/corePlugins/entityPluginTest.ts +++ b/packages/roosterjs-editor-core/test/corePlugins/entityPluginTest.ts @@ -20,25 +20,17 @@ describe('EntityPlugin', () => { let triggerPluginEvent: jasmine.Spy; let state: EntityPluginState; let editor: IEditor; - let addDomEventHandler: jasmine.Spy; - let onDragStartFunc: (e: any) => void; beforeEach(() => { triggerPluginEvent = jasmine.createSpy('triggerPluginEvent'); plugin = new EntityPlugin(); state = plugin.getState(); - addDomEventHandler = jasmine - .createSpy('addDomEventHandler') - .and.callFake((eventName, onDragStart) => { - onDragStartFunc = onDragStart; - }); editor = ({ getDocument: () => document, getElementAtCursor: (selector: string, node: Node) => node, addContentEditFeature: () => {}, triggerPluginEvent, isFeatureEnabled: () => false, - addDomEventHandler, }); plugin.initialize(editor); }); @@ -662,36 +654,4 @@ describe('EntityPlugin', () => { }); }); }); - - it('Do Not allow dragging readonly entity', () => { - const wrapper = document.createElement('div'); - const preventDefault = jasmine.createSpy('preventDefault'); - - commitEntity.default(wrapper, 'TestEntity', true); - - expect(addDomEventHandler).toHaveBeenCalledTimes(1); - expect(addDomEventHandler.calls.argsFor(0)[0]).toBe('dragstart'); - - onDragStartFunc({ - target: wrapper, - preventDefault, - }); - - expect(preventDefault).toHaveBeenCalledTimes(1); - }); - - it('Still allow dragging when click normal node', () => { - const wrapper = document.createElement('div'); - const preventDefault = jasmine.createSpy('preventDefault'); - - expect(addDomEventHandler).toHaveBeenCalledTimes(1); - expect(addDomEventHandler.calls.argsFor(0)[0]).toBe('dragstart'); - - onDragStartFunc({ - target: wrapper, - preventDefault, - }); - - expect(preventDefault).not.toHaveBeenCalled(); - }); }); diff --git a/packages/roosterjs-editor-dom/lib/table/VTable.ts b/packages/roosterjs-editor-dom/lib/table/VTable.ts index 7ec1f85be17..5fe335aedce 100644 --- a/packages/roosterjs-editor-dom/lib/table/VTable.ts +++ b/packages/roosterjs-editor-dom/lib/table/VTable.ts @@ -111,6 +111,17 @@ export default class VTable { } } } + for (let col = 0; col < this.cells![rowIndex].length; col++) { + if (!this.cells![rowIndex][col]) { + this.cells![rowIndex][col] = { + td: null, + spanLeft: false, + spanAbove: false, + width: undefined, + height: undefined, + }; + } + } }); this.formatInfo = getTableFormatInfo(this.table); if (normalizeSize) { diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts index 7714936ed52..7173d0e0f49 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/ImageEdit.ts @@ -432,11 +432,6 @@ export default class ImageEdit implements EditorPlugin { // Set image src to original src to help show editing UI, also it will be used when regenerate image dataURL after editing if (this.clonedImage) { this.clonedImage.src = this.pngSource ?? this.editInfo.src; - setFlipped( - this.clonedImage, - this.editInfo.flippedHorizontal, - this.editInfo.flippedVertical - ); this.clonedImage.style.position = 'absolute'; } @@ -525,6 +520,8 @@ export default class ImageEdit implements EditorPlugin { leftPercent, rightPercent, topPercent, + flippedHorizontal, + flippedVertical, } = this.editInfo; // Width/height of the image @@ -557,6 +554,9 @@ export default class ImageEdit implements EditorPlugin { this.clonedImage.style.width = getPx(originalWidth); this.clonedImage.style.height = getPx(originalHeight); + //Update flip direction + setFlipped(this.clonedImage.parentElement, flippedHorizontal, flippedVertical); + if (this.isCropping) { // For crop, we also need to set position of the overlays setSize( @@ -596,7 +596,14 @@ export default class ImageEdit implements EditorPlugin { const viewport = this.editor?.getVisibleViewport(); const isSmall = isASmallImage(targetWidth, targetHeight); if (rotateHandle && rotateCenter && viewport) { - updateRotateHandleState(viewport, rotateCenter, rotateHandle, isSmall); + updateRotateHandleState( + viewport, + angleRad, + wrapper, + rotateCenter, + rotateHandle, + isSmall + ); } updateSideHandlesVisibility(resizeHandles, isSmall); @@ -770,11 +777,13 @@ function getColorString(color: string | ModeIndependentColor, isDarkMode: boolea } function setFlipped( - element: HTMLImageElement, + element: HTMLElement | null, flippedHorizontally?: boolean, flippedVertically?: boolean ) { - element.style.transform = `scale(${flippedHorizontally ? '-1' : '1'}, ${ - flippedVertically ? '-1' : '1' - })`; + if (element) { + element.style.transform = `scale(${flippedHorizontally ? -1 : 1}, ${ + flippedVertically ? -1 : 1 + })`; + } } diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/constants/constants.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/constants/constants.ts index 754ad0f4a08..ab693701b73 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/constants/constants.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/constants/constants.ts @@ -14,11 +14,14 @@ export const ROTATION: Record = { ne: 180, se: 270, }; +export const Xs: DNDDirectionX[] = ['w', '', 'e']; +export const Ys: DnDDirectionY[] = ['s', '', 'n']; + export const ROTATE_WIDTH = 1; export const ROTATE_HANDLE_TOP = ROTATE_GAP + RESIZE_HANDLE_MARGIN; export const CROP_HANDLE_SIZE = 22; export const CROP_HANDLE_WIDTH = 7; -export const Xs: DNDDirectionX[] = ['w', '', 'e']; -export const Ys: DnDDirectionY[] = ['s', '', 'n']; +export const XS_CROP: DNDDirectionX[] = ['w', 'e']; +export const YS_CROP: DnDDirectionY[] = ['s', 'n']; export const MIN_HEIGHT_WIDTH = 3 * RESIZE_HANDLE_SIZE + 2 * RESIZE_HANDLE_MARGIN; diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts index fe3e4a786f6..181d53796c3 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Cropper.ts @@ -1,10 +1,16 @@ import DragAndDropContext, { DNDDirectionX, DnDDirectionY } from '../types/DragAndDropContext'; import DragAndDropHandler from '../../../pluginUtils/DragAndDropHandler'; import { CreateElementData } from 'roosterjs-editor-types'; -import { CROP_HANDLE_SIZE, CROP_HANDLE_WIDTH, ROTATION, Xs, Ys } from '../constants/constants'; import { CropInfo } from '../types/ImageEditInfo'; import { ImageEditElementClass } from '../types/ImageEditElementClass'; import { rotateCoordinate } from './Resizer'; +import { + CROP_HANDLE_SIZE, + CROP_HANDLE_WIDTH, + ROTATION, + XS_CROP, + YS_CROP, +} from '../constants/constants'; /** * @internal @@ -96,7 +102,9 @@ export function getCropHTML(): CreateElementData[] { children: [], }; if (containerHTML) { - Xs.forEach(x => Ys.forEach(y => containerHTML.children?.push(getCropHTMLInternal(x, y)))); + XS_CROP.forEach(x => + YS_CROP.forEach(y => containerHTML.children?.push(getCropHTMLInternal(x, y))) + ); } return [containerHTML, overlayHTML, overlayHTML, overlayHTML, overlayHTML]; } diff --git a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts index 9616c242bd1..3625d74afd8 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/ImageEdit/imageEditors/Rotator.ts @@ -50,6 +50,8 @@ export const Rotator: DragAndDropHandler = { */ export function updateRotateHandleState( editorRect: Rect, + angleRad: number, + wrapper: HTMLElement, rotateCenter: HTMLElement, rotateHandle: HTMLElement, isSmallImage: boolean @@ -62,21 +64,35 @@ export function updateRotateHandleState( rotateCenter.style.display = ''; rotateHandle.style.display = ''; const rotateHandleRect = rotateHandle.getBoundingClientRect(); + const wrapperRect = wrapper.getBoundingClientRect(); - if (rotateHandleRect) { - const top = rotateHandleRect.top - editorRect.top; - const left = rotateHandleRect.left - editorRect.left; - const right = rotateHandleRect.right - editorRect.right; - const bottom = rotateHandleRect.bottom - editorRect.bottom; + if (rotateHandleRect && wrapperRect) { let adjustedDistance = Number.MAX_SAFE_INTEGER; - if (top <= 0) { + const angle = angleRad * DEG_PER_RAD; + + if (angle < 45 && angle > -45 && wrapperRect.top - editorRect.top < ROTATE_GAP) { + const top = rotateHandleRect.top - editorRect.top; adjustedDistance = top; - } else if (left <= 0) { + } else if ( + angle <= -80 && + angle >= -100 && + wrapperRect.left - editorRect.left < ROTATE_GAP + ) { + const left = rotateHandleRect.left - editorRect.left; adjustedDistance = left; - } else if (right >= 0) { - adjustedDistance = right; - } else if (bottom >= 0) { - adjustedDistance = bottom; + } else if ( + angle >= 80 && + angle <= 100 && + editorRect.right - wrapperRect.right < ROTATE_GAP + ) { + const right = rotateHandleRect.right - editorRect.right; + adjustedDistance = Math.min(editorRect.right - wrapperRect.right, right); + } else if ( + (angle <= -160 || angle >= 160) && + editorRect.bottom - wrapperRect.bottom < ROTATE_GAP + ) { + const bottom = rotateHandleRect.bottom - editorRect.bottom; + adjustedDistance = Math.min(editorRect.bottom - wrapperRect.bottom, bottom); } const rotateGap = Math.max(Math.min(ROTATE_GAP, adjustedDistance), 0); diff --git a/packages/roosterjs-editor-plugins/lib/plugins/Paste/Paste.ts b/packages/roosterjs-editor-plugins/lib/plugins/Paste/Paste.ts index 4e14e79a39d..bbfdb8faabf 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/Paste/Paste.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/Paste/Paste.ts @@ -7,8 +7,8 @@ import convertPastedContentFromWord from './wordConverter/convertPastedContentFr import handleLineMerge from './lineMerge/handleLineMerge'; import sanitizeHtmlColorsFromPastedContent from './sanitizeHtmlColorsFromPastedContent/sanitizeHtmlColorsFromPastedContent'; import sanitizeLinks from './sanitizeLinks/sanitizeLinks'; -import { getPasteSource } from 'roosterjs-editor-dom'; -import { KnownPasteSourceType } from 'roosterjs-editor-types'; +import { chainSanitizerCallback, getPasteSource } from 'roosterjs-editor-dom'; +import { HtmlSanitizerOptions, KnownPasteSourceType } from 'roosterjs-editor-types'; import { EditorPlugin, IEditor, @@ -104,9 +104,16 @@ export default class Paste implements EditorPlugin { } sanitizeLinks(sanitizingOption); sanitizeHtmlColorsFromPastedContent(sanitizingOption); + sanitizeBlockStyles(sanitizingOption); // Replace unknown tags with SPAN sanitizingOption.unknownTagReplacement = this.unknownTagReplacement; } } } + +function sanitizeBlockStyles(sanitizingOption: Required) { + chainSanitizerCallback(sanitizingOption.cssStyleCallbacks, 'display', (value: string) => { + return value != 'flex'; // return whether we keep the style + }); +} diff --git a/packages/roosterjs-editor-plugins/lib/plugins/Paste/wordConverter/convertPastedContentFromWord.ts b/packages/roosterjs-editor-plugins/lib/plugins/Paste/wordConverter/convertPastedContentFromWord.ts index b9fa0a45e8c..f3b897faf07 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/Paste/wordConverter/convertPastedContentFromWord.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/Paste/wordConverter/convertPastedContentFromWord.ts @@ -26,7 +26,7 @@ export default function convertPastedContentFromWord(event: BeforePasteEvent) { let wordConverter = createWordConverter(); // First find all the nodes that we need to check for list item information - // This call will return all the p and header elements under the root node.. These are the elements that + // This call will return all the p and heading elements under the root node.. These are the elements that // Word uses a list items, so we'll only process them and avoid walking the whole tree. let elements = fragment.querySelectorAll(LIST_ELEMENTS_SELECTOR) as NodeListOf; if (elements.length > 0) { diff --git a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyDownEvent.ts b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyDownEvent.ts index e6e1354c0d2..a8f7ac8fa75 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyDownEvent.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyDownEvent.ts @@ -9,6 +9,7 @@ import { TableCellSelectionState } from '../TableCellSelectionState'; import { updateSelection } from '../utils/updateSelection'; import { contains, + createRange, isCtrlOrMetaPressed, Position, safeInstanceOf, @@ -37,6 +38,7 @@ export function handleKeyDownEvent( return; } + const range = editor.getSelectionRangeEx(); if (shiftKey) { if (!state.firstTarget) { const pos = editor.getFocusedPosition(); @@ -70,10 +72,15 @@ export function handleKeyDownEvent( } }); } else if ( - editor.getSelectionRangeEx()?.type == SelectionRangeTypes.TableSelection && + range?.type == SelectionRangeTypes.TableSelection && (!isCtrlOrMetaPressed(event.rawEvent) || which == Keys.HOME || which == Keys.END) ) { - editor.select(null); + // Select all content in the first cell + const row = range.ranges[0]; + const firstCell = row.startContainer.childNodes[row.startOffset]; + const children = firstCell.childNodes; + const contentRange = createRange(children[0], children[children.length - 1]); + editor.select(contentRange); } } diff --git a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyUpEvent.ts b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyUpEvent.ts index 617c86b4ca5..62530408ee9 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyUpEvent.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/keyUtils/handleKeyUpEvent.ts @@ -1,5 +1,6 @@ import { clearState } from '../utils/clearState'; import { IEditor, Keys, PluginKeyUpEvent } from 'roosterjs-editor-types'; +import { isCharacterValue } from 'roosterjs-editor-dom'; import { TableCellSelectionState } from '../TableCellSelectionState'; const IGNORE_KEY_UP_KEYS = [ @@ -26,6 +27,9 @@ export function handleKeyUpEvent( !state.preventKeyUp && IGNORE_KEY_UP_KEYS.indexOf(which) == -1 ) { + if (isCharacterValue(event.rawEvent)) { + editor.addUndoSnapshot(); + } clearState(state, editor); } state.preventKeyUp = false; diff --git a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/mouseUtils/handleMouseDownEvent.ts b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/mouseUtils/handleMouseDownEvent.ts index b68a93cfd92..26d97110906 100644 --- a/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/mouseUtils/handleMouseDownEvent.ts +++ b/packages/roosterjs-editor-plugins/lib/plugins/TableCellSelection/mouseUtils/handleMouseDownEvent.ts @@ -24,8 +24,9 @@ export function handleMouseDownEvent( state: TableCellSelectionState, editor: IEditor ) { - const { which, shiftKey, target } = event.rawEvent; + const { which, shiftKey, target, detail } = event.rawEvent; const table = editor.getElementAtCursor('table', target as Node, event); + const tripleClick = detail >= 3; if (table && !table.isContentEditable) { return; @@ -58,60 +59,63 @@ export function handleMouseDownEvent( } } } - if (which == LEFT_CLICK && !shiftKey) { - clearState(state, editor); + if (which == LEFT_CLICK) { + if (!shiftKey && !tripleClick) { + clearState(state, editor); - if (getTableAtCursor(editor, event.rawEvent.target)) { - const doc = editor.getDocument() || document; + if (getTableAtCursor(editor, event.rawEvent.target)) { + const doc = editor.getDocument() || document; - const mouseUpListener = getOnMouseUp(state); - const mouseMoveListener = onMouseMove(state, editor); - doc.addEventListener('mouseup', mouseUpListener, true /*setCapture*/); - doc.addEventListener('mousemove', mouseMoveListener, true /*setCapture*/); + const mouseUpListener = getOnMouseUp(state); + const mouseMoveListener = onMouseMove(state, editor); + doc.addEventListener('mouseup', mouseUpListener, true /*setCapture*/); + doc.addEventListener('mousemove', mouseMoveListener, true /*setCapture*/); - state.mouseMoveDisposer = () => { - doc.removeEventListener('mouseup', mouseUpListener, true /*setCapture*/); - doc.removeEventListener('mousemove', mouseMoveListener, true /*setCapture*/); - }; + state.mouseMoveDisposer = () => { + doc.removeEventListener('mouseup', mouseUpListener, true /*setCapture*/); + doc.removeEventListener('mousemove', mouseMoveListener, true /*setCapture*/); + }; - state.startedSelection = true; + state.startedSelection = true; + } } - } - if (which == LEFT_CLICK && shiftKey) { - editor.runAsync(editor => { - const sel = editor.getDocument().defaultView?.getSelection(); - const first = getCellAtCursor(editor, sel?.anchorNode); - const last = getCellAtCursor(editor, sel?.focusNode); - const firstTable = getTableAtCursor(editor, first); - const targetTable = getTableAtCursor(editor, first); - if ( - firstTable! == targetTable! && - safeInstanceOf(first, 'HTMLTableCellElement') && - safeInstanceOf(last, 'HTMLTableCellElement') - ) { - state.vTable = new VTable(first); - const firstCord = getCellCoordinates(state.vTable, first); - const lastCord = getCellCoordinates(state.vTable, last); + if (shiftKey || tripleClick) { + editor.runAsync(editor => { + const sel = editor.getDocument().defaultView?.getSelection(); + const first = getCellAtCursor(editor, sel?.anchorNode); + // Triple clicking a cell will select that cell only + // Assign last the same as first to make sure we can select the cell + const last = tripleClick ? first : getCellAtCursor(editor, sel?.focusNode); + const firstTable = getTableAtCursor(editor, first); + if ( + firstTable && + safeInstanceOf(first, 'HTMLTableCellElement') && + safeInstanceOf(last, 'HTMLTableCellElement') + ) { + state.vTable = new VTable(first); + const firstCord = getCellCoordinates(state.vTable, first); + const lastCord = getCellCoordinates(state.vTable, last); + + if (!firstCord || !lastCord) { + return; + } + state.vTable.selection = { + firstCell: firstCord, + lastCell: lastCord, + }; + + state.firstTarget = first; + state.lastTarget = last; + selectTable(editor, state); - if (!firstCord || !lastCord) { - return; + state.tableSelection = true; + state.firstTable = firstTable as HTMLTableElement; + state.targetTable = firstTable; + updateSelection(editor, first, 0); } - state.vTable.selection = { - firstCell: firstCord, - lastCell: lastCord, - }; - - state.firstTarget = first; - state.lastTarget = last; - selectTable(editor, state); - - state.tableSelection = true; - state.firstTable = firstTable as HTMLTableElement; - state.targetTable = targetTable; - updateSelection(editor, first, 0); - } - }); + }); + } } } diff --git a/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts b/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts index 04582424529..72dd5b694c8 100644 --- a/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts +++ b/packages/roosterjs-editor-plugins/test/imageEdit/imageEditTest.ts @@ -25,7 +25,7 @@ describe('ImageEdit | rotate and flip', () => { }); function runRotateTest(angle: number, editInfo?: ImageEditInfo) { - const IMG_ID = 'IMAGE_ID'; + const IMG_ID = 'IMAGE_ID_ROTATION'; const content = ``; editor.setContent(content); const image = document.getElementById(IMG_ID) as HTMLImageElement; @@ -46,7 +46,7 @@ describe('ImageEdit | rotate and flip', () => { flippedVertical?: boolean, editInfo?: ImageEditInfo ) { - const IMG_ID = 'IMAGE_ID'; + const IMG_ID = 'IMAGE_ID_FLIP'; const content = ``; editor.setContent(content); const image = document.getElementById(IMG_ID) as HTMLImageElement; @@ -212,7 +212,7 @@ describe('ImageEdit | rotate and flip', () => { }); it('start image editing', () => { - const IMG_ID = 'IMAGE_ID'; + const IMG_ID = 'IMAGE_ID_EDITING'; const content = ``; editor.setContent(content); const image = document.getElementById(IMG_ID) as HTMLImageElement; @@ -220,7 +220,7 @@ describe('ImageEdit | rotate and flip', () => { editor.select(image); plugin.setEditingImage(image, ImageEditOperation.Resize); expect(editor.getContent()).toBe( - '' + '' ); }); }); @@ -271,7 +271,7 @@ describe('ImageEdit | plugin events | quitting', () => { }; it('image selection quit editing', () => { - const IMG_ID = 'IMAGE_ID'; + const IMG_ID = 'IMAGE_ID_QUIT'; const SPAN_ID = 'SPAN_ID'; const content = ``; editor.setContent(content); @@ -286,7 +286,7 @@ describe('ImageEdit | plugin events | quitting', () => { }); it('mousedown quit editing', () => { - const IMG_ID = 'IMAGE_ID'; + const IMG_ID = 'IMAGE_ID_MOUSE'; const SPAN_ID = 'SPAN_ID'; const content = ``; editor.setContent(content); @@ -314,7 +314,7 @@ describe('ImageEdit | plugin events | quitting', () => { describe('ImageEdit | wrapper', () => { let editor: IEditor; - const TEST_ID = 'imageEditTest'; + const TEST_ID = 'imageEditTestWrapper'; let plugin: ImageEdit; beforeEach(() => { @@ -331,7 +331,7 @@ describe('ImageEdit | wrapper', () => { }); it('image selection, remove max-width', () => { - const IMG_ID = 'IMAGE_ID'; + const IMG_ID = 'IMAGE_ID_SELECTION'; const content = ``; editor.setContent(content); const image = document.getElementById(IMG_ID) as HTMLImageElement; @@ -345,7 +345,7 @@ describe('ImageEdit | wrapper', () => { }); it('image selection, cloned image should use style width/height attributes', () => { - const IMG_ID = 'IMAGE_ID'; + const IMG_ID = 'IMAGE_ID_SELECTION_2'; const content = ``; editor.setContent(content); const image = document.getElementById(IMG_ID) as HTMLImageElement; diff --git a/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts b/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts index 58f790c1004..5159be20093 100644 --- a/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts +++ b/packages/roosterjs-editor-plugins/test/imageEdit/rotatorTest.ts @@ -92,7 +92,7 @@ describe('Rotate: rotate only', () => { describe('updateRotateHandlePosition', () => { let editor: IEditor; - const TEST_ID = 'imageEditTest'; + const TEST_ID = 'imageEditTest_rotateHandlePosition'; let plugin: ImageEdit; let editorGetVisibleViewport: any; beforeEach(() => { @@ -119,19 +119,25 @@ describe('updateRotateHandlePosition', () => { rotatePosition: DOMRect, rotateCenterTop: string, rotateCenterHeight: string, - rotateHandleTop: string + rotateHandleTop: string, + wrapperPosition: DOMRect, + angle: number ) { - const IMG_ID = 'IMAGE_ID'; - const content = ``; + const IMG_ID = 'IMAGE_ID_ROTATION'; + const WRAPPER_ID = 'WRAPPER_ID_ROTATION'; + const content = ``; editor.setContent(content); const image = document.getElementById(IMG_ID) as HTMLImageElement; plugin.setEditingImage(image, ImageEditOperation.Rotate); const rotate = getRotateHTML(options)[0]; const rotateHTML = createElement(rotate, document); - image.parentElement!.appendChild(rotateHTML!); + const imageParent = image.parentElement; + imageParent!.appendChild(rotateHTML!); + const wrapper = document.getElementById(WRAPPER_ID) as HTMLElement; const rotateCenter = document.getElementsByClassName('r_rotateC')[0] as HTMLElement; const rotateHandle = document.getElementsByClassName('r_rotateH')[0] as HTMLElement; spyOn(rotateHandle, 'getBoundingClientRect').and.returnValues(rotatePosition); + spyOn(wrapper, 'getBoundingClientRect').and.returnValues(wrapperPosition); const viewport: Rect = { top: 1, bottom: 200, @@ -139,8 +145,9 @@ describe('updateRotateHandlePosition', () => { right: 200, }; editorGetVisibleViewport.and.returnValue(viewport); + const angleRad = angle / DEG_PER_RAD; - updateRotateHandleState(viewport, rotateCenter, rotateHandle, false); + updateRotateHandleState(viewport, angleRad, wrapper, rotateCenter, rotateHandle, false); expect(rotateCenter.style.top).toBe(rotateCenterTop); expect(rotateCenter.style.height).toBe(rotateCenterHeight); @@ -162,7 +169,19 @@ describe('updateRotateHandlePosition', () => { }, '-6px', '0px', - '0px' + '0px', + { + top: 2, + bottom: 3, + left: 2, + right: 5, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + 0 ); }); @@ -181,7 +200,19 @@ describe('updateRotateHandlePosition', () => { }, '-21px', '15px', - '-32px' + '-32px', + { + top: 0, + bottom: 20, + left: 3, + right: 5, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + 50 ); }); @@ -190,7 +221,7 @@ describe('updateRotateHandlePosition', () => { { top: 2, bottom: 3, - left: 0, + left: 2, right: 5, height: 2, width: 2, @@ -198,9 +229,21 @@ describe('updateRotateHandlePosition', () => { y: 3, toJSON: () => {}, }, - '-6px', + '-7px', + '1px', '0px', - '0px' + { + top: 2, + bottom: 3, + left: 2, + right: 5, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + -90 ); }); @@ -219,7 +262,19 @@ describe('updateRotateHandlePosition', () => { }, '-6px', '0px', - '0px' + '0px', + { + top: 0, + bottom: 190, + left: 3, + right: 190, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + 180 ); }); @@ -238,7 +293,19 @@ describe('updateRotateHandlePosition', () => { }, '-6px', '0px', - '0px' + '0px', + { + top: 0, + bottom: 190, + left: 3, + right: 190, + height: 2, + width: 2, + x: 1, + y: 3, + toJSON: () => {}, + }, + 90 ); }); }); diff --git a/packages/roosterjs-editor-types/lib/interface/FormatState.ts b/packages/roosterjs-editor-types/lib/interface/FormatState.ts index 350e1c577d7..de2bd0bc932 100644 --- a/packages/roosterjs-editor-types/lib/interface/FormatState.ts +++ b/packages/roosterjs-editor-types/lib/interface/FormatState.ts @@ -85,7 +85,13 @@ export interface ElementBasedFormatState { canAddImageAltText?: boolean; /** - * Header level (0-6, 0 means no header) + * Heading level (0-6, 0 means no heading) + */ + headingLevel?: number; + + /** + * @deprecated Use headingLevel instead + * Heading level (0-6, 0 means no heading) */ headerLevel?: number; diff --git a/versions.json b/versions.json index 5e2f1465dcb..41180a1fa5a 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,5 @@ { - "packages": "8.53.0", - "packages-ui": "8.50.1", - "packages-content-model": "0.13.0" + "packages": "8.54.0", + "packages-ui": "8.51.0", + "packages-content-model": "0.14.0" }