diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js
index af588dfc0c..5b25d8da85 100644
--- a/services/web/app/src/Features/Project/ProjectController.js
+++ b/services/web/app/src/Features/Project/ProjectController.js
@@ -893,6 +893,22 @@ const ProjectController = {
}
)
},
+ dictionaryEditorAssignment(cb) {
+ SplitTestHandler.getAssignment(
+ req,
+ res,
+ 'dictionary-editor',
+ {},
+ (error, assignment) => {
+ // do not fail editor load if assignment fails
+ if (error) {
+ cb(null, { variant: 'default' })
+ } else {
+ cb(null, assignment)
+ }
+ }
+ )
+ },
persistentUpgradePromptsAssignment(cb) {
SplitTestHandler.getAssignment(
req,
@@ -923,6 +939,7 @@ const ProjectController = {
newSourceEditorAssignment,
pdfDetachAssignment,
pdfjsAssignment,
+ dictionaryEditorAssignment,
persistentUpgradePromptsAssignment,
}
) => {
@@ -1029,6 +1046,11 @@ const ProjectController = {
!Features.hasFeature('saas') ||
(user.features && user.features.symbolPalette)
+ const dictionaryEditorEnabled = shouldDisplayFeature(
+ 'dictionary-editor',
+ dictionaryEditorAssignment.variant === 'enabled'
+ )
+
// Persistent upgrade prompts
const showHeaderUpgradePrompt =
persistentUpgradePromptsAssignment.variant ===
@@ -1098,6 +1120,7 @@ const ProjectController = {
wsUrl,
showSupport: Features.hasFeature('support'),
pdfjsVariant: pdfjsAssignment.variant,
+ dictionaryEditorEnabled,
showPdfDetach,
debugPdfDetach,
showNewSourceEditorOption,
diff --git a/services/web/app/src/Features/Spelling/SpellingController.js b/services/web/app/src/Features/Spelling/SpellingController.js
index 84efab4f55..6c0dc15e89 100644
--- a/services/web/app/src/Features/Spelling/SpellingController.js
+++ b/services/web/app/src/Features/Spelling/SpellingController.js
@@ -19,6 +19,15 @@ module.exports = {
})
},
+ unlearn(req, res, next) {
+ const { word } = req.body
+ const userId = SessionManager.getLoggedInUserId(req.session)
+ LearnedWordsManager.unlearnWord(userId, word, err => {
+ if (err) return next(err)
+ res.sendStatus(204)
+ })
+ },
+
proxyRequestToSpellingApi(req, res) {
const { language } = req.body
diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js
index 2ba11e92a5..349e57957b 100644
--- a/services/web/app/src/router.js
+++ b/services/web/app/src/router.js
@@ -932,6 +932,17 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
SpellingController.learn
)
+ webRouter.post(
+ '/spelling/unlearn',
+ validate({
+ body: Joi.object({
+ word: Joi.string().required(),
+ }),
+ }),
+ AuthenticationController.requireLogin(),
+ SpellingController.unlearn
+ )
+
webRouter.get(
'/project/:project_id/messages',
AuthorizationMiddleware.blockRestrictedUserFromProject,
diff --git a/services/web/app/views/project/editor/left-menu.pug b/services/web/app/views/project/editor/left-menu.pug
index 679268db88..953e390692 100644
--- a/services/web/app/views/project/editor/left-menu.pug
+++ b/services/web/app/views/project/editor/left-menu.pug
@@ -117,6 +117,16 @@ aside#left-menu.full-size(
value=language.code
)= language.name
+ if dictionaryEditorEnabled
+ .form-controls(ng-controller="DictionaryModalController")
+ label #{translate("dictionary")}
+ button.btn.btn-default.btn-sm(ng-click="openModal()") #{translate("edit")}
+
+ dictionary-modal(
+ handle-hide="handleHide"
+ show="show"
+ )
+
.form-controls
label(for="autoComplete") #{translate("auto_complete")}
select(
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 4ca762c2f4..eaf18b62b8 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -105,6 +105,9 @@
"dropbox_synced": "",
"duplicate_file": "",
"easily_manage_your_project_files_everywhere": "",
+ "edit_dictionary_empty": "",
+ "edit_dictionary_remove": "",
+ "edit_dictionary": "",
"editing": "",
"editor_and_pdf": "",
"editor_only_hide_pdf": "",
diff --git a/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx b/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx
new file mode 100644
index 0000000000..44c6c05fb3
--- /dev/null
+++ b/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx
@@ -0,0 +1,78 @@
+import { useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Alert, Button, Modal } from 'react-bootstrap'
+import Icon from '../../../shared/components/icon'
+import Tooltip from '../../../shared/components/tooltip'
+import useAsync from '../../../shared/hooks/use-async'
+import { postJSON } from '../../../infrastructure/fetch-json'
+import ignoredWords from '../ignored-words'
+
+type DictionaryModalContentProps = {
+ handleHide: () => void
+}
+
+export default function DictionaryModalContent({
+ handleHide,
+}: DictionaryModalContentProps) {
+ const { t } = useTranslation()
+
+ const { isError, runAsync } = useAsync()
+
+ const handleRemove = useCallback(
+ word => {
+ ignoredWords.remove(word)
+ runAsync(
+ postJSON('/spelling/unlearn', {
+ body: {
+ word,
+ },
+ })
+ ).catch(console.error)
+ },
+ [runAsync]
+ )
+
+ return (
+ <>
+
+ {t('edit_dictionary')}
+
+
+
+ {isError ? (
+ {t('generic_something_went_wrong')}
+ ) : null}
+
+ {ignoredWords.learnedWords?.size > 0 ? (
+
+ {[...ignoredWords.learnedWords].sort().map(learnedWord => (
+ -
+
+
+
+ {learnedWord}
+
+ ))}
+
+ ) : (
+ {t('edit_dictionary_empty')}
+ )}
+
+
+
+
+
+ >
+ )
+}
diff --git a/services/web/frontend/js/features/dictionary/components/dictionary-modal.tsx b/services/web/frontend/js/features/dictionary/components/dictionary-modal.tsx
new file mode 100644
index 0000000000..b5e6096732
--- /dev/null
+++ b/services/web/frontend/js/features/dictionary/components/dictionary-modal.tsx
@@ -0,0 +1,25 @@
+import React from 'react'
+import DictionaryModalContent from './dictionary-modal-content'
+import AccessibleModal from '../../../shared/components/accessible-modal'
+import withErrorBoundary from '../../../infrastructure/error-boundary'
+
+type DictionaryModalProps = {
+ show?: boolean
+ handleHide: () => void
+}
+
+function DictionaryModal({ show, handleHide }: DictionaryModalProps) {
+ return (
+
+
+
+ )
+}
+
+export default withErrorBoundary(DictionaryModal)
diff --git a/services/web/frontend/js/features/dictionary/controllers/modal-controller.js b/services/web/frontend/js/features/dictionary/controllers/modal-controller.js
new file mode 100644
index 0000000000..83236b2591
--- /dev/null
+++ b/services/web/frontend/js/features/dictionary/controllers/modal-controller.js
@@ -0,0 +1,26 @@
+import App from '../../../base'
+import { react2angular } from 'react2angular'
+import DictionaryModal from '../components/dictionary-modal'
+import { rootContext } from '../../../shared/context/root-context'
+
+export default App.controller('DictionaryModalController', function ($scope) {
+ $scope.show = false
+
+ $scope.handleHide = () => {
+ $scope.$applyAsync(() => {
+ $scope.show = false
+ window.dispatchEvent(new CustomEvent('learnedWords:reset'))
+ })
+ }
+
+ $scope.openModal = () => {
+ $scope.$applyAsync(() => {
+ $scope.show = true
+ })
+ }
+})
+
+App.component(
+ 'dictionaryModal',
+ react2angular(rootContext.use(DictionaryModal), ['show', 'handleHide'])
+)
diff --git a/services/web/frontend/js/features/dictionary/ignored-words.ts b/services/web/frontend/js/features/dictionary/ignored-words.ts
index 3f3a846f28..0d7156069d 100644
--- a/services/web/frontend/js/features/dictionary/ignored-words.ts
+++ b/services/web/frontend/js/features/dictionary/ignored-words.ts
@@ -8,10 +8,12 @@ export class IgnoredWords {
constructor() {
this.reset()
this.ignoredMisspellings = new Set(IGNORED_MISSPELLINGS)
+ window.addEventListener('learnedWords:doreset', () => this.reset()) // for tests
}
reset() {
this.learnedWords = new Set(getMeta('ol-learnedWords'))
+ window.dispatchEvent(new CustomEvent('learnedWords:reset'))
}
add(wordText) {
@@ -21,6 +23,13 @@ export class IgnoredWords {
)
}
+ remove(wordText) {
+ this.learnedWords.delete(wordText)
+ window.dispatchEvent(
+ new CustomEvent('learnedWords:remove', { detail: wordText })
+ )
+ }
+
has(wordText) {
return (
this.ignoredMisspellings.has(wordText) || this.learnedWords.has(wordText)
diff --git a/services/web/frontend/js/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.js b/services/web/frontend/js/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.js
index bba602de62..a8c9f0e609 100644
--- a/services/web/frontend/js/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.js
+++ b/services/web/frontend/js/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.js
@@ -61,6 +61,12 @@ class SpellCheckManager {
this.selectedHighlightContents = null
+ window.addEventListener('learnedWords:reset', () => this.reset())
+
+ window.addEventListener('learnedWords:remove', ({ detail: word }) =>
+ this.removeWordFromCache(word)
+ )
+
$(document).on('click', e => {
// There is a bug (?) in Safari when ctrl-clicking an element, and the
// the contextmenu event is preventDefault-ed. In this case, the
@@ -101,11 +107,15 @@ class SpellCheckManager {
}
}
- reInitForLangChange() {
+ reset() {
this.adapter.highlightedWordManager.reset()
this.init()
}
+ reInitForLangChange() {
+ this.reset()
+ }
+
isSpellCheckEnabled() {
return !!(
this.$scope.spellCheck &&
@@ -188,6 +198,11 @@ class SpellCheckManager {
}
}
+ removeWordFromCache(word) {
+ const language = this.$scope.spellCheckLanguage
+ this.cache.remove(`${language}:${word}`)
+ }
+
learnWord(highlight) {
this.apiRequest('/learn', { word: highlight.word })
this.adapter.highlightedWordManager.removeWord(highlight.word)
diff --git a/services/web/frontend/js/ide/settings/index.js b/services/web/frontend/js/ide/settings/index.js
index c096ffbbca..ed0c5699e7 100644
--- a/services/web/frontend/js/ide/settings/index.js
+++ b/services/web/frontend/js/ide/settings/index.js
@@ -2,3 +2,4 @@
// Fix any style issues and re-enable lint.
import './services/settings'
import './controllers/SettingsController'
+import '../../features/dictionary/controllers/modal-controller'
diff --git a/services/web/frontend/stylesheets/app/editor/left-menu.less b/services/web/frontend/stylesheets/app/editor/left-menu.less
index 1817892f75..906f1d61de 100644
--- a/services/web/frontend/stylesheets/app/editor/left-menu.less
+++ b/services/web/frontend/stylesheets/app/editor/left-menu.less
@@ -82,6 +82,7 @@
padding-right: 5px;
white-space: nowrap;
}
+ button,
select {
width: 50%;
margin: 9px 0;
@@ -125,3 +126,9 @@
background-color: #999;
z-index: 99;
}
+
+#dictionary-modal {
+ li {
+ word-break: break-all;
+ }
+}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index cafee760cd..abd6a22f3b 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -287,6 +287,10 @@
"make_primary": "Make Primary",
"make_email_primary_description": "Make this the primary email, used to log in",
"github_sync_repository_not_found_description": "The linked repository has either been removed, or you no longer have access to it. You can set up sync with a new repository by cloning the project and using the ‘GitHub’ menu item. You can also unlink the repository from this project.",
+ "dictionary": "Dictionary",
+ "edit_dictionary": "Edit Dictionary",
+ "edit_dictionary_empty": "Your custom dictionary is empty.",
+ "edit_dictionary_remove": "Remove from dictionary",
"unarchive": "Restore",
"cant_see_what_youre_looking_for_question": "Can’t see what you’re looking for?",
"something_went_wrong_canceling_your_subscription": "Something went wrong canceling your subscription. Please contact support.",
diff --git a/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.test.js b/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.test.js
new file mode 100644
index 0000000000..437eadfd09
--- /dev/null
+++ b/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.test.js
@@ -0,0 +1,59 @@
+import { screen, fireEvent } from '@testing-library/react'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import DictionaryModal from '../../../../../frontend/js/features/dictionary/components/dictionary-modal'
+import { renderWithEditorContext } from '../../../helpers/render-with-context'
+
+function setLearnedWords(words) {
+ window.metaAttributesCache.set('ol-learnedWords', words)
+ window.dispatchEvent(new CustomEvent('learnedWords:doreset'))
+}
+describe('', function () {
+ beforeEach(function () {
+ window.metaAttributesCache = window.metaAttributesCache || new Map()
+ })
+
+ afterEach(function () {
+ window.metaAttributesCache = new Map()
+ fetchMock.reset()
+ })
+
+ it('list words', async function () {
+ setLearnedWords(['foo', 'bar'])
+ renderWithEditorContext( {}} />)
+ screen.getByText('foo')
+ screen.getByText('bar')
+ })
+
+ it('shows message when empty', async function () {
+ setLearnedWords([])
+ renderWithEditorContext( {}} />)
+ screen.getByText('Your custom dictionary is empty.')
+ })
+
+ it('removes words', async function () {
+ fetchMock.post('/spelling/unlearn', 200)
+ setLearnedWords(['foo', 'bar'])
+ renderWithEditorContext( {}} />)
+ screen.getByText('bar')
+ const [firstButton] = screen.getAllByRole('button', {
+ name: 'Remove from dictionary',
+ })
+ fireEvent.click(firstButton)
+ expect(screen.queryByText('bar')).to.not.exist
+ screen.getByText('foo')
+ })
+
+ it('handles errors', async function () {
+ fetchMock.post('/spelling/unlearn', 500)
+ setLearnedWords(['foo'])
+ renderWithEditorContext( {}} />)
+ const [firstButton] = screen.getAllByRole('button', {
+ name: 'Remove from dictionary',
+ })
+ fireEvent.click(firstButton)
+ await fetchMock.flush()
+ screen.getByText('Sorry, something went wrong')
+ screen.getByText('Your custom dictionary is empty.')
+ })
+})