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 `_ (the open source, GPL-licensed version). Operators who have +licensed premium TinyMCE plugins such as the Accessibility Checker (``a11ychecker``) or PowerPaste (``powerpaste``) can +load them by adding the following to ``env.config.jsx``: + +* ``TINYMCE_LICENSE_KEY``: your commercial TinyMCE license key (defaults to ``gpl``). +* ``TINYMCE_EXTERNAL_PLUGINS``: an object mapping each plugin name to the URL of its ``plugin.min.js``. The plugins are + loaded into every editor; ``a11ychecker`` additionally gets its toolbar button, and ``powerpaste`` gets sensible defaults. +* ``TINYMCE_PLUGIN_OPTIONS`` (optional): an object of extra TinyMCE init options to pass to those plugins. + +.. code-block:: javascript + + const config = { + ...process.env, + TINYMCE_LICENSE_KEY: 'your-license-key', + TINYMCE_EXTERNAL_PLUGINS: { + a11ychecker: 'https://cdn.example.com/tinymce/plugins/a11ychecker/plugin.min.js', + powerpaste: 'https://cdn.example.com/tinymce/plugins/powerpaste/plugin.min.js', + }, + TINYMCE_PLUGIN_OPTIONS: { + powerpaste_word_import: 'clean', + }, + }; + Feature: New Proctoring Exams View ================================== diff --git a/package-lock.json b/package-lock.json index 90c5f0bd7b..e512aadedd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,6 @@ "fast-xml-parser": "5.11.1", "file-saver": "^2.0.5", "formik": "2.4.9", - "frontend-components-tinymce-advanced-plugins": "^1.0.3", "jszip": "^3.10.1", "lodash": "4.18.1", "meilisearch": "^0.41.0", @@ -75,7 +74,7 @@ "redux-logger": "^3.0.6", "redux-thunk": "^2.4.1", "reselect": "^4.1.5", - "tinymce": "^5.10.4", + "tinymce": "^7.0.0", "universal-cookie": "^8.0.0", "uuid": "^11.1.0", "xmlchecker": "^0.1.0", @@ -13811,15 +13810,6 @@ "node": ">= 0.6" } }, - "node_modules/frontend-components-tinymce-advanced-plugins": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/frontend-components-tinymce-advanced-plugins/-/frontend-components-tinymce-advanced-plugins-1.0.4.tgz", - "integrity": "sha512-3PzEaOa9k1csUsVqvrJ11LXiUgu804lax0sq6eWmirtfYMJNYrajUEwW4REKTnpQrv1ByDSkVV/kaporN0PkWA==", - "license": "AGPL-3.0", - "dependencies": { - "tinymce": "^5.10.4" - } - }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -23708,10 +23698,10 @@ } }, "node_modules/tinymce": { - "version": "5.10.9", - "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-5.10.9.tgz", - "integrity": "sha512-5bkrors87X9LhYX2xq8GgPHrIgJYHl87YNs+kBcjQ5I3CiUgzo/vFcGvT3MZQ9QHsEeYMhYO6a5CLGGffR8hMg==", - "license": "LGPL-2.1" + "version": "7.9.3", + "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-7.9.3.tgz", + "integrity": "sha512-Mtm54U5YJ6Pyo/GaAx+JSHXTGEuxrg2AowVWCD9zy1eBolp5Ub7S1rTtsyQdxhPegfhLuR3VLiTKGw1tacv09g==", + "license": "GPL-2.0-or-later" }, "node_modules/tmp": { "version": "0.2.7", diff --git a/package.json b/package.json index 21fe4a5365..9ba9b5f198 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "fast-xml-parser": "5.11.1", "file-saver": "^2.0.5", "formik": "2.4.9", - "frontend-components-tinymce-advanced-plugins": "^1.0.3", "jszip": "^3.10.1", "lodash": "4.18.1", "meilisearch": "^0.41.0", @@ -99,7 +98,7 @@ "redux-logger": "^3.0.6", "redux-thunk": "^2.4.1", "reselect": "^4.1.5", - "tinymce": "^5.10.4", + "tinymce": "^7.0.0", "universal-cookie": "^8.0.0", "uuid": "^11.1.0", "xmlchecker": "^0.1.0", diff --git a/src/editors/EditorContainer.test.tsx b/src/editors/EditorContainer.test.tsx index 1aff7a898f..6367ebba74 100644 --- a/src/editors/EditorContainer.test.tsx +++ b/src/editors/EditorContainer.test.tsx @@ -35,8 +35,6 @@ jest.mock('react-redux', () => ({ }), })); -// Mock this plugins component: -jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' })); // Always mock out the "fetch course images" endpoint: jest.spyOn(editorCmsApi, 'fetchCourseImages').mockImplementation(async () => ( // eslint-disable-next-line { data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } } diff --git a/src/editors/EditorPage.test.tsx b/src/editors/EditorPage.test.tsx index 6e6af6b24f..e2e9f6a4d5 100644 --- a/src/editors/EditorPage.test.tsx +++ b/src/editors/EditorPage.test.tsx @@ -8,8 +8,6 @@ import editorCmsApi from './data/services/cms/api'; import EditorPage from './EditorPage'; -// Mock this plugins component: -jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' })); // Always mock out the "fetch course images" endpoint: jest.spyOn(editorCmsApi, 'fetchCourseImages').mockImplementation(async () => ( // eslint-disable-next-line { data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } } diff --git a/src/editors/containers/EditorContainer/index.test.tsx b/src/editors/containers/EditorContainer/index.test.tsx index b1c16e1799..4d27e68143 100644 --- a/src/editors/containers/EditorContainer/index.test.tsx +++ b/src/editors/containers/EditorContainer/index.test.tsx @@ -11,8 +11,6 @@ import editorCmsApi from '../../data/services/cms/api'; import EditorPage from '../../EditorPage'; import * as hooks from './hooks'; -// Mock this plugins component: -jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' })); // Always mock out the "fetch course images" endpoint: jest.spyOn(editorCmsApi, 'fetchCourseImages').mockImplementation(async () => ( // eslint-disable-next-line { data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } } diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.test.js b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.test.js index da85d82273..07fede8b3a 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.test.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/hooks.test.js @@ -59,7 +59,9 @@ describe('Answer Options Hooks', () => { const answer = { id: 'A', correct: false }; const dispatch = useDispatch(); it('dispatches actions.problem.deleteAnswer', () => { - windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'answer-A': { getContent: () => 'string' } } } })); + windowSpy.mockImplementation(() => ({ + tinymce: { get: () => [{ id: 'answer-A', getContent: () => 'string' }] }, + })); module.removeAnswer({ answer, dispatch, @@ -180,7 +182,7 @@ describe('Answer Options Hooks', () => { const key = state.keys.isFeedbackVisible; output = module.useFeedback(answerWithOnlyFeedback); windowSpy.mockImplementation(() => ({ - tinymce: { editors: { 'selectedFeedback-A': { getContent: () => 'string' } } }, + tinymce: { get: () => [{ id: 'selectedFeedback-A', getContent: () => 'string' }] }, })); output.toggleFeedback(false); expect(state.setState[key]).toHaveBeenCalledWith(true); @@ -189,7 +191,7 @@ describe('Answer Options Hooks', () => { const key = state.keys.isFeedbackVisible; output = module.useFeedback(answerWithOnlyFeedback); windowSpy.mockImplementation(() => ({ - tinymce: { editors: { 'unselectedFeedback-A': { getContent: () => 'string' } } }, + tinymce: { get: () => [{ id: 'unselectedFeedback-A', getContent: () => 'string' }] }, })); output.toggleFeedback(false); expect(state.setState[key]).toHaveBeenCalledWith(true); @@ -197,7 +199,9 @@ describe('Answer Options Hooks', () => { test('toggleFeedback with unselected feedback', () => { const key = state.keys.isFeedbackVisible; output = module.useFeedback(answerWithOnlyFeedback); - windowSpy.mockImplementation(() => ({ tinymce: { editors: { 'answer-A': { getContent: () => 'string' } } } })); + windowSpy.mockImplementation(() => ({ + tinymce: { get: () => [{ id: 'answer-A', getContent: () => 'string' }] }, + })); output.toggleFeedback(false); expect(state.setState[key]).toHaveBeenCalledWith(false); }); diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js index e8e47c5445..ad2f3f2fc1 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.js @@ -20,46 +20,37 @@ export const saveWarningModalToggle = () => { }; }; +/** Returns every tinymce editor instance currently on the page */ +const getEditors = () => window.tinymce.get(); + /** Checks if any tinymce editor in window is dirty */ -export const checkIfEditorsDirty = () => { - const EditorsArray = window.tinymce.editors; - return Object.entries(EditorsArray).some(([id, editor]) => { - if (Number.isNaN(parseInt(id, 10))) { - if (!editor.isNotDirty) { - return true; - } - } - return false; - }); -}; +export const checkIfEditorsDirty = () => getEditors().some((editor) => editor.isDirty()); export const fetchEditorContent = ({ format }) => { const editorObject = { hints: [] }; - const EditorsArray = window.tinymce.editors; - Object.entries(EditorsArray).forEach(([id, editor]) => { - if (Number.isNaN(parseInt(id, 10))) { - if (id.startsWith('answer')) { - const { answers } = editorObject; - const answerId = id.substring(id.indexOf('-') + 1); - editorObject.answers = { ...answers, [answerId]: editor.getContent({ format }) }; - } else if (id.includes('Feedback')) { - const { selectedFeedback, unselectedFeedback, groupFeedback } = editorObject; - const feedbackId = id.substring(id.indexOf('-') + 1); - if (id.startsWith('selected')) { - editorObject.selectedFeedback = { ...selectedFeedback, [feedbackId]: editor.getContent() }; - } - if (id.startsWith('unselected')) { - editorObject.unselectedFeedback = { ...unselectedFeedback, [feedbackId]: editor.getContent() }; - } - if (id.startsWith('group')) { - editorObject.groupFeedback = { ...groupFeedback, [feedbackId]: editor.getContent() }; - } - } else if (id.startsWith('hint')) { - const { hints } = editorObject; - hints.push(editor.getContent()); - } else { - editorObject[id] = editor.getContent(); + getEditors().forEach((editor) => { + const { id } = editor; + if (id.startsWith('answer')) { + const { answers } = editorObject; + const answerId = id.substring(id.indexOf('-') + 1); + editorObject.answers = { ...answers, [answerId]: editor.getContent({ format }) }; + } else if (id.includes('Feedback')) { + const { selectedFeedback, unselectedFeedback, groupFeedback } = editorObject; + const feedbackId = id.substring(id.indexOf('-') + 1); + if (id.startsWith('selected')) { + editorObject.selectedFeedback = { ...selectedFeedback, [feedbackId]: editor.getContent() }; + } + if (id.startsWith('unselected')) { + editorObject.unselectedFeedback = { ...unselectedFeedback, [feedbackId]: editor.getContent() }; + } + if (id.startsWith('group')) { + editorObject.groupFeedback = { ...groupFeedback, [feedbackId]: editor.getContent() }; } + } else if (id.startsWith('hint')) { + const { hints } = editorObject; + hints.push(editor.getContent()); + } else { + editorObject[id] = editor.getContent(); } }); return editorObject; diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.test.js b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.test.js index d21b71a24d..d78313ffcc 100644 --- a/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.test.js +++ b/src/editors/containers/ProblemEditor/components/EditProblemView/hooks.test.js @@ -2,6 +2,11 @@ import { ProblemTypeKeys, ShowAnswerTypesKeys } from '../../../../data/constants import * as hooks from './hooks'; import { MockUseState } from '../../../../testUtils'; +/** Mock the tinymce editor registry with the given `{ [editorId]: editorMock }` entries */ +const mockEditors = (editors) => { + window.tinymce.get = jest.fn(() => Object.entries(editors).map(([id, editor]) => ({ id, ...editor }))); +}; + const mockRawMarkdown = 'Raw Markdown'; const mockMarkdownToXML = 'Raw Markdown'; const mockRawOLX = 'rawOLX'; @@ -106,37 +111,37 @@ describe('EditProblemView hooks parseState', () => { describe('fetchEditorContent', () => { const getContent = () => '

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', () => {});