diff --git a/e2e/client/playwright/editor3.spec.ts b/e2e/client/playwright/editor3.spec.ts new file mode 100644 index 0000000000..e27fc5a54b --- /dev/null +++ b/e2e/client/playwright/editor3.spec.ts @@ -0,0 +1,208 @@ +import {test, expect} from '@playwright/test'; +import {Monitoring} from './page-object-models/monitoring'; +import {restoreDatabaseSnapshot, s} from './utils'; +import {getEditor3Paragraphs} from './utils/editor3'; + +test('accepting a spelling suggestion', async ({page}) => { + await restoreDatabaseSnapshot({snapshotName: 'spellchecker'}); + + await page.goto('/#/workspace/monitoring'); + + await page.locator( + s('monitoring-group=Sports / Working Stage', 'article-item=spellchecker test'), + ).dblclick(); + + await expect(page.locator(s('authoring', 'authoring-field=body_html', 'spellchecker-warning'))).toHaveCount(2); + + expect(await getEditor3Paragraphs(page.locator(s('authoring', 'authoring-field=body_html')))) + .toStrictEqual(['ghello world', 'ghello world']); + + await page.locator(s('authoring', 'authoring-field=body_html', 'spellchecker-warning')) + .first() + .click({button: 'right'}); + + await page.locator(s('spellchecker-menu')).getByRole('button', {name: 'hello'}).click(); + + await expect( + (await page.locator(s('authoring', 'authoring-field=body_html', 'spellchecker-warning')).all()).length, + ).toBe(1); + + expect(await getEditor3Paragraphs(page.locator(s('authoring', 'authoring-field=body_html')))) + .toStrictEqual(['hello world', 'ghello world']); +}); + +test('adding word marked as a spellchecker issue to dictionary', async ({page}) => { + await restoreDatabaseSnapshot({snapshotName: 'spellchecker'}); + + await page.goto('/#/workspace/monitoring'); + + await page.locator( + s('monitoring-group=Sports / Working Stage', 'article-item=spellchecker test'), + ).dblclick(); + + await expect(page.locator(s('authoring', 'authoring-field=body_html', 'spellchecker-warning'))).toHaveCount(2); + + expect(await getEditor3Paragraphs(page.locator(s('authoring', 'authoring-field=body_html')))) + .toStrictEqual(['ghello world', 'ghello world']); + + await page.locator(s('authoring', 'authoring-field=body_html', 'spellchecker-warning')) + .first() + .click({button: 'right'}); + + await page.locator(s('spellchecker-menu')).getByRole('button', {name: 'Add to dictionary'}).click(); + + /** + * it expects zero, because when a word is added to dictionary + * it should remove warnings for all instances of that word + * including nested editors (e.g. multi-line-quote) + */ + await expect( + page.locator(s('authoring', 'authoring-field=body_html', 'spellchecker-warning')), + ).toHaveCount(0); + + expect(await getEditor3Paragraphs(page.locator(s('authoring', 'authoring-field=body_html')))) + .toStrictEqual(['ghello world', 'ghello world']); +}); + +/** + * FYI undo/redo isn't working the same as in the main editor outside tables + * and it's not great that it's character based. + */ +test('tables maintaining cursor position at the start when executing "undo" action', async ({page}) => { + const monitoring = new Monitoring(page); + + await restoreDatabaseSnapshot({snapshotName: 'editor3-tables'}); + + await page.goto('/#/workspace/monitoring'); + + await monitoring.selectDeskOrWorkspace('Sports'); + + await page.locator( + s('monitoring-group=Sports / Working Stage', 'article-item=test sports story'), + ).dblclick(); + + await page.locator( + s('authoring', 'authoring-field=body_html', 'toolbar'), + ).getByRole('button', {name: 'table'}).click(); + + await page.locator(s('authoring', 'authoring-field=body_html', 'table-block')) + .locator('[contenteditable]').first().pressSequentially('foo', {delay: 100}); + + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('Control+z'); + + await page.locator(s('authoring', 'authoring-field=body_html', 'table-block')) + .locator('[contenteditable]').first().pressSequentially('bar', {delay: 100}); + + await expect( + page.locator(s('authoring', 'authoring-field=body_html', 'table-block')).locator('[contenteditable]').first(), + ).toHaveText('barfo'); +}); + +/** + * FYI undo/redo isn't working the same as in the main editor outside tables + * and it's not great that it's character based. + */ +test('tables maintaining cursor position in the middle when executing "undo" action', async ({page}) => { + const monitoring = new Monitoring(page); + + await restoreDatabaseSnapshot({snapshotName: 'editor3-tables'}); + + await page.goto('/#/workspace/monitoring'); + + await monitoring.selectDeskOrWorkspace('Sports'); + + await page.locator( + s('monitoring-group=Sports / Working Stage', 'article-item=test sports story'), + ).dblclick(); + + await page.locator( + s('authoring', 'authoring-field=body_html', 'toolbar'), + ).getByRole('button', {name: 'table'}).click(); + + await page.locator(s('authoring', 'authoring-field=body_html', 'table-block')) + .locator('[contenteditable]').first().pressSequentially('foo', {delay: 100}); + + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('Control+z'); + await page.keyboard.press('Control+z'); + + await page.locator(s('authoring', 'authoring-field=body_html', 'table-block')) + .locator('[contenteditable]').first().pressSequentially('bar', {delay: 100}); + + await expect( + page.locator(s('authoring', 'authoring-field=body_html', 'table-block')).locator('[contenteditable]').first(), + ).toHaveText('fbar'); +}); + +/** + * FYI undo/redo isn't working the same as in the main editor outside tables + * and it's not great that it's character based. + */ +test('tables maintaining cursor position at the end when executing "undo" action', async ({page}) => { + const monitoring = new Monitoring(page); + + await restoreDatabaseSnapshot({snapshotName: 'editor3-tables'}); + + await page.goto('/#/workspace/monitoring'); + + await monitoring.selectDeskOrWorkspace('Sports'); + + await page.locator( + s('monitoring-group=Sports / Working Stage', 'article-item=test sports story'), + ).dblclick(); + + await page.locator( + s('authoring', 'authoring-field=body_html', 'toolbar'), + ).getByRole('button', {name: 'table'}).click(); + + await page.locator(s('authoring', 'authoring-field=body_html', 'table-block')) + .locator('[contenteditable]').first().pressSequentially('foo', {delay: 100}); + + await page.keyboard.press('Control+z'); // undo last character + + await page.locator(s('authoring', 'authoring-field=body_html', 'table-block')) + .locator('[contenteditable]').first().pressSequentially('bar', {delay: 100}); + + await expect( + page.locator(s('authoring', 'authoring-field=body_html', 'table-block')).locator('[contenteditable]').first(), + ).toHaveText('fobar'); +}); + +/** + * FYI undo/redo isn't working the same as in the main editor outside tables + * and it's not great that it's character based. + */ +test('tables maintaining cursor position when executing "redo" action', async ({page}) => { + const monitoring = new Monitoring(page); + + await restoreDatabaseSnapshot({snapshotName: 'editor3-tables'}); + + await page.goto('/#/workspace/monitoring'); + + await monitoring.selectDeskOrWorkspace('Sports'); + + await page.locator( + s('monitoring-group=Sports / Working Stage', 'article-item=test sports story'), + ).dblclick(); + + await page.locator( + s('authoring', 'authoring-field=body_html', 'toolbar'), + ).getByRole('button', {name: 'table'}).click(); + + await page.locator(s('authoring', 'authoring-field=body_html', 'table-block')) + .locator('[contenteditable]').first().pressSequentially('foo', {delay: 100}); + + await page.keyboard.press('Control+z'); + await page.keyboard.press('Control+y'); + + await page.locator(s('authoring', 'authoring-field=body_html', 'table-block')) + .locator('[contenteditable]').first().pressSequentially('bar', {delay: 100}); + + await expect( + page.locator(s('authoring', 'authoring-field=body_html', 'table-block')).locator('[contenteditable]').first(), + ).toHaveText('fobaro'); +}); diff --git a/e2e/client/playwright/utils/editor3.tsx b/e2e/client/playwright/utils/editor3.tsx new file mode 100644 index 0000000000..39ab2697f4 --- /dev/null +++ b/e2e/client/playwright/utils/editor3.tsx @@ -0,0 +1,11 @@ +import {Locator} from '@playwright/test'; + +export function getEditor3Paragraphs(field: Locator): Promise> { + return field.locator('.DraftEditor-root') + .first() // there might be multiple roots when working with nested blocks e.g. multi-line-quote + .locator('[data-contents="true"]') + .first() // there might be multiple [data-contents] when working with nested blocks e.g. multi-line-quote + .locator('> *') + .allInnerTexts() + .then((items) => items.filter((text) => text.trim().length > 0)); +} \ No newline at end of file diff --git a/e2e/client/specs/editor3_spec.ts b/e2e/client/specs/editor3_spec.ts index 7ac584c918..57607e58ce 100644 --- a/e2e/client/specs/editor3_spec.ts +++ b/e2e/client/specs/editor3_spec.ts @@ -57,7 +57,6 @@ describe('editor3', () => { const bodyEditor = new Editor3(editors.get(1)); const headlineEditor = new Editor3(editors.get(0)); const selectAll = protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.UP); - const tableEl = editors.get(1).element(by.className('table-block')); beforeEach(() => { monitoring.openMonitoring(); @@ -89,76 +88,6 @@ describe('editor3', () => { expect(body.element(by.tagName('a')).getAttribute('href')).toBe('https://example.com/'); }); - it('can add tables', () => { - const tableEditor = new Editor3(tableEl); - - bodyEditor.toolbar.table(); - tableEditor.sendKeys('foo'); - - const body = getPreviewBody(); - - expect(body.element(by.tagName('table')).getText()).toBe('foo'); - }); - - it('ctrl+z on tables mantains cursor position at the end', () => { - const tableEditor = new Editor3(tableEl); - - bodyEditor.toolbar.table(); - tableEditor.sendKeys('foo'); - tableEditor.sendKeys(protractor.Key.CONTROL, 'z'); - tableEditor.sendKeys('bar'); - - const body = getPreviewBody(); - - expect(body.element(by.tagName('table')).getText()).toBe('fobar'); - }); - - it('ctrl+z on tables mantains cursor position at the beginning', () => { - const tableEditor = new Editor3(editors.get(1).element(by.className('table-block'))); - - bodyEditor.toolbar.table(); - tableEditor.sendKeys('foo'); - tableEditor.sendKeys(protractor.Key.ARROW_LEFT); - tableEditor.sendKeys(protractor.Key.ARROW_LEFT); - tableEditor.sendKeys(protractor.Key.ARROW_LEFT); - tableEditor.sendKeys(protractor.Key.CONTROL, 'z'); - tableEditor.sendKeys('bar'); - - const body = getPreviewBody(); - - expect(body.element(by.tagName('table')).getText()).toBe('barfo'); - }); - - it('ctrl+z on tables mantains cursor position in the middle', () => { - const tableEditor = new Editor3(editors.get(1).element(by.className('table-block'))); - - bodyEditor.toolbar.table(); - tableEditor.sendKeys('foo'); - tableEditor.sendKeys(protractor.Key.ARROW_LEFT); - tableEditor.sendKeys(protractor.Key.ARROW_LEFT); - tableEditor.sendKeys(protractor.Key.CONTROL, 'z'); - tableEditor.sendKeys(protractor.Key.CONTROL, 'z'); - tableEditor.sendKeys('bar'); - - const body = getPreviewBody(); - - expect(body.element(by.tagName('table')).getText()).toBe('fbar'); - }); - - it('ctrl+y on tables mantains cursor position', () => { - const tableEditor = new Editor3(editors.get(1).element(by.className('table-block'))); - - bodyEditor.toolbar.table(); - tableEditor.sendKeys('foo'); - tableEditor.sendKeys(protractor.Key.CONTROL, 'z'); - tableEditor.sendKeys(protractor.Key.CONTROL, 'y'); - tableEditor.sendKeys('bar'); - - const body = getPreviewBody(); - - expect(body.element(by.tagName('table')).getText()).toBe('fobaro'); - }); - function getPreviewBody() { authoring.save(); assertToastMsg('success', 'Item updated.'); diff --git a/e2e/server/dump/records/README.md b/e2e/server/dump/records/README.md new file mode 100644 index 0000000000..b6ee9b62ae --- /dev/null +++ b/e2e/server/dump/records/README.md @@ -0,0 +1 @@ +editor3-tables only adds tables formatting option to story content profile. It can be merged to main. The only reason it was added as a separate record is to avoid merge conflicts on open PRs. \ No newline at end of file diff --git a/e2e/server/dump/records/editor3-tables.json.bz2 b/e2e/server/dump/records/editor3-tables.json.bz2 new file mode 100644 index 0000000000..09efd6a3c5 Binary files /dev/null and b/e2e/server/dump/records/editor3-tables.json.bz2 differ diff --git a/e2e/server/dump/records/spellchecker.json.bz2 b/e2e/server/dump/records/spellchecker.json.bz2 new file mode 100644 index 0000000000..93fb9e354a Binary files /dev/null and b/e2e/server/dump/records/spellchecker.json.bz2 differ diff --git a/karma.conf.js b/karma.conf.js index ae10940015..44f070b081 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -2,7 +2,9 @@ var path = require('path'); var grunt = require('grunt'); var makeConfig = require('./webpack.config.js'); -process.env.TZ = "Europe/Prague"; +require('karma-spec-reporter'); + +process.env.TZ = 'Europe/Prague'; module.exports = function(config) { var webpackConfig = makeConfig(grunt); @@ -54,7 +56,7 @@ module.exports = function(config) { }, // test results reporter to use - reporters: ['dots'], + reporters: ['spec'], // web server port port: 8080, diff --git a/package-lock.json b/package-lock.json index a48c087255..22188a56b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9046,6 +9046,15 @@ } } }, + "karma-spec-reporter": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/karma-spec-reporter/-/karma-spec-reporter-0.0.36.tgz", + "integrity": "sha512-11bvOl1x6ryKZph7kmbmMpbi8vsngEGxGOoeTlIcDaH3ab3j8aPJnZ+r+K/SS0sBSGy5VGkGYO2+hLct7hw/6w==", + "dev": true, + "requires": { + "colors": "1.4.0" + } + }, "karma-webpack": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/karma-webpack/-/karma-webpack-2.0.13.tgz", diff --git a/package.json b/package.json index 3d39aa0b0c..57c8ec0ac9 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,7 @@ "karma-jasmine": "^1.1.1", "karma-ng-html2js-preprocessor": "^1.0.0", "karma-sourcemap-loader": "^0.3.7", + "karma-spec-reporter": "0.0.36", "karma-webpack": "^2.0.13", "react-addons-test-utils": "^15.6.0", "react-test-renderer": "^16.13.1", diff --git a/scripts/apps/authoring-react/fields/editor3/editor.tsx b/scripts/apps/authoring-react/fields/editor3/editor.tsx index d34b63106f..eeaacd98ad 100644 --- a/scripts/apps/authoring-react/fields/editor3/editor.tsx +++ b/scripts/apps/authoring-react/fields/editor3/editor.tsx @@ -63,6 +63,7 @@ interface IState { export class Editor extends React.PureComponent { private eventListenersToRemoveBeforeUnmounting: Array<() => void>; + private unmountAbortController: AbortController; constructor(props: IProps) { super(props); @@ -74,6 +75,7 @@ export class Editor extends React.PureComponent { }; this.eventListenersToRemoveBeforeUnmounting = []; + this.unmountAbortController = new AbortController(); this.getCharacterLimitPreference = this.getCharacterLimitPreference.bind(this); this.syncPropsWithReduxStore = this.syncPropsWithReduxStore.bind(this); @@ -117,46 +119,33 @@ export class Editor extends React.PureComponent { const store = this.props.value.store; - const spellcheck = ng.get('spellcheck'); - - spellcheck.getDictionary(this.props.language).then((dict) => { - spellcheck.isActiveDictionary = !!dict.length; - spellcheck.setLanguage(this.props.language); - spellcheck.setSpellcheckerStatus(true); - - this.syncPropsWithReduxStore(); - - Promise.all([ - getAutocompleteSuggestions(this.props.editorId, this.props.language), - initializeSpellchecker(store, spellcheck), - ]).then((res) => { - const [autocompleteSuggestions] = res; - - this.setState({ready: true, autocompleteSuggestions}); - - /** - * If `spellchecker__set_status` is dispatched on `componentDidMount` in AuthoringReact, - * the event is fired before this component mounts and starts listening to the event. - * Because of this, requesting status explicitly is needed. - */ - dispatchEditorEvent('spellchecker__request_status', null); - - /** - * Avoid triggering `onChange` when nothing has actually changed. - * Spellchecker modifies inline styles (instead of being implemented as a decorator) - * and thus makes it impossible to check in a performant manner whether there - * were any actual changes when comparing 2 content states. - */ - setTimeout(() => { - store.subscribe(() => { - const contentState = store.getState().editorState.getCurrentContent(); - - if (this.props.value.contentState !== contentState) { - this.props.onChange({store, contentState}); - } - }); - }, 1000); - }); + this.syncPropsWithReduxStore(); + + getAutocompleteSuggestions(this.props.editorId, this.props.language).then((autocompleteSuggestions) => { + this.setState({ready: true, autocompleteSuggestions}); + + /** + * If `spellchecker__set_status` is dispatched on `componentDidMount` in AuthoringReact, + * the event is fired before this component mounts and starts listening to the event. + * Because of this, requesting status explicitly is needed. + */ + dispatchEditorEvent('spellchecker__request_status', null); + + /** + * Avoid triggering `onChange` when nothing has actually changed. + * Spellchecker modifies inline styles (instead of being implemented as a decorator) + * and thus makes it impossible to check in a performant manner whether there + * were any actual changes when comparing 2 content states. + */ + setTimeout(() => { + store.subscribe(() => { + const contentState = store.getState().editorState.getCurrentContent(); + + if (this.props.value.contentState !== contentState) { + this.props.onChange({store, contentState}); + } + }); + }, 1000); }); } @@ -290,12 +279,18 @@ export class Editor extends React.PureComponent { this.eventListenersToRemoveBeforeUnmounting.push( addEditorEventListener('spellchecker__set_status', (event) => { - this.props.value.store.dispatch(setSpellcheckerStatus(event.detail)); + this.props.value.store.dispatch( + setSpellcheckerStatus( + event.detail, + this.unmountAbortController.signal, + ), + ); }), ); } componentWillUnmount() { + this.unmountAbortController.abort(); for (const fn of this.eventListenersToRemoveBeforeUnmounting) { fn(); } diff --git a/scripts/core/editor3/actions/spellchecker.tsx b/scripts/core/editor3/actions/spellchecker.tsx index 1cd92479f2..296a9c6eeb 100644 --- a/scripts/core/editor3/actions/spellchecker.tsx +++ b/scripts/core/editor3/actions/spellchecker.tsx @@ -13,15 +13,15 @@ export function replaceWord(data: IReplaceWordData) { }; } -export function setSpellcheckerStatus(enabled: boolean): any { +export function setSpellcheckerStatus(enabled: boolean, abortSignal: AbortSignal): any { if (enabled) { - return reloadSpellcheckerWarnings(); + return reloadSpellcheckerWarnings(abortSignal); } else { return disableSpellchecker(); } } -export function reloadSpellcheckerWarnings() { +export function reloadSpellcheckerWarnings(abortSignal: AbortSignal) { return function(dispatch, getState) { const state: IEditorStore = getState(); const spellchecker = getSpellchecker(state.spellchecking.language); @@ -30,7 +30,11 @@ export function reloadSpellcheckerWarnings() { return; } - getSpellcheckWarningsByBlock(spellchecker, getState().editorState).then((spellcheckWarningsByBlock) => { + getSpellcheckWarningsByBlock( + spellchecker, + getState().editorState, + abortSignal, + ).then((spellcheckWarningsByBlock) => { dispatch(applySpellcheck(spellcheckWarningsByBlock)); }); }; diff --git a/scripts/core/editor3/components/Editor3.tsx b/scripts/core/editor3/components/Editor3.tsx index 018d411c49..926de3fdc7 100644 --- a/scripts/core/editor3/components/Editor3.tsx +++ b/scripts/core/editor3/components/Editor3.tsx @@ -5,6 +5,7 @@ import {Editor3Component} from './Editor3Component'; import {MultipleHighlights} from './MultipleHighlights'; import * as actions from '../actions'; import {EditorState} from 'draft-js'; +import {Editor3InitializeSpellchecker} from './Editor3InitializeSpellchecker'; export class Editor3Base extends React.Component { static defaultProps: any; @@ -17,9 +18,11 @@ export class Editor3Base extends React.Component { render() { return ( - - - + + + + + ); } } diff --git a/scripts/core/editor3/components/Editor3Component.tsx b/scripts/core/editor3/components/Editor3Component.tsx index 1b19ec854a..173cbeb4b6 100644 --- a/scripts/core/editor3/components/Editor3Component.tsx +++ b/scripts/core/editor3/components/Editor3Component.tsx @@ -16,7 +16,7 @@ import {getVisibleSelectionRect} from 'draft-js'; import {Map} from 'immutable'; import Toolbar from './toolbar'; -import {blockRenderer} from './blockRenderer'; +import {getBlockRenderer} from './blockRenderer'; import {customStyleMap} from './customStyleMap'; import classNames from 'classnames'; import {handlePastedText} from './handlePastedText'; @@ -27,7 +27,7 @@ import UnstyledWrapper from './UnstyledWrapper'; import * as Suggestions from '../helpers/suggestions'; import {getCurrentAuthor} from '../helpers/author'; import {setSpellcheckerProgress, applySpellcheck, PopupTypes} from '../actions'; -import {noop} from 'lodash'; +import {debounce} from 'lodash'; import {getSpellcheckWarningsByBlock} from './spellchecker/SpellcheckerDecorator'; import {getSpellchecker} from './spellchecker/default-spellcheckers'; import {IEditorStore} from '../store'; @@ -42,6 +42,7 @@ import {querySelectorParent} from 'core/helpers/dom/querySelectorParent'; import {MEDIA_TYPES_TRIGGER_DROP_ZONE} from 'core/constants'; import {isMacOS} from 'core/utils'; import {canAddArticleEmbed} from './article-embed/can-add-article-embed'; +import {addInternalEventListener} from 'core/internal-events'; export const EVENT_TYPES_TRIGGER_DROP_ZONE = [ ...MEDIA_TYPES_TRIGGER_DROP_ZONE, @@ -174,9 +175,11 @@ export class Editor3Component extends React.Component void; onDragEnd: () => void; - removeListeners: Array<() => void> = []; + private removeListeners: Array<() => void> = []; + + private spellcheckAbortController: AbortController; + private scheduleSpellchecking: () => void; constructor(props) { super(props); @@ -191,7 +194,9 @@ export class Editor3Component extends React.Component { if (this.state.draggingInProgress !== false) { @@ -203,6 +208,8 @@ export class Editor3Component extends React.Component { - let canceled = false; - - setTimeout(() => { - if (!canceled) { - if (this.props.spellchecking.inProgress !== true) { - this.props.dispatch(setSpellcheckerProgress(true)); - } - - const spellchecker = getSpellchecker(this.props.spellchecking.language); + if (this.props.spellchecking.inProgress !== true) { + this.props.dispatch(setSpellcheckerProgress(true)); + } - if (spellchecker == null) { - return; - } + const spellchecker = getSpellchecker(this.props.spellchecking.language); - getSpellcheckWarningsByBlock(spellchecker, this.props.editorState) - .then((spellcheckWarningsByBlock) => { - if (!canceled) { - this.props.dispatch(applySpellcheck(spellcheckWarningsByBlock)); - this.spellcheckCancelFn = noop; - } - }); - } - }, 500); + if (spellchecker == null) { + return; + } - return () => canceled = true; - })(); + getSpellcheckWarningsByBlock(spellchecker, this.props.editorState, this.spellcheckAbortController.signal) + .then((spellcheckWarningsByBlock) => { + this.props.dispatch(applySpellcheck(spellcheckWarningsByBlock)); + }); } /** @@ -518,6 +513,10 @@ export class Editor3Component extends React.Component { diff --git a/scripts/core/editor3/components/Editor3InitializeSpellchecker.tsx b/scripts/core/editor3/components/Editor3InitializeSpellchecker.tsx new file mode 100644 index 0000000000..df32ea0ec4 --- /dev/null +++ b/scripts/core/editor3/components/Editor3InitializeSpellchecker.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import {IEditorStore, initializeSpellchecker} from '../store'; +import ng from 'core/services/ng'; + +interface IProps { + spellchecking: IEditorStore['spellchecking']; + dispatch(): void; +} + +interface IState { + loading: boolean; +} + +export class Editor3InitializeSpellchecker extends React.PureComponent { + constructor(props: IProps) { + super(props); + + this.state = { + loading: props.spellchecking.enabled === true, + }; + + this.load = this.load.bind(this); + } + + private load() { + if (this.props.spellchecking.enabled === true) { + const spellcheck = ng.get('spellcheck'); + const language = this.props.spellchecking.language; + + spellcheck.getDictionary(language).then((dict) => { + spellcheck.isActiveDictionary = !!dict.length; + spellcheck.setLanguage(language); + spellcheck.setSpellcheckerStatus(true); + + initializeSpellchecker(this.props.dispatch, spellcheck).then(() => { + this.setState({loading: false}); + }); + }); + } else if (this.state.loading === true) { + this.setState({loading: false}); + } + } + + componentDidMount(): void { + this.load(); + } + + componentDidUpdate(prevProps: Readonly): void { + if ( + this.props.spellchecking.enabled !== prevProps.spellchecking.enabled + || this.props.spellchecking.language !== prevProps.spellchecking.language + ) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({loading: true}, this.load); + } + } + + render() { + if (this.state.loading) { + return null; + } else { + return this.props.children; + } + } +} diff --git a/scripts/core/editor3/components/blockRenderer.tsx b/scripts/core/editor3/components/blockRenderer.tsx index 4943e35cb3..a2b95975f2 100644 --- a/scripts/core/editor3/components/blockRenderer.tsx +++ b/scripts/core/editor3/components/blockRenderer.tsx @@ -2,16 +2,18 @@ import React from 'react'; import PropTypes from 'prop-types'; import {MediaBlock} from './media'; import {EmbedBlock} from './embeds'; -import {TableBlock} from './tables'; +import {TableBlock} from './tables/TableBlock'; import {ContentBlock} from 'draft-js'; import {DragableEditor3Block} from './media/dragable-editor3-block'; import {MultiLineQuote} from './multi-line-quote'; import {CustomEditor3Entity} from '../constants'; import {ArticleEmbed} from './article-embed/article-embed'; +import {IEditorStore} from '../store'; const BlockRendererComponent: React.StatelessComponent = (props) => { const {block, contentState} = props; const entityKey = block.getEntityAt(0); + const spellchecking: IEditorStore['spellchecking'] | undefined | null = props.blockProps.spellchecking; if (!entityKey) { return null; @@ -19,15 +21,24 @@ const BlockRendererComponent: React.StatelessComponent = (props) => { const type = contentState.getEntity(entityKey).getType(); + const disabledSpellcheckerConfig: IEditorStore['spellchecking'] = { + enabled: false, + language: 'en', + inProgress: false, + warningsByBlock: {}, + }; + function getComponent() { if (type === CustomEditor3Entity.MEDIA) { return ; } else if (type === CustomEditor3Entity.EMBED) { return ; } else if (type === CustomEditor3Entity.TABLE) { - return ; + // Spellchecker is disabled for tables to avoid performance issues. + // As it is currently implemented, it would send one spellchecking request for each table cell. + return ; } else if (type === CustomEditor3Entity.MULTI_LINE_QUOTE) { - return ; + return ; } else if (type === CustomEditor3Entity.ARTICLE_EMBED) { return ; } else { @@ -53,9 +64,14 @@ BlockRendererComponent.propTypes = { contentState: PropTypes.object.isRequired, }; -export function blockRenderer(contentBlock: ContentBlock) { - return contentBlock.getType() !== 'atomic' ? null : { - component: BlockRendererComponent, - editable: false, +export function getBlockRenderer(spellchecking: IEditorStore['spellchecking']) { + return (contentBlock: ContentBlock) => { + return contentBlock.getType() !== 'atomic' ? null : { + component: BlockRendererComponent, + editable: false, + props: { + spellchecking, + }, + }; }; } diff --git a/scripts/core/editor3/components/multi-line-quote/MultiLineQuote.tsx b/scripts/core/editor3/components/multi-line-quote/MultiLineQuote.tsx index 8f7c47998b..9da79e4872 100644 --- a/scripts/core/editor3/components/multi-line-quote/MultiLineQuote.tsx +++ b/scripts/core/editor3/components/multi-line-quote/MultiLineQuote.tsx @@ -4,6 +4,7 @@ import {connect} from 'react-redux'; import {EditorState, ContentBlock} from 'draft-js'; import {TableBlock} from '../tables/TableBlock'; import {IActiveCell} from 'superdesk-api'; +import {IEditorStore} from 'core/editor3/store'; export const MULTI_LINE_QUOTE_CLASS = 'multi-line-quote'; @@ -11,6 +12,7 @@ interface IProps { block: ContentBlock; readOnly: boolean; editorState: EditorState; + spellchecking: IEditorStore['spellchecking']; parentOnChange: (newEditorState: EditorState, force: boolean) => void; activeCell?: IActiveCell; setActiveCell: (row: number, col: number, blockKey: string, currentStyle: Array, selection: any) => void; @@ -35,6 +37,7 @@ export class MultiLineQuoteComponent extends React.Component { editorState={this.props.editorState} setActiveCell={this.props.setActiveCell} parentOnChange={this.props.parentOnChange} + spellchecking={this.props.spellchecking} /> ); } diff --git a/scripts/core/editor3/components/spellchecker/SpellcheckerContextMenu.tsx b/scripts/core/editor3/components/spellchecker/SpellcheckerContextMenu.tsx index 28c31b2eb9..55ec665db1 100644 --- a/scripts/core/editor3/components/spellchecker/SpellcheckerContextMenu.tsx +++ b/scripts/core/editor3/components/spellchecker/SpellcheckerContextMenu.tsx @@ -7,37 +7,56 @@ import { ISpellchecker, ISpellcheckerSuggestion, } from './interfaces'; -import {reloadSpellcheckerWarnings} from '../../actions'; import {gettext} from 'core/utils'; +import {dispatchInternalEvent} from 'core/internal-events'; +import {IReplaceWordData} from 'core/editor3/reducers/spellchecker'; + +export type IAcceptSuggestion = 'store-based' | ((replaceWordData: IReplaceWordData) => void); interface IProps { warning: ISpellcheckWarning; targetElement: any; spellchecker: ISpellchecker; dispatch: any; + acceptSuggestion: IAcceptSuggestion; } export class SpellcheckerContextMenuComponent extends React.Component { stickyElementTracker: any; dropdownElement: any; + private reloadSpellcheckerAbortController: AbortController; + + constructor(props: IProps) { + super(props); + + this.reloadSpellcheckerAbortController = new AbortController(); + } + componentDidMount() { this.stickyElementTracker = new StickElementsWithTracking(this.props.targetElement, this.dropdownElement); } componentWillUnmount() { this.stickyElementTracker.destroy(); + this.reloadSpellcheckerAbortController.abort(); } onSuggestionClick(suggestion: ISpellcheckerSuggestion) { - this.props.dispatch( - actions.replaceWord({ - word: { - text: this.props.warning.text, - offset: this.props.warning.startOffset, - }, - newWord: suggestion.text, - }), - ); + const replaceWordData: IReplaceWordData = { + word: { + text: this.props.warning.text, + offset: this.props.warning.startOffset, + }, + newWord: suggestion.text, + }; + + if (this.props.acceptSuggestion === 'store-based') { + this.props.dispatch( + actions.replaceWord(replaceWordData), + ); + } else { + this.props.acceptSuggestion(replaceWordData); + } } render() { @@ -95,11 +114,14 @@ export class SpellcheckerContextMenuComponent extends React.Component { return (