diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts index 6e216f2da1a..1e9085c302b 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts +++ b/demo/scripts/controlsV2/sidePane/editorOptions/EditorOptionsPlugin.ts @@ -58,7 +58,7 @@ const initialState: OptionState = { handleTabKey: true, }, customReplacements: emojiReplacements, - experimentalFeatures: new Set(['PersistCache']), + experimentalFeatures: new Set(['PersistCache', 'HandleEnterKey']), }; export class EditorOptionsPlugin extends SidePanePluginImpl { diff --git a/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx b/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx index da543da70c9..2bd26858972 100644 --- a/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx +++ b/demo/scripts/controlsV2/sidePane/editorOptions/ExperimentalFeatures.tsx @@ -9,7 +9,13 @@ export interface DefaultFormatProps { export class ExperimentalFeatures extends React.Component { render() { - return this.renderFeature('PersistCache'); + return ( + <> + {this.renderFeature('PersistCache')} + {this.renderFeature('HandleEnterKey')} + {this.renderFeature('LegacyImageSelection')} + + ); } private renderFeature(featureName: ExperimentalFeature): JSX.Element { diff --git a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts index b0fd0c791d9..62ba6d1248e 100644 --- a/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts +++ b/packages/roosterjs-content-model-core/lib/corePlugin/copyPaste/CopyPastePlugin.ts @@ -33,6 +33,8 @@ import type { ReadonlyTableSelectionContext, } from 'roosterjs-content-model-types'; +const TEMP_DIV_ID = 'roosterJS_copyCutTempDiv'; + /** * Copy and paste plugin for handling onCopy and onPaste event */ @@ -235,6 +237,7 @@ class CopyPastePlugin implements PluginWithState { div.childNodes.forEach(node => div.removeChild(node)); div.style.display = ''; + div.id = TEMP_DIV_ID; div.focus(); return div; diff --git a/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts b/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts index a4b9a49474a..67eb876eae8 100644 --- a/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts +++ b/packages/roosterjs-content-model-core/lib/editor/core/DOMHelperImpl.ts @@ -81,6 +81,13 @@ class DOMHelperImpl implements DOMHelper { const paddingRight = parseValueWithUnit(style?.paddingRight); return this.contentDiv.clientWidth - (paddingLeft + paddingRight); } + + /** + * Get a deep cloned root element + */ + getClonedRoot(): HTMLElement { + return this.contentDiv.cloneNode(true /*deep*/) as HTMLElement; + } } /** diff --git a/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts b/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts index fb490373ed3..2481f57a73a 100644 --- a/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts +++ b/packages/roosterjs-content-model-core/test/editor/core/DOMHelperImplTest.ts @@ -350,4 +350,20 @@ describe('DOMHelperImpl', () => { expect(domHelper.getClientWidth()).toBe(1000); }); }); + + describe('getClonedRoot', () => { + it('getClonedRoot', () => { + const mockedClone = 'CLONE' as any; + const cloneSpy = jasmine.createSpy('cloneSpy').and.returnValue(mockedClone); + const mockedDiv: HTMLElement = { + cloneNode: cloneSpy, + } as any; + const domHelper = createDOMHelper(mockedDiv); + + const result = domHelper.getClonedRoot(); + + expect(result).toBe(mockedClone); + expect(cloneSpy).toHaveBeenCalledWith(true); + }); + }); }); diff --git a/packages/roosterjs-content-model-dom/lib/domToModel/processors/brProcessor.ts b/packages/roosterjs-content-model-dom/lib/domToModel/processors/brProcessor.ts index f5677434c77..eb060bdd3b9 100644 --- a/packages/roosterjs-content-model-dom/lib/domToModel/processors/brProcessor.ts +++ b/packages/roosterjs-content-model-dom/lib/domToModel/processors/brProcessor.ts @@ -1,5 +1,6 @@ import { addSegment } from '../../modelApi/common/addSegment'; import { createBr } from '../../modelApi/creators/createBr'; +import { getRegularSelectionOffsets } from '../utils/getRegularSelectionOffsets'; import type { ElementProcessor } from 'roosterjs-content-model-types'; /** @@ -7,11 +8,21 @@ import type { ElementProcessor } from 'roosterjs-content-model-types'; */ export const brProcessor: ElementProcessor = (group, element, context) => { const br = createBr(context.segmentFormat); + const [start, end] = getRegularSelectionOffsets(context, element); + + if (start >= 0) { + context.isInSelection = true; + } if (context.isInSelection) { br.isSelected = true; } const paragraph = addSegment(group, br, context.blockFormat); + + if (end >= 0) { + context.isInSelection = false; + } + context.domIndexer?.onSegment(element, paragraph, [br]); }; diff --git a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts index 3ea33badf72..08497fea2a2 100644 --- a/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts +++ b/packages/roosterjs-content-model-dom/lib/modelApi/editing/mergeModel.ts @@ -50,20 +50,22 @@ export function mergeModel( const insertPosition = options?.insertPosition ?? deleteSelection(target, [], context).insertPoint; - if (options?.addParagraphAfterMergedContent) { + const { addParagraphAfterMergedContent, mergeFormat, mergeTable } = options || {}; + + if (addParagraphAfterMergedContent && !mergeTable) { const { paragraph, marker } = insertPosition || {}; const newPara = createParagraph(false /* isImplicit */, paragraph?.format, marker?.format); addBlock(source, newPara); } if (insertPosition) { - if (options?.mergeFormat && options.mergeFormat != 'none') { + if (mergeFormat && mergeFormat != 'none') { const newFormat: ContentModelSegmentFormat = { ...(target.format || {}), ...insertPosition.marker.format, }; - applyDefaultFormat(source, newFormat, options?.mergeFormat); + applyDefaultFormat(source, newFormat, mergeFormat); } for (let i = 0; i < source.blocks.length; i++) { @@ -84,8 +86,8 @@ export function mergeModel( break; case 'Table': - if (source.blocks.length == 1 && options?.mergeTable) { - mergeTable(insertPosition, block, source); + if (source.blocks.length == 1 && mergeTable) { + mergeTables(insertPosition, block, source); } else { insertBlock(insertPosition, block); } @@ -176,7 +178,7 @@ function mergeParagraph( } } -function mergeTable( +function mergeTables( markerPosition: InsertPoint, newTable: ContentModelTable, source: ContentModelDocument diff --git a/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts b/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts index f92da141de7..9ba29b9601b 100644 --- a/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts +++ b/packages/roosterjs-content-model-dom/test/domToModel/processors/brProcessorTest.ts @@ -100,4 +100,74 @@ describe('brProcessor', () => { }); expect(onSegmentSpy).toHaveBeenCalledWith(br, paragraphModel, [brModel]); }); + + it('Selection starts in BR', () => { + const doc = createContentModelDocument(); + const div = document.createElement('div'); + const br = document.createElement('br'); + const range = document.createRange(); + + div.appendChild(br); + range.setStart(br, 0); + range.setEnd(div, 1); + context.selection = { + type: 'range', + range: range, + isReverted: false, + }; + + brProcessor(doc, br, context); + + const brModel: ContentModelBr = { + segmentType: 'Br', + format: {}, + isSelected: true, + }; + const paragraphModel: ContentModelParagraph = { + blockType: 'Paragraph', + isImplicit: true, + segments: [brModel], + format: {}, + }; + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [paragraphModel], + }); + expect(context.isInSelection).toBeTrue(); + }); + + it('Selection ends in BR', () => { + const doc = createContentModelDocument(); + const br = document.createElement('br'); + const range = document.createRange(); + + range.setEnd(br, 0); + context.selection = { + type: 'range', + range: range, + isReverted: false, + }; + context.isInSelection = true; + + brProcessor(doc, br, context); + + const brModel: ContentModelBr = { + segmentType: 'Br', + format: {}, + isSelected: true, + }; + const paragraphModel: ContentModelParagraph = { + blockType: 'Paragraph', + isImplicit: true, + segments: [brModel], + format: {}, + }; + + expect(doc).toEqual({ + blockGroupType: 'Document', + blocks: [paragraphModel], + }); + expect(context.isInSelection).toBeFalse(); + }); }); diff --git a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts index f6e6b58e40b..6d55c59b8d7 100644 --- a/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts +++ b/packages/roosterjs-content-model-dom/test/modelApi/editing/mergeModelTest.ts @@ -1507,6 +1507,157 @@ describe('mergeModel', () => { }); }); + it('table to table, merge table 4, mergeTable and addParagraphAfterMergedContent should be noop', () => { + const majorModel = createContentModelDocument(); + const sourceModel = createContentModelDocument(); + + const para1 = createParagraph(); + const text1 = createText('test1'); + const cell01 = createTableCell(false, false, false, { backgroundColor: '01' }); + const cell02 = createTableCell(false, false, false, { backgroundColor: '02' }); + const cell11 = createTableCell(false, false, false, { backgroundColor: '11' }); + const cell12 = createTableCell(false, false, false, { backgroundColor: '12' }); + const cell21 = createTableCell(false, false, false, { backgroundColor: '21' }); + const cell22 = createTableCell(false, false, false, { backgroundColor: '22' }); + const cell31 = createTableCell(false, false, false, { backgroundColor: '31' }); + const cell32 = createTableCell(false, false, false, { backgroundColor: '32' }); + const table1 = createTable(4); + + para1.segments.push(text1); + text1.isSelected = true; + cell12.blocks.push(para1); + table1.rows = [ + { format: {}, height: 0, cells: [cell01, cell02] }, + { format: {}, height: 0, cells: [cell11, cell12] }, + { format: {}, height: 0, cells: [cell21, cell22] }, + { format: {}, height: 0, cells: [cell31, cell32] }, + ]; + + majorModel.blocks.push(table1); + + const newPara1 = createParagraph(); + const newText1 = createText('newText1'); + const newCell11 = createTableCell(false, false, false, { backgroundColor: 'n11' }); + const newCell12 = createTableCell(false, false, false, { backgroundColor: 'n12' }); + const newCell21 = createTableCell(false, false, false, { backgroundColor: 'n21' }); + const newCell22 = createTableCell(false, false, false, { backgroundColor: 'n22' }); + const newTable1 = createTable(2); + + newPara1.segments.push(newText1); + newCell12.blocks.push(newPara1); + newTable1.rows = [ + { format: {}, height: 0, cells: [newCell11, newCell12] }, + { format: {}, height: 0, cells: [newCell21, newCell22] }, + ]; + + sourceModel.blocks.push(newTable1); + + spyOn(applyTableFormat, 'applyTableFormat'); + spyOn(normalizeTable, 'normalizeTable'); + + const result = mergeModel( + majorModel, + sourceModel, + { newEntities: [], deletedEntities: [], newImages: [] }, + { + mergeTable: true, + addParagraphAfterMergedContent: true, + } + ); + + const marker: ContentModelSelectionMarker = { + segmentType: 'SelectionMarker', + isSelected: true, + format: {}, + }; + const paragraph: ContentModelParagraph = { + blockType: 'Paragraph', + segments: [marker], + format: {}, + isImplicit: true, + }; + const tableCell: ContentModelTableCell = { + blockGroupType: 'TableCell', + blocks: [paragraph], + format: { + backgroundColor: 'n11', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }; + const table: ContentModelTable = { + blockType: 'Table', + rows: [ + { + format: {}, + height: 0, + cells: [ + cell01, + cell02, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + backgroundColor: '02', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + { + format: {}, + height: 0, + cells: [cell11, tableCell, newCell12], + }, + { format: {}, height: 0, cells: [cell21, newCell21, newCell22] }, + { + format: {}, + height: 0, + cells: [ + cell31, + cell32, + { + blockGroupType: 'TableCell', + blocks: [], + format: { + backgroundColor: '32', + }, + spanLeft: false, + spanAbove: false, + isHeader: false, + dataset: {}, + }, + ], + }, + ], + format: {}, + widths: [], + dataset: {}, + }; + + expect(normalizeTable.normalizeTable).toHaveBeenCalledTimes(1); + expect(majorModel).toEqual({ + blockGroupType: 'Document', + blocks: [table], + }); + expect(result).toEqual({ + marker, + paragraph, + path: [tableCell, majorModel], + tableContext: { + table, + rowIndex: 1, + colIndex: 1, + isWholeTableSelected: false, + }, + }); + }); + it('Use customized insert position', () => { const majorModel = createContentModelDocument(); const sourceModel = createContentModelDocument(); @@ -3870,4 +4021,35 @@ describe('mergeModel', () => { ], }); }); + + it('Merge model with addParagraphAfterMergedContent and mergeTable, addParagraphAfterMergedContent should be noop', () => { + const source = createContentModelDocument(); + const para = createParagraph(); + para.segments.push(createText('Merge')); + source.blocks.push(para); + + const target = createContentModelDocument(); + const paraTarget = createParagraph(); + paraTarget.segments.push(createSelectionMarker()); + target.blocks.push(paraTarget); + + mergeModel(target, source, undefined, { + addParagraphAfterMergedContent: true, + mergeTable: true, + }); + + expect(target).toEqual({ + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { segmentType: 'Text', text: 'Merge', format: {} }, + { segmentType: 'SelectionMarker', isSelected: true, format: {} }, + ], + format: {}, + }, + ], + }); + }); }); diff --git a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts index 82bf6f54b52..0e3af9c35c3 100644 --- a/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/edit/EditPlugin.ts @@ -63,7 +63,7 @@ export class EditPlugin implements EditorPlugin { */ initialize(editor: IEditor) { this.editor = editor; - this.handleNormalEnter = this.editor.isExperimentalFeatureEnabled('PersistCache'); + this.handleNormalEnter = this.editor.isExperimentalFeatureEnabled('HandleEnterKey'); if (editor.getEnvironment().isAndroid) { this.disposer = this.editor.attachDomEvent({ diff --git a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts index cea7b9d8b69..fb3a13ef529 100644 --- a/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts +++ b/packages/roosterjs-content-model-plugins/lib/imageEdit/ImageEditPlugin.ts @@ -53,6 +53,8 @@ const DefaultOptions: Partial = { const MouseRightButton = 2; const DRAG_ID = '_dragging'; +const IMAGE_EDIT_CLASS = 'imageEdit'; +const IMAGE_EDIT_CLASS_CARET = 'imageEditCaretColor'; /** * ImageEdit plugin handles the following image editing features: @@ -66,7 +68,7 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private shadowSpan: HTMLSpanElement | null = null; private selectedImage: HTMLImageElement | null = null; protected wrapper: HTMLSpanElement | null = null; - private imageEditInfo: ImageMetadataFormat | null = null; + protected imageEditInfo: ImageMetadataFormat | null = null; private imageHTMLOptions: ImageHtmlOptions | null = null; private dndHelpers: DragAndDropHelper[] = []; private clonedImage: HTMLImageElement | null = null; @@ -232,8 +234,15 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { private keyDownHandler(editor: IEditor, event: KeyDownEvent) { if (this.isEditing) { - if (event.rawEvent.key === 'Escape') { - this.removeImageWrapper(); + if ( + event.rawEvent.key === 'Escape' || + event.rawEvent.key === 'Delete' || + event.rawEvent.key === 'Backspace' + ) { + if (event.rawEvent.key === 'Escape') { + this.removeImageWrapper(); + } + this.cleanInfo(); } else { this.applyFormatWithContentModel( editor, @@ -384,9 +393,11 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { this.croppers = croppers; this.zoomScale = editor.getDOMHelper().calculateZoomScale(); - editor.setEditorStyle('imageEdit', `outline-style:none!important;`, [ + editor.setEditorStyle(IMAGE_EDIT_CLASS, `outline-style:none!important;`, [ `span:has(>img${getSafeIdSelector(this.selectedImage.id)})`, ]); + + editor.setEditorStyle(IMAGE_EDIT_CLASS_CARET, `caret-color: transparent;`); } public startRotateAndResize(editor: IEditor, image: HTMLImageElement) { @@ -606,8 +617,12 @@ export class ImageEditPlugin implements ImageEditor, EditorPlugin { ); } - private cleanInfo() { - this.editor?.setEditorStyle('imageEdit', null); + /** + * Exported for testing purpose only + */ + public cleanInfo() { + this.editor?.setEditorStyle(IMAGE_EDIT_CLASS, null); + this.editor?.setEditorStyle(IMAGE_EDIT_CLASS_CARET, null); this.selectedImage = null; this.shadowSpan = null; this.wrapper = null; diff --git a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts index bbe1a0acb9b..6ad3df27402 100644 --- a/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/edit/EditPluginTest.ts @@ -162,7 +162,7 @@ describe('EditPlugin', () => { it('Enter, normal enter enabled', () => { isExperimentalFeatureEnabledSpy.and.callFake( - (featureName: string) => featureName == 'PersistCache' + (featureName: string) => featureName == 'HandleEnterKey' ); plugin = new EditPlugin(); const rawEvent = { which: 13, key: 'Enter' } as any; diff --git a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts index f0bbc6fd7da..c9a6851b20d 100644 --- a/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/imageEdit/ImageEditPluginTest.ts @@ -180,6 +180,40 @@ describe('ImageEditPlugin', () => { plugin.dispose(); }); + it('keyDown - DELETE', () => { + const mockedImage = { + getAttribute: getAttributeSpy, + }; + const plugin = new ImageEditPlugin(); + plugin.initialize(editor); + const cleanInfoSpy = spyOn(plugin, 'cleanInfo'); + getDOMSelectionSpy.and.returnValue({ + type: 'image', + image: mockedImage, + }); + const image = createImage(''); + const paragraph = createParagraph(); + paragraph.segments.push(image); + plugin.onPluginEvent({ + eventType: 'mouseUp', + rawEvent: { + button: 0, + target: mockedImage, + } as any, + }); + plugin.onPluginEvent({ + eventType: 'keyDown', + rawEvent: { + key: 'Delete', + target: mockedImage, + } as any, + }); + expect(cleanInfoSpy).toHaveBeenCalled(); + expect(cleanInfoSpy).toHaveBeenCalledTimes(1); + expect(formatContentModelSpy).toHaveBeenCalledTimes(1); + plugin.dispose(); + }); + it('mouseUp', () => { const mockedImage = { getAttribute: getAttributeSpy, @@ -430,4 +464,61 @@ describe('ImageEditPlugin', () => { expect(formatContentModelSpy).toHaveBeenCalledTimes(3); plugin.dispose(); }); + + it('flip setEditorStyle', () => { + const model: ContentModelDocument = { + blockGroupType: 'Document', + blocks: [ + { + blockType: 'Paragraph', + segments: [ + { + segmentType: 'Image', + src: 'test', + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + id: 'image_0', + maxWidth: '1800px', + }, + dataset: {}, + isSelectedAsImageSelection: true, + isSelected: true, + }, + ], + format: {}, + segmentFormat: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: 'rgb(0, 0, 0)', + }, + }, + ], + format: { + fontFamily: 'Calibri', + fontSize: '11pt', + textColor: '#000000', + }, + }; + const plugin = new ImageEditPlugin(); + const editor = initEditor('image_edit', [plugin], model); + spyOn(editor, 'setEditorStyle').and.callThrough(); + + plugin.initialize(editor); + plugin.flipImage('horizontal'); + plugin.dispose(); + + expect(editor.setEditorStyle).toHaveBeenCalledWith( + 'imageEdit', + 'outline-style:none!important;', + ['span:has(>img#image_0)'] + ); + expect(editor.setEditorStyle).toHaveBeenCalledWith( + 'imageEditCaretColor', + 'caret-color: transparent;' + ); + expect(editor.setEditorStyle).toHaveBeenCalledWith('imageEdit', null); + expect(editor.setEditorStyle).toHaveBeenCalledWith('imageEditCaretColor', null); + }); }); diff --git a/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts b/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts index 15159013fd9..46e58d639df 100644 --- a/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts +++ b/packages/roosterjs-content-model-types/lib/editor/ExperimentalFeature.ts @@ -12,4 +12,8 @@ export type ExperimentalFeature = /** * Workaround for the Legacy Image Edit */ - | 'LegacyImageSelection'; + | 'LegacyImageSelection' + /** + * Use Content Model handle ENTER key + */ + | 'HandleEnterKey'; diff --git a/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts b/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts index 91bd2d976ad..27169dd2681 100644 --- a/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts +++ b/packages/roosterjs-content-model-types/lib/parameter/DOMHelper.ts @@ -92,4 +92,9 @@ export interface DOMHelper { * Get the width of the editable area of the editor content div */ getClientWidth(): number; + + /** + * Get a deep cloned root element + */ + getClonedRoot(): HTMLElement; }