diff --git a/README.rst b/README.rst
index e3506f60ea..e277469fda 100644
--- a/README.rst
+++ b/README.rst
@@ -167,6 +167,32 @@ Feature: New React XBlock Editors
New React editors for the HTML, Video, and Problem XBlocks are provided here and are rendered by this MFE instead of by the XBlock's authoring view.
+Configuration
+-------------
+
+The rich text editor is `TinyMCE
testString
'; test('returns answers', () => { - window.tinymce.editors = { 'answer-A': { getContent } }; + mockEditors({ 'answer-A': { getContent } }); const editorObject = hooks.fetchEditorContent({ format: '' }); expect(editorObject).toEqual({ answers: { A: 'testString
' }, hints: [] }); }); test('returns hints', () => { - window.tinymce.editors = { 'hint-0': { getContent } }; + mockEditors({ 'hint-0': { getContent } }); const editorObject = hooks.fetchEditorContent({ format: '' }); expect(editorObject).toEqual({ hints: ['testString
'] }); }); test('returns question', () => { - window.tinymce.editors = { question: { getContent } }; + mockEditors({ question: { getContent } }); const editorObject = hooks.fetchEditorContent({ format: '' }); expect(editorObject).toEqual({ question: 'testString
', hints: [] }); }); test('returns selectedFeedback', () => { - window.tinymce.editors = { 'selectedFeedback-A': { getContent } }; + mockEditors({ 'selectedFeedback-A': { getContent } }); const editorObject = hooks.fetchEditorContent({ format: '' }); expect(editorObject).toEqual({ selectedFeedback: { A: 'testString
' }, hints: [] }); }); test('returns unselectedFeedback', () => { - window.tinymce.editors = { 'unselectedFeedback-A': { getContent } }; + mockEditors({ 'unselectedFeedback-A': { getContent } }); const editorObject = hooks.fetchEditorContent({ format: '' }); expect(editorObject).toEqual({ unselectedFeedback: { A: 'testString
' }, hints: [] }); }); test('returns groupFeedback', () => { - window.tinymce.editors = { 'groupFeedback-0': { getContent } }; + mockEditors({ 'groupFeedback-0': { getContent } }); const editorObject = hooks.fetchEditorContent({ format: '' }); expect(editorObject).toEqual({ groupFeedback: { 0: 'testString
' }, hints: [] }); }); test('returns groupFeedback', () => { - window.tinymce.editors = {}; + mockEditors({}); const editorObject = hooks.fetchEditorContent({ format: '' }); expect(editorObject).toEqual({ hints: [] }); }); @@ -219,10 +224,10 @@ describe('EditProblemView hooks parseState', () => { jest.clearAllMocks(); }); it('should call openSaveWarningModal for single select problem with empty title', () => { - window.tinymce.editors = { + mockEditors({ 'answer-A': { getContent: () => '' }, 'answer-B': { getContent: () => 'sOmevALUe' }, - }; + }); const expected = hooks.checkForNoAnswers({ openSaveWarningModal, problem: { @@ -234,7 +239,7 @@ describe('EditProblemView hooks parseState', () => { expect(expected).toEqual(true); }); it('returns true for single select with title but no correct answer', () => { - window.tinymce.editors = { 'answer-A': { getContent: () => 'sOmevALUe' } }; + mockEditors({ 'answer-A': { getContent: () => 'sOmevALUe' } }); const expected = hooks.checkForNoAnswers({ openSaveWarningModal, problem: { @@ -246,7 +251,7 @@ describe('EditProblemView hooks parseState', () => { expect(expected).toEqual(true); }); it('returns true for single select with title and correct answer', () => { - window.tinymce.editors = { 'answer-A': { getContent: () => 'sOmevALUe' } }; + mockEditors({ 'answer-A': { getContent: () => 'sOmevALUe' } }); const expected = hooks.checkForNoAnswers({ openSaveWarningModal, problem: { @@ -417,13 +422,13 @@ describe('checkIfEditorsDirty', () => { test('should return false if none of editors are dirty', () => { windowSpy.mockImplementation(() => ({ tinymce: { - editors: { - some_id: { isNotDirty: true }, - some_id2: { isNotDirty: true }, - some_id3: { isNotDirty: true }, - some_id4: { isNotDirty: true }, - some_id5: { isNotDirty: true }, - }, + get: () => [ + { id: 'some_id', isDirty: () => false }, + { id: 'some_id2', isDirty: () => false }, + { id: 'some_id3', isDirty: () => false }, + { id: 'some_id4', isDirty: () => false }, + { id: 'some_id5', isDirty: () => false }, + ], }, })); expect(hooks.checkIfEditorsDirty()).toEqual(false); @@ -431,13 +436,13 @@ describe('checkIfEditorsDirty', () => { test('should return true if any editor is dirty', () => { windowSpy.mockImplementation(() => ({ tinymce: { - editors: { - some_id: { isNotDirty: true }, - some_id2: { isNotDirty: true }, - some_id3: { isNotDirty: false }, - some_id4: { isNotDirty: true }, - some_id5: { isNotDirty: false }, - }, + get: () => [ + { id: 'some_id', isDirty: () => false }, + { id: 'some_id2', isDirty: () => false }, + { id: 'some_id3', isDirty: () => true }, + { id: 'some_id4', isDirty: () => false }, + { id: 'some_id5', isDirty: () => true }, + ], }, })); expect(hooks.checkIfEditorsDirty()).toEqual(true); diff --git a/src/editors/data/constants/tinyMCE.js b/src/editors/data/constants/tinyMCE.js index 2e8a3f170f..4b15421b79 100644 --- a/src/editors/data/constants/tinyMCE.js +++ b/src/editors/data/constants/tinyMCE.js @@ -33,7 +33,7 @@ export const buttons = StrictDict({ vert: 'flipv', horiz: 'fliph', }), - formatSelect: 'formatSelect', + formatSelect: 'blocks', // block format dropdown (was 'formatselect' before TinyMCE 6) hr: 'hr', imageUploadButton: 'imageuploadbutton', indent: 'indent', @@ -52,8 +52,8 @@ export const buttons = StrictDict({ table: 'table', undo: 'undo', underline: 'underline', - a11ycheck: 'a11ycheck', embediframe: 'embediframe', + a11ycheck: 'a11ycheck', // provided by the premium 'a11ychecker' plugin, if an operator has configured it }); export const plugins = listKeyStore([ @@ -62,15 +62,11 @@ export const plugins = listKeyStore([ 'codesample', 'emoticons', 'table', - 'hr', 'charmap', 'code', 'autoresize', 'image', - 'imagetools', 'quickbars', - 'a11ychecker', - 'powerpaste', 'embediframe', ]); diff --git a/src/editors/data/redux/problem/reducers.test.ts b/src/editors/data/redux/problem/reducers.test.ts index 21a3c0a34b..45fe7bbb68 100644 --- a/src/editors/data/redux/problem/reducers.test.ts +++ b/src/editors/data/redux/problem/reducers.test.ts @@ -173,7 +173,7 @@ describe('problem reducer', () => { it('sets a default when deleting the last answer', () => { windowSpy.mockImplementation(() => ({ tinymce: { - editors: 'mock-editors', + get: () => null, }, })); const payload = { @@ -205,7 +205,7 @@ describe('problem reducer', () => { it('sets answers and correctAnswerCount', () => { windowSpy.mockImplementation(() => ({ tinymce: { - editors: 'mock-editors', + get: () => null, }, })); const payload = { @@ -241,10 +241,11 @@ describe('problem reducer', () => { const setContent = jest.fn(); windowSpy.mockImplementation(() => ({ tinymce: { - editors: { - 'answer-A': { setContent }, - 'answer-B': { setContent }, - }, + get: (id: string) => + ({ + 'answer-A': { setContent }, + 'answer-B': { setContent }, + })[id], }, })); const payload = { @@ -282,10 +283,11 @@ describe('problem reducer', () => { it('sets selectedFeedback and unselectedFeedback with editorState', () => { windowSpy.mockImplementation(() => ({ tinymce: { - editors: { - 'answer-A': 'mockEditor', - 'answer-B': 'mockEditor', - }, + get: (id: string) => + ({ + 'answer-A': 'mockEditor', + 'answer-B': 'mockEditor', + })[id], }, })); const payload = { @@ -323,14 +325,15 @@ describe('problem reducer', () => { const setContent = jest.fn(); windowSpy.mockImplementation(() => ({ tinymce: { - editors: { - 'answer-A': { setContent }, - 'answer-B': { setContent }, - 'selectedFeedback-A': { setContent }, - 'selectedFeedback-B': { setContent }, - 'unselectedFeedback-A': { setContent }, - 'unselectedFeedback-B': { setContent }, - }, + get: (id: string) => + ({ + 'answer-A': { setContent }, + 'answer-B': { setContent }, + 'selectedFeedback-A': { setContent }, + 'selectedFeedback-B': { setContent }, + 'unselectedFeedback-A': { setContent }, + 'unselectedFeedback-B': { setContent }, + })[id], }, })); const payload = { @@ -354,15 +357,15 @@ describe('problem reducer', () => { }, actions.deleteAnswer(payload), ); - expect((window as any).tinymce.editors['answer-A'].setContent).toHaveBeenCalled(); - expect((window as any).tinymce.editors['answer-A'].setContent).toHaveBeenCalledWith('editorAnsB'); - expect((window as any).tinymce.editors['selectedFeedback-A'].setContent).toHaveBeenCalledWith('editSelFB'); - expect((window as any).tinymce.editors['unselectedFeedback-A'].setContent).toHaveBeenCalledWith('editUnselFB'); + expect((window as any).tinymce.get('answer-A').setContent).toHaveBeenCalled(); + expect((window as any).tinymce.get('answer-A').setContent).toHaveBeenCalledWith('editorAnsB'); + expect((window as any).tinymce.get('selectedFeedback-A').setContent).toHaveBeenCalledWith('editSelFB'); + expect((window as any).tinymce.get('unselectedFeedback-A').setContent).toHaveBeenCalledWith('editUnselFB'); }); it('sets groupFeedbackList by removing the checked item in the groupFeedback', () => { windowSpy.mockImplementation(() => ({ tinymce: { - editors: 'mock-editors', + get: () => null, }, })); const payload = { @@ -413,7 +416,7 @@ describe('problem reducer', () => { it('if you delete an answer range, it will be replaced with a blank answer', () => { windowSpy.mockImplementation(() => ({ tinymce: { - editors: 'mock-editors', + get: () => null, }, })); const payload = { diff --git a/src/editors/data/redux/problem/reducers.ts b/src/editors/data/redux/problem/reducers.ts index 4afceb5ac0..a367ae2bf5 100644 --- a/src/editors/data/redux/problem/reducers.ts +++ b/src/editors/data/redux/problem/reducers.ts @@ -88,7 +88,7 @@ const problem = createSlice({ }, deleteAnswer: (state, { payload }) => { const { id, correct, editorState } = payload; - const EditorsArray = (window as any).tinymce.editors; + const getEditor = (editorId: string) => (window as any).tinymce.get(editorId); if (state.answers.length === 1) { return { ...state, @@ -120,18 +120,12 @@ const problem = createSlice({ ...newAnswer, title: editorState.answers[answer.id], }; - if (EditorsArray[`answer-${newId}`]) { - EditorsArray[`answer-${newId}`].setContent(newAnswer.title ?? ''); - } + getEditor(`answer-${newId}`)?.setContent(newAnswer.title ?? ''); } // Note: The following assumes selectedFeedback and unselectedFeedback is using ExpandedTextArea // Content only needs to be set here when the 'next' feedback fields are shown. - if (EditorsArray[`selectedFeedback-${newId}`]) { - EditorsArray[`selectedFeedback-${newId}`].setContent(newAnswer.selectedFeedback ?? ''); - } - if (EditorsArray[`unselectedFeedback-${newId}`]) { - EditorsArray[`unselectedFeedback-${newId}`].setContent(newAnswer.unselectedFeedback ?? ''); - } + getEditor(`selectedFeedback-${newId}`)?.setContent(newAnswer.selectedFeedback ?? ''); + getEditor(`unselectedFeedback-${newId}`)?.setContent(newAnswer.unselectedFeedback ?? ''); return newAnswer; }); const groupFeedbackList = state.groupFeedbackList.map(feedback => { diff --git a/src/editors/setupEditorTest.js b/src/editors/setupEditorTest.js index 85c5885af8..3eefa23155 100644 --- a/src/editors/setupEditorTest.js +++ b/src/editors/setupEditorTest.js @@ -125,8 +125,3 @@ jest.mock('react-redux', () => { useSelector: jest.fn((selector) => ({ useSelector: selector })), }; }); - -// Mock the plugins repo so jest will stop complaining about ES6 syntax -jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ - a11ycheckerCss: '', -})); diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.test.js b/src/editors/sharedComponents/TinyMceWidget/hooks.test.js index 9a99d05d02..efb5b485ec 100644 --- a/src/editors/sharedComponents/TinyMceWidget/hooks.test.js +++ b/src/editors/sharedComponents/TinyMceWidget/hooks.test.js @@ -293,13 +293,10 @@ describe('TinyMceEditor hooks', () => { editorType: props.editorType, }; expect(output.init.plugins).toEqual(pluginConfig(pluginProps).plugins); - expect(output.init.imagetools_toolbar).toEqual(pluginConfig(pluginProps).imageToolbar); expect(output.init.toolbar).toEqual(pluginConfig(pluginProps).toolbar); Object.keys(pluginConfig(pluginProps).config).forEach(key => { expect(output.init[key]).toEqual(pluginConfig(pluginProps).config[key]); }); - // Commented out as we investigate whether this is only needed for image proxy - // expect(output.init.imagetools_cors_hosts).toMatchObject([props.lmsEndpointUrl]); }); }); describe('text editor plugins and toolbar for content library', () => { @@ -310,7 +307,6 @@ describe('TinyMceEditor hooks', () => { }; output = module.editorConfig({ ...props, isLibrary: true }); expect(output.init.plugins).toEqual(pluginConfig(pluginProps).plugins); - expect(output.init.imagetools_toolbar).toEqual(pluginConfig(pluginProps).imageToolbar); expect(output.init.toolbar).toEqual(pluginConfig(pluginProps).toolbar); expect(output.init.quickbars_insert_toolbar).toEqual(pluginConfig(pluginProps).quickbarsInsertToolbar); expect(output.init.quickbars_selection_toolbar).toEqual(pluginConfig(pluginProps).quickbarsSelectionToolbar); @@ -332,7 +328,6 @@ describe('TinyMceEditor hooks', () => { placeholder: 'soMEtExT', }); expect(output.init.plugins).toEqual(pluginConfig(pluginProps).plugins); - expect(output.init.imagetools_toolbar).toEqual(pluginConfig(pluginProps).imageToolbar); expect(output.init.toolbar).toEqual(pluginConfig(pluginProps).toolbar); expect(output.init.quickbars_insert_toolbar).toEqual(pluginConfig(pluginProps).quickbarsInsertToolbar); expect(output.init.quickbars_selection_toolbar).toEqual(pluginConfig(pluginProps).quickbarsSelectionToolbar); @@ -355,7 +350,6 @@ describe('TinyMceEditor hooks', () => { placeholder: 'soMEtExT', }); expect(output.init.plugins).toEqual(pluginConfig(pluginProps).plugins); - expect(output.init.imagetools_toolbar).toEqual(pluginConfig(pluginProps).imageToolbar); expect(output.init.toolbar).toEqual(pluginConfig(pluginProps).toolbar); expect(output.init.quickbars_insert_toolbar).toEqual(pluginConfig(pluginProps).quickbarsInsertToolbar); expect(output.init.quickbars_selection_toolbar).toEqual(pluginConfig(pluginProps).quickbarsSelectionToolbar); @@ -389,6 +383,7 @@ describe('TinyMceEditor hooks', () => { images: mockImagesRef, lmsEndpointUrl: props.lmsEndpointUrl, learningContextId: props.learningContextId, + imageToolbar: pluginConfig({ editorType: props.editorType }).imageToolbar, }), ); }); diff --git a/src/editors/sharedComponents/TinyMceWidget/hooks.ts b/src/editors/sharedComponents/TinyMceWidget/hooks.ts index edf14a262f..e85afabd84 100644 --- a/src/editors/sharedComponents/TinyMceWidget/hooks.ts +++ b/src/editors/sharedComponents/TinyMceWidget/hooks.ts @@ -6,7 +6,6 @@ import { } from 'react'; import { getConfig } from '@edx/frontend-platform'; import { getLocale, isRtl } from '@edx/frontend-platform/i18n'; -import { a11ycheckerCss } from 'frontend-components-tinymce-advanced-plugins'; import { isEmpty } from 'lodash'; import tinyMCEStyles from '../../data/constants/tinyMCEStyles'; import { StrictDict } from '../../utils'; @@ -305,6 +304,7 @@ export const setupCustomBehavior = ({ setImage, lmsEndpointUrl, learningContextId, + imageToolbar, }) => (editor) => { // image upload button @@ -324,6 +324,15 @@ export const setupCustomBehavior = ({ openImgModal, }), }); + // toolbar shown when an image is selected (the TinyMCE 5 "imagetools" plugin used to provide this) + if (imageToolbar) { + editor.ui.registry.addContextToolbar('imageSettings', { + predicate: (node) => node.nodeName === 'IMG' && !node.hasAttribute('data-mce-object'), + items: imageToolbar, + position: 'node', + scope: 'node', + }); + } // overriding the code plugin's icon with 'HTML' text editor.ui.registry.addButton(tinyMCE.buttons.code, { text: 'HTML', @@ -414,9 +423,6 @@ export const setupCustomBehavior = ({ editor.on('ObjectResized', getImageResizeHandler({ editor, imagesRef: images, setImage })); }; -// imagetools_cors_hosts needs a protocol-sanatized url -export const removeProtocolFromUrl = (url) => url.replace(/^https?:\/\//, ''); - export const editorConfig = ({ editorType, setEditorRef, @@ -436,7 +442,6 @@ export const editorConfig = ({ enableImageUpload, }) => { const lmsEndpointUrl = getConfig().LMS_BASE_URL; - const studioEndpointUrl = getConfig().STUDIO_BASE_URL; const baseURL = staticRootUrl || lmsEndpointUrl; const { @@ -460,14 +465,12 @@ export const editorConfig = ({ ...config, skin: false, content_css: false, - content_style: tinyMCEStyles + a11ycheckerCss, + content_style: tinyMCEStyles, min_height: minHeight, max_height: maxHeight, contextmenu: 'link table', directionality: isLocaleRtl ? 'rtl' as const : 'ltr' as const, document_base_url: baseURL, - imagetools_cors_hosts: [removeProtocolFromUrl(lmsEndpointUrl), removeProtocolFromUrl(studioEndpointUrl)], - imagetools_toolbar: imageToolbar, formats: { label: { inline: 'label' } }, setup: setupCustomBehavior({ editorType, @@ -480,6 +483,7 @@ export const editorConfig = ({ content, images, learningContextId, + imageToolbar, }), quickbars_insert_toolbar: quickbarsInsertToolbar, quickbars_selection_toolbar: quickbarsSelectionToolbar, diff --git a/src/editors/sharedComponents/TinyMceWidget/index.scss b/src/editors/sharedComponents/TinyMceWidget/index.scss new file mode 100644 index 0000000000..3b3aa23a77 --- /dev/null +++ b/src/editors/sharedComponents/TinyMceWidget/index.scss @@ -0,0 +1,26 @@ +// TInyMCE v7's oxide skin added a drop shadow and changed the toolbar icon +// size/spacing. This override restores the same look as v5, which fits better +// into the Authoring MFE UI. +// Selectors are identical to the rules in the oxide skin, +// so these rules win by being imported after the oxide skin rules. +.tox:not(.tox-tinymce-inline) .tox-editor-header { + box-shadow: none; + padding: 0; + border-bottom: 1px solid #E3E3E3; +} + +.tox .tox-toolbar__group { + padding: 0 4px; +} + +.tox:not([dir="rtl"]) .tox-toolbar__group:not(:last-of-type) { + border-right: 1px solid #E3E3E3; +} + +.tox .tox-tbtn { + margin: 2px 0 3px; +} + +.tox-tinymce { + border-radius: 0; +} \ No newline at end of file diff --git a/src/editors/sharedComponents/TinyMceWidget/index.tsx b/src/editors/sharedComponents/TinyMceWidget/index.tsx index 66bef9832e..3de0e84b6f 100644 --- a/src/editors/sharedComponents/TinyMceWidget/index.tsx +++ b/src/editors/sharedComponents/TinyMceWidget/index.tsx @@ -5,9 +5,21 @@ import { getConfig } from '@edx/frontend-platform'; import 'tinymce'; import 'tinymce/themes/silver'; -import 'tinymce/skins/ui/oxide/skin.css'; +import 'tinymce/models/dom'; import 'tinymce/icons/default'; -import 'frontend-components-tinymce-advanced-plugins'; +import 'tinymce/skins/ui/oxide/skin.css'; +import './index.scss'; +import 'tinymce/plugins/autoresize'; +import 'tinymce/plugins/charmap'; +import 'tinymce/plugins/code'; +import 'tinymce/plugins/codesample'; +import 'tinymce/plugins/emoticons'; +import 'tinymce/plugins/emoticons/js/emojis'; +import 'tinymce/plugins/image'; +import 'tinymce/plugins/link'; +import 'tinymce/plugins/lists'; +import 'tinymce/plugins/quickbars'; +import 'tinymce/plugins/table'; import ImageUploadModal from '../ImageUploadModal'; import SourceCodeModal from '../SourceCodeModal'; @@ -79,6 +91,10 @@ const TinyMceWidget = ({ id={id} disabled={disabled} onEditorChange={onChange} + // TinyMCE 7 is GPL-2.0-or-later licensed. Declaring the open source license key + // silences the "running in evaluation mode" console warning. Operators using premium + // plugins must provide their commercial license key instead. + licenseKey={getConfig().TINYMCE_LICENSE_KEY || 'gpl'} { // @ts-ignore FIXME: this will have type errors until `editorConfig` gets proper type definitions. ...hooks.editorConfig({ diff --git a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js index 0d3bee9ee3..45f18df503 100644 --- a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js +++ b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.js @@ -1,11 +1,34 @@ +import { getConfig } from '@edx/frontend-platform'; import { StrictDict } from '../../utils'; import { buttons, plugins } from '../../data/constants/tinyMCE'; const mapToolbars = toolbars => toolbars.map(toolbar => toolbar.join(' ')).join(' | '); +/** + * Operators who have licensed TinyMCE premium plugins (e.g. "a11ychecker" or "powerpaste") can load them by setting + * `TINYMCE_EXTERNAL_PLUGINS` (plugin name -> plugin script URL) in `env.config.jsx`, along with `TINYMCE_LICENSE_KEY`. + * Any options those plugins need can be passed via `TINYMCE_PLUGIN_OPTIONS`. + */ +export const getExternalPluginConfig = () => { + const externalPlugins = getConfig().TINYMCE_EXTERNAL_PLUGINS || {}; + const hasPowerPaste = 'powerpaste' in externalPlugins; + return { + externalPlugins, + hasA11yChecker: 'a11ychecker' in externalPlugins, + pluginOptions: { + ...(hasPowerPaste && { + powerpaste_allow_local_images: true, + powerpaste_word_import: 'prompt', + powerpaste_html_import: 'prompt', + powerpaste_googledoc_import: 'prompt', + }), + ...getConfig().TINYMCE_PLUGIN_OPTIONS, + }, + }; +}; + const pluginConfig = ({ placeholder, editorType, enableImageUpload }) => { const image = enableImageUpload ? plugins.image : ''; - const imageTools = enableImageUpload ? plugins.imagetools : ''; const imageUploadButton = enableImageUpload ? buttons.imageUploadButton : ''; const editImageSettings = enableImageUpload ? buttons.editImageSettings : ''; const codePlugin = editorType === 'text' ? plugins.code : ''; @@ -17,6 +40,8 @@ const pluginConfig = ({ placeholder, editorType, enableImageUpload }) => { const autoresizeBottomMargin = editorType === 'expandable' ? 10 : 50; const defaultFormat = (editorType === 'question' || editorType === 'expandable') ? 'div' : 'p'; const hasStudioHeader = document.querySelector('.studio-header'); + const { externalPlugins, hasA11yChecker, pluginOptions } = getExternalPluginConfig(); + const a11yCheckButton = hasA11yChecker ? buttons.a11ycheck : ''; return ( StrictDict({ @@ -26,16 +51,13 @@ const pluginConfig = ({ placeholder, editorType, enableImageUpload }) => { plugins.codesample, plugins.emoticons, plugins.table, - plugins.hr, plugins.charmap, codePlugin, plugins.autoresize, image, - imageTools, quickToolbar, - plugins.a11ychecker, - plugins.powerpaste, plugins.embediframe, + ...Object.keys(externalPlugins), ].join(' '), menubar: false, toolbar: toolbar ? @@ -58,7 +80,7 @@ const pluginConfig = ({ placeholder, editorType, enableImageUpload }) => { ], [imageUploadButton, buttons.link, buttons.unlink, buttons.blockQuote, buttons.codeBlock], [buttons.table, buttons.emoticons, buttons.charmap, buttons.hr], - [buttons.removeFormat, codeButton, buttons.a11ycheck, buttons.embediframe], + [buttons.removeFormat, codeButton, a11yCheckButton, buttons.embediframe], ]) : false, imageToolbar: mapToolbars([ @@ -81,7 +103,7 @@ const pluginConfig = ({ placeholder, editorType, enableImageUpload }) => { buttons.numlist, ], [imageUploadButton, buttons.blockQuote, buttons.codeBlock], - [buttons.table, buttons.emoticons, buttons.charmap, buttons.removeFormat, buttons.a11ycheck], + [buttons.table, buttons.emoticons, buttons.charmap, buttons.removeFormat, a11yCheckButton], ]), config: { branding: false, @@ -97,11 +119,9 @@ const pluginConfig = ({ placeholder, editorType, enableImageUpload }) => { block_formats: 'Header 1=h1;Header 2=h2;Header 3=h3;Header 4=h4;Header 5=h5;Header 6=h6;Div=div;Paragraph=p;Preformatted=pre', forced_root_block: defaultFormat, - powerpaste_allow_local_images: true, - powerpaste_word_import: 'prompt', - powerpaste_html_import: 'prompt', - powerpaste_googledoc_import: 'prompt', autoresize_bottom_margin: autoresizeBottomMargin, + external_plugins: externalPlugins, + ...pluginOptions, }, }) ); diff --git a/src/editors/sharedComponents/TinyMceWidget/pluginConfig.test.ts b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.test.ts new file mode 100644 index 0000000000..31538914b1 --- /dev/null +++ b/src/editors/sharedComponents/TinyMceWidget/pluginConfig.test.ts @@ -0,0 +1,46 @@ +import { mergeConfig } from '@edx/frontend-platform'; +import pluginConfig from './pluginConfig'; + +const baseProps = { placeholder: '', enableImageUpload: true }; + +const externalPlugins = { + a11ychecker: 'https://cdn.example.com/a11ychecker/plugin.min.js', + powerpaste: 'https://cdn.example.com/powerpaste/plugin.min.js', +}; + +describe('pluginConfig', () => { + afterEach(() => { + mergeConfig({ TINYMCE_EXTERNAL_PLUGINS: {}, TINYMCE_PLUGIN_OPTIONS: {} }); + }); + + test('does not load any external plugins by default', () => { + const { plugins, toolbar, config } = pluginConfig({ ...baseProps, editorType: 'text' }); + expect(config.external_plugins).toEqual({}); + expect(plugins).not.toContain('a11ychecker'); + expect(toolbar).not.toContain('a11ycheck'); + expect(config).not.toHaveProperty('powerpaste_word_import'); + }); + + test('loads operator-configured premium plugins', () => { + mergeConfig({ + TINYMCE_EXTERNAL_PLUGINS: externalPlugins, + TINYMCE_PLUGIN_OPTIONS: { powerpaste_word_import: 'clean', a11ychecker_level: 'aaa' }, + }); + const { plugins, toolbar, quickbarsSelectionToolbar, config } = pluginConfig({ ...baseProps, editorType: 'text' }); + expect(config.external_plugins).toEqual(externalPlugins); + expect(plugins.split(' ')).toEqual(expect.arrayContaining(['a11ychecker', 'powerpaste'])); + expect(toolbar).toContain('a11ycheck'); + expect(quickbarsSelectionToolbar).toBe(false); + expect(config.powerpaste_allow_local_images).toBe(true); + expect(config.powerpaste_word_import).toBe('clean'); // operator override wins over the default + expect(config.a11ychecker_level).toBe('aaa'); + }); + + test('adds the accessibility checker button to the expandable editor quickbar', () => { + mergeConfig({ TINYMCE_EXTERNAL_PLUGINS: { a11ychecker: externalPlugins.a11ychecker } }); + const { toolbar, quickbarsSelectionToolbar, config } = pluginConfig({ ...baseProps, editorType: 'expandable' }); + expect(toolbar).toBe(false); + expect(quickbarsSelectionToolbar).toContain('a11ycheck'); + expect(config).not.toHaveProperty('powerpaste_word_import'); + }); +}); diff --git a/src/library-authoring/add-content/AddContent.test.tsx b/src/library-authoring/add-content/AddContent.test.tsx index cf891e28e1..d7a88a0c2e 100644 --- a/src/library-authoring/add-content/AddContent.test.tsx +++ b/src/library-authoring/add-content/AddContent.test.tsx @@ -33,7 +33,6 @@ import { ComponentEditorModal } from '../components/ComponentEditorModal'; // mockCreateLibraryBlock.applyMock(); // Mocks for ComponentEditorModal to work in tests. -jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' })); const { libraryId } = mockContentLibrary; const render = (collectionId?: string) => { diff --git a/src/library-authoring/add-content/AddContentWorkflow.test.tsx b/src/library-authoring/add-content/AddContentWorkflow.test.tsx index 35d0f60a86..7662665f70 100644 --- a/src/library-authoring/add-content/AddContentWorkflow.test.tsx +++ b/src/library-authoring/add-content/AddContentWorkflow.test.tsx @@ -35,7 +35,6 @@ jest.spyOn(editorCmsApi as any, 'fetchBlockById').mockImplementation( ), ); jest.spyOn(textEditorHooks, 'getContent').mockImplementation(() => () => 'Edited HTML content
'); -jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' })); const saveSpy = jest.spyOn(editorCmsApi as any, 'saveBlock'); const { libraryId } = mockContentLibrary; diff --git a/src/setupTest.js b/src/setupTest.js index 4f1b3a0e90..acd3df6a70 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -69,5 +69,6 @@ class ResizeObserver { window.ResizeObserver = ResizeObserver; -// Mock the plugins repo so jest will stop complaining about ES6 syntax -jest.mock('frontend-components-tinymce-advanced-plugins', () => {}); +// TinyMCE's UI cannot initialize under JSDOM. Stub out its theme so that editor initialization never completes in +// tests (it will wait forever for the theme script to load). Tests that need editor content mock the relevant hooks. +jest.mock('tinymce/themes/silver', () => {});