import { AlloyComponent, AlloySpec, Behaviour, Blocking, Composing, DomFactory, Replacing, SketchSpec } from '@ephox/alloy'; import { Arr, Cell, Optional, Singleton, Type } from '@ephox/katamari'; import { Attribute, Class, Css, Focus, SugarElement, SugarNode } from '@ephox/sugar'; import { EventUtilsEvent } from 'tinymce/core/api/dom/EventUtils'; import Editor from 'tinymce/core/api/Editor'; import { ExecCommandEvent } from 'tinymce/core/api/EventTypes'; import Delay from 'tinymce/core/api/util/Delay'; import { EditorEvent } from 'tinymce/core/api/util/EventDispatcher'; import * as Events from '../../api/Events'; import { UiFactoryBackstageProviders, UiFactoryBackstageShared } from '../../backstage/Backstage'; const getBusySpec = (providerBackstage: UiFactoryBackstageProviders) => (_root: AlloyComponent, _behaviours: Behaviour.AlloyBehaviourRecord): AlloySpec => ({ dom: { tag: 'div', attributes: { 'aria-label': providerBackstage.translate('Loading...'), 'tabindex': '0' }, classes: [ 'tox-throbber__busy-spinner' ] }, components: [ { dom: DomFactory.fromHtml('
') } ], }); const focusBusyComponent = (throbber: AlloyComponent): void => Composing.getCurrent(throbber).each((comp) => Focus.focus(comp.element)); // When the throbber is enabled, prevent the iframe from being part of the sequential keyboard navigation when Tabbing // TODO: TINY-7500 Only works for iframe mode at this stage const toggleEditorTabIndex = (editor: Editor, state: boolean) => { const tabIndexAttr = 'tabindex'; const dataTabIndexAttr = `data-mce-${tabIndexAttr}`; Optional.from(editor.iframeElement) .map(SugarElement.fromDom) .each((iframe) => { if (state) { Attribute.getOpt(iframe, tabIndexAttr).each((tabIndex) => Attribute.set(iframe, dataTabIndexAttr, tabIndex)); Attribute.set(iframe, tabIndexAttr, -1); } else { Attribute.remove(iframe, tabIndexAttr); Attribute.getOpt(iframe, dataTabIndexAttr).each((tabIndex) => { Attribute.set(iframe, tabIndexAttr, tabIndex); Attribute.remove(iframe, dataTabIndexAttr); }); } }); }; /* * If the throbber has been toggled on, only focus the throbber if the editor had focus as we don't to steal focus if it is on an input or dialog * If the throbber has been toggled off, only put focus back on the editor if the throbber had focus. * The next logical focus transition from the throbber is to put it back on the editor */ const toggleThrobber = (editor: Editor, comp: AlloyComponent, state: boolean, providerBackstage: UiFactoryBackstageProviders) => { const element = comp.element; toggleEditorTabIndex(editor, state); if (state) { Blocking.block(comp, getBusySpec(providerBackstage)); Css.remove(element, 'display'); Attribute.remove(element, 'aria-hidden'); if (editor.hasFocus()) { focusBusyComponent(comp); } } else { // Get the focus of the busy component before it is removed from the DOM const throbberFocus = Composing.getCurrent(comp).exists((busyComp) => Focus.hasFocus(busyComp.element)); Blocking.unblock(comp); Css.set(element, 'display', 'none'); Attribute.set(element, 'aria-hidden', 'true'); if (throbberFocus) { editor.focus(); } } }; const renderThrobber = (spec: SketchSpec): AlloySpec => ({ uid: spec.uid, dom: { tag: 'div', attributes: { 'aria-hidden': 'true' }, classes: [ 'tox-throbber' ], styles: { display: 'none' } }, behaviours: Behaviour.derive([ Replacing.config({}), Blocking.config({ focus: false }), Composing.config({ find: (comp) => Arr.head(comp.components()) }) ]), components: [ ] }); const isFocusEvent = (event: EditorEvent | EventUtilsEvent): event is EventUtilsEvent => event.type === 'focusin'; const isPasteBinTarget = (event: EditorEvent | EventUtilsEvent) => { if (isFocusEvent(event)) { const node = event.composed ? Arr.head(event.composedPath()) : Optional.from(event.target); return node .map(SugarElement.fromDom) .filter(SugarNode.isElement) .exists((targetElm) => Class.has(targetElm, 'mce-pastebin')); } else { return false; } }; const setup = (editor: Editor, lazyThrobber: () => AlloyComponent, sharedBackstage: UiFactoryBackstageShared): void => { const throbberState = Cell(false); const timer = Singleton.value(); const stealFocus = (e: EditorEvent | EventUtilsEvent) => { if (throbberState.get() && !isPasteBinTarget(e)) { e.preventDefault(); focusBusyComponent(lazyThrobber()); editor.editorManager.setActive(editor); } }; // TODO: TINY-7500 Only worrying about iframe mode at this stage since inline mode has a number of other issues if (!editor.inline) { editor.on('PreInit', () => { // Cover focus when the editor is focused natively editor.dom.bind(editor.getWin(), 'focusin', stealFocus); // Cover stealing focus when editor.focus() is called editor.on('BeforeExecCommand', (e) => { // If skipFocus is specified as true in the command, don't focus the Throbber if (e.command.toLowerCase() === 'mcefocus' && e.value !== true) { stealFocus(e); } }); }); } const toggle = (state: boolean) => { if (state !== throbberState.get()) { throbberState.set(state); toggleThrobber(editor, lazyThrobber(), state, sharedBackstage.providers); Events.fireAfterProgressState(editor, state); } }; editor.on('ProgressState', (e) => { timer.on(clearTimeout); if (Type.isNumber(e.time)) { const timerId = Delay.setEditorTimeout(editor, () => toggle(e.state), e.time); timer.set(timerId); } else { toggle(e.state); timer.clear(); } }); }; export { renderThrobber, setup };