import { Keys, UiFinder, Waiter } from '@ephox/agar'; import { beforeEach, context, describe, it } from '@ephox/bedrock-client'; import { Arr, Fun } from '@ephox/katamari'; import { Css, Scroll, SugarBody } from '@ephox/sugar'; import { TinyContentActions, TinyDom, TinyHooks, TinySelections, TinyUiActions } from '@ephox/wrap-mcagar'; import { assert } from 'chai'; import Editor from 'tinymce/core/api/Editor'; import FullscreenPlugin from 'tinymce/plugins/fullscreen/Plugin'; import { getGreenImageDataUrl } from '../../../module/Assets'; interface Scenario { readonly content: string; readonly contentStyles?: string; readonly cursor: { readonly elementPath: number[]; readonly offset: number; }; readonly classes: string; } describe('browser.tinymce.themes.silver.editor.contexttoolbar.ContextToolbarIFramePosition test', () => { const topSelector = '.tox-pop.tox-pop--bottom:not(.tox-pop--inset):not(.tox-pop--transition)'; const bottomSelector = '.tox-pop.tox-pop--top:not(.tox-pop--inset):not(.tox-pop--transition)'; const rightSelector = '.tox-pop.tox-pop--left:not(.tox-pop--inset):not(.tox-pop--transition)'; const topInsetSelector = '.tox-pop.tox-pop--top.tox-pop--inset:not(.tox-pop--transition)'; const bottomInsetSelector = '.tox-pop.tox-pop--bottom.tox-pop--inset:not(.tox-pop--transition)'; const fullscreenSelector = '.tox.tox-fullscreen'; const fullscreenButtonSelector = 'button[aria-label="Fullscreen"]'; const hook = TinyHooks.bddSetup({ plugins: 'fullscreen', toolbar: 'fullscreen', height: 400, base_url: '/project/tinymce/js/tinymce', // Note: We provide overrides to keep consistent positions across all browsers/os's content_style: 'body, p { margin: 0; line-height: 22px; font-family: Arial,sans-serif; } tr { height: 36px; }', setup: (ed: Editor) => { ed.ui.registry.addButton('alpha', { text: 'Alpha', onAction: Fun.noop }); ed.ui.registry.addContextToolbar('test-selection-toolbar', { predicate: (node) => node.nodeName.toLowerCase() === 'a', items: 'alpha' }); ed.ui.registry.addContextToolbar('test-selection-toolbar-2', { predicate: (node) => node.nodeName.toLowerCase() === 'p' && !ed.selection.isCollapsed(), items: 'alpha', position: 'selection' }); ed.ui.registry.addContextToolbar('test-node-toolbar', { predicate: (node) => node.nodeName.toLowerCase() === 'img', items: 'alpha', position: 'node' }); ed.ui.registry.addContextToolbar('test-line-toolbar', { predicate: (node) => node.nodeName.toLowerCase() === 'div', items: 'alpha', position: 'line' }); ed.ui.registry.addContextToolbar('test-table-toolbar', { predicate: (node) => node.nodeName.toLowerCase() === 'table', items: 'alpha', position: 'node' }); } }, [ FullscreenPlugin ], true); beforeEach(() => { // Reset scroll position for each test scrollTo(hook.editor(), 0, 0); }); const scrollTo = (editor: Editor, x: number, y: number) => Scroll.to(x, y, TinyDom.document(editor)); const pAssertPosition = (position: string, value: number, diff = 5) => Waiter.pTryUntil('Wait for toolbar to be positioned', () => { const ele = UiFinder.findIn(SugarBody.body(), '.tox-pop').getOrDie(); const styles = parseInt(Css.getRaw(ele, position).getOr('0').replace('px', ''), 10); assert.approximately(styles, value, diff, `Assert toolbar position - ${position} ${styles}px ~= ${value}px`); }); const pAssertFullscreenPosition = (position: 'top' | 'bottom', value: number, diff = 5) => Waiter.pTryUntil('Wait for toolbar to be positioned', () => { const ele = UiFinder.findIn(SugarBody.body(), '.tox-pop').getOrDie(); // The context toolbar is positioned relative to the sink, so the value can change between browsers due to different default styles // as such we can't reliably test using the actual top/bottom position, so use the bounding client rect instead. const pos = ele.dom.getBoundingClientRect(); assert.approximately(pos[position], value, diff, `Assert toolbar position - ${position} ${pos[position]}px ~= ${value}px`); }); const pWaitForToolbarHidden = () => UiFinder.pWaitForHidden('Waiting for toolbar to be hidden', SugarBody.body(), '.tox-pop'); const pAssertHasNotFlipped = async (selector: string) => { await Waiter.pWait(50); // Need to wait a fixed amount as nothing will change await UiFinder.pWaitForVisible('Ensure the context toolbar has not flipped', SugarBody.body(), selector); }; const pAssertToolbarIsTransitioning = () => Waiter.pTryUntil('Wait for toolbar to include the transition class', () => { UiFinder.exists(SugarBody.body(), '.tox-pop.tox-pop--transition'); }); const assertToolbarIsNotTransitioning = () => { UiFinder.notExists(SugarBody.body(), '.tox-pop.tox-pop--transition'); }; const pTestPositionWhileScrolling = (scenario: Scenario) => async () => { const editor = hook.editor(); editor.setContent( '

' + '

' + '

' + `

${scenario.content}

` + '

' + '

' + '

' ); editor.focus(); scrollTo(editor, 0, 200); TinySelections.setCursor(editor, scenario.cursor.elementPath, scenario.cursor.offset); await UiFinder.pWaitForVisible('Waiting for toolbar to appear above content', SugarBody.body(), topSelector + scenario.classes); await pAssertPosition('bottom', 223); // Position the link at the top of the viewport, just below the toolbar scrollTo(editor, 0, 300); await UiFinder.pWaitForVisible('Waiting for toolbar to appear below content', SugarBody.body(), bottomSelector + scenario.classes); await pAssertPosition('top', -277); // Position the behind the menu/toolbar and check the context toolbar is hidden scrollTo(editor, 0, 400); await pWaitForToolbarHidden(); // Position the element back into view scrollTo(editor, 0, 200); await UiFinder.pWaitForVisible('Waiting for toolbar to appear above content', SugarBody.body(), topSelector + scenario.classes); await pAssertPosition('bottom', 223); // Position the element off the top of the screen and check the context toolbar is hidden scrollTo(editor, 0, 600); await pWaitForToolbarHidden(); }; context('Context toolbar selection position while scrolling', () => { // north/south it('north to south to hidden', pTestPositionWhileScrolling({ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit link', cursor: { elementPath: [ 3, 1, 0 ], offset: 1 }, classes: '' })); // northeast/southeast it('northeast to southeast to hidden', pTestPositionWhileScrolling({ content: 'link Lorem ipsum dolor sit amet, consectetur adipiscing elit', cursor: { elementPath: [ 3, 0, 0 ], offset: 1 }, classes: '.tox-pop--align-left' })); // northeast/southeast it('northwest to southwest to hidden', pTestPositionWhileScrolling({ content: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit link', contentStyles: 'text-align: right', cursor: { elementPath: [ 3, 1, 0 ], offset: 4 }, classes: '.tox-pop--align-right' })); }); it('TBA: Context toolbar falls back to positioning inside the content', async () => { const editor = hook.editor(); editor.setContent(`

`); TinySelections.select(editor, 'img', []); await UiFinder.pWaitForVisible('Waiting for toolbar to appear to top inside content', SugarBody.body(), topInsetSelector); await pAssertPosition('top', -312); TinySelections.setCursor(editor, [ 0 ], 1); TinyContentActions.keystroke(editor, Keys.enter()); TinyContentActions.keystroke(editor, Keys.enter()); TinyContentActions.keystroke(editor, Keys.enter()); editor.nodeChanged(); TinySelections.select(editor, 'img', []); await UiFinder.pWaitForVisible('Waiting for toolbar to appear below content', SugarBody.body(), bottomSelector); await pAssertPosition('top', -86); }); it(`TINY-4586: Line context toolbar remains inside iframe container and doesn't overlap the header`, async () => { const editor = hook.editor(); editor.setContent( '

' + '
' + '

' ); scrollTo(editor, 0, 225); TinySelections.setCursor(editor, [ 1, 0 ], 0); // Middle await UiFinder.pWaitForVisible('Waiting for toolbar to appear', SugarBody.body(), rightSelector); await pAssertPosition('top', -145); // Scroll so div is below the status bar scrollTo(editor, 0, 50); await pWaitForToolbarHidden(); // Bottom scrollTo(editor, 0, 105); await UiFinder.pWaitForVisible('Waiting for toolbar to appear', SugarBody.body(), rightSelector); await pAssertPosition('top', -44); // Scroll so div is behind header scrollTo(editor, 0, 450); await pWaitForToolbarHidden(); // Top scrollTo(editor, 0, 400); await UiFinder.pWaitForVisible('Waiting for toolbar to appear', SugarBody.body(), rightSelector); await pAssertPosition('top', -314); }); it('TINY-4023: Context toolbar is visible in fullscreen mode', async () => { const editor = hook.editor(); TinyUiActions.clickOnToolbar(editor, fullscreenButtonSelector); TinyUiActions.pWaitForUi(editor, fullscreenSelector); editor.setContent(`

`); TinySelections.select(editor, 'img', []); await UiFinder.pWaitForVisible('Waiting for toolbar to appear to below the content', SugarBody.body(), bottomSelector); await pAssertFullscreenPosition('top', 478); await UiFinder.pWaitForVisible('Check toolbar is still visible', SugarBody.body(), bottomSelector); TinyUiActions.clickOnToolbar(editor, fullscreenButtonSelector); await Waiter.pTryUntil('Wait for fullscreen to turn off', () => UiFinder.notExists(SugarBody.body(), fullscreenSelector)); }); it('TBA: Context toolbar should hide when scrolled out of view', async () => { const editor = hook.editor(); Css.set(TinyDom.container(editor), 'margin-bottom', '5000px'); editor.setContent('

link

'); TinySelections.setCursor(editor, [ 0, 0, 0 ], 1); await UiFinder.pWaitForVisible('Waiting for toolbar to appear below content', SugarBody.body(), bottomSelector); await Waiter.pWait(250); // TODO: Find out why Safari fails without this wait window.scrollTo(0, 2000); await UiFinder.pWaitForHidden('Waiting for toolbar to be hidden', SugarBody.body(), '.tox-pop'); Css.remove(TinyDom.container(editor), 'margin-bottom'); }); it('TINY-7739: Selection context toolbar should not escape the bounds or use an inset layout', async () => { const editor = hook.editor(); editor.setContent('

text

'); TinySelections.setSelection(editor, [ 3, 0 ], 1, [ 3, 0 ], 3); // Place the selected text right at the bottom of the editor so only ~1px of the selection is visible scrollTo(editor, 0, 86); await UiFinder.pWaitForVisible('Waiting for toolbar to appear above the content', SugarBody.body(), topSelector); // Moving 2px more the selected text is now offscreen so the context toolbar should hide scrollTo(editor, 0, 84); await UiFinder.pWaitForHidden('Waiting for toolbar to be hidden', SugarBody.body(), '.tox-pop'); }); it('TINY-7545: Context toolbar preserves the previous position when scrolling top to bottom and back', async () => { const editor = hook.editor(); editor.setContent( '

' + `

` + '

' ); TinySelections.select(editor, 'img', []); await UiFinder.pWaitForVisible('Waiting for toolbar to appear at the top outside content', SugarBody.body(), topSelector); await pAssertPosition('bottom', 104); scrollTo(editor, 0, 400); assertToolbarIsNotTransitioning(); await UiFinder.pWaitForVisible('Waiting for toolbar to appear at the top inside content', SugarBody.body(), topInsetSelector); await pAssertPosition('top', -312); scrollTo(editor, 0, 700); await pAssertToolbarIsTransitioning(); await UiFinder.pWaitForVisible('Waiting for toolbar to appear at the bottom outside content', SugarBody.body(), bottomSelector); await pAssertPosition('top', -242); scrollTo(editor, 0, 400); assertToolbarIsNotTransitioning(); await UiFinder.pWaitForVisible('Waiting for toolbar to appear at the bottom inside content', SugarBody.body(), bottomInsetSelector); await pAssertPosition('bottom', 34); scrollTo(editor, 0, 0); await pAssertToolbarIsTransitioning(); await UiFinder.pWaitForVisible('Waiting for toolbar to appear at the bottom inside content', SugarBody.body(), topSelector); await pAssertPosition('bottom', 104); }); it('TINY-7545: Moving from different anchor points should reset the placement', async () => { const editor = hook.editor(); editor.setContent( '

' + '
' + `

` + '

' ); // Select the div and make sure the toolbar shows to the right TinySelections.setCursor(editor, [ 1, 0 ], 0); await UiFinder.pWaitForVisible('Waiting for toolbar to appear', SugarBody.body(), rightSelector); // Scroll to and select the image, then make sure the toolbar appears at the top scrollTo(editor, 0, 400); TinySelections.select(editor, 'img', []); await UiFinder.pWaitForVisible('Waiting for toolbar to appear at the top inside content', SugarBody.body(), topInsetSelector); }); it('TINY-7192: Toolbar should flip to the opposite position when the selection overlaps', async () => { const editor = hook.editor(); editor.setContent( '' + '' + Arr.range(10, (i) => ``).join('') + '' + '
Cell ${i + 1}
' ); scrollTo(editor, 0, 0); // Select the 1st row in the table, then make sure the toolbar appears at the bottom due to the overlap TinySelections.setCursor(editor, [ 0, 0, 0, 0, 0 ], 0); await UiFinder.pWaitForVisible('Waiting for toolbar to appear at the bottom inside content', SugarBody.body(), bottomInsetSelector); // select the 4th row (middle of the viewport) and ensure it doesn't move TinySelections.setCursor(editor, [ 0, 0, 3, 0, 0 ], 0); await pAssertHasNotFlipped(bottomInsetSelector); // select the 8th row (bottom of the viewport) and ensure it goes back to the top due to the overlap TinySelections.setCursor(editor, [ 0, 0, 7, 0, 0 ], 0); await UiFinder.pWaitForVisible('Waiting for toolbar to appear at the top inside content', SugarBody.body(), topInsetSelector); // select the 4th row (middle of the viewport) and again ensure it doesn't move TinySelections.setCursor(editor, [ 0, 0, 3, 0, 0 ], 0); await pAssertHasNotFlipped(topInsetSelector); // Select the 1st row again to make sure it flips back to the bottom TinySelections.setCursor(editor, [ 0, 0, 0, 0, 0 ], 0); await UiFinder.pWaitForVisible('Waiting for toolbar to appear back at the bottom inside content', SugarBody.body(), bottomInsetSelector); }); it('TINY-7192: It should not flip when the selection overlaps and the user is scrolling', async () => { const editor = hook.editor(); editor.setContent( '

' + '' + '' + Arr.range(10, (i) => ``).join('') + '' + '
Cell ${i + 1}
' ); const pWaitForNoNodeChanges = () => TinyContentActions.pWaitForEventToStopFiring( editor, 'nodeChange', { delay: 20, timeout: 1000 } ); // Select the 1st row in the table, then make sure the toolbar appears above the table scrollTo(editor, 0, 0); TinySelections.setCursor(editor, [ 1, 0, 0, 0, 0 ], 0); await UiFinder.pWaitForVisible('Waiting for toolbar to appear at the top', SugarBody.body(), topSelector); // Need to wait for all NodeChange events to finish firing await pWaitForNoNodeChanges(); // Scroll the 1st row to the top and make sure the toolbar doesn't flip to the bottom scrollTo(editor, 0, 220); await UiFinder.pWaitForVisible('Waiting for toolbar to appear at the top inside content', SugarBody.body(), topInsetSelector); // Select the 4th row TinySelections.setCursor(editor, [ 1, 0, 3, 0, 0 ], 0); // Need to wait for all NodeChange events to finish firing await pWaitForNoNodeChanges(); // Scroll the 4th row so it is now at the top and make sure the toolbar hasn't moved scrollTo(editor, 0, 220 + 3 * 22); await pAssertHasNotFlipped(topInsetSelector); }); });