diff --git a/packages/roosterjs-content-model-plugins/test/contextMenuBase/ContextMenuPluginBaseTest.ts b/packages/roosterjs-content-model-plugins/test/contextMenuBase/ContextMenuPluginBaseTest.ts new file mode 100644 index 000000000000..fb578ba01f2d --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/contextMenuBase/ContextMenuPluginBaseTest.ts @@ -0,0 +1,237 @@ +import { + ContextMenuOptions, + ContextMenuPluginBase, +} from '../../lib/contextMenuBase/ContextMenuPluginBase'; +import { IEditor, PluginEvent } from 'roosterjs-content-model-types'; + +describe('ContextMenuPluginBase', () => { + let renderSpy: jasmine.Spy; + let dismissSpy: jasmine.Spy; + let createElementSpy: jasmine.Spy; + let appendChildSpy: jasmine.Spy; + let removeChildSpy: jasmine.Spy; + let setPropertySpy: jasmine.Spy; + let preventDefaultSpy: jasmine.Spy; + + let editor: IEditor; + let mockedContainer: HTMLElement; + let mockedBody: HTMLElement; + + function createPlugin(options?: Partial>) { + return new ContextMenuPluginBase({ + render: renderSpy, + dismiss: dismissSpy, + ...options, + }); + } + + function createContextMenuEvent( + items: (string | null)[], + pageX: number = 100, + pageY: number = 200 + ): PluginEvent { + return ({ + eventType: 'contextMenu', + items, + rawEvent: { + pageX, + pageY, + preventDefault: preventDefaultSpy, + }, + }); + } + + beforeEach(() => { + renderSpy = jasmine.createSpy('render'); + dismissSpy = jasmine.createSpy('dismiss'); + appendChildSpy = jasmine.createSpy('appendChild'); + removeChildSpy = jasmine.createSpy('removeChild'); + setPropertySpy = jasmine.createSpy('setProperty'); + preventDefaultSpy = jasmine.createSpy('preventDefault'); + + mockedBody = ({ + appendChild: appendChildSpy, + }); + + mockedContainer = ({ + style: { + setProperty: setPropertySpy, + }, + parentNode: null, + }); + + createElementSpy = jasmine.createSpy('createElement').and.returnValue(mockedContainer); + + editor = ({ + getDocument: () => ({ + createElement: createElementSpy, + body: mockedBody, + }), + }); + }); + + it('getName', () => { + const plugin = createPlugin(); + + expect(plugin.getName()).toBe('ContextMenu'); + }); + + it('initialize then dispose without showing a menu', () => { + const plugin = createPlugin(); + + plugin.initialize(editor); + plugin.dispose(); + + expect(dismissSpy).not.toHaveBeenCalled(); + expect(removeChildSpy).not.toHaveBeenCalled(); + }); + + it('onPluginEvent ignores non-contextMenu events', () => { + const plugin = createPlugin(); + plugin.initialize(editor); + + plugin.onPluginEvent(({ eventType: 'input', items: ['a'] })); + + expect(createElementSpy).not.toHaveBeenCalled(); + expect(renderSpy).not.toHaveBeenCalled(); + }); + + it('onPluginEvent ignores contextMenu event with empty items', () => { + const plugin = createPlugin(); + plugin.initialize(editor); + + plugin.onPluginEvent(createContextMenuEvent([])); + + expect(createElementSpy).not.toHaveBeenCalled(); + expect(renderSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('onPluginEvent creates container, prevents default and renders menu', () => { + const plugin = createPlugin(); + plugin.initialize(editor); + + const items = ['Item1', 'Item2']; + plugin.onPluginEvent(createContextMenuEvent(items, 50, 75)); + + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + expect(createElementSpy).toHaveBeenCalledWith('div'); + expect(appendChildSpy).toHaveBeenCalledWith(mockedContainer); + expect(mockedContainer.style.position).toBe('fixed'); + expect(mockedContainer.style.width).toBe('0'); + expect(mockedContainer.style.height).toBe('0'); + expect(setPropertySpy).toHaveBeenCalledWith('left', '50px'); + expect(setPropertySpy).toHaveBeenCalledWith('top', '75px'); + expect(renderSpy).toHaveBeenCalledTimes(1); + expect(renderSpy.calls.argsFor(0)[0]).toBe(mockedContainer); + expect(renderSpy.calls.argsFor(0)[1]).toBe(items); + }); + + it('onPluginEvent does not prevent default when allowDefaultMenu is true', () => { + const plugin = createPlugin({ allowDefaultMenu: true }); + plugin.initialize(editor); + + plugin.onPluginEvent(createContextMenuEvent(['Item1'])); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(renderSpy).toHaveBeenCalledTimes(1); + }); + + it('onPluginEvent does nothing when editor is not initialized', () => { + const plugin = createPlugin(); + + // No initialize() call, so editor is null and container cannot be created + plugin.onPluginEvent(createContextMenuEvent(['Item1'])); + + expect(createElementSpy).not.toHaveBeenCalled(); + expect(renderSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).toHaveBeenCalledTimes(1); + }); + + it('onPluginEvent reuses existing container on a second event and dismisses the previous menu', () => { + const plugin = createPlugin(); + plugin.initialize(editor); + + plugin.onPluginEvent(createContextMenuEvent(['Item1'], 10, 20)); + + expect(createElementSpy).toHaveBeenCalledTimes(1); + expect(dismissSpy).not.toHaveBeenCalled(); + + plugin.onPluginEvent(createContextMenuEvent(['Item2'], 30, 40)); + + // Container is reused, not recreated + expect(createElementSpy).toHaveBeenCalledTimes(1); + // Previous menu was dismissed before showing the new one + expect(dismissSpy).toHaveBeenCalledTimes(1); + expect(dismissSpy).toHaveBeenCalledWith(mockedContainer); + expect(setPropertySpy).toHaveBeenCalledWith('left', '30px'); + expect(setPropertySpy).toHaveBeenCalledWith('top', '40px'); + expect(renderSpy).toHaveBeenCalledTimes(2); + }); + + it('onDismiss callback passed to render dismisses the showing menu only once', () => { + const plugin = createPlugin(); + plugin.initialize(editor); + + plugin.onPluginEvent(createContextMenuEvent(['Item1'])); + + const onDismiss = renderSpy.calls.argsFor(0)[2] as () => void; + + onDismiss(); + expect(dismissSpy).toHaveBeenCalledTimes(1); + expect(dismissSpy).toHaveBeenCalledWith(mockedContainer); + + // Calling again does nothing since the menu is no longer showing + onDismiss(); + expect(dismissSpy).toHaveBeenCalledTimes(1); + }); + + it('onPluginEvent works when dismiss option is not provided', () => { + const plugin = createPlugin({ dismiss: undefined }); + plugin.initialize(editor); + + plugin.onPluginEvent(createContextMenuEvent(['Item1'])); + + // Second event triggers onDismiss internally; should not throw without a dismiss callback + expect(() => plugin.onPluginEvent(createContextMenuEvent(['Item2']))).not.toThrow(); + expect(renderSpy).toHaveBeenCalledTimes(2); + }); + + it('dispose dismisses showing menu and removes container from its parent', () => { + const plugin = createPlugin(); + plugin.initialize(editor); + + plugin.onPluginEvent(createContextMenuEvent(['Item1'])); + + (mockedContainer as any).parentNode = { + removeChild: removeChildSpy, + }; + + plugin.dispose(); + + expect(dismissSpy).toHaveBeenCalledTimes(1); + expect(dismissSpy).toHaveBeenCalledWith(mockedContainer); + expect(removeChildSpy).toHaveBeenCalledWith(mockedContainer); + }); + + it('dispose removes container even when no menu is showing', () => { + const plugin = createPlugin(); + plugin.initialize(editor); + + plugin.onPluginEvent(createContextMenuEvent(['Item1'])); + + // Dismiss the menu first so isMenuShowing is false + const onDismiss = renderSpy.calls.argsFor(0)[2] as () => void; + onDismiss(); + dismissSpy.calls.reset(); + + (mockedContainer as any).parentNode = { + removeChild: removeChildSpy, + }; + + plugin.dispose(); + + expect(dismissSpy).not.toHaveBeenCalled(); + expect(removeChildSpy).toHaveBeenCalledWith(mockedContainer); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/customReplace/CustomReplacePluginTest.ts b/packages/roosterjs-content-model-plugins/test/customReplace/CustomReplacePluginTest.ts index 5918b9e1a2d0..fe0d63bfabab 100644 --- a/packages/roosterjs-content-model-plugins/test/customReplace/CustomReplacePluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/customReplace/CustomReplacePluginTest.ts @@ -124,3 +124,200 @@ describe('Content Model Custom Replace Plugin Test', () => { expect(formatTextSegmentBeforeSelectionMarkerSpy).not.toHaveBeenCalled(); }); }); + +describe('CustomReplacePlugin guard branches and replacement callback', () => { + let editor: IEditor; + let getDOMSelectionSpy: jasmine.Spy; + let formatTextSegmentBeforeSelectionMarkerSpy: jasmine.Spy; + let plugin: CustomReplacePlugin; + + const replacements: CustomReplace[] = [ + { + stringToReplace: ':)', + replacementString: '😀', + replacementHandler: replaceEmojis, + }, + ]; + + function collapsedRangeSelection() { + return { + type: 'range', + range: { collapsed: true }, + } as any; + } + + function inputEvent(data: string | null, inputType: string = 'insertText'): any { + return { + eventType: 'input', + rawEvent: { + inputType, + data, + } as any, + }; + } + + beforeEach(() => { + formatTextSegmentBeforeSelectionMarkerSpy = spyOn( + formatTextSegmentBeforeSelectionMarker, + 'formatTextSegmentBeforeSelectionMarker' + ); + getDOMSelectionSpy = jasmine + .createSpy('getDOMSelection') + .and.returnValue(collapsedRangeSelection()); + + editor = ({ + getDOMSelection: getDOMSelectionSpy, + formatContentModel: () => {}, + } as any) as IEditor; + }); + + afterEach(() => { + plugin?.dispose(); + }); + + function createPlugin(rules: CustomReplace[] = replacements) { + plugin = new CustomReplacePlugin(rules); + plugin.initialize(editor); + return plugin; + } + + it('getName', () => { + createPlugin(); + expect(plugin.getName()).toBe('CustomReplace'); + }); + + it('does nothing for non-input events', () => { + createPlugin(); + + plugin.onPluginEvent({ eventType: 'keyDown', rawEvent: {} as any }); + + expect(getDOMSelectionSpy).not.toHaveBeenCalled(); + expect(formatTextSegmentBeforeSelectionMarkerSpy).not.toHaveBeenCalled(); + }); + + it('does nothing when editor is disposed', () => { + createPlugin(); + plugin.dispose(); + + plugin.onPluginEvent(inputEvent(')')); + + expect(getDOMSelectionSpy).not.toHaveBeenCalled(); + expect(formatTextSegmentBeforeSelectionMarkerSpy).not.toHaveBeenCalled(); + }); + + it('does not replace when there are no custom replacements', () => { + createPlugin([]); + + plugin.onPluginEvent(inputEvent(')')); + + expect(formatTextSegmentBeforeSelectionMarkerSpy).not.toHaveBeenCalled(); + }); + + it('does not replace when inputType is not insertText', () => { + createPlugin(); + + plugin.onPluginEvent(inputEvent(')', 'deleteContentBackward')); + + expect(formatTextSegmentBeforeSelectionMarkerSpy).not.toHaveBeenCalled(); + }); + + it('does not replace when there is no selection', () => { + createPlugin(); + getDOMSelectionSpy.and.returnValue(null); + + plugin.onPluginEvent(inputEvent(')')); + + expect(formatTextSegmentBeforeSelectionMarkerSpy).not.toHaveBeenCalled(); + }); + + it('does not replace when selection is not a range', () => { + createPlugin(); + getDOMSelectionSpy.and.returnValue({ type: 'image' } as any); + + plugin.onPluginEvent(inputEvent(')')); + + expect(formatTextSegmentBeforeSelectionMarkerSpy).not.toHaveBeenCalled(); + }); + + it('does not replace when range is not collapsed', () => { + createPlugin(); + getDOMSelectionSpy.and.returnValue({ + type: 'range', + range: { collapsed: false }, + } as any); + + plugin.onPluginEvent(inputEvent(')')); + + expect(formatTextSegmentBeforeSelectionMarkerSpy).not.toHaveBeenCalled(); + }); + + it('does not replace when key data is empty', () => { + createPlugin(); + + plugin.onPluginEvent(inputEvent(null)); + + expect(formatTextSegmentBeforeSelectionMarkerSpy).not.toHaveBeenCalled(); + }); + + it('does not replace when the typed key is not a trigger key', () => { + createPlugin(); + + plugin.onPluginEvent(inputEvent('a')); + + expect(formatTextSegmentBeforeSelectionMarkerSpy).not.toHaveBeenCalled(); + }); + + it('invokes formatTextSegmentBeforeSelectionMarker on a trigger key', () => { + createPlugin(); + + plugin.onPluginEvent(inputEvent(')')); + + expect(formatTextSegmentBeforeSelectionMarkerSpy).toHaveBeenCalledTimes(1); + expect(formatTextSegmentBeforeSelectionMarkerSpy.calls.argsFor(0)[0]).toBe(editor); + }); + + it('callback replaces matching text and sets canUndoByBackspace', () => { + createPlugin(); + + plugin.onPluginEvent(inputEvent(')')); + + const callback = formatTextSegmentBeforeSelectionMarkerSpy.calls.argsFor(0)[1]; + const previousSegment = { text: ':)' } as ContentModelText; + const paragraph = {} as ContentModelParagraph; + const context = {} as FormatContentModelContext; + + const result = callback( + {} as ContentModelDocument, + previousSegment, + paragraph, + {} as ContentModelSegmentFormat, + context + ); + + expect(result).toBe(true); + expect(previousSegment.text).toBe('😀'); + expect(context.canUndoByBackspace).toBe(true); + }); + + it('callback returns false when no replacement matches', () => { + createPlugin(); + + plugin.onPluginEvent(inputEvent(')')); + + const callback = formatTextSegmentBeforeSelectionMarkerSpy.calls.argsFor(0)[1]; + const previousSegment = { text: 'no match' } as ContentModelText; + const context = {} as FormatContentModelContext; + + const result = callback( + {} as ContentModelDocument, + previousSegment, + {} as ContentModelParagraph, + {} as ContentModelSegmentFormat, + context + ); + + expect(result).toBe(false); + expect(previousSegment.text).toBe('no match'); + expect(context.canUndoByBackspace).toBeUndefined(); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts index c743c099750b..8e452788b78b 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditPluginTest.ts @@ -255,3 +255,157 @@ describe('TableEditPlugin', () => { expect(pluginName).toBe(expectedName); }); }); + +describe('TableEditPlugin onMouseMove and table rects', () => { + // Note: this suite uses a MOCKED editor (not a real one). A real editor attaches a + // document 'selectionchange' listener whose async callback can fire after the editor is + // disposed in afterEach, throwing "Editor is already disposed" and failing the full-suite + // run. onMouseMove only needs getDocument/getDOMHelper/attachDomEvent, so a mock is enough. + let editor: IEditor; + let plugin: TableEditPlugin; + let mouseMoveHandler: (event: Event) => void; + let domTables: HTMLTableElement[]; + + function setup(tableSelector?: any) { + const scrollContainer = { + addEventListener: () => {}, + removeEventListener: () => {}, + }; + editor = ({ + attachDomEvent: (handlers: any) => { + mouseMoveHandler = handlers.mousemove.beforeDispatch; + return () => {}; + }, + getScrollContainer: () => scrollContainer, + getDocument: () => document, + getDOMHelper: () => ({ queryElements: () => domTables }), + }); + plugin = new TableEditPlugin(undefined, undefined, undefined, tableSelector); + plugin.initialize(editor); + } + + function mouseMoveEvent(pageX: number, pageY: number, buttons: number = 0): any { + return { pageX, pageY, buttons }; + } + + function selectorReturning(rect: Partial) { + const fakeTable = document.createElement('table'); + spyOn(fakeTable, 'getBoundingClientRect').and.returnValue(rect as DOMRect); + return { + fakeTable, + selector: jasmine + .createSpy('tableSelector') + .and.returnValue([{ table: fakeTable, logicalRoot: null }]), + }; + } + + beforeEach(() => { + domTables = []; + }); + + afterEach(() => { + plugin.dispose(); + document.body = document.createElement('body'); + }); + + it('does nothing while a mouse button is pressed', () => { + const { selector } = selectorReturning({ left: 100, right: 200, top: 100, bottom: 200 }); + setup(selector); + const setTableEditorSpy = spyOn(plugin, 'setTableEditor'); + + mouseMoveHandler(mouseMoveEvent(150, 150, /* buttons */ 1)); + + expect(selector).not.toHaveBeenCalled(); + expect(setTableEditorSpy).not.toHaveBeenCalled(); + }); + + it('does nothing when the editor has no default view', () => { + const { selector } = selectorReturning({ left: 100, right: 200, top: 100, bottom: 200 }); + setup(selector); + spyOn(editor, 'getDocument').and.returnValue(({ defaultView: null })); + const setTableEditorSpy = spyOn(plugin, 'setTableEditor'); + + mouseMoveHandler(mouseMoveEvent(150, 150)); + + expect(selector).not.toHaveBeenCalled(); + expect(setTableEditorSpy).not.toHaveBeenCalled(); + }); + + it('selects the table whose rect is under the mouse', () => { + const { selector } = selectorReturning({ left: 100, right: 200, top: 100, bottom: 200 }); + setup(selector); + const setTableEditorSpy = spyOn(plugin, 'setTableEditor').and.callThrough(); + + mouseMoveHandler(mouseMoveEvent(150, 150)); + + expect(selector).toHaveBeenCalledTimes(1); + expect(setTableEditorSpy).toHaveBeenCalledTimes(1); + const [entry] = setTableEditorSpy.calls.argsFor(0); + expect((entry as any)?.table).toBeTruthy(); + }); + + it('selects null when the mouse is outside every table rect', () => { + const { selector } = selectorReturning({ left: 100, right: 200, top: 100, bottom: 200 }); + setup(selector); + const setTableEditorSpy = spyOn(plugin, 'setTableEditor').and.callThrough(); + + mouseMoveHandler(mouseMoveEvent(500, 500)); + + expect(setTableEditorSpy).toHaveBeenCalledWith(null, jasmine.anything() as any); + }); + + it('ignores tables with an empty (all-zero) rect and caches the rect map', () => { + const { selector } = selectorReturning({ left: 0, right: 0, top: 0, bottom: 0 }); + setup(selector); + const setTableEditorSpy = spyOn(plugin, 'setTableEditor').and.callThrough(); + + mouseMoveHandler(mouseMoveEvent(150, 150)); + mouseMoveHandler(mouseMoveEvent(160, 160)); + + // The table with an all-zero rect is filtered out, so nothing is ever under the mouse + expect(setTableEditorSpy).toHaveBeenCalledWith(null, jasmine.anything() as any); + // Rect map is cached after the first build, so the selector runs only once + expect(selector).toHaveBeenCalledTimes(1); + }); + + it('forwards the mouse position to the active table editor', () => { + const { selector } = selectorReturning({ left: 100, right: 200, top: 100, bottom: 200 }); + setup(selector); + spyOn(plugin, 'setTableEditor'); // no-op so the fake editor is preserved + const onMouseMoveSpy = jasmine.createSpy('onMouseMove'); + (plugin as any).tableEditor = { onMouseMove: onMouseMoveSpy, dispose: () => {} }; + + mouseMoveHandler(mouseMoveEvent(150, 175)); + + expect(onMouseMoveSpy).toHaveBeenCalledWith(150, 175); + }); + + it('uses the default table selector to pick up content-editable tables', () => { + // A content-editable table that the default selector should pick up + const wrapper = document.createElement('div'); + wrapper.contentEditable = 'true'; + const table = document.createElement('table'); + wrapper.appendChild(table); + document.body.appendChild(wrapper); + domTables = [table]; + + setup(/* default selector */); + const setTableEditorSpy = spyOn(plugin, 'setTableEditor').and.callThrough(); + + // Any position works; this exercises defaultTableSelector + ensureTableRects + mouseMoveHandler(mouseMoveEvent(5, 5)); + + expect(setTableEditorSpy).toHaveBeenCalled(); + }); + + it('disposes an active table editor on dispose', () => { + setup(/* default selector */); + const disposeSpy = jasmine.createSpy('dispose'); + (plugin as any).tableEditor = { dispose: disposeSpy }; + + plugin.dispose(); + + expect(disposeSpy).toHaveBeenCalled(); + expect((plugin as any).tableEditor).toBeNull(); + }); +}); diff --git a/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditorTest.ts b/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditorTest.ts index b785a57caf02..fe2f0ffbd352 100644 --- a/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditorTest.ts +++ b/packages/roosterjs-content-model-plugins/test/tableEdit/tableEditorTest.ts @@ -156,6 +156,276 @@ describe('TableEditor', () => { }); }); + describe('public methods and editing lifecycle', () => { + let editor: IEditor; + let table: HTMLTableElement; + let tEditor: TableEditor; + let onChangedSpy: jasmine.Spy; + const TEST_ID = 'tableEditorLifecycleTest'; + + function setup(cmTable: ContentModelTable = getModelTable(), isRtl: boolean = false) { + const s = beforeTableTest(TEST_ID); + editor = s.editor; + const editorDiv = editor.getDocument().getElementById(TEST_ID) as HTMLElement; + editorDiv.style.height = '400px'; + editorDiv.style.padding = '100px'; + const rect = initialize(editor, cmTable, isRtl); + table = editor.getDOMHelper().queryElements('table')[0]; + const contentDiv = editor.getDocument().getElementById(TEST_ID); + onChangedSpy = jasmine.createSpy('onChanged'); + tEditor = new TableEditor( + editor, + table, + null, + onChangedSpy, + undefined, + contentDiv, + undefined + ); + return rect; + } + + afterEach(() => { + afterTableTest(editor, tEditor, TEST_ID); + }); + + it('isEditing is false before any editing starts', () => { + setup(); + expect(tEditor.isEditing()).toBe(false); + }); + + it('onSelect focuses the editor and sets a table selection', () => { + setup(); + const focusSpy = spyOn(editor, 'focus'); + const setDOMSelectionSpy = spyOn(editor, 'setDOMSelection'); + + tEditor.onSelect(table); + + expect(focusSpy).toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith( + jasmine.objectContaining({ + type: 'table', + table, + firstRow: 0, + firstColumn: 0, + }) as any + ); + }); + + it('onSelect only focuses when no table is passed', () => { + setup(); + const focusSpy = spyOn(editor, 'focus'); + const setDOMSelectionSpy = spyOn(editor, 'setDOMSelection'); + + tEditor.onSelect(null as any); + + expect(focusSpy).toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('isOwnedElement returns true for a feature element and false for unrelated nodes', () => { + const tableRect = setup(); + // Hover the center to ensure features are created + tEditor.onMouseMove( + tableRect.left + tableRect.width / 2, + tableRect.top + tableRect.height / 2 + ); + + const mover = editor.getDocument().getElementById(TABLE_MOVER_ID); + expect(!!mover).toBe(true); + expect(tEditor.isOwnedElement(mover!)).toBe(true); + + // A child of a feature element is also considered owned + const child = editor.getDocument().createElement('span'); + mover!.appendChild(child); + expect(tEditor.isOwnedElement(child)).toBe(true); + + expect(tEditor.isOwnedElement(editor.getDocument().body)).toBe(false); + }); + + it('onFinishEditing restores the saved range, snapshots and reports change', () => { + setup(); + const focusSpy = spyOn(editor, 'focus'); + const setDOMSelectionSpy = spyOn(editor, 'setDOMSelection'); + const takeSnapshotSpy = spyOn(editor, 'takeSnapshot'); + const mockedRange = { id: 'range' } as any; + (tEditor as any).range = mockedRange; + (tEditor as any).isCurrentlyEditing = true; + + const result = (tEditor as any).onFinishEditing(); + + expect(result).toBe(false); + expect(focusSpy).toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + expect(takeSnapshotSpy).toHaveBeenCalled(); + expect(onChangedSpy).toHaveBeenCalled(); + expect(tEditor.isEditing()).toBe(false); + expect((tEditor as any).range).toBeNull(); + }); + + it('onFinishEditing does not restore selection when there is no saved range', () => { + setup(); + const setDOMSelectionSpy = spyOn(editor, 'setDOMSelection'); + spyOn(editor, 'takeSnapshot'); + + (tEditor as any).range = null; + (tEditor as any).onFinishEditing(); + + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('onStartTableResize saves the current range and marks editing', () => { + setup(); + const setLogicalRootSpy = spyOn(editor, 'setLogicalRoot'); + const takeSnapshotSpy = spyOn(editor, 'takeSnapshot'); + const mockedRange = { id: 'range' } as any; + spyOn(editor, 'getDOMSelection').and.returnValue({ + type: 'range', + range: mockedRange, + } as any); + + (tEditor as any).onStartTableResize(); + + expect(tEditor.isEditing()).toBe(true); + expect(setLogicalRootSpy).toHaveBeenCalledWith(null); + expect(takeSnapshotSpy).toHaveBeenCalled(); + expect((tEditor as any).range).toBe(mockedRange); + }); + + it('onStartResize does not save a range when the selection is not a range', () => { + setup(); + spyOn(editor, 'setLogicalRoot'); + spyOn(editor, 'takeSnapshot'); + spyOn(editor, 'getDOMSelection').and.returnValue({ type: 'image' } as any); + + (tEditor as any).onStartResize(); + + expect((tEditor as any).range).toBeNull(); + }); + + it('onStartCellResize marks editing and disposes the table resizer', () => { + setup(); + spyOn(editor, 'setLogicalRoot'); + spyOn(editor, 'takeSnapshot'); + spyOn(editor, 'getDOMSelection').and.returnValue(null as any); + + (tEditor as any).onStartCellResize(); + + expect(tEditor.isEditing()).toBe(true); + expect(editor.getDocument().getElementById(TABLE_RESIZER_ID)).toBeNull(); + }); + + it('onStartTableMove marks editing, sets logical root and disposes features', () => { + const tableRect = setup(); + tEditor.onMouseMove( + tableRect.left + tableRect.width / 2, + tableRect.top + tableRect.height / 2 + ); + const setLogicalRootSpy = spyOn(editor, 'setLogicalRoot'); + + (tEditor as any).onStartTableMove(); + + expect(tEditor.isEditing()).toBe(true); + expect(setLogicalRootSpy).toHaveBeenCalledWith(null); + expect(editor.getDocument().getElementById(TABLE_RESIZER_ID)).toBeNull(); + }); + + it('onEndTableMove disposes the mover when requested and finishes editing', () => { + setup(); + const finishSpy = spyOn(tEditor as any, 'onFinishEditing').and.returnValue(false); + const disposeMoverSpy = spyOn(tEditor as any, 'disposeTableMover').and.callThrough(); + + const result = (tEditor as any).onEndTableMove(true); + + expect(disposeMoverSpy).toHaveBeenCalled(); + expect(finishSpy).toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it('onEndTableMove keeps the mover when not requested', () => { + setup(); + spyOn(tEditor as any, 'onFinishEditing').and.returnValue(false); + const disposeMoverSpy = spyOn(tEditor as any, 'disposeTableMover').and.callThrough(); + + (tEditor as any).onEndTableMove(false); + + expect(disposeMoverSpy).not.toHaveBeenCalled(); + }); + + it('onAfterInsert disposes the table resizer and finishes editing', () => { + setup(); + const finishSpy = spyOn(tEditor as any, 'onFinishEditing').and.returnValue(false); + + (tEditor as any).onAfterInsert(); + + expect(editor.getDocument().getElementById(TABLE_RESIZER_ID)).toBeNull(); + expect(finishSpy).toHaveBeenCalled(); + }); + + it('onBeforeEditTable applies the logical root', () => { + setup(); + const setLogicalRootSpy = spyOn(editor, 'setLogicalRoot'); + + (tEditor as any).onBeforeEditTable(); + + expect(setLogicalRootSpy).toHaveBeenCalledWith(null); + }); + + it('getOnMouseOut disposes the editor when the mouse leaves to an outside element', () => { + setup(); + const feature = editor.getDocument().createElement('div'); + const disposeSpy = spyOn(tEditor, 'dispose'); + const handler = (tEditor as any).getOnMouseOut(feature); + + handler(({ + relatedTarget: editor.getDocument().createElement('span'), + })); + + expect(disposeSpy).toHaveBeenCalled(); + }); + + it('getOnMouseOut does not dispose while editing', () => { + setup(); + const feature = editor.getDocument().createElement('div'); + const disposeSpy = spyOn(tEditor, 'dispose'); + (tEditor as any).isCurrentlyEditing = true; + const handler = (tEditor as any).getOnMouseOut(feature); + + handler(({ + relatedTarget: editor.getDocument().createElement('span'), + })); + + expect(disposeSpy).not.toHaveBeenCalled(); + }); + + it('getOnMouseOut does not dispose when leaving to the feature itself', () => { + setup(); + const feature = editor.getDocument().createElement('div'); + const disposeSpy = spyOn(tEditor, 'dispose'); + const handler = (tEditor as any).getOnMouseOut(feature); + + handler(({ relatedTarget: feature })); + + expect(disposeSpy).not.toHaveBeenCalled(); + }); + + it('builds RTL editor features for a right-to-left table', () => { + const tableRect = setup(getModelTable(), /* isRtl */ true); + + expect((tEditor as any).isRTL).toBe(true); + + // Exercise the RTL branches of onMouseMove (top and side hover areas) + expect(() => tEditor.onMouseMove(tableRect.right, tableRect.top - 5)).not.toThrow(); + expect(() => + tEditor.onMouseMove(tableRect.right + 5, tableRect.top + tableRect.height / 2) + ).not.toThrow(); + }); + }); + describe('anchorContainer', () => { let editor: IEditor; let tEditor: TableEditor; diff --git a/packages/roosterjs-content-model-plugins/test/touch/TouchPluginTest.ts b/packages/roosterjs-content-model-plugins/test/touch/TouchPluginTest.ts new file mode 100644 index 000000000000..92c14e5a8478 --- /dev/null +++ b/packages/roosterjs-content-model-plugins/test/touch/TouchPluginTest.ts @@ -0,0 +1,368 @@ +import * as getNodePositionFromEventFile from 'roosterjs-content-model-dom/lib/domUtils/event/getNodePositionFromEvent'; +import { TouchPlugin } from '../../lib/touch/TouchPlugin'; +import { IEditor, PluginEvent } from 'roosterjs-content-model-types'; + +describe('TouchPlugin', () => { + let plugin: TouchPlugin; + let editor: IEditor; + + let focusSpy: jasmine.Spy; + let setDOMSelectionSpy: jasmine.Spy; + let getDOMHelperSpy: jasmine.Spy; + let createRangeSpy: jasmine.Spy; + let setStartSpy: jasmine.Spy; + let setEndSpy: jasmine.Spy; + let setTimeoutSpy: jasmine.Spy; + let clearTimeoutSpy: jasmine.Spy; + let getNodePositionFromEventSpy: jasmine.Spy; + + let mockedRange: any; + let mockedDOMHelper: any; + let mockedWindow: any; + let mockedDocument: any; + let timerCallback: (() => void) | null; + + const TIMER_ID = 123; + + beforeEach(() => { + focusSpy = jasmine.createSpy('focus'); + setDOMSelectionSpy = jasmine.createSpy('setDOMSelection'); + setStartSpy = jasmine.createSpy('setStart'); + setEndSpy = jasmine.createSpy('setEnd'); + timerCallback = null; + + mockedRange = { + setStart: setStartSpy, + setEnd: setEndSpy, + }; + + createRangeSpy = jasmine.createSpy('createRange').and.returnValue(mockedRange); + clearTimeoutSpy = jasmine.createSpy('clearTimeout'); + setTimeoutSpy = jasmine.createSpy('setTimeout').and.callFake((callback: () => void) => { + timerCallback = callback; + return TIMER_ID; + }); + + mockedDOMHelper = {}; + getDOMHelperSpy = jasmine.createSpy('getDOMHelper').and.returnValue(mockedDOMHelper); + + mockedWindow = { + setTimeout: setTimeoutSpy, + clearTimeout: clearTimeoutSpy, + }; + + mockedDocument = { + defaultView: mockedWindow, + createRange: createRangeSpy, + }; + + getNodePositionFromEventSpy = spyOn( + getNodePositionFromEventFile, + 'getNodePositionFromEvent' + ); + + editor = ({ + getDocument: () => mockedDocument, + getDOMHelper: getDOMHelperSpy, + focus: focusSpy, + setDOMSelection: setDOMSelectionSpy, + }); + + plugin = new TouchPlugin(); + plugin.initialize(editor); + }); + + afterEach(() => { + plugin.dispose(); + }); + + function pointerDownEvent(x: number = 10, y: number = 20): PluginEvent { + return ({ + eventType: 'pointerDown', + originalEvent: { + preventDefault: jasmine.createSpy('preventDefault'), + }, + rawEvent: { x, y }, + }); + } + + function doubleClickEvent(x: number = 10, y: number = 20): PluginEvent { + return ({ + eventType: 'doubleClick', + rawEvent: { + x, + y, + preventDefault: jasmine.createSpy('preventDefault'), + }, + }); + } + + function runTimer() { + expect(timerCallback).not.toBeNull(); + timerCallback!(); + } + + it('getName', () => { + expect(plugin.getName()).toBe('Touch'); + }); + + it('does nothing when editor is not set (disposed)', () => { + plugin.dispose(); + + plugin.onPluginEvent(pointerDownEvent()); + + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); + + it('ignores unrelated event types', () => { + plugin.onPluginEvent(({ eventType: 'keyDown', rawEvent: {} })); + + expect(setTimeoutSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + describe('pointerDown', () => { + it('prevents default and schedules the detection timer', () => { + const event = pointerDownEvent(); + + plugin.onPluginEvent(event); + + expect((event as any).originalEvent.preventDefault).toHaveBeenCalled(); + expect(setTimeoutSpy).toHaveBeenCalledTimes(1); + expect(setTimeoutSpy.calls.argsFor(0)[1]).toBe(150); + }); + + it('does not schedule a timer when there is no default view', () => { + mockedDocument.defaultView = null; + + plugin.onPluginEvent(pointerDownEvent()); + + expect(setTimeoutSpy).not.toHaveBeenCalled(); + }); + + it('clears a pending timer when a second pointerDown happens', () => { + plugin.onPluginEvent(pointerDownEvent()); + plugin.onPluginEvent(pointerDownEvent()); + + expect(clearTimeoutSpy).toHaveBeenCalledWith(TIMER_ID); + expect(setTimeoutSpy).toHaveBeenCalledTimes(2); + }); + + it('sets a collapsed selection at the caret when caret position is null', () => { + getNodePositionFromEventSpy.and.returnValue(null); + + plugin.onPluginEvent(pointerDownEvent()); + runTimer(); + + expect(focusSpy).toHaveBeenCalled(); + expect(setStartSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + }); + + it('keeps caret at clicked position when character is a space', () => { + const node = document.createTextNode('a b'); + getNodePositionFromEventSpy.and.returnValue({ node, offset: 1 }); // space at index 1 + + plugin.onPluginEvent(pointerDownEvent()); + runTimer(); + + expect(setStartSpy).toHaveBeenCalledTimes(1); + expect(setStartSpy).toHaveBeenCalledWith(node, 1); + expect(setEndSpy).toHaveBeenCalledWith(node, 1); + expect(setDOMSelectionSpy).toHaveBeenCalled(); + }); + + it('keeps caret at clicked position when character is punctuation', () => { + const node = document.createTextNode('hi.there'); + getNodePositionFromEventSpy.and.returnValue({ node, offset: 2 }); // '.' at index 2 + + plugin.onPluginEvent(pointerDownEvent()); + runTimer(); + + expect(setStartSpy).toHaveBeenCalledTimes(1); + expect(setStartSpy).toHaveBeenCalledWith(node, 2); + }); + + it('does not adjust caret for non-text nodes', () => { + const node = document.createElement('div'); + getNodePositionFromEventSpy.and.returnValue({ node, offset: 0 }); + + plugin.onPluginEvent(pointerDownEvent()); + runTimer(); + + expect(setStartSpy).toHaveBeenCalledTimes(1); + expect(setStartSpy).toHaveBeenCalledWith(node, 0); + }); + + it('moves caret towards the nearer word boundary (right side closer)', () => { + // 'hello world', offset 4 ('o'); left=4, right=1 -> move +1 to position 5 + const node = document.createTextNode('hello world'); + getNodePositionFromEventSpy.and.returnValue({ node, offset: 4 }); + + plugin.onPluginEvent(pointerDownEvent()); + runTimer(); + + // First a collapsed range at the original offset, then moved to the boundary + expect(setStartSpy).toHaveBeenCalledWith(node, 4); + expect(setStartSpy).toHaveBeenCalledWith(node, 5); + expect(setEndSpy).toHaveBeenCalledWith(node, 5); + }); + + it('moves caret towards the nearer word boundary (left side closer)', () => { + // 'hello world', offset 2 ('l'); left=2, right=3 -> move -2 to position 0 + const node = document.createTextNode('hello world'); + getNodePositionFromEventSpy.and.returnValue({ node, offset: 2 }); + + plugin.onPluginEvent(pointerDownEvent()); + runTimer(); + + expect(setStartSpy).toHaveBeenCalledWith(node, 0); + expect(setEndSpy).toHaveBeenCalledWith(node, 0); + }); + + it('does not move caret when nearest boundary exceeds the max move distance', () => { + // 14-char word, offset 7; both sides 7 > MAX(6) -> movingOffset becomes 0, no move + const node = document.createTextNode('abcdefghijklmn'); + getNodePositionFromEventSpy.and.returnValue({ node, offset: 7 }); + + plugin.onPluginEvent(pointerDownEvent()); + runTimer(); + + // Only the original collapsed range is set, never adjusted + expect(setStartSpy).toHaveBeenCalledTimes(1); + expect(setStartSpy).toHaveBeenCalledWith(node, 7); + }); + + it('does nothing in the timer when a double click happened in between', () => { + const node = document.createTextNode('hello'); + getNodePositionFromEventSpy.and.returnValue({ node, offset: 1 }); + + plugin.onPluginEvent(pointerDownEvent()); + // Simulate a double click marking isDblClicked = true before the timer fires + plugin.onPluginEvent(doubleClickEvent()); + setDOMSelectionSpy.calls.reset(); + + runTimer(); + + expect(focusSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('does nothing in the timer when the editor was disposed before it fired', () => { + plugin.onPluginEvent(pointerDownEvent()); + const callback = timerCallback!; + + plugin.dispose(); + callback(); + + expect(focusSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + }); + + describe('doubleClick', () => { + // A doubleClick only acts when preceded by a touch/pen pointerDown + function primeTouchPointer() { + plugin.onPluginEvent(pointerDownEvent()); + } + + it('does nothing when not preceded by a touch/pen pointer event', () => { + plugin.onPluginEvent(doubleClickEvent()); + + expect(getNodePositionFromEventSpy).not.toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('prevents default and does nothing when caret position is null', () => { + primeTouchPointer(); + getNodePositionFromEventSpy.and.returnValue(null); + + const event = doubleClickEvent(); + plugin.onPluginEvent(event); + + expect((event as any).rawEvent.preventDefault).toHaveBeenCalled(); + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('returns early for non-text nodes', () => { + primeTouchPointer(); + const node = document.createElement('div'); + getNodePositionFromEventSpy.and.returnValue({ node, offset: 0 }); + + plugin.onPluginEvent(doubleClickEvent()); + + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('selects a single punctuation character', () => { + primeTouchPointer(); + const node = document.createTextNode('hi.there'); + getNodePositionFromEventSpy.and.returnValue({ node, offset: 2 }); // '.' + + plugin.onPluginEvent(doubleClickEvent()); + + expect(setStartSpy).toHaveBeenCalledWith(node, 2); + expect(setEndSpy).toHaveBeenCalledWith(node, 3); + expect(setDOMSelectionSpy).toHaveBeenCalledWith({ + type: 'range', + range: mockedRange, + isReverted: false, + }); + }); + + it('selects the first trailing space when the rest of the node is spaces', () => { + primeTouchPointer(); + const node = document.createTextNode('word '); // trailing spaces + getNodePositionFromEventSpy.and.returnValue({ node, offset: 5 }); // a space, all-spaces to the right + + plugin.onPluginEvent(doubleClickEvent()); + + // walks back to the first space (index 4) and selects one character + expect(setStartSpy).toHaveBeenCalledWith(node, 4); + expect(setEndSpy).toHaveBeenCalledWith(node, 5); + expect(setDOMSelectionSpy).toHaveBeenCalled(); + }); + + it('does nothing for a space that has a word to its right', () => { + primeTouchPointer(); + const node = document.createTextNode('a b'); + getNodePositionFromEventSpy.and.returnValue({ node, offset: 1 }); // space, 'b' to the right + + plugin.onPluginEvent(doubleClickEvent()); + + expect(setDOMSelectionSpy).not.toHaveBeenCalled(); + }); + + it('selects the whole word when double clicking inside a word', () => { + primeTouchPointer(); + const node = document.createTextNode('hello world'); + getNodePositionFromEventSpy.and.returnValue({ node, offset: 2 }); // inside 'hello' + + plugin.onPluginEvent(doubleClickEvent()); + + expect(setStartSpy).toHaveBeenCalledWith(node, 0); + expect(setEndSpy).toHaveBeenCalledWith(node, 5); + expect(setDOMSelectionSpy).toHaveBeenCalled(); + }); + }); + + describe('dispose', () => { + it('clears a pending timer on dispose', () => { + plugin.onPluginEvent(pointerDownEvent()); + + plugin.dispose(); + + expect(clearTimeoutSpy).toHaveBeenCalledWith(TIMER_ID); + }); + + it('does not clear a timer when none is pending', () => { + plugin.dispose(); + + expect(clearTimeoutSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts index 89a8b6cf8922..7746edb9abb0 100644 --- a/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts +++ b/packages/roosterjs-editor-adapter/test/editor/EditorAdapterTest.ts @@ -1,5 +1,13 @@ import { EditorAdapter } from '../../lib/editor/EditorAdapter'; +import { EditorAdapterOptions } from '../../lib/publicTypes/EditorAdapterOptions'; import { EditorCore } from 'roosterjs-content-model-types'; +import { + ChangeSource, + ColorTransformDirection, + ContentPosition, + GetContentMode, + PluginEventType, +} from 'roosterjs-editor-types'; describe('EditorAdapter', () => { it('default format', () => { @@ -57,3 +65,443 @@ describe('EditorAdapter', () => { expect(div.style.fontFamily).toBe('Arial'); }); }); + +describe('EditorAdapter API coverage', () => { + let div: HTMLDivElement; + let editor: EditorAdapter; + + function createEditor(options?: EditorAdapterOptions, initialHtml?: string) { + // Note: intentionally NOT attached to document.body. A live editor sets an initial + // DOM selection which queues an async selectionchange that can fire after the editor + // is disposed in afterEach, throwing "Editor is already disposed" and failing the run. + div = document.createElement('div'); + if (initialHtml) { + div.innerHTML = initialHtml; + } + editor = new EditorAdapter(div, options); + return editor; + } + + afterEach(() => { + if (editor && !editor.isDisposed()) { + editor.dispose(); + } + div?.parentNode?.removeChild(div); + }); + + it('isDisposed', () => { + createEditor(); + + expect(editor.isDisposed()).toBe(false); + + editor.dispose(); + + expect(editor.isDisposed()).toBe(true); + }); + + it('getContentModelEditorCore throws after dispose', () => { + createEditor(); + editor.dispose(); + + expect(() => editor.getSizeTransformer()).toThrowError('Editor is already disposed'); + }); + + describe('addDomEventHandler', () => { + it('converts a single function handler', () => { + createEditor(); + const attachSpy = spyOn(editor, 'attachDomEvent').and.returnValue(() => {}); + const handler = () => {}; + + editor.addDomEventHandler('click', handler); + + const map = attachSpy.calls.argsFor(0)[0] as any; + expect(map.click.beforeDispatch).toBe(handler); + expect(map.click.pluginEventType).toBeNull(); + }); + + it('converts a numeric (plugin event type) handler', () => { + createEditor(); + const attachSpy = spyOn(editor, 'attachDomEvent').and.returnValue(() => {}); + + editor.addDomEventHandler({ keydown: PluginEventType.KeyDown }); + + const map = attachSpy.calls.argsFor(0)[0] as any; + expect(map.keydown.pluginEventType).toBeDefined(); + expect(map.keydown.beforeDispatch).toBeNull(); + }); + + it('converts an object handler with beforeDispatch and numeric pluginEventType', () => { + createEditor(); + const attachSpy = spyOn(editor, 'attachDomEvent').and.returnValue(() => {}); + const beforeDispatch = () => {}; + + editor.addDomEventHandler({ + input: { beforeDispatch, pluginEventType: PluginEventType.Input }, + }); + + const map = attachSpy.calls.argsFor(0)[0] as any; + expect(map.input.beforeDispatch).toBe(beforeDispatch); + expect(map.input.pluginEventType).toBeDefined(); + }); + + it('converts an object handler with no numeric pluginEventType', () => { + createEditor(); + const attachSpy = spyOn(editor, 'attachDomEvent').and.returnValue(() => {}); + const beforeDispatch = () => {}; + + editor.addDomEventHandler({ + input: { beforeDispatch, pluginEventType: undefined } as any, + }); + + const map = attachSpy.calls.argsFor(0)[0] as any; + expect(map.input.beforeDispatch).toBe(beforeDispatch); + expect(map.input.pluginEventType).toBeUndefined(); + }); + }); + + describe('getSelectionRange', () => { + it('returns the range when selection is a range', () => { + createEditor(); + const mockedRange = { id: 'range' } as any; + spyOn(editor, 'getDOMSelection').and.returnValue({ + type: 'range', + range: mockedRange, + } as any); + + expect(editor.getSelectionRange()).toBe(mockedRange); + }); + + it('returns null when selection is not a range', () => { + createEditor(); + spyOn(editor, 'getDOMSelection').and.returnValue({ type: 'image' } as any); + + expect(editor.getSelectionRange()).toBeNull(); + }); + }); + + it('getSelectionRangeEx delegates to selection converter', () => { + createEditor(); + spyOn(editor, 'getDOMSelection').and.returnValue(null as any); + + const result = editor.getSelectionRangeEx(); + + expect(result).toBeTruthy(); + expect(Array.isArray(result.ranges)).toBe(true); + }); + + describe('getContent', () => { + it('exports HTML by default', () => { + createEditor(undefined, '

Hello

'); + + const content = editor.getContent(); + + expect(typeof content).toBe('string'); + expect(content).toContain('Hello'); + }); + + it('exports plain text', () => { + createEditor(undefined, '

Hello

'); + + const content = editor.getContent(GetContentMode.PlainText); + + expect(typeof content).toBe('string'); + expect(content).toContain('Hello'); + }); + + it('exports plain text fast', () => { + createEditor(undefined, '

Hello

'); + + const content = editor.getContent(GetContentMode.PlainTextFast); + + expect(typeof content).toBe('string'); + expect(content).toContain('Hello'); + }); + }); + + it('undo does not throw on a fresh editor', () => { + createEditor(); + + expect(() => editor.undo()).not.toThrow(); + }); + + it('redo does not throw on a fresh editor', () => { + createEditor(); + + expect(() => editor.redo()).not.toThrow(); + }); + + describe('setZoomScale', () => { + it('triggers a zoomChanged event for a valid scale', () => { + createEditor(); + const triggerSpy = spyOn(editor, 'triggerEvent'); + + editor.setZoomScale(2); + + expect(triggerSpy).toHaveBeenCalledWith('zoomChanged', { newZoomScale: 2 }, true); + }); + + it('ignores a non-positive scale', () => { + createEditor(); + const triggerSpy = spyOn(editor, 'triggerEvent'); + + editor.setZoomScale(0); + + expect(triggerSpy).not.toHaveBeenCalled(); + }); + + it('ignores a scale greater than 10', () => { + createEditor(); + const triggerSpy = spyOn(editor, 'triggerEvent'); + + editor.setZoomScale(11); + + expect(triggerSpy).not.toHaveBeenCalled(); + }); + }); + + it('getZoomScale delegates to the DOM helper', () => { + createEditor(); + spyOn(editor, 'getDOMHelper').and.returnValue({ + calculateZoomScale: () => 2.5, + } as any); + + expect(editor.getZoomScale()).toBe(2.5); + }); + + it('triggerContentChangedEvent triggers a ContentChanged plugin event', () => { + createEditor(); + const triggerSpy = spyOn(editor, 'triggerPluginEvent'); + + editor.triggerContentChangedEvent(ChangeSource.SetContent, { x: 1 }); + + expect(triggerSpy).toHaveBeenCalledWith(PluginEventType.ContentChanged, { + source: ChangeSource.SetContent, + data: { x: 1 }, + }); + }); + + describe('setEditorDomAttribute / getEditorDomAttribute', () => { + it('delegates to the DOM helper', () => { + createEditor(); + const setDomAttributeSpy = jasmine.createSpy('setDomAttribute'); + const getDomAttributeSpy = jasmine.createSpy('getDomAttribute').and.returnValue('val'); + spyOn(editor, 'getDOMHelper').and.returnValue({ + setDomAttribute: setDomAttributeSpy, + getDomAttribute: getDomAttributeSpy, + } as any); + + editor.setEditorDomAttribute('data-test', '1'); + const value = editor.getEditorDomAttribute('data-test'); + + expect(setDomAttributeSpy).toHaveBeenCalledWith('data-test', '1'); + expect(getDomAttributeSpy).toHaveBeenCalledWith('data-test'); + expect(value).toBe('val'); + }); + }); + + describe('node manipulation', () => { + it('deleteNode returns false for a node outside the editor', () => { + createEditor(); + const orphan = document.createElement('span'); + + expect(editor.deleteNode(orphan)).toBe(false); + }); + + it('deleteNode removes a node within the editor', () => { + createEditor(); + const child = document.createElement('span'); + div.appendChild(child); + + expect(editor.deleteNode(child)).toBe(true); + expect(child.parentNode).toBeNull(); + }); + + it('replaceNode returns false for a node outside the editor', () => { + createEditor(); + const orphan = document.createElement('span'); + const replacement = document.createElement('div'); + + expect(editor.replaceNode(orphan, replacement)).toBe(false); + }); + + it('replaceNode replaces a node within the editor', () => { + createEditor(); + const child = document.createElement('span'); + div.appendChild(child); + const replacement = document.createElement('div'); + + expect(editor.replaceNode(child, replacement)).toBe(true); + expect(child.parentNode).toBeNull(); + expect(replacement.parentNode).toBe(div); + }); + + it('insertNode returns false for a null node', () => { + createEditor(); + + expect(editor.insertNode(null as any)).toBe(false); + }); + + it('insertNode inserts a node outside the editor for ContentPosition.Outside', () => { + createEditor(); + // Give the editor div a (detached) parent so the Outside branch has somewhere to insert + const parent = document.createElement('div'); + parent.appendChild(div); + const node = document.createElement('span'); + + const result = editor.insertNode(node, { + position: ContentPosition.Outside, + updateCursor: false, + replaceSelection: false, + insertOnNewLine: false, + }); + + expect(result).toBe(true); + expect(node.parentNode).toBe(parent); + }); + }); + + describe('contains', () => { + it('returns false for null', () => { + createEditor(); + + expect(editor.contains(null)).toBe(false); + }); + + it('returns true for a contained node', () => { + createEditor(); + const child = document.createElement('span'); + div.appendChild(child); + + expect(editor.contains(child)).toBe(true); + }); + + it('returns false for a node outside the editor', () => { + createEditor(); + const orphan = document.createElement('span'); + + expect(editor.contains(orphan)).toBe(false); + }); + }); + + it('getCustomData stores and reuses the value via the getter', () => { + createEditor(); + const getter = jasmine.createSpy('getter').and.returnValue({ value: 1 }); + + const first = editor.getCustomData('key', getter); + const second = editor.getCustomData('key', getter); + + expect(first).toBe(second); + expect(getter).toHaveBeenCalledTimes(1); + }); + + it('isFeatureEnabled reflects the configured experimental features', () => { + createEditor({ experimentalFeatures: ['FeatureA' as any] }); + + expect(editor.isFeatureEnabled('FeatureA' as any)).toBe(true); + expect(editor.isFeatureEnabled('FeatureB' as any)).toBe(false); + }); + + it('getDefaultFormat reads from the core default format', () => { + createEditor({ + defaultSegmentFormat: { + fontWeight: 'bold', + italic: true, + underline: true, + fontFamily: 'Arial', + fontSize: '10pt', + textColor: 'black', + backgroundColor: 'white', + }, + }); + + const format = editor.getDefaultFormat(); + + expect(format.bold).toBe(true); + expect(format.italic).toBe(true); + expect(format.underline).toBe(true); + expect(format.fontFamily).toBe('Arial'); + expect(format.fontSize).toBe('10pt'); + expect(format.textColor).toBe('black'); + expect(format.backgroundColor).toBe('white'); + }); + + it('isInIME reflects the dom event state', () => { + createEditor(); + const core = (editor as any).getCore(); + + expect(editor.isInIME()).toBe(false); + + core.domEvent.isInIME = true; + expect(editor.isInIME()).toBe(true); + }); + + it('getSizeTransformer and getDarkColorHandler return core objects', () => { + createEditor(); + + expect(editor.getSizeTransformer()).toBeTruthy(); + expect(editor.getDarkColorHandler()).toBeTruthy(); + }); + + it('getUndoState reports canUndo when there is new content', () => { + createEditor(); + const core = (editor as any).getCore(); + core.undo.snapshotsManager.hasNewContent = true; + + expect(editor.getUndoState().canUndo).toBe(true); + }); + + describe('content edit features', () => { + it('adds and removes a content edit feature', () => { + createEditor(); + const core = (editor as any).getContentModelEditorCore(); + const feature = { + keys: [PluginEventType.KeyDown], + shouldHandleEvent: () => true, + } as any; + + editor.addContentEditFeature(feature); + expect(core.edit.features[PluginEventType.KeyDown]).toContain(feature); + + editor.removeContentEditFeature(feature); + expect(core.edit.features[PluginEventType.KeyDown]).toBeUndefined(); + }); + }); + + describe('runAsync', () => { + it('runs the callback asynchronously and returns a canceler', () => { + createEditor(); + const win = editor.getDocument().defaultView as Window; + spyOn(win, 'requestAnimationFrame').and.callFake((cb: FrameRequestCallback) => { + cb(0); + return 42; + }); + const cancelSpy = spyOn(win, 'cancelAnimationFrame'); + const callback = jasmine.createSpy('callback'); + + const canceler = editor.runAsync(callback); + + expect(callback).toHaveBeenCalledWith(editor as any); + + canceler(); + expect(cancelSpy).toHaveBeenCalledWith(42 as any); + }); + }); + + it('transformToDarkColor does not modify nodes in light mode', () => { + createEditor(); + const span = document.createElement('span'); + span.style.color = 'red'; + + editor.transformToDarkColor(span, ColorTransformDirection.LightToDark); + + // In light mode the call is a no-op, so the inline color is untouched + expect(span.style.color).toBe('red'); + }); + + it('getRelativeDistanceToEditor returns null for an element outside the editor', () => { + createEditor(); + const orphan = document.createElement('span'); + + expect(editor.getRelativeDistanceToEditor(orphan)).toBeNull(); + }); +}); diff --git a/packages/roosterjs-editor-adapter/test/editor/utils/buildRangeExTest.ts b/packages/roosterjs-editor-adapter/test/editor/utils/buildRangeExTest.ts new file mode 100644 index 000000000000..6dcd76029e9a --- /dev/null +++ b/packages/roosterjs-editor-adapter/test/editor/utils/buildRangeExTest.ts @@ -0,0 +1,117 @@ +import { buildRangeEx } from '../../../lib/editor/utils/buildRangeEx'; +import { SelectionRangeTypes } from 'roosterjs-editor-types'; + +describe('buildRangeEx', () => { + let root: HTMLDivElement; + + beforeEach(() => { + root = document.createElement('div'); + root.innerHTML = '
line1
line2
'; + document.body.appendChild(root); + }); + + afterEach(() => { + root.parentNode?.removeChild(root); + }); + + it('returns the SelectionRangeEx as-is when given one', () => { + const input = { + type: SelectionRangeTypes.Normal, + ranges: [], + areAllCollapsed: true, + } as any; + + expect(buildRangeEx(root, input)).toBe(input); + }); + + it('builds a table selection from a table element and coordinates', () => { + const table = document.createElement('table'); + const coordinates = { + firstCell: { x: 0, y: 0 }, + lastCell: { x: 1, y: 1 }, + } as any; + + const result = buildRangeEx(root, table, coordinates); + + expect(result.type).toBe(SelectionRangeTypes.TableSelection); + expect((result as any).table).toBe(table); + expect((result as any).coordinates).toBe(coordinates); + expect(result.areAllCollapsed).toBe(false); + }); + + it('builds a table selection with undefined coordinates when given null', () => { + const table = document.createElement('table'); + + const result = buildRangeEx(root, table, null); + + expect(result.type).toBe(SelectionRangeTypes.TableSelection); + expect((result as any).coordinates).toBeUndefined(); + }); + + it('builds an image selection from an image element', () => { + const image = document.createElement('img'); + + const result = buildRangeEx(root, image); + + expect(result.type).toBe(SelectionRangeTypes.ImageSelection); + expect((result as any).image).toBe(image); + }); + + it('builds an empty normal selection for null', () => { + const result = buildRangeEx(root, null); + + expect(result.type).toBe(SelectionRangeTypes.Normal); + expect(result.ranges).toEqual([]); + expect(result.areAllCollapsed).toBe(true); + }); + + it('builds a normal selection from a Range', () => { + const range = document.createRange(); + range.selectNodeContents(root); + + const result = buildRangeEx(root, range); + + expect(result.type).toBe(SelectionRangeTypes.Normal); + expect(result.ranges[0]).toBe(range); + expect(result.areAllCollapsed).toBe(range.collapsed); + }); + + it('reports areAllCollapsed for a collapsed Range', () => { + const range = document.createRange(); + range.setStart(root, 0); + range.collapse(true); + + const result = buildRangeEx(root, range); + + expect(result.type).toBe(SelectionRangeTypes.Normal); + expect(result.areAllCollapsed).toBe(true); + }); + + it('builds a normal selection from a SelectionPath', () => { + const path = { start: [0, 0], end: [0, 3] } as any; + + const result = buildRangeEx(root, path); + + expect(result.type).toBe(SelectionRangeTypes.Normal); + expect(result.ranges.length).toBe(1); + }); + + it('builds a normal selection from a NodePosition', () => { + const textNode = root.firstChild!.firstChild!; // 'line1' text node + const position = { node: textNode, offset: 2 } as any; + + const result = buildRangeEx(root, position); + + expect(result.type).toBe(SelectionRangeTypes.Normal); + expect(result.ranges.length).toBe(1); + }); + + it('builds a normal selection from a Node', () => { + const node = root.firstChild!; // first
+ + const result = buildRangeEx(root, node, 0 as any); + + expect(result.type).toBe(SelectionRangeTypes.Normal); + expect(result.ranges.length).toBe(1); + }); +}); diff --git a/packages/roosterjs-editor-adapter/test/editor/utils/insertNodeTest.ts b/packages/roosterjs-editor-adapter/test/editor/utils/insertNodeTest.ts new file mode 100644 index 000000000000..286d4ee46a41 --- /dev/null +++ b/packages/roosterjs-editor-adapter/test/editor/utils/insertNodeTest.ts @@ -0,0 +1,246 @@ +import { ContentPosition } from 'roosterjs-editor-types'; +import { DOMSelection } from 'roosterjs-content-model-types'; +import { insertNode } from '../../../lib/editor/utils/insertNode'; +import { InsertOption } from 'roosterjs-editor-types'; + +describe('insertNode', () => { + let contentDiv: HTMLDivElement; + + beforeEach(() => { + contentDiv = document.createElement('div'); + document.body.appendChild(contentDiv); + }); + + afterEach(() => { + contentDiv.parentNode?.removeChild(contentDiv); + }); + + function option(overrides: Record): InsertOption { + return { + position: ContentPosition.SelectionStart, + insertOnNewLine: false, + updateCursor: true, + replaceSelection: true, + insertToRegionRoot: false, + ...overrides, + } as InsertOption; + } + + function rangeSelection(range: Range): DOMSelection { + return { type: 'range', range, isReverted: false }; + } + + describe('ContentPosition.Begin / End', () => { + it('appends into an empty editor (no block)', () => { + const node = document.createElement('span'); + node.textContent = 'X'; + + insertNode(contentDiv, null, node, option({ position: ContentPosition.Begin })); + + expect(contentDiv.firstChild).toBe(node); + }); + + it('inserts before the first text node for Begin', () => { + contentDiv.innerHTML = 'hello'; + const node = document.createElement('span'); + + insertNode(contentDiv, null, node, option({ position: ContentPosition.Begin })); + + expect(contentDiv.firstChild).toBe(node); + }); + + it('inserts after the last text node for End', () => { + contentDiv.innerHTML = 'hello'; + const node = document.createElement('span'); + + insertNode(contentDiv, null, node, option({ position: ContentPosition.End })); + + expect(contentDiv.lastChild).toBe(node); + }); + + it('inserts the children of a DocumentFragment', () => { + contentDiv.innerHTML = 'hello'; + const fragment = document.createDocumentFragment(); + const inner = document.createElement('b'); + fragment.appendChild(inner); + + insertNode(contentDiv, null, fragment, option({ position: ContentPosition.Begin })); + + expect(contentDiv.firstChild).toBe(inner); + }); + + it('wraps an inline node in a DIV when inserting on a new line', () => { + contentDiv.innerHTML = 'hello'; + const node = document.createElement('span'); + + insertNode( + contentDiv, + null, + node, + option({ position: ContentPosition.Begin, insertOnNewLine: true }) + ); + + expect(node.parentElement?.tagName).toBe('DIV'); + expect(contentDiv.contains(node)).toBe(true); + }); + }); + + describe('ContentPosition.DomEnd', () => { + it('appends the node at the end', () => { + contentDiv.innerHTML = 'hello'; + const node = document.createElement('span'); + + insertNode(contentDiv, null, node, option({ position: ContentPosition.DomEnd })); + + expect(contentDiv.lastChild).toBe(node); + }); + + it('wraps an inline node in a DIV when inserting on a new line', () => { + contentDiv.innerHTML = 'hello'; + const node = document.createElement('span'); + + insertNode( + contentDiv, + null, + node, + option({ position: ContentPosition.DomEnd, insertOnNewLine: true }) + ); + + expect(node.parentElement?.tagName).toBe('DIV'); + }); + }); + + describe('ContentPosition.SelectionStart / Range', () => { + it('returns undefined and inserts nothing when there is no range', () => { + const node = document.createElement('span'); + + const result = insertNode( + contentDiv, + null, + node, + option({ position: ContentPosition.SelectionStart }) + ); + + expect(result).toBeUndefined(); + expect(contentDiv.contains(node)).toBe(false); + }); + + it('inserts at a collapsed selection and returns a range selection', () => { + contentDiv.innerHTML = 'hello world'; + const text = contentDiv.firstChild as Text; + const range = document.createRange(); + range.setStart(text, 5); + range.collapse(true); + const node = document.createElement('span'); + node.textContent = 'X'; + + const result = insertNode( + contentDiv, + rangeSelection(range), + node, + option({ position: ContentPosition.SelectionStart, updateCursor: true }) + ); + + expect(result?.type).toBe('range'); + expect(contentDiv.contains(node)).toBe(true); + }); + + it('removes the selected content when replaceSelection is set', () => { + contentDiv.innerHTML = 'hello world'; + const text = contentDiv.firstChild as Text; + const range = document.createRange(); + range.setStart(text, 0); + range.setEnd(text, 5); // selects 'hello' + const node = document.createElement('span'); + node.textContent = 'X'; + + insertNode( + contentDiv, + rangeSelection(range), + node, + option({ + position: ContentPosition.SelectionStart, + replaceSelection: true, + updateCursor: false, + }) + ); + + expect(contentDiv.textContent).not.toContain('hello'); + expect(contentDiv.contains(node)).toBe(true); + }); + + it('inserts at an explicit range for ContentPosition.Range', () => { + contentDiv.innerHTML = 'hello world'; + const text = contentDiv.firstChild as Text; + const optionRange = document.createRange(); + optionRange.setStart(text, 2); + optionRange.collapse(true); + const node = document.createElement('span'); + node.textContent = 'X'; + + const result = insertNode( + contentDiv, + null, + node, + option({ + position: ContentPosition.Range, + range: optionRange, + replaceSelection: false, + updateCursor: false, + }) + ); + + // No range to restore (no original selection, updateCursor off) + expect(result).toBeUndefined(); + expect(contentDiv.contains(node)).toBe(true); + }); + + it('adjusts the insert position to a new line within a block', () => { + contentDiv.innerHTML = '
hello
'; + const text = contentDiv.querySelector('div')!.firstChild as Text; + const range = document.createRange(); + range.setStart(text, 2); + range.collapse(true); + const node = document.createElement('span'); + node.textContent = 'X'; + + expect(() => + insertNode( + contentDiv, + rangeSelection(range), + node, + option({ + position: ContentPosition.SelectionStart, + insertOnNewLine: true, + insertToRegionRoot: false, + }) + ) + ).not.toThrow(); + expect(contentDiv.contains(node)).toBe(true); + }); + + it('adjusts the insert position to the region root inside a table', () => { + contentDiv.innerHTML = '
cell
'; + const text = contentDiv.querySelector('td')!.firstChild as Text; + const range = document.createRange(); + range.setStart(text, 2); + range.collapse(true); + const node = document.createElement('span'); + node.textContent = 'X'; + + expect(() => + insertNode( + contentDiv, + rangeSelection(range), + node, + option({ + position: ContentPosition.SelectionStart, + insertOnNewLine: true, + insertToRegionRoot: true, + }) + ) + ).not.toThrow(); + expect(contentDiv.contains(node)).toBe(true); + }); + }); +});